From 2bfe3d63b41b638b0bee1962b47bbaf6949adec5 Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Sat, 3 Oct 2026 14:57:58 +0200 Subject: [PATCH 1/7] fix: buy each perishable on the first trip or the latest trip before use The planner gave each perishable the first chosen trip inside its freshness window, which could be an in-between trip. A yogurt for day 15 with a window of [1, 2] went on trip 1, although the user shops on trip 2 anyway and the yogurt is fresher from there. The first trip is usually a home delivery, so it takes everything that it keeps fresh. The other perishables now go on the latest trip in their window, as close to their use as possible. The assignment runs after the trip set is final, so it never adds a trip. It can empty the first greedy trip when trip 0 is added later and keeps all of its items fresh. Closes #77 Co-Authored-By: Claude Opus 5.5 --- .../lib/shopping/multi_trip_planner.dart | 22 +++++ .../test/multi_trip_planner_test.dart | 82 +++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/menu_management/lib/shopping/multi_trip_planner.dart b/menu_management/lib/shopping/multi_trip_planner.dart index 019c8b2..2ccd5fa 100644 --- a/menu_management/lib/shopping/multi_trip_planner.dart +++ b/menu_management/lib/shopping/multi_trip_planner.dart @@ -52,6 +52,9 @@ class ShoppingTrip { /// interval point cover then picks the minimum number of trips that covers /// every event. /// +/// After the trip set is final, each perishable event goes on the first trip when the first trip +/// keeps it fresh. Otherwise it goes on the latest trip that keeps it fresh, close to its use. +/// /// Items whose unit has no matching product, or whose matching product has /// [Product.shelfLifeDaysClosed] = null, are treated as non-perishable: they /// have no upper expiry constraint and ride along on the trips already chosen @@ -149,6 +152,14 @@ List planShoppingTrips({ event.assignedTrip = assigned; } + // The trip set is now final. The greedy pass only picked the trips, so give each perishable its trip again: + // the first trip (usually a home delivery) when it keeps the item fresh, otherwise the latest trip in the window, + // to buy the item as close to its use as possible. + chosenTrips.sort(); + for (_PlanEvent event in perishable) { + event.assignedTrip = _tripForPerishable(event, chosenTrips: chosenTrips); + } + return _aggregate(events: events, ingredientsById: ingredientsById); } @@ -265,6 +276,17 @@ void _attachTripWindow(_PlanEvent event, {required int maxWeekIndex}) { event.latestWeek = latestWeek; } +/// The trip of a perishable [event], picked from [chosenTrips] (sorted ascending). +/// +/// Returns the first trip when it is inside the window of the event. Otherwise returns the latest trip inside the window. +/// The greedy pass put a trip inside every window, so a trip always matches. +int _tripForPerishable(_PlanEvent event, {required List chosenTrips}) { + bool isInWindow(int trip) => trip >= event.earliestWeek && trip <= event.latestWeek; + int firstTrip = chosenTrips.first; + if (isInWindow(firstTrip)) return firstTrip; + return chosenTrips.lastWhere(isInWindow); +} + int _compareForGreedy(_PlanEvent a, _PlanEvent b) { int byLatest = a.latestWeek.compareTo(b.latestWeek); if (byLatest != 0) return byLatest; diff --git a/menu_management/test/multi_trip_planner_test.dart b/menu_management/test/multi_trip_planner_test.dart index 8a985ef..0becdfa 100644 --- a/menu_management/test/multi_trip_planner_test.dart +++ b/menu_management/test/multi_trip_planner_test.dart @@ -66,6 +66,11 @@ double _gramsBoughtFor(List trips, Ingredient ingredient) { return total; } +/// Each trip week mapped to its items, written as "ingredientId days {cookDays}". +Map> _itemsByTrip(List trips) { + return {for (ShoppingTrip trip in trips) trip.weekIndex: trip.items.map((TripItem item) => "${item.ingredientId} days ${item.cookDays}").toList()}; +} + void main() { group("planShoppingTrips", () { test("returns no trips when timeline is empty", () { @@ -714,6 +719,83 @@ void main() { }); }); + group("planShoppingTrips trip of each perishable", () { + // Meat keeps 2 days, so a meat need on day 0, 7 and 14 forces the trips of week 0, 1 and 2. + Ingredient meat = _ingredient(id: "meat", products: [_product(shelfLifeDaysClosed: 2)]); + + test("a perishable that the first trip cannot keep fresh goes on the latest trip in its window", () { + // Yogurt keeps 14 days and is used on day 15. Trip 0 (day -1) is 16 days early, so its window is [1, 2]. + // Trip 1 and trip 2 both keep it fresh. Trip 2 is closer to the use. + Ingredient yogurt = _ingredient(id: "yogurt", products: [_product(shelfLifeDaysClosed: 14)]); + Map> timeline = { + "meat": [_event(day: 0), _event(day: 7), _event(day: 14)], + "yogurt": [_event(day: 15)], + }; + + List trips = planShoppingTrips(cookingTimeline: timeline, ingredients: [meat, yogurt]); + + expect(_itemsByTrip(trips), { + 0: ["meat days {0}"], + 1: ["meat days {7}"], + 2: ["meat days {14}", "yogurt days {15}"], + }); + }); + + test("a perishable that the first trip keeps fresh goes on the first trip, also when a later trip fits", () { + // Cheese keeps 30 days and is used on day 15, so its window is [0, 2]. + // Salt keeps forever and adds trip 0 after the meat picked trip 1 and trip 2. + Ingredient cheese = _ingredient(id: "cheese", products: [_product(shelfLifeDaysClosed: 30)]); + Ingredient salt = _ingredient(id: "salt"); + Map> timeline = { + "meat": [_event(day: 7), _event(day: 14)], + "cheese": [_event(day: 15)], + "salt": [_event(day: 2)], + }; + + List trips = planShoppingTrips(cookingTimeline: timeline, ingredients: [meat, cheese, salt]); + + expect(_itemsByTrip(trips), { + 0: ["cheese days {15}", "salt days {2}"], + 1: ["meat days {7}"], + 2: ["meat days {14}"], + }); + }); + + test("a perishable that no trip keeps fresh still goes on the latest trip on or before its use", () { + // Mushroom keeps 1 day and is used on day 10. No trip keeps it fresh, so it falls back to trip 1 (day 6). + Ingredient mushroom = _ingredient(id: "mushroom", products: [_product(shelfLifeDaysClosed: 1)]); + Map> timeline = { + "meat": [_event(day: 0), _event(day: 7), _event(day: 14)], + "mushroom": [_event(day: 10)], + }; + + List trips = planShoppingTrips(cookingTimeline: timeline, ingredients: [meat, mushroom]); + + expect(_itemsByTrip(trips), { + 0: ["meat days {0}"], + 1: ["meat days {7}", "mushroom days {10}"], + 2: ["meat days {14}"], + }); + }); + + test("drops a later trip when the first trip keeps every perishable of that trip fresh", () { + // Milk keeps 30 days and is used on day 10, so the greedy pass picks trip 1 for it. + // Salt then adds trip 0. Trip 0 keeps the milk fresh, so the milk moves there and trip 1 buys nothing. + Ingredient milk = _ingredient(id: "milk", products: [_product(shelfLifeDaysClosed: 30)]); + Ingredient salt = _ingredient(id: "salt"); + Map> timeline = { + "milk": [_event(day: 10)], + "salt": [_event(day: 3)], + }; + + List trips = planShoppingTrips(cookingTimeline: timeline, ingredients: [milk, salt]); + + expect(_itemsByTrip(trips), { + 0: ["milk days {10}", "salt days {3}"], + }); + }); + }); + group("ShoppingTrip.dayForWeek", () { test("puts the trip on the day before the week starts", () { expect(ShoppingTrip.dayForWeek(0), -1); From 7a159623ed719b2a88d8717b298ab6b6e970bf8a Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Sat, 3 Oct 2026 14:57:59 +0200 Subject: [PATCH 2/7] docs: record the trip rule of each perishable in ADR 0014 Explain why a perishable goes on the first trip when it keeps from there and on the latest trip in its window otherwise: home delivery first, then buy close to use. Record the rejected alternatives and the one case that drops a trip. Update the test count in AGENTS.md. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- adr/0014-multi-trip-shopping-planner.md | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 071ee34..91d2c3b 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/` | 1066 tests across 42 files | +| Run all tests | `cd menu_management && flutter test test/` | 1070 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 9d3d8fe..5b235f9 100644 --- a/adr/0014-multi-trip-shopping-planner.md +++ b/adr/0014-multi-trip-shopping-planner.md @@ -26,6 +26,22 @@ Perishable events are sorted by `latestWeek` ascending and processed greedily: r Non-perishable events are processed afterwards: each is placed on the earliest already-chosen trip that is on or before the event's `latestWeek`. If none exists (e.g., no perishables at all), trip 0 is added. This biases non-perishables toward early purchase rather than late, which matches user expectation (buy the pantry stuff up front). +### Trip of each perishable + +The greedy pass only picks the trip weeks. Freezing-required and non-perishable events can still add trip 0 after it. When the trip set is final, the planner gives each perishable event its trip again: + +- When the first trip is inside the window of the event, the event goes on the first trip. +- Otherwise the event goes on the latest trip inside its window. + +The reason is how the user shops. The first trip is usually a home delivery, so it takes everything that it keeps fresh. The later trips are small trips to the shop, for the perishables that do not keep from the delivery. On those trips the user buys each item as close to its use as possible. + +Rejected alternatives: + +- **The first chosen trip inside the window** (the greedy result): it can pick an in-between trip. Example: trips in week 0, 1 and 2, and a yogurt for day 15 with a window of [1, 2]. The yogurt went on trip 1, but the user goes to the shop for trip 2 anyway, and the yogurt is fresher from there. +- **Always the latest trip inside the window**: it moves items off the home delivery onto the small trips to the shop. The user rejected it. + +The assignment cannot add a trip, because each window already holds the trip that the greedy pass gave it. It can empty a trip in one case: trip 0 came after the greedy pass, and trip 0 keeps fresh every event of the first greedy trip. The plan then has one trip fewer. Only the first greedy trip can empty, because every later greedy trip holds the event that created it, and the window of that event does not hold an earlier trip. + When no trip can possibly serve a perishable event fresh (very short shelf life relative to cooking day), the planner falls back to the latest trip on or before the event day. The existing menu expiry warning (ADR 0010) continues to surface this to the user; the shopping list does not silently drop the item. ### Cross-week leftovers From 001da67d079bcd2bc95eefbd8e3925eb1a68cd8e Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Sat, 3 Oct 2026 15:03:54 +0200 Subject: [PATCH 3/7] fix: put pantry items on the first trip after the final trip set Non-perishables got their trip before the trip set was final, in the key order of the timeline. A pantry item could take a later greedy trip before another pantry item added trip 0. The final pass then moved the perishables of that later trip to trip 0, and the later trip stayed open for the pantry item alone. The user made an extra trip for an item that keeps forever, and the plan depended on map order. The non-perishable step now only decides whether trip 0 is needed. Each non-perishable gets the first trip after the trip set is final. Co-Authored-By: Claude Opus 5.5 --- .../lib/shopping/multi_trip_planner.dart | 38 +++++++--------- .../test/multi_trip_planner_test.dart | 43 +++++++++++++++++++ 2 files changed, 58 insertions(+), 23 deletions(-) diff --git a/menu_management/lib/shopping/multi_trip_planner.dart b/menu_management/lib/shopping/multi_trip_planner.dart index 2ccd5fa..94c93bb 100644 --- a/menu_management/lib/shopping/multi_trip_planner.dart +++ b/menu_management/lib/shopping/multi_trip_planner.dart @@ -57,8 +57,8 @@ class ShoppingTrip { /// /// Items whose unit has no matching product, or whose matching product has /// [Product.shelfLifeDaysClosed] = null, are treated as non-perishable: they -/// have no upper expiry constraint and ride along on the trips already chosen -/// by perishable items, only adding trip 0 if no other trip exists. +/// have no upper expiry constraint. The planner adds trip 0 when no chosen trip is on or before +/// the `latestWeek` of such an event. When the trip set is final, each non-perishable goes on the first trip. /// /// If an event cannot be served fresh by any prior trip (very short shelf life /// relative to the cooking day), the planner falls back to the latest trip on @@ -132,34 +132,26 @@ List planShoppingTrips({ event.assignedTrip = 0; } - // Non-perishables ride along on the earliest chosen trip that is on or before - // their event day. If no such trip exists, add trip 0 (so we never store a - // non-perishable beyond its event by accident, and so single-non-perishable - // menus end up with a single trip 0). - for (_PlanEvent event in nonPerishable) { - chosenTrips.sort(); - int? assigned; - for (int trip in chosenTrips) { - if (trip <= event.latestWeek) { - assigned = trip; - break; - } - } - if (assigned == null) { - assigned = 0; - if (!chosenTrips.contains(0)) chosenTrips.add(0); - } - event.assignedTrip = assigned; - } + // A non-perishable needs a trip on or before its event. When no chosen trip is that early, add trip 0. + // A menu with only non-perishables therefore ends up with trip 0 alone. + bool nonPerishableNeedsTripZero = nonPerishable.any((_PlanEvent event) => chosenTrips.every((int trip) => trip > event.latestWeek)); + if (nonPerishableNeedsTripZero && !chosenTrips.contains(0)) chosenTrips.add(0); // The trip set is now final. The greedy pass only picked the trips, so give each perishable its trip again: - // the first trip (usually a home delivery) when it keeps the item fresh, otherwise the latest trip in the window, - // to buy the item as close to its use as possible. + // the first trip (usually a home delivery) when the first trip is inside the window of the event. + // Otherwise the perishable goes on the latest trip inside the window, to buy the item as close to its use as possible. chosenTrips.sort(); for (_PlanEvent event in perishable) { event.assignedTrip = _tripForPerishable(event, chosenTrips: chosenTrips); } + // Every non-perishable goes on the first trip, so a later trip never stays open for a pantry item alone. + // The first trip is on or before the event of each non-perishable, because the check above added trip 0 when needed. + // When a freezing event exists, trip 0 is in the set, so the first trip is trip 0. + for (_PlanEvent event in nonPerishable) { + event.assignedTrip = chosenTrips.first; + } + return _aggregate(events: events, ingredientsById: ingredientsById); } diff --git a/menu_management/test/multi_trip_planner_test.dart b/menu_management/test/multi_trip_planner_test.dart index 0becdfa..697dae4 100644 --- a/menu_management/test/multi_trip_planner_test.dart +++ b/menu_management/test/multi_trip_planner_test.dart @@ -794,6 +794,49 @@ void main() { 0: ["milk days {10}", "salt days {3}"], }); }); + + test("puts a pantry item on the first trip when that trip appears after the pantry item got a later trip", () { + // Milk keeps 30 days and is used on day 10, so the greedy pass picks trip 1 for it. + // Salt comes before pepper in the timeline, so salt gets trip 1 before pepper adds trip 0. + // Trip 0 keeps the milk fresh, so the milk moves there. Salt must move too, or trip 1 buys only salt. + Ingredient milk = _ingredient(id: "milk", products: [_product(shelfLifeDaysClosed: 30)]); + Ingredient salt = _ingredient(id: "salt"); + Ingredient pepper = _ingredient(id: "pepper"); + Map> timeline = { + "milk": [_event(day: 10)], + "salt": [_event(day: 10)], + "pepper": [_event(day: 3)], + }; + + List trips = planShoppingTrips(cookingTimeline: timeline, ingredients: [milk, salt, pepper]); + + expect(_itemsByTrip(trips), { + 0: ["milk days {10}", "pepper days {3}", "salt days {10}"], + }); + }); + + test("puts a pantry item on the first trip when owned stock covers its early need", () { + // The owned salt covers the need of day 3, so only the salt need of day 10 is left, and it gets trip 1. + // Pepper then adds trip 0, and the milk moves there. Salt must move too, or trip 1 buys only salt. + Ingredient milk = _ingredient(id: "milk", products: [_product(shelfLifeDaysClosed: 30)]); + Ingredient salt = _ingredient(id: "salt"); + Ingredient pepper = _ingredient(id: "pepper"); + Map> timeline = { + "milk": [_event(day: 10)], + "salt": [_event(day: 3), _event(day: 10)], + "pepper": [_event(day: 3)], + }; + + List trips = planShoppingTrips( + cookingTimeline: timeline, + ingredients: [milk, salt, pepper], + ownedAmounts: const {"salt": OwnedStock(amount: 100, unit: Unit.grams)}, + ); + + expect(_itemsByTrip(trips), { + 0: ["milk days {10}", "pepper days {3}", "salt days {10}"], + }); + }); }); group("ShoppingTrip.dayForWeek", () { From 42fdcb694bde748f6617012353e8d226332cdce2 Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Sat, 3 Oct 2026 15:04:40 +0200 Subject: [PATCH 4/7] test: assert full trip items and cover one-trip mode The trip helper of the tests printed only the ingredient and its cook days. A wrong amount, a wrong unit or a lost freeze flag passed every test of the trip assignment. The helper now prints all of them. One-trip mode (assumeFreezerForFreezable) is the main place where trip 0 appears after the greedy pass, because a frozen item pins it. A new test covers it: a cheese that trip 0 keeps fresh joins the frozen chicken on trip 0, and the later greedy trip disappears. Without the final pass of each perishable, the plan keeps two trips, so the test guards the new behavior of this branch. Co-Authored-By: Claude Opus 5.5 --- .../test/multi_trip_planner_test.dart | 51 ++++++++++++++----- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/menu_management/test/multi_trip_planner_test.dart b/menu_management/test/multi_trip_planner_test.dart index 697dae4..fe44e09 100644 --- a/menu_management/test/multi_trip_planner_test.dart +++ b/menu_management/test/multi_trip_planner_test.dart @@ -66,9 +66,14 @@ double _gramsBoughtFor(List trips, Ingredient ingredient) { return total; } -/// Each trip week mapped to its items, written as "ingredientId days {cookDays}". +/// Each trip week mapped to its items, written as "ingredientId amount unit days {cookDays} freeze freezeOnArrival". Map> _itemsByTrip(List trips) { - return {for (ShoppingTrip trip in trips) trip.weekIndex: trip.items.map((TripItem item) => "${item.ingredientId} days ${item.cookDays}").toList()}; + return { + for (ShoppingTrip trip in trips) + trip.weekIndex: trip.items + .map((TripItem item) => "${item.ingredientId} ${item.amount} ${item.unit.name} days ${item.cookDays} freeze ${item.freezeOnArrival}") + .toList(), + }; } void main() { @@ -735,9 +740,9 @@ void main() { List trips = planShoppingTrips(cookingTimeline: timeline, ingredients: [meat, yogurt]); expect(_itemsByTrip(trips), { - 0: ["meat days {0}"], - 1: ["meat days {7}"], - 2: ["meat days {14}", "yogurt days {15}"], + 0: ["meat 100.0 grams days {0} freeze false"], + 1: ["meat 100.0 grams days {7} freeze false"], + 2: ["meat 100.0 grams days {14} freeze false", "yogurt 100.0 grams days {15} freeze false"], }); }); @@ -755,9 +760,9 @@ void main() { List trips = planShoppingTrips(cookingTimeline: timeline, ingredients: [meat, cheese, salt]); expect(_itemsByTrip(trips), { - 0: ["cheese days {15}", "salt days {2}"], - 1: ["meat days {7}"], - 2: ["meat days {14}"], + 0: ["cheese 100.0 grams days {15} freeze false", "salt 100.0 grams days {2} freeze false"], + 1: ["meat 100.0 grams days {7} freeze false"], + 2: ["meat 100.0 grams days {14} freeze false"], }); }); @@ -772,9 +777,9 @@ void main() { List trips = planShoppingTrips(cookingTimeline: timeline, ingredients: [meat, mushroom]); expect(_itemsByTrip(trips), { - 0: ["meat days {0}"], - 1: ["meat days {7}", "mushroom days {10}"], - 2: ["meat days {14}"], + 0: ["meat 100.0 grams days {0} freeze false"], + 1: ["meat 100.0 grams days {7} freeze false", "mushroom 100.0 grams days {10} freeze false"], + 2: ["meat 100.0 grams days {14} freeze false"], }); }); @@ -791,7 +796,25 @@ void main() { List trips = planShoppingTrips(cookingTimeline: timeline, ingredients: [milk, salt]); expect(_itemsByTrip(trips), { - 0: ["milk days {10}", "salt days {3}"], + 0: ["milk 100.0 grams days {10} freeze false", "salt 100.0 grams days {3} freeze false"], + }); + }); + + test("in one-trip mode, a perishable that trip 0 keeps fresh joins the frozen item on trip 0", () { + // Chicken keeps 3 days and is used on day 14, so it must be frozen and pins trip 0. + // Cheese keeps 30 days and is used on day 10, so the greedy pass picks trip 1 for it. Its window is [0, 1]. + // Trip 0 is inside the window of the cheese, so the cheese moves to trip 0 and trip 1 buys nothing. + Ingredient chicken = _ingredient(id: "chicken", products: [_product(shelfLifeDaysClosed: 3, canBeFrozen: true)]); + Ingredient cheese = _ingredient(id: "cheese", products: [_product(shelfLifeDaysClosed: 30)]); + Map> timeline = { + "chicken": [_event(day: 14, amount: 200)], + "cheese": [_event(day: 10)], + }; + + List trips = planShoppingTrips(cookingTimeline: timeline, ingredients: [chicken, cheese], assumeFreezerForFreezable: true); + + expect(_itemsByTrip(trips), { + 0: ["cheese 100.0 grams days {10} freeze false", "chicken 200.0 grams days {14} freeze true"], }); }); @@ -811,7 +834,7 @@ void main() { List trips = planShoppingTrips(cookingTimeline: timeline, ingredients: [milk, salt, pepper]); expect(_itemsByTrip(trips), { - 0: ["milk days {10}", "pepper days {3}", "salt days {10}"], + 0: ["milk 100.0 grams days {10} freeze false", "pepper 100.0 grams days {3} freeze false", "salt 100.0 grams days {10} freeze false"], }); }); @@ -834,7 +857,7 @@ void main() { ); expect(_itemsByTrip(trips), { - 0: ["milk days {10}", "pepper days {3}", "salt days {10}"], + 0: ["milk 100.0 grams days {10} freeze false", "pepper 100.0 grams days {3} freeze false", "salt 100.0 grams days {10} freeze false"], }); }); }); From cfddad927c43673add56fc5b10c596648daec08f Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Sat, 3 Oct 2026 15:05:05 +0200 Subject: [PATCH 5/7] docs: describe the trip window in the planner comments The doc comment of planShoppingTrips said that the planner finds the latest trip that gets the item fresh and that the greedy picks the minimum number of trips. The code checks the [earliestWeek, latestWeek] window, the greedy picks the trips for perishables only, and a trip 0 added later can make the plan bigger than needed. A fallback window can also hold a trip that does not keep the item fresh, so the comments now say "inside the window of the event" instead of "keeps it fresh". Co-Authored-By: Claude Opus 5.5 --- .../lib/shopping/multi_trip_planner.dart | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/menu_management/lib/shopping/multi_trip_planner.dart b/menu_management/lib/shopping/multi_trip_planner.dart index 94c93bb..62cb8cb 100644 --- a/menu_management/lib/shopping/multi_trip_planner.dart +++ b/menu_management/lib/shopping/multi_trip_planner.dart @@ -44,16 +44,18 @@ class ShoppingTrip { static int dayForWeek(int weekIndex) => weekIndex * 7 - 1; } -/// Plans a minimal set of shopping trips that respects sealed shelf life. +/// Plans the shopping trips of a menu, with few trips, and respects sealed shelf life. /// /// One trip is scheduled the day before each week it covers. For every cooking -/// event, the planner finds the latest trip that still gets the item fresh -/// (using the matching product's [Product.shelfLifeDaysClosed]). A greedy -/// interval point cover then picks the minimum number of trips that covers -/// every event. +/// event, the planner computes a window of trips `[earliestWeek, latestWeek]`. +/// `earliestWeek` is the earliest trip that keeps the item fresh (from the matching +/// product's [Product.shelfLifeDaysClosed]). `latestWeek` is the latest trip on or before the use. +/// A greedy interval point cover then picks the trips for the perishable events, +/// so that each window of a perishable event holds a chosen trip. /// /// After the trip set is final, each perishable event goes on the first trip when the first trip -/// keeps it fresh. Otherwise it goes on the latest trip that keeps it fresh, close to its use. +/// is inside the window of the event. Otherwise it goes on the latest trip inside the window, +/// so the user buys the item as close to its use as possible. /// /// Items whose unit has no matching product, or whose matching product has /// [Product.shelfLifeDaysClosed] = null, are treated as non-perishable: they @@ -62,7 +64,8 @@ class ShoppingTrip { /// /// If an event cannot be served fresh by any prior trip (very short shelf life /// relative to the cooking day), the planner falls back to the latest trip on -/// or before the event day. The matching menu warning surfaces this to the user. +/// or before the event day. The window then holds only that trip, which does not keep the item fresh. +/// The matching menu warning surfaces this to the user. /// /// [ownedAmounts] holds the user's owned stock per ingredient (one amount + one selected /// unit, or "packs"). It is drawn down via the shared [OwnedStockConsumer] (a single grams pool) From 0f40a1614487ce4a25002282bb3e34f5babad80b Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Sat, 3 Oct 2026 15:07:58 +0200 Subject: [PATCH 6/7] docs: limit the minimum-trips claim in ADR 0014 and 0015 Both ADRs said that the plan always has the minimum number of trips. The greedy is optimal only for the freshness windows of the perishables. A trip 0 that non-perishables or freezing events add later can make the plan bigger than needed, and the final assignment recovers one trip only in one case. ADR 0014 also records that non-perishables now go on the first trip after the trip set is final, and why the old order-dependent ride-along was rejected. Co-Authored-By: Claude Opus 5.5 --- adr/0014-multi-trip-shopping-planner.md | 8 +++++--- adr/0015-freezable-products-and-freezer-aware-trips.md | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/adr/0014-multi-trip-shopping-planner.md b/adr/0014-multi-trip-shopping-planner.md index 5b235f9..d4701a0 100644 --- a/adr/0014-multi-trip-shopping-planner.md +++ b/adr/0014-multi-trip-shopping-planner.md @@ -24,7 +24,9 @@ For each cooking event (per ingredient, per unit, per day) the planner computes Perishable events are sorted by `latestWeek` ascending and processed greedily: reuse a previously chosen trip if it falls within the event's window, otherwise add the event's `latestWeek` as a new chosen trip. This is the textbook minimum interval point cover. -Non-perishable events are processed afterwards: each is placed on the earliest already-chosen trip that is on or before the event's `latestWeek`. If none exists (e.g., no perishables at all), trip 0 is added. This biases non-perishables toward early purchase rather than late, which matches user expectation (buy the pantry stuff up front). +Non-perishable events are processed afterwards. When no chosen trip is on or before the `latestWeek` of such an event (e.g., no perishables at all), trip 0 is added. When the trip set is final, each non-perishable goes on the first trip. The first trip is always on or before the event, because the planner added trip 0 when no chosen trip was. This biases non-perishables toward early purchase rather than late, which matches user expectation (buy the pantry stuff up front). + +Rejected alternative: give each non-perishable the earliest chosen trip before the trip set is final. The result then depends on the key order of the timeline. A pantry item can take a later trip before another pantry item adds trip 0, and that later trip can stay open for the pantry item alone. ### Trip of each perishable @@ -40,7 +42,7 @@ Rejected alternatives: - **The first chosen trip inside the window** (the greedy result): it can pick an in-between trip. Example: trips in week 0, 1 and 2, and a yogurt for day 15 with a window of [1, 2]. The yogurt went on trip 1, but the user goes to the shop for trip 2 anyway, and the yogurt is fresher from there. - **Always the latest trip inside the window**: it moves items off the home delivery onto the small trips to the shop. The user rejected it. -The assignment cannot add a trip, because each window already holds the trip that the greedy pass gave it. It can empty a trip in one case: trip 0 came after the greedy pass, and trip 0 keeps fresh every event of the first greedy trip. The plan then has one trip fewer. Only the first greedy trip can empty, because every later greedy trip holds the event that created it, and the window of that event does not hold an earlier trip. +The assignment cannot add a trip, because each window already holds the trip that the greedy pass gave it. It can empty a trip in one case: trip 0 came after the greedy pass, and trip 0 is inside the window of every perishable event of the first greedy trip. The non-perishables of that trip also move to trip 0, because each non-perishable goes on the first trip. The plan then has one trip fewer. Only the first greedy trip can empty, because every later greedy trip holds the event that created it, and the window of that event does not hold an earlier trip. When no trip can possibly serve a perishable event fresh (very short shelf life relative to cooking day), the planner falls back to the latest trip on or before the event day. The existing menu expiry warning (ADR 0010) continues to surface this to the user; the shopping list does not silently drop the item. @@ -84,7 +86,7 @@ One floating Export button replaced the floating copy button. It opens `showExpo ## Consequences - The user can now produce a "buy these things on this trip" plan with one click, without having to mentally split the menu themselves. -- The greedy is optimal for minimum trip count under the week-boundary trip schedule: standard interval point cover. There is no smaller set of trips that respects every event's freshness window. +- The greedy is optimal for the freshness windows of the perishables alone: standard interval point cover. No smaller set of trips holds a trip inside every perishable window. A trip 0 that non-perishables or freezing events add later can make the plan bigger than needed. The final assignment recovers one trip in the case that "Trip of each perishable" describes. The planner does not search for the smallest plan overall. - Trips are weekly-only by construction. If the user has a real-world cadence like "I shop on Wednesday and Saturday", the planner cannot match it. Adding configurable trip days would mean exposing trip schedules in the UI; deferred until requested. - The planner originally used the first-matching-unit product for shelf life. ADR 0015 promoted this to an any-match: the longest sealed shelf life among same-unit variants drives trip planning, and any freezable variant makes the ingredient freezable for the freezer-aware mode. The shopping page's pack-display code (`products.first`) is unaffected; only the planner's shelf-life and freezable lookups changed. - Non-perishables defaulting to trip 0 means a menu of only non-perishables produces a single trip 0, matching the prior single-trip behavior exactly. diff --git a/adr/0015-freezable-products-and-freezer-aware-trips.md b/adr/0015-freezable-products-and-freezer-aware-trips.md index c484d8f..3221ec8 100644 --- a/adr/0015-freezable-products-and-freezer-aware-trips.md +++ b/adr/0015-freezable-products-and-freezer-aware-trips.md @@ -40,7 +40,7 @@ Red trumps blue: a meal with one impossible ingredient and one freeze-required i The previous shopping page toggle (`_ensureFreshness`) had two states: off = single flat list ignoring shelf life, on = multi-trip splitting by sealed shelf life. The "ignore shelf life" mode is dropped entirely. The new toggle is labeled **"Try to make one trip"** in the AppBar (internal flag `_useFreezerStrategy`). The two states are: -- **Off (Multi-trip mode)**: identical to ADR 0014's "on" behavior. The planner picks a minimum set of weekly trips so every event is within sealed shelf life. Freezing flags are ignored. Tooltip: "shop multiple times so nothing expires before cooking". +- **Off (Multi-trip mode)**: identical to ADR 0014's "on" behavior. The planner picks weekly trips so every event is within sealed shelf life (ADR 0014 says when the set is the smallest). Freezing flags are ignored. Tooltip: "shop multiple times so nothing expires before cooking". - **On (One-trip mode)**: every event whose matching product is freezable (any-match across same-unit variants) is treated as non-perishable for trip assignment AND pinned to trip 0. Such an event is also tagged `freezeOnArrival = true` when its sealed shelf life would not have covered the gap from trip 0 to the cooking day (i.e. the user actually has to freeze it). Non-freezable perishables continue to flow through the original interval-cover algorithm and may still force later trips when their shelf life is exceeded. Tooltip: "shop once and freeze items that would otherwise expire". The detailed export (and the on-screen banner) reflect the active mode, and the detailed export is sectioned by trip in both modes. When `freezeOnArrival = true` for an aggregated `TripItem`, the corresponding line gets a `(freeze on arrival)` suffix. The simplified export writes no trip section, but it keeps that same suffix: without it the one-trip plan cannot be followed safely, because the plan assumes the user freezes those items on the day of the trip. `computeFreezeOnArrivalIngredientIds` in `shopping_copy_text.dart` names the ingredients to freeze, and both formats read it from the same trip plan. It holds one flag per ingredient: one trip that freezes an ingredient marks every line of that ingredient in the simplified text, because the reader of that text buys every batch on day one. @@ -63,4 +63,4 @@ Aggregation collapses events with the same `(ingredient, unit, trip)` and OR-com - Red-trumps-blue tooltip rendering may surprise users when a meal has both impossible and freeze-required ingredients. The tooltip mentions the freeze-required ones explicitly so the user can still tell which ones are recoverable. A two-icon cluster (red + blue side by side) was considered but not adopted, to avoid clutter on small meal cards. - Shopping copy lines for ingredients with multiple product variants get the `(freeze on arrival)` suffix at the ingredient header, not per pack-size sub-line. This matches the per-`TripItem` granularity of the planner output. - The single-shopping-trip assumption documented in ADR 0010's last paragraph still holds for the menu warning's `mayBeExpiredOnDay` calculation. Freezing changes the warning's color, not when it fires. -- The planner's interval-cover greedy is unchanged for non-freezable perishables, so all existing ADR 0014 properties (minimum trips for the freshness constraint, weekly-only trip schedule, fallback to closest trip on or before the event when no trip can serve it fresh) remain true in the off mode. +- The planner's interval-cover greedy is unchanged for non-freezable perishables, so all existing ADR 0014 properties (minimum trips for the freshness windows of the perishables, weekly-only trip schedule, fallback to closest trip on or before the event when no trip can serve it fresh) remain true in the off mode. In the on mode, the trip 0 that freezing events pin can make the plan bigger than needed. The final assignment of ADR 0014 then moves to trip 0 each perishable whose window holds trip 0, and every non-perishable, so the plan can lose one trip. From 3d48c534cac77fe71d69c19d239b005c05509680 Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Sat, 3 Oct 2026 15:08:39 +0200 Subject: [PATCH 7/7] docs: update the test count in AGENTS.md The review fixes of this branch add three trip planner tests, so the suite now has 1073 tests and the command table showed a stale number. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 91d2c3b..1cc74b6 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/` | 1070 tests across 42 files | +| Run all tests | `cd menu_management && flutter test test/` | 1073 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` |