From a8b793e5243a655aadf7a18dcf0a69fbfe1718c6 Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Thu, 24 Sep 2026 13:19:25 +0200 Subject: [PATCH 1/3] feat: record the cook days that each shopping trip item buys The planner puts an item that keeps on an earlier trip, also for a later week (ADR 0014, 0015). The week of a trip therefore does not say which meals its items feed, and callers had no way to know it. TripItem.cookDays now holds the day of every cooking event that the item buys for. The next commits use it to justify each trip in the shopping PDF. Co-Authored-By: Claude Opus 5.5 --- .../lib/shopping/multi_trip_planner.dart | 23 ++++++++++++++++--- .../test/multi_trip_planner_test.dart | 19 +++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/menu_management/lib/shopping/multi_trip_planner.dart b/menu_management/lib/shopping/multi_trip_planner.dart index 2769f0e..019c8b2 100644 --- a/menu_management/lib/shopping/multi_trip_planner.dart +++ b/menu_management/lib/shopping/multi_trip_planner.dart @@ -7,12 +7,19 @@ import "package:menu_management/shopping/owned_amount.dart"; /// A grouped item to buy on a specific [ShoppingTrip]. class TripItem { - const TripItem({required this.ingredientId, required this.amount, required this.unit, this.freezeOnArrival = false}); + const TripItem({required this.ingredientId, required this.amount, required this.unit, this.freezeOnArrival = false, this.cookDays = const {}}); final String ingredientId; final double amount; final Unit unit; + /// The day indexes (`CookingEvent.dayIndex`) of the cooking events that this item buys for. + /// + /// The planner can put the need of a later week on an earlier trip, when the item keeps long + /// enough. The week of the trip therefore does not say which meals the item feeds. Read this set + /// to know it. + final Set cookDays; + /// True when the planner assumed this item is frozen on arrival to extend its shelf life. /// Only set when [planShoppingTrips] is called with `assumeFreezerForFreezable: true` /// AND the item's matching product has [Product.canBeFrozen] set AND the item would @@ -274,6 +281,7 @@ List _aggregate({required List<_PlanEvent> events, required Map _aggregate({required List<_PlanEvent> events, required Map items = []; for (MapEntry> ie in ingredientMap.entries) { for (MapEntry ue in ie.value.entries) { - items.add(TripItem(ingredientId: ie.key, amount: ue.value.amount, unit: ue.key, freezeOnArrival: ue.value.requiresFreezing)); + items.add( + TripItem( + ingredientId: ie.key, + amount: ue.value.amount, + unit: ue.key, + freezeOnArrival: ue.value.requiresFreezing, + cookDays: ue.value.cookDays, + ), + ); } } items.sort((TripItem a, TripItem b) { @@ -300,9 +316,10 @@ List _aggregate({required List<_PlanEvent> events, required Map cookDays; } class _PlanEvent { diff --git a/menu_management/test/multi_trip_planner_test.dart b/menu_management/test/multi_trip_planner_test.dart index 5c92158..8a985ef 100644 --- a/menu_management/test/multi_trip_planner_test.dart +++ b/menu_management/test/multi_trip_planner_test.dart @@ -154,6 +154,25 @@ void main() { expect(trips.first.items.length, 2); }); + test("records the cook days that each item buys, also a later week that an earlier trip buys because the item keeps", () { + // Salt keeps forever, so the first trip buys the salt of day 2 and of day 14. + // Banana keeps 5 days, so it forces a trip for week 2. + Ingredient salt = _ingredient(id: "salt"); + Ingredient banana = _ingredient(id: "banana", products: [_product(shelfLifeDaysClosed: 5)]); + Map> timeline = { + "salt": [_event(day: 2, amount: 50), _event(day: 14, amount: 30)], + "banana": [_event(day: 7, amount: 200)], + }; + + List trips = planShoppingTrips(cookingTimeline: timeline, ingredients: [salt, banana]); + + expect(trips.map((ShoppingTrip trip) => trip.weekIndex).toList(), [0, 1]); + expect(trips[0].items.single.ingredientId, "salt"); + expect(trips[0].items.single.cookDays, {2, 14}); + expect(trips[1].items.single.ingredientId, "banana"); + expect(trips[1].items.single.cookDays, {7}); + }); + test("same ingredient used in two distant weeks with short shelf life splits across two trips", () { // Banana used on day 2 and day 9. Shelf life 5 days closed. // Day 2: trip 0 (-1) gives 3 days → fresh. latestW=0. From fe75ed29c0af1a3879731258ca7e960dc21e85df Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Thu, 24 Sep 2026 13:19:25 +0200 Subject: [PATCH 2/3] refactor: store the cook day of a meal requirement, not only its week A shopping trip buys cook days, not weeks, so a meal can only be matched to its trip by the day of its cook event. _fedSubMeals already tracked that day and dropped it. IngredientMealRequirement now stores cookDayIndex. cookWeekIndex stays as a getter derived from it, so the week is not stored twice. The model is never saved to a file, so the renamed JSON key breaks no user data. Co-Authored-By: Claude Opus 5.5 --- .../lib/menu/models/multi_week_menu.dart | 28 ++++++------ .../shopping/ingredient_meal_requirement.dart | 17 ++++--- .../ingredient_meal_requirement.freezed.dart | 44 +++++++++---------- .../ingredient_meal_requirement.g.dart | 4 +- .../test/multi_week_menu_test.dart | 3 ++ 5 files changed, 53 insertions(+), 43 deletions(-) diff --git a/menu_management/lib/menu/models/multi_week_menu.dart b/menu_management/lib/menu/models/multi_week_menu.dart index a420b17..8d255f9 100644 --- a/menu_management/lib/menu/models/multi_week_menu.dart +++ b/menu_management/lib/menu/models/multi_week_menu.dart @@ -187,7 +187,7 @@ abstract class MultiWeekMenu with _$MultiWeekMenu { /// /// The shopping totals ([allIngredients], [ingredientSources]) and the per-meal breakdown /// ([ingredientMealRequirements]) all read this list, so they count the same meals. - List<({int weekIndex, MealTime mealTime, int subMealIndex, SubMeal subMeal, Recipe recipe, int cookWeekIndex})> _fedSubMeals({ + List<({int weekIndex, MealTime mealTime, int subMealIndex, SubMeal subMeal, Recipe recipe, int cookDayIndex})> _fedSubMeals({ required List recipes, }) { List<({int weekIndex, Meal meal})> ordered = [ @@ -201,9 +201,9 @@ abstract class MultiWeekMenu with _$MultiWeekMenu { return a.meal.mealTime.goesBefore(b.meal.mealTime) ? -1 : 1; }); - // The latest cook event of each recipe so far: its absolute day and its week. - Map latestCook = {}; - List<({int weekIndex, MealTime mealTime, int subMealIndex, SubMeal subMeal, Recipe recipe, int cookWeekIndex})> fed = []; + // The absolute day of the latest cook event of each recipe so far. + Map latestCook = {}; + List<({int weekIndex, MealTime mealTime, int subMealIndex, SubMeal subMeal, Recipe recipe, int cookDayIndex})> fed = []; for (({int weekIndex, Meal meal}) entry in ordered) { int day = entry.weekIndex * 7 + entry.meal.mealTime.weekDay.value; @@ -214,14 +214,14 @@ abstract class MultiWeekMenu with _$MultiWeekMenu { Recipe? recipe = recipes.firstWhereOrNull((Recipe r) => r.id == cooking.recipeId); if (recipe == null) continue; - int cookWeekIndex; + int cookDayIndex; if (cooking.yield > 0) { - latestCook[recipe.id] = (day: day, weekIndex: entry.weekIndex); - cookWeekIndex = entry.weekIndex; + latestCook[recipe.id] = day; + cookDayIndex = day; } else { - ({int day, int weekIndex})? cook = latestCook[recipe.id]; - if (cook == null || day - cook.day > recipe.maxStorageDays) continue; - cookWeekIndex = cook.weekIndex; + int? cookDay = latestCook[recipe.id]; + if (cookDay == null || day - cookDay > recipe.maxStorageDays) continue; + cookDayIndex = cookDay; } fed.add(( weekIndex: entry.weekIndex, @@ -229,7 +229,7 @@ abstract class MultiWeekMenu with _$MultiWeekMenu { subMealIndex: subMealIndex, subMeal: subMeal, recipe: recipe, - cookWeekIndex: cookWeekIndex, + cookDayIndex: cookDayIndex, )); } } @@ -240,7 +240,7 @@ abstract class MultiWeekMenu with _$MultiWeekMenu { /// appears in [_fedSubMeals]. This number is the servings that the whole menu cooks of the recipe. List<({Recipe recipe, int servings})> _servingsPerRecipe({required List recipes}) { Map byRecipeId = {}; - for (({int weekIndex, MealTime mealTime, int subMealIndex, SubMeal subMeal, Recipe recipe, int cookWeekIndex}) fed in _fedSubMeals( + for (({int weekIndex, MealTime mealTime, int subMealIndex, SubMeal subMeal, Recipe recipe, int cookDayIndex}) fed in _fedSubMeals( recipes: recipes, )) { int servings = byRecipeId[fed.recipe.id]?.servings ?? 0; @@ -306,7 +306,7 @@ abstract class MultiWeekMenu with _$MultiWeekMenu { Map> ingredientMealRequirements({required List recipes}) { Map> requirements = {}; - for (({int weekIndex, MealTime mealTime, int subMealIndex, SubMeal subMeal, Recipe recipe, int cookWeekIndex}) fed in _fedSubMeals( + for (({int weekIndex, MealTime mealTime, int subMealIndex, SubMeal subMeal, Recipe recipe, int cookDayIndex}) fed in _fedSubMeals( recipes: recipes, )) { for (MapEntry> ingredient in fed.recipe.perServingQuantities().entries) { @@ -315,7 +315,7 @@ abstract class MultiWeekMenu with _$MultiWeekMenu { .add( IngredientMealRequirement( weekIndex: fed.weekIndex, - cookWeekIndex: fed.cookWeekIndex, + cookDayIndex: fed.cookDayIndex, mealTime: fed.mealTime, subMealIndex: fed.subMealIndex, recipeId: fed.recipe.id, diff --git a/menu_management/lib/shopping/ingredient_meal_requirement.dart b/menu_management/lib/shopping/ingredient_meal_requirement.dart index aedcef1..c406819 100644 --- a/menu_management/lib/shopping/ingredient_meal_requirement.dart +++ b/menu_management/lib/shopping/ingredient_meal_requirement.dart @@ -14,17 +14,19 @@ part "ingredient_meal_requirement.g.dart"; /// [isCookEvent] is false when the meal eats leftovers of an earlier cook. Such a meal still /// needs the ingredient, because the earlier cook buys the food for it. /// -/// [cookWeekIndex] is the week of the cook event that cooks the food of this meal. For a cook -/// meal it equals [weekIndex]. For a leftover meal it can be an earlier week, because a cook -/// event late in one week can feed a meal of the next week. The shopping trip that buys the -/// food is the trip of that cook event, so use [cookWeekIndex] to put the meal under a trip. +/// [cookDayIndex] is the day of the cook event that cooks the food of this meal, counted from the +/// first day of the menu (`weekIndex * 7 + weekDay`, the same count as `CookingEvent.dayIndex`). +/// For a leftover meal it is an earlier day, and it can be in an earlier week, because a cook +/// event late in one week can feed a meal of the next week. The shopping trip that buys the food +/// is the trip that buys that cook day (`TripItem.cookDays`), so use [cookDayIndex] to put the +/// meal under a trip. /// /// The record holds the recipe id, never the Recipe object, the same way Cooking does. @freezed abstract class IngredientMealRequirement with _$IngredientMealRequirement { const factory IngredientMealRequirement({ required int weekIndex, - required int cookWeekIndex, + required int cookDayIndex, required MealTime mealTime, required int subMealIndex, required String recipeId, @@ -34,5 +36,10 @@ abstract class IngredientMealRequirement with _$IngredientMealRequirement { @Default([]) List quantities, }) = _IngredientMealRequirement; + const IngredientMealRequirement._(); + + /// The week of the cook event that cooks the food of this meal. + int get cookWeekIndex => cookDayIndex ~/ 7; + factory IngredientMealRequirement.fromJson(Map json) => _$IngredientMealRequirementFromJson(json); } diff --git a/menu_management/lib/shopping/ingredient_meal_requirement.freezed.dart b/menu_management/lib/shopping/ingredient_meal_requirement.freezed.dart index c2bb3ba..1fcdca9 100644 --- a/menu_management/lib/shopping/ingredient_meal_requirement.freezed.dart +++ b/menu_management/lib/shopping/ingredient_meal_requirement.freezed.dart @@ -15,7 +15,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$IngredientMealRequirement { - int get weekIndex; int get cookWeekIndex; MealTime get mealTime; int get subMealIndex; String get recipeId; String get recipeName; int get people; bool get isCookEvent; List get quantities; + int get weekIndex; int get cookDayIndex; MealTime get mealTime; int get subMealIndex; String get recipeId; String get recipeName; int get people; bool get isCookEvent; List get quantities; /// Create a copy of IngredientMealRequirement /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -28,16 +28,16 @@ $IngredientMealRequirementCopyWith get copyWith => _$ @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is IngredientMealRequirement&&(identical(other.weekIndex, weekIndex) || other.weekIndex == weekIndex)&&(identical(other.cookWeekIndex, cookWeekIndex) || other.cookWeekIndex == cookWeekIndex)&&(identical(other.mealTime, mealTime) || other.mealTime == mealTime)&&(identical(other.subMealIndex, subMealIndex) || other.subMealIndex == subMealIndex)&&(identical(other.recipeId, recipeId) || other.recipeId == recipeId)&&(identical(other.recipeName, recipeName) || other.recipeName == recipeName)&&(identical(other.people, people) || other.people == people)&&(identical(other.isCookEvent, isCookEvent) || other.isCookEvent == isCookEvent)&&const DeepCollectionEquality().equals(other.quantities, quantities)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is IngredientMealRequirement&&(identical(other.weekIndex, weekIndex) || other.weekIndex == weekIndex)&&(identical(other.cookDayIndex, cookDayIndex) || other.cookDayIndex == cookDayIndex)&&(identical(other.mealTime, mealTime) || other.mealTime == mealTime)&&(identical(other.subMealIndex, subMealIndex) || other.subMealIndex == subMealIndex)&&(identical(other.recipeId, recipeId) || other.recipeId == recipeId)&&(identical(other.recipeName, recipeName) || other.recipeName == recipeName)&&(identical(other.people, people) || other.people == people)&&(identical(other.isCookEvent, isCookEvent) || other.isCookEvent == isCookEvent)&&const DeepCollectionEquality().equals(other.quantities, quantities)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,weekIndex,cookWeekIndex,mealTime,subMealIndex,recipeId,recipeName,people,isCookEvent,const DeepCollectionEquality().hash(quantities)); +int get hashCode => Object.hash(runtimeType,weekIndex,cookDayIndex,mealTime,subMealIndex,recipeId,recipeName,people,isCookEvent,const DeepCollectionEquality().hash(quantities)); @override String toString() { - return 'IngredientMealRequirement(weekIndex: $weekIndex, cookWeekIndex: $cookWeekIndex, mealTime: $mealTime, subMealIndex: $subMealIndex, recipeId: $recipeId, recipeName: $recipeName, people: $people, isCookEvent: $isCookEvent, quantities: $quantities)'; + return 'IngredientMealRequirement(weekIndex: $weekIndex, cookDayIndex: $cookDayIndex, mealTime: $mealTime, subMealIndex: $subMealIndex, recipeId: $recipeId, recipeName: $recipeName, people: $people, isCookEvent: $isCookEvent, quantities: $quantities)'; } @@ -48,7 +48,7 @@ abstract mixin class $IngredientMealRequirementCopyWith<$Res> { factory $IngredientMealRequirementCopyWith(IngredientMealRequirement value, $Res Function(IngredientMealRequirement) _then) = _$IngredientMealRequirementCopyWithImpl; @useResult $Res call({ - int weekIndex, int cookWeekIndex, MealTime mealTime, int subMealIndex, String recipeId, String recipeName, int people, bool isCookEvent, List quantities + int weekIndex, int cookDayIndex, MealTime mealTime, int subMealIndex, String recipeId, String recipeName, int people, bool isCookEvent, List quantities }); @@ -65,10 +65,10 @@ class _$IngredientMealRequirementCopyWithImpl<$Res> /// Create a copy of IngredientMealRequirement /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? weekIndex = null,Object? cookWeekIndex = null,Object? mealTime = null,Object? subMealIndex = null,Object? recipeId = null,Object? recipeName = null,Object? people = null,Object? isCookEvent = null,Object? quantities = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? weekIndex = null,Object? cookDayIndex = null,Object? mealTime = null,Object? subMealIndex = null,Object? recipeId = null,Object? recipeName = null,Object? people = null,Object? isCookEvent = null,Object? quantities = null,}) { return _then(_self.copyWith( weekIndex: null == weekIndex ? _self.weekIndex : weekIndex // ignore: cast_nullable_to_non_nullable -as int,cookWeekIndex: null == cookWeekIndex ? _self.cookWeekIndex : cookWeekIndex // ignore: cast_nullable_to_non_nullable +as int,cookDayIndex: null == cookDayIndex ? _self.cookDayIndex : cookDayIndex // ignore: cast_nullable_to_non_nullable as int,mealTime: null == mealTime ? _self.mealTime : mealTime // ignore: cast_nullable_to_non_nullable as MealTime,subMealIndex: null == subMealIndex ? _self.subMealIndex : subMealIndex // ignore: cast_nullable_to_non_nullable as int,recipeId: null == recipeId ? _self.recipeId : recipeId // ignore: cast_nullable_to_non_nullable @@ -170,10 +170,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( int weekIndex, int cookWeekIndex, MealTime mealTime, int subMealIndex, String recipeId, String recipeName, int people, bool isCookEvent, List quantities)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( int weekIndex, int cookDayIndex, MealTime mealTime, int subMealIndex, String recipeId, String recipeName, int people, bool isCookEvent, List quantities)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _IngredientMealRequirement() when $default != null: -return $default(_that.weekIndex,_that.cookWeekIndex,_that.mealTime,_that.subMealIndex,_that.recipeId,_that.recipeName,_that.people,_that.isCookEvent,_that.quantities);case _: +return $default(_that.weekIndex,_that.cookDayIndex,_that.mealTime,_that.subMealIndex,_that.recipeId,_that.recipeName,_that.people,_that.isCookEvent,_that.quantities);case _: return orElse(); } @@ -191,10 +191,10 @@ return $default(_that.weekIndex,_that.cookWeekIndex,_that.mealTime,_that.subMeal /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( int weekIndex, int cookWeekIndex, MealTime mealTime, int subMealIndex, String recipeId, String recipeName, int people, bool isCookEvent, List quantities) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( int weekIndex, int cookDayIndex, MealTime mealTime, int subMealIndex, String recipeId, String recipeName, int people, bool isCookEvent, List quantities) $default,) {final _that = this; switch (_that) { case _IngredientMealRequirement(): -return $default(_that.weekIndex,_that.cookWeekIndex,_that.mealTime,_that.subMealIndex,_that.recipeId,_that.recipeName,_that.people,_that.isCookEvent,_that.quantities);case _: +return $default(_that.weekIndex,_that.cookDayIndex,_that.mealTime,_that.subMealIndex,_that.recipeId,_that.recipeName,_that.people,_that.isCookEvent,_that.quantities);case _: throw StateError('Unexpected subclass'); } @@ -211,10 +211,10 @@ return $default(_that.weekIndex,_that.cookWeekIndex,_that.mealTime,_that.subMeal /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( int weekIndex, int cookWeekIndex, MealTime mealTime, int subMealIndex, String recipeId, String recipeName, int people, bool isCookEvent, List quantities)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( int weekIndex, int cookDayIndex, MealTime mealTime, int subMealIndex, String recipeId, String recipeName, int people, bool isCookEvent, List quantities)? $default,) {final _that = this; switch (_that) { case _IngredientMealRequirement() when $default != null: -return $default(_that.weekIndex,_that.cookWeekIndex,_that.mealTime,_that.subMealIndex,_that.recipeId,_that.recipeName,_that.people,_that.isCookEvent,_that.quantities);case _: +return $default(_that.weekIndex,_that.cookDayIndex,_that.mealTime,_that.subMealIndex,_that.recipeId,_that.recipeName,_that.people,_that.isCookEvent,_that.quantities);case _: return null; } @@ -225,12 +225,12 @@ return $default(_that.weekIndex,_that.cookWeekIndex,_that.mealTime,_that.subMeal /// @nodoc @JsonSerializable() -class _IngredientMealRequirement implements IngredientMealRequirement { - const _IngredientMealRequirement({required this.weekIndex, required this.cookWeekIndex, required this.mealTime, required this.subMealIndex, required this.recipeId, required this.recipeName, required this.people, required this.isCookEvent, final List quantities = const []}): _quantities = quantities; +class _IngredientMealRequirement extends IngredientMealRequirement { + const _IngredientMealRequirement({required this.weekIndex, required this.cookDayIndex, required this.mealTime, required this.subMealIndex, required this.recipeId, required this.recipeName, required this.people, required this.isCookEvent, final List quantities = const []}): _quantities = quantities,super._(); factory _IngredientMealRequirement.fromJson(Map json) => _$IngredientMealRequirementFromJson(json); @override final int weekIndex; -@override final int cookWeekIndex; +@override final int cookDayIndex; @override final MealTime mealTime; @override final int subMealIndex; @override final String recipeId; @@ -258,16 +258,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _IngredientMealRequirement&&(identical(other.weekIndex, weekIndex) || other.weekIndex == weekIndex)&&(identical(other.cookWeekIndex, cookWeekIndex) || other.cookWeekIndex == cookWeekIndex)&&(identical(other.mealTime, mealTime) || other.mealTime == mealTime)&&(identical(other.subMealIndex, subMealIndex) || other.subMealIndex == subMealIndex)&&(identical(other.recipeId, recipeId) || other.recipeId == recipeId)&&(identical(other.recipeName, recipeName) || other.recipeName == recipeName)&&(identical(other.people, people) || other.people == people)&&(identical(other.isCookEvent, isCookEvent) || other.isCookEvent == isCookEvent)&&const DeepCollectionEquality().equals(other._quantities, _quantities)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _IngredientMealRequirement&&(identical(other.weekIndex, weekIndex) || other.weekIndex == weekIndex)&&(identical(other.cookDayIndex, cookDayIndex) || other.cookDayIndex == cookDayIndex)&&(identical(other.mealTime, mealTime) || other.mealTime == mealTime)&&(identical(other.subMealIndex, subMealIndex) || other.subMealIndex == subMealIndex)&&(identical(other.recipeId, recipeId) || other.recipeId == recipeId)&&(identical(other.recipeName, recipeName) || other.recipeName == recipeName)&&(identical(other.people, people) || other.people == people)&&(identical(other.isCookEvent, isCookEvent) || other.isCookEvent == isCookEvent)&&const DeepCollectionEquality().equals(other._quantities, _quantities)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,weekIndex,cookWeekIndex,mealTime,subMealIndex,recipeId,recipeName,people,isCookEvent,const DeepCollectionEquality().hash(_quantities)); +int get hashCode => Object.hash(runtimeType,weekIndex,cookDayIndex,mealTime,subMealIndex,recipeId,recipeName,people,isCookEvent,const DeepCollectionEquality().hash(_quantities)); @override String toString() { - return 'IngredientMealRequirement(weekIndex: $weekIndex, cookWeekIndex: $cookWeekIndex, mealTime: $mealTime, subMealIndex: $subMealIndex, recipeId: $recipeId, recipeName: $recipeName, people: $people, isCookEvent: $isCookEvent, quantities: $quantities)'; + return 'IngredientMealRequirement(weekIndex: $weekIndex, cookDayIndex: $cookDayIndex, mealTime: $mealTime, subMealIndex: $subMealIndex, recipeId: $recipeId, recipeName: $recipeName, people: $people, isCookEvent: $isCookEvent, quantities: $quantities)'; } @@ -278,7 +278,7 @@ abstract mixin class _$IngredientMealRequirementCopyWith<$Res> implements $Ingre factory _$IngredientMealRequirementCopyWith(_IngredientMealRequirement value, $Res Function(_IngredientMealRequirement) _then) = __$IngredientMealRequirementCopyWithImpl; @override @useResult $Res call({ - int weekIndex, int cookWeekIndex, MealTime mealTime, int subMealIndex, String recipeId, String recipeName, int people, bool isCookEvent, List quantities + int weekIndex, int cookDayIndex, MealTime mealTime, int subMealIndex, String recipeId, String recipeName, int people, bool isCookEvent, List quantities }); @@ -295,10 +295,10 @@ class __$IngredientMealRequirementCopyWithImpl<$Res> /// Create a copy of IngredientMealRequirement /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? weekIndex = null,Object? cookWeekIndex = null,Object? mealTime = null,Object? subMealIndex = null,Object? recipeId = null,Object? recipeName = null,Object? people = null,Object? isCookEvent = null,Object? quantities = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? weekIndex = null,Object? cookDayIndex = null,Object? mealTime = null,Object? subMealIndex = null,Object? recipeId = null,Object? recipeName = null,Object? people = null,Object? isCookEvent = null,Object? quantities = null,}) { return _then(_IngredientMealRequirement( weekIndex: null == weekIndex ? _self.weekIndex : weekIndex // ignore: cast_nullable_to_non_nullable -as int,cookWeekIndex: null == cookWeekIndex ? _self.cookWeekIndex : cookWeekIndex // ignore: cast_nullable_to_non_nullable +as int,cookDayIndex: null == cookDayIndex ? _self.cookDayIndex : cookDayIndex // ignore: cast_nullable_to_non_nullable as int,mealTime: null == mealTime ? _self.mealTime : mealTime // ignore: cast_nullable_to_non_nullable as MealTime,subMealIndex: null == subMealIndex ? _self.subMealIndex : subMealIndex // ignore: cast_nullable_to_non_nullable as int,recipeId: null == recipeId ? _self.recipeId : recipeId // ignore: cast_nullable_to_non_nullable diff --git a/menu_management/lib/shopping/ingredient_meal_requirement.g.dart b/menu_management/lib/shopping/ingredient_meal_requirement.g.dart index e9bcfcc..e3c86b9 100644 --- a/menu_management/lib/shopping/ingredient_meal_requirement.g.dart +++ b/menu_management/lib/shopping/ingredient_meal_requirement.g.dart @@ -10,7 +10,7 @@ _IngredientMealRequirement _$IngredientMealRequirementFromJson( Map json, ) => _IngredientMealRequirement( weekIndex: (json['weekIndex'] as num).toInt(), - cookWeekIndex: (json['cookWeekIndex'] as num).toInt(), + cookDayIndex: (json['cookDayIndex'] as num).toInt(), mealTime: MealTime.fromJson(json['mealTime'] as Map), subMealIndex: (json['subMealIndex'] as num).toInt(), recipeId: json['recipeId'] as String, @@ -28,7 +28,7 @@ Map _$IngredientMealRequirementToJson( _IngredientMealRequirement instance, ) => { 'weekIndex': instance.weekIndex, - 'cookWeekIndex': instance.cookWeekIndex, + 'cookDayIndex': instance.cookDayIndex, 'mealTime': instance.mealTime.toJson(), 'subMealIndex': instance.subMealIndex, 'recipeId': instance.recipeId, diff --git a/menu_management/test/multi_week_menu_test.dart b/menu_management/test/multi_week_menu_test.dart index 5cd08b8..7ec344b 100644 --- a/menu_management/test/multi_week_menu_test.dart +++ b/menu_management/test/multi_week_menu_test.dart @@ -729,6 +729,9 @@ void main() { (1, WeekDay.monday, 1), (1, WeekDay.tuesday, 1), ]); + // The day of the cook event, counted from the first day of the menu: Friday of week 0 is + // day 6, and Monday of week 1 is day 9. A shopping trip buys the food of that day. + expect(requirements.map((IngredientMealRequirement r) => r.cookDayIndex), [6, 6, 9, 9]); }); test("counts a leftover meal of the next week in the servings of the recipe source", () { From b5aa5695f2966c759db6f6c722230c61c8447897 Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Thu, 24 Sep 2026 13:19:25 +0200 Subject: [PATCH 3/3] fix: list every meal that a shopping trip buys for in the PDF The shopping PDF assumed that a trip buys only the weeks up to the next trip. The planner puts an item that keeps on an earlier trip, also for later weeks, so the PDF dropped those meals. With the bundled defaults, trip 1 bought 28 eggs but listed only the 8 eggs of week 1, and the meals of weeks 2 and 3 appeared on no page. Each meal, and each cooking event that ranks the products of a trip, now goes under the trip whose items hold its cook day. A meal that the owned stock covers goes under the first trip that buys the ingredient, because the list shows the whole need. ADR 0014 now states the day rule. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- adr/0014-multi-trip-shopping-planner.md | 2 +- .../lib/shopping/shopping_pdf_document.dart | 65 +++++---- .../test/shopping_pdf_document_test.dart | 137 +++++++++++++++++- 4 files changed, 174 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 36bdd2c..c55a031 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,7 @@ Each kind of knowledge has one home. Write a change in the home that matches it; | Code gen (watch) | `cd menu_management && dart run build_runner watch --delete-conflicting-outputs` | | | Build release | `cd menu_management && flutter build windows` | | | Build + copy to Desktop | `./build_and_copy.bat` | Run from repo root; it `cd`s into `menu_management` and runs `build_and_copy.ps1`. Windows only; copies portable build to Desktop | -| Run all tests | `cd menu_management && flutter test test/` | 1050 tests across 42 files | +| Run all tests | `cd menu_management && flutter test test/` | 1053 tests across 42 files | | Run single test | `cd menu_management && flutter test test/.dart` | | | List devices | `flutter devices` | | | Format check | `cd menu_management && find lib test -name "*.dart" ! -name "*.freezed.dart" ! -name "*.g.dart" -print0 \| xargs -0 dart format --set-exit-if-changed` | Bash/Git Bash; excludes generated files; fix drift by re-running without `--set-exit-if-changed` | diff --git a/adr/0014-multi-trip-shopping-planner.md b/adr/0014-multi-trip-shopping-planner.md index 98cb2c6..015a883 100644 --- a/adr/0014-multi-trip-shopping-planner.md +++ b/adr/0014-multi-trip-shopping-planner.md @@ -30,7 +30,7 @@ When no trip can possibly serve a perishable event fresh (very short shelf life ### Cross-week leftovers -A cook event late in week N can feed a leftover meal in week N+1. The food of that meal is bought with the cook event, because the cook cooks it on the cook day. `buildCookingTimeline` puts the whole amount of a cook event on its cook day, so the planner assigns it to the trip of the cook event. The shopping PDF follows the same rule: a trip section lists a leftover meal under the trip of its cook event (`IngredientMealRequirement.cookWeekIndex`), not under the trip of the week of the meal. +A cook event late in week N can feed a leftover meal in week N+1. The food of that meal is bought with the cook event, because the cook cooks it on the cook day. `buildCookingTimeline` puts the whole amount of a cook event on its cook day, so the planner assigns it to the trip of the cook event. The shopping PDF follows the same rule at the level of the day. `TripItem.cookDays` records the cook days that each trip item buys for. A trip section lists a meal under the trip whose items hold its cook day (`IngredientMealRequirement.cookDayIndex`). A rule by week range is not enough, because the planner can put a long-life item of a later week on an earlier trip (see also ADR 0015). A cook day that no trip buys, because owned stock covers it, goes under the first trip that buys the ingredient. ### Per-event shelf life lookup diff --git a/menu_management/lib/shopping/shopping_pdf_document.dart b/menu_management/lib/shopping/shopping_pdf_document.dart index a4c402a..fa42d58 100644 --- a/menu_management/lib/shopping/shopping_pdf_document.dart +++ b/menu_management/lib/shopping/shopping_pdf_document.dart @@ -118,17 +118,25 @@ abstract class ShoppingPdfDocument with _$ShoppingPdfDocument { /// [multiWeekMenu] and [recipes] serve the justification only. `ingredientMealRequirements` says /// which meal needs which amount of an ingredient. /// -/// A trip section lists a meal under the trip of the week of its cook event -/// (`IngredientMealRequirement.cookWeekIndex`), not of the week of the meal. The cook event cooks -/// the food of its leftover meals too, so that trip buys the food of a leftover meal of the next week. +/// A trip section lists a meal under the trip that buys the day of its cook event +/// (`IngredientMealRequirement.cookDayIndex` against `TripItem.cookDays`), not under the trip of the +/// week of the meal. Two reasons make the week of the meal wrong: +/// +/// 1. The cook event cooks the food of its leftover meals too, so its trip buys the food of a +/// leftover meal of the next week. +/// 2. The planner buys an item that keeps on an earlier trip, also for a later week (ADR 0014 and +/// 0015). The trip of the week of the meal then buys none of it. +/// +/// A meal whose cook day no trip buys, because the owned stock covers it, goes under the first trip +/// that buys the ingredient. The meals show the whole need, so such a meal still needs a place. /// /// Known gap: the amounts of the meals of one ingredient do not always add up to the amount to /// buy. Two reasons cause it, and neither of them is a fault: /// /// 1. The amount to buy has the stock that the user already owns taken off it. The meals show the /// whole need, because a meal needs the food whoever paid for it. -/// 2. A trip section shows the share of that one trip. Its meals are the meals of the weeks that -/// the trip buys for, so the two sides cover the same weeks but not the same rounding. +/// 2. A trip section shows the share of that one trip. Its meals are the meals whose cook days the +/// trip buys, so the two sides cover the same meals but not the same rounding. /// /// Do not build on that difference. ShoppingPdfDocument buildShoppingPdfDocument({ @@ -143,19 +151,21 @@ ShoppingPdfDocument buildShoppingPdfDocument({ final Map> mealRequirements = multiWeekMenu.ingredientMealRequirements(recipes: recipes); final List sorted = sortIngredientsForCopy(ingredients); - /// Builds one line of one section. [weeks] holds the weeks that the section buys for, or null - /// for the section that covers the whole menu. The justification and the ranking both read it, - /// so the amount, the meals under it and the recommended product all cover the same weeks. + /// Builds one line of one section. [tripWeekIndex] names the trip of the section, or is null for + /// the section that covers the whole menu. The justification and the ranking both read it, so + /// the amount, the meals under it and the recommended product all cover the same cook days. ShoppingPdfIngredientEntry? entryOf({ required Ingredient ingredient, required List remaining, required bool freezeOnArrival, - required Set? weeks, + required int? tripWeekIndex, }) { assertWholeShoppingAmounts(ingredient: ingredient, remaining: remaining); if (!remaining.any((Quantity quantity) => quantity.amount > 0)) return null; final List events = cookingTimeline[ingredient.id] ?? const []; final List requirements = mealRequirements[ingredient.id] ?? const []; + bool isOnThisTrip(int cookDay) => + tripWeekIndex == null || _tripOfCookDay(trips: trips, ingredientId: ingredient.id, cookDay: cookDay) == tripWeekIndex; return ShoppingPdfIngredientEntry( ingredientName: ingredient.name, amounts: shoppingAmountsText(remaining), @@ -163,12 +173,12 @@ ShoppingPdfDocument buildShoppingPdfDocument({ products: _productOptions( ingredient: ingredient, remaining: remaining, - events: weeks == null ? events : events.where((CookingEvent event) => weeks.contains(event.dayIndex ~/ 7)).toList(), + events: events.where((CookingEvent event) => isOnThisTrip(event.dayIndex)).toList(), ), meals: _mealNeeds( ingredient: ingredient, startDate: multiWeekMenu.startDate, - requirements: weeks == null ? requirements : requirements.where((IngredientMealRequirement r) => weeks.contains(r.cookWeekIndex)).toList(), + requirements: requirements.where((IngredientMealRequirement r) => isOnThisTrip(r.cookDayIndex)).toList(), ), ); } @@ -182,7 +192,7 @@ ShoppingPdfDocument buildShoppingPdfDocument({ ingredient: ingredient, remaining: remainingForCopy(ingredient: ingredient, remainingByIngredientId: remainingByIngredientId), freezeOnArrival: false, - weeks: null, + tripWeekIndex: null, ); if (entry != null) entries.add(entry); } @@ -190,7 +200,6 @@ ShoppingPdfDocument buildShoppingPdfDocument({ return ShoppingPdfDocument(title: _documentTitle(multiWeekMenu), trips: sections); } - final Map> weeksByTrip = _weeksByTrip(trips: trips, weekCount: multiWeekMenu.weeks.length); final Map> entriesByWeek = {for (ShoppingTrip trip in trips) trip.weekIndex: []}; for (Ingredient ingredient in sorted) { final List remaining = remainingForCopy(ingredient: ingredient, remainingByIngredientId: remainingByIngredientId); @@ -199,7 +208,7 @@ ShoppingPdfDocument buildShoppingPdfDocument({ ingredient: ingredient, remaining: allocation.quantities, freezeOnArrival: allocation.freezeOnArrival, - weeks: weeksByTrip[allocation.weekIndex] ?? const {}, + tripWeekIndex: allocation.weekIndex, ); if (entry != null) entriesByWeek[allocation.weekIndex]!.add(entry); } @@ -219,19 +228,25 @@ ShoppingPdfDocument buildShoppingPdfDocument({ return ShoppingPdfDocument(title: _documentTitle(multiWeekMenu), trips: sections); } -/// Says which weeks of the menu each trip buys for, keyed by the week of the trip. +/// Says which trip buys the cook event of [ingredientId] on [cookDay], as the week of that trip. +/// +/// The first trip whose items of the ingredient hold [cookDay] wins. An ingredient that the +/// planner splits over two units can in theory buy one cook day on two trips, and the meal of that +/// day then goes under the earlier one. /// -/// The planner puts one trip on the day before a week starts (ADR 0014). A trip therefore buys -/// for its own week and for every week up to the next trip. The last trip buys for every week -/// that is left. -Map> _weeksByTrip({required List trips, required int weekCount}) { - final Map> weeks = {}; - for (int index = 0; index < trips.length; index++) { - final int first = trips[index].weekIndex; - final int last = index + 1 < trips.length ? trips[index + 1].weekIndex - 1 : weekCount - 1; - weeks[first] = {for (int week = first; week <= last; week++) week}; +/// A cook day that no trip buys goes to the first trip that buys the ingredient. The planner skips +/// a cook event that the owned stock covers, and the owned stock covers the earliest events first. +/// It returns null when no trip buys the ingredient at all. +int? _tripOfCookDay({required List trips, required String ingredientId, required int cookDay}) { + int? firstTrip; + for (ShoppingTrip trip in trips) { + for (TripItem item in trip.items) { + if (item.ingredientId != ingredientId) continue; + firstTrip ??= trip.weekIndex; + if (item.cookDays.contains(cookDay)) return trip.weekIndex; + } } - return weeks; + return firstTrip; } /// Names the document after the days that the menu covers, for example diff --git a/menu_management/test/shopping_pdf_document_test.dart b/menu_management/test/shopping_pdf_document_test.dart index dc1763a..8b21d10 100644 --- a/menu_management/test/shopping_pdf_document_test.dart +++ b/menu_management/test/shopping_pdf_document_test.dart @@ -74,15 +74,20 @@ MultiWeekMenu _twoWeekMenu() => MultiWeekMenu( ], ); -/// One trip per week, each trip carrying the same amount of the noodles. +/// One trip per week, each trip carrying the same amount of the noodles. Each trip buys the +/// Saturday of its own week: day 0 and day 7 of [_twoWeekMenu]. const List _tripPerWeek = [ ShoppingTrip( weekIndex: 0, - items: [TripItem(ingredientId: "n1", amount: 250, unit: Unit.grams)], + items: [ + TripItem(ingredientId: "n1", amount: 250, unit: Unit.grams, cookDays: {0}), + ], ), ShoppingTrip( weekIndex: 1, - items: [TripItem(ingredientId: "n1", amount: 250, unit: Unit.grams)], + items: [ + TripItem(ingredientId: "n1", amount: 250, unit: Unit.grams, cookDays: {7}), + ], ), ]; @@ -438,14 +443,19 @@ void main() { ), ], ); + // The Friday cook of week 1 is day 6, and the Wednesday cook of week 2 is day 11. const List trips = [ ShoppingTrip( weekIndex: 0, - items: [TripItem(ingredientId: "n1", amount: 400, unit: Unit.grams)], + items: [ + TripItem(ingredientId: "n1", amount: 400, unit: Unit.grams, cookDays: {6}), + ], ), ShoppingTrip( weekIndex: 1, - items: [TripItem(ingredientId: "n1", amount: 200, unit: Unit.grams)], + items: [ + TripItem(ingredientId: "n1", amount: 200, unit: Unit.grams, cookDays: {11}), + ], ), ]; @@ -491,6 +501,123 @@ void main() { ]); }); + test("lists every meal that a trip buys for, also a meal of a later week that the trip buys because the item keeps", () { + // Noodles keep forever, so the planner buys the noodles of both weeks on the first trip. + // Lettuce keeps 3 days, so the planner adds a second trip for the lettuce of week 2. + const Ingredient noodles = Ingredient( + id: "n1", + name: "Noodles", + products: [Product(link: "", quantityPerItem: 500, unit: Unit.grams)], + ); + const Ingredient lettuce = Ingredient( + id: "n2", + name: "Lettuce", + products: [Product(link: "", quantityPerItem: 1, unit: Unit.pieces, shelfLifeDaysClosed: 3)], + ); + const Recipe salad = Recipe( + id: "r1", + name: "Pasta salad", + instructions: [ + Instruction( + id: "i1", + description: "Mix.", + ingredientsUsed: [ + IngredientUsage( + ingredient: "n1", + quantity: Quantity(amount: 100, unit: Unit.grams), + ), + IngredientUsage( + ingredient: "n2", + quantity: Quantity(amount: 1, unit: Unit.pieces), + ), + ], + ), + ], + ); + MultiWeekMenu multiWeek = _twoWeekMenu(); + Map> timeline = buildCookingTimeline(multiWeekMenu: multiWeek, recipes: const [salad]); + List trips = planShoppingTrips(cookingTimeline: timeline, ingredients: const [noodles, lettuce]); + + ShoppingPdfDocument document = _document( + ingredients: const [noodles, lettuce], + remaining: const { + "n1": [Quantity(amount: 400, unit: Unit.grams)], + "n2": [Quantity(amount: 4, unit: Unit.pieces)], + }, + trips: trips, + multiWeekMenu: multiWeek, + recipes: const [salad], + cookingTimeline: timeline, + ); + + ShoppingPdfIngredientEntry firstTripNoodles = document.trips.first.ingredients.firstWhere( + (ShoppingPdfIngredientEntry entry) => entry.ingredientName == "Noodles", + ); + expect(firstTripNoodles.amounts, "400 grams"); + expect(firstTripNoodles.meals, const [ + ShoppingPdfMealNeed( + weekLabel: "Week 1", + dayLabel: "Wednesday 6 Aug", + mealName: "Lunch", + recipeName: "Pasta salad", + people: 2, + isCookEvent: true, + amounts: "200 grams", + ), + ShoppingPdfMealNeed( + weekLabel: "Week 2", + dayLabel: "Wednesday 13 Aug", + mealName: "Lunch", + recipeName: "Pasta salad", + people: 2, + isCookEvent: true, + amounts: "200 grams", + ), + ]); + }); + + test("lists a meal that the owned stock covers under the first trip that buys the ingredient", () { + // The user owns the noodles of week 1, so the planner buys only the noodles of day 7. The + // meal of week 1 still needs the food, and the list shows the whole need. + const List trips = [ + ShoppingTrip( + weekIndex: 1, + items: [ + TripItem(ingredientId: "n1", amount: 200, unit: Unit.grams, cookDays: {7}), + ], + ), + ]; + + ShoppingPdfDocument document = _document( + remaining: const { + "n1": [Quantity(amount: 200, unit: Unit.grams)], + }, + trips: trips, + multiWeekMenu: _twoWeekMenu(), + ); + + expect(document.trips.single.ingredients.single.meals, const [ + ShoppingPdfMealNeed( + weekLabel: "Week 1", + dayLabel: "Wednesday 6 Aug", + mealName: "Lunch", + recipeName: "Pasta", + people: 2, + isCookEvent: true, + amounts: "200 grams", + ), + ShoppingPdfMealNeed( + weekLabel: "Week 2", + dayLabel: "Wednesday 13 Aug", + mealName: "Lunch", + recipeName: "Pasta", + people: 2, + isCookEvent: true, + amounts: "200 grams", + ), + ]); + }); + test("writes no meal for an ingredient that no meal of the menu needs", () { ShoppingPdfDocument document = _document( ingredients: const [Ingredient(id: "n2", name: "Salt")],