From 6677acaa8344cdca47b70e1ae4da76df078361b8 Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Fri, 2 Oct 2026 19:33:49 +0200 Subject: [PATCH 01/10] fix: name the trip on a shopping row bought only on a later trip A product that only a later shopping trip buys showed a plain "Buy 2 pieces", which reads as "buy it now". The user then bought a perishable too early, for example a lettuce that a week 3 meal uses. The per-trip list now keeps a single purchase when it is not the first trip, and the row adds that trip's label (the same shoppingTripLabel that the per-trip lines use). A product that only the first trip buys still shows the plain total. The single line keeps the under-buy count: it shows packsToBuy minus one, not the purchase's full round-up, so the warning chip and the count agree. Closes #73 Co-Authored-By: Claude Opus 5.5 --- .../lib/shopping/shopping_ingredient.dart | 11 ++-- .../lib/shopping/shopping_product_row.dart | 23 +++++--- .../test/shopping_ingredient_test.dart | 15 ++++++ .../test/shopping_product_row_test.dart | 53 +++++++++++++++++++ 4 files changed, 91 insertions(+), 11 deletions(-) diff --git a/menu_management/lib/shopping/shopping_ingredient.dart b/menu_management/lib/shopping/shopping_ingredient.dart index 5c5a4d9..5d1f39a 100644 --- a/menu_management/lib/shopping/shopping_ingredient.dart +++ b/menu_management/lib/shopping/shopping_ingredient.dart @@ -109,7 +109,8 @@ class ShoppingIngredient extends StatefulWidget { final List sources; /// Planned shopping trips for the whole menu. When 2+ trips buy this ingredient, - /// each product row shows the per-trip buy split instead of a single total. + /// each product row shows the per-trip buy split instead of a single total. When only a later + /// trip buys it, the single total names that trip. final List plannedTrips; /// First day of the menu. It turns the per-trip buy lines into real shopping dates. @@ -267,7 +268,8 @@ class _ShoppingIngredientState extends State { /// same unit and prints the waste-minimal mix. An ingredient with two products of the same unit /// can therefore read differently on the page and in the copy. /// - /// Returns an empty list (single-total display) unless 2+ trips actually buy this product. + /// Returns an empty list (plain single-total display) when no trip buys this product, or when + /// only the first trip buys it. One later trip alone gives a one-entry list. List _tripPurchasesForProduct(Product product) { if (widget.plannedTrips.length < 2) return const []; int firstWeek = widget.plannedTrips.first.weekIndex; // trips are sorted ascending by the planner @@ -285,8 +287,9 @@ class _ShoppingIngredientState extends State { if (packs <= 0) continue; purchases.add(ProductTripPurchase(weekIndex: allocation.weekIndex, packs: packs, isFirstTrip: allocation.weekIndex == firstWeek)); } - // A row is a "split" only when 2+ trips actually buy this product. - if (purchases.length < 2) return const []; + // A row is a "split" when 2+ trips buy this product. One later trip stays in the list, so the + // row names that trip. The first trip alone means "buy it now", so the row shows the plain total. + if (purchases.length == 1 && purchases.single.isFirstTrip) return const []; return purchases; } diff --git a/menu_management/lib/shopping/shopping_product_row.dart b/menu_management/lib/shopping/shopping_product_row.dart index 3074f0f..3cb4ca3 100644 --- a/menu_management/lib/shopping/shopping_product_row.dart +++ b/menu_management/lib/shopping/shopping_product_row.dart @@ -40,7 +40,8 @@ class ShoppingProductRow extends StatefulWidget { final int packsToBuy; /// Per-trip split of [packsToBuy]. When it has 2+ entries the buy area shows one line per - /// trip (e.g. "Buy 6 packs now" + "3 packs week 2"); otherwise it shows a single total. + /// trip (e.g. "Buy 6 packs now" + "+ 3 packs Week 2"). With 1 entry it shows the single total + /// with that trip's label (e.g. "Buy 3 packs Week 2"). When it is empty it shows the plain total. final List tripPurchases; /// First day of the menu. When it is set, a later trip line names its real shopping date @@ -91,21 +92,25 @@ class _ShoppingProductRowState extends State { /// is actually bought when owned stock did not change the count, which holds exactly when /// `packsToBuy == recommendation.packsNeeded + 1` (packsNeeded is already the reduced count). /// Also suppressed in the multi-trip split (2+ purchases), where the per-trip lines render the - /// full round-up, so a "buying less" chip would contradict them. + /// full round-up, so a "buying less" chip would contradict them. A single trip purchase keeps + /// the reduction, because its line shows [packsToBuy] minus one, not the purchase's packs. bool get _appliesUnderBuy => widget.recommendation.underBuy && widget.tripPurchases.length < 2 && widget.packsToBuy == widget.recommendation.packsNeeded + 1; String _tripPurchaseLabel(ProductTripPurchase purchase, {required bool isFirstLine}) { String prefix = isFirstLine ? "Buy" : "+"; - // shoppingTripLabel owns the whole rule: "now" for the first trip, a real date after it, - // and "Week N" without a start date. - String when = shoppingTripLabel( + return "$prefix ${purchase.packs} ${_packWord(purchase.packs)} ${_tripWhen(purchase)}"; + } + + /// Names the trip of [purchase]. shoppingTripLabel owns the whole rule: "now" for the first + /// trip, a real date after it, and "Week N" without a start date. + String _tripWhen(ProductTripPurchase purchase) { + return shoppingTripLabel( startDate: widget.startDate, weekIndex: purchase.weekIndex, tripDay: ShoppingTrip.dayForWeek(purchase.weekIndex), isFirstTrip: purchase.isFirstTrip, ); - return "$prefix ${purchase.packs} ${_packWord(purchase.packs)} $when"; } String _wasteBreakdown() { @@ -283,7 +288,11 @@ class _ShoppingProductRowState extends State { ], ) : Text( - "Buy $effectivePacksToBuy ${_packWord(effectivePacksToBuy)}", + // A single trip purchase names its trip. The count stays effectivePacksToBuy, + // so the under-buy reduction applies to this line too. + widget.tripPurchases.length == 1 + ? "Buy $effectivePacksToBuy ${_packWord(effectivePacksToBuy)} ${_tripWhen(widget.tripPurchases.single)}" + : "Buy $effectivePacksToBuy ${_packWord(effectivePacksToBuy)}", style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold), textAlign: TextAlign.right, ), diff --git a/menu_management/test/shopping_ingredient_test.dart b/menu_management/test/shopping_ingredient_test.dart index 2fd75f7..c44986e 100644 --- a/menu_management/test/shopping_ingredient_test.dart +++ b/menu_management/test/shopping_ingredient_test.dart @@ -250,6 +250,21 @@ void main() { expect(find.textContaining("now"), findsNothing); }); + testWidgets("names the later trip when it is the only trip that buys the product", (WidgetTester tester) async { + // The first trip buys a different ingredient, so only the week 2 trip buys this product. + await _pumpIngredient( + tester, + remainingGrams: 600, + plannedTrips: [ + _trip(0, [_item(ingredientId: "rice", amount: 600)]), + _trip(1, [_item(amount: 600)]), + ], + ); + + expect(find.text("Buy 6 packs Week 2"), findsOneWidget); + expect(find.text("Buy 6 packs"), findsNothing); + }); + testWidgets("an ingredient with products shows a per-product owned input, not the header input", (WidgetTester tester) async { double? reportedIndex; double? reportedCount; diff --git a/menu_management/test/shopping_product_row_test.dart b/menu_management/test/shopping_product_row_test.dart index 771da30..7a1c23b 100644 --- a/menu_management/test/shopping_product_row_test.dart +++ b/menu_management/test/shopping_product_row_test.dart @@ -221,6 +221,33 @@ void main() { expect(find.text("Buy 2 pieces now"), findsOneWidget); expect(find.text("+ 1 piece Week 3"), findsOneWidget); }); + + testWidgets("names the trip when the only trip purchase is a later trip", (WidgetTester tester) async { + const Product piecesProduct = Product(link: "", quantityPerItem: 1, itemsPerPack: 1, unit: Unit.pieces); + await _pumpRow( + tester, + product: piecesProduct, + packsToBuy: 2, + tripPurchases: const [ProductTripPurchase(weekIndex: 2, packs: 2, isFirstTrip: false)], + ); + + expect(find.text("Buy 2 pieces Week 3"), findsOneWidget); + // The plain total would tell the user to buy it now. + expect(find.text("Buy 2 pieces"), findsNothing); + }); + + testWidgets("names the shopping date of a single later trip when the menu has a start date", (WidgetTester tester) async { + Product product = _packProduct(); + await _pumpRow( + tester, + product: product, + packsToBuy: 3, + startDate: DateTime(2025, 8, 6), + tripPurchases: const [ProductTripPurchase(weekIndex: 1, packs: 3, isFirstTrip: false)], + ); + + expect(find.text("Buy 3 packs Tuesday 12 Aug"), findsOneWidget); + }); }); group("ShoppingProductRow under-buy warning", () { @@ -306,5 +333,31 @@ void main() { expect(find.text("+ 1 pack Week 2"), findsOneWidget); expect(find.textContaining("short"), findsNothing); }); + + testWidgets("buys one pack less on a single later-trip line and keeps the warning chip", (WidgetTester tester) async { + Product product = _packProduct(); + ProductRecommendation underBuy = ProductRecommendation( + product: product, + packsNeeded: 2, + overBuyWaste: 0, + expiryWaste: 0, + isViable: true, + underBuy: true, + shortfall: 100, + ); + + // The trip purchase holds the full round-up (3 packs). The row must still drop one pack. + await _pumpRow( + tester, + product: product, + packsToBuy: 3, + recommendation: underBuy, + tripPurchases: const [ProductTripPurchase(weekIndex: 1, packs: 3, isFirstTrip: false)], + ); + + expect(find.text("100 grams short"), findsOneWidget); + expect(find.text("Buy 2 packs Week 2"), findsOneWidget); + expect(find.text("Buy 3 packs Week 2"), findsNothing); + }); }); } From 0963add1548fbe4d5be6bc2e4a5ce92d705e3a56 Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Fri, 2 Oct 2026 19:33:50 +0200 Subject: [PATCH 02/10] docs: update the test count in AGENTS.md The fix for #73 adds 4 tests. 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 c55a031..3361e33 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/` | 1053 tests across 42 files | +| Run all tests | `cd menu_management && flutter test test/` | 1057 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` | From e710c8285c3ec6bdfd9affe17df6ca2066a0a316 Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Fri, 2 Oct 2026 19:40:12 +0200 Subject: [PATCH 03/10] fix: name the earliest trip that needs a product bought on one trip The largest-remainder split can round a small early need down to 0 and move the whole buy to a later trip. The row then sent the user to that later trip, although a recipe before it still needs the product. A row that one trip buys now names the earliest trip that holds a trip item of the ingredient. When that trip is the first trip of the plan, the row shows the plain total, the same as a one-trip plan. Co-Authored-By: Claude Opus 5.5 --- .../lib/shopping/shopping_ingredient.dart | 24 ++++++++++---- .../test/shopping_ingredient_test.dart | 33 +++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/menu_management/lib/shopping/shopping_ingredient.dart b/menu_management/lib/shopping/shopping_ingredient.dart index 5d1f39a..f1efe44 100644 --- a/menu_management/lib/shopping/shopping_ingredient.dart +++ b/menu_management/lib/shopping/shopping_ingredient.dart @@ -268,8 +268,14 @@ class _ShoppingIngredientState extends State { /// same unit and prints the waste-minimal mix. An ingredient with two products of the same unit /// can therefore read differently on the page and in the copy. /// - /// Returns an empty list (plain single-total display) when no trip buys this product, or when - /// only the first trip buys it. One later trip alone gives a one-entry list. + /// Returns an empty list (plain single-total display) when the plan has fewer than 2 trips, when + /// no trip buys this product, or when the earliest trip of the plan holds this ingredient and the + /// split gives the product to one trip only. One trip alone otherwise gives a one-entry list. + /// + /// The one-entry list names the earliest trip that holds a [TripItem] of this ingredient, not + /// the trip that the split picked. The largest-remainder split can round a small early need down + /// to 0 and move the whole buy to a later trip. A recipe before that later trip still needs the + /// product, so the row must name the earlier trip. List _tripPurchasesForProduct(Product product) { if (widget.plannedTrips.length < 2) return const []; int firstWeek = widget.plannedTrips.first.weekIndex; // trips are sorted ascending by the planner @@ -287,10 +293,16 @@ class _ShoppingIngredientState extends State { if (packs <= 0) continue; purchases.add(ProductTripPurchase(weekIndex: allocation.weekIndex, packs: packs, isFirstTrip: allocation.weekIndex == firstWeek)); } - // A row is a "split" when 2+ trips buy this product. One later trip stays in the list, so the - // row names that trip. The first trip alone means "buy it now", so the row shows the plain total. - if (purchases.length == 1 && purchases.single.isFirstTrip) return const []; - return purchases; + // A row is a "split" when 2+ trips buy this product. + if (purchases.length != 1) return purchases; // The split weighs each trip by the grams of all its items of this ingredient, whatever their + // unit, so the earliest trip is found by ingredient and not by the unit of this product. + // One purchase means that at least one trip holds this ingredient, so firstWhere finds a trip. + ShoppingTrip earliestTrip = widget.plannedTrips.firstWhere( + (ShoppingTrip trip) => trip.items.any((TripItem item) => item.ingredientId == widget.ingredient.id), + ); + // The earliest trip of the plan alone gives the plain total, the same as a one-trip plan. + if (earliestTrip.weekIndex == firstWeek) return const []; + return [ProductTripPurchase(weekIndex: earliestTrip.weekIndex, packs: purchases.single.packs, isFirstTrip: false)]; } /// Returns the indexes inside [group] of the equivalent members whose row renders. diff --git a/menu_management/test/shopping_ingredient_test.dart b/menu_management/test/shopping_ingredient_test.dart index c44986e..d3755b5 100644 --- a/menu_management/test/shopping_ingredient_test.dart +++ b/menu_management/test/shopping_ingredient_test.dart @@ -265,6 +265,39 @@ void main() { expect(find.text("Buy 6 packs"), findsNothing); }); + testWidgets("shows the plain total when the rounding moves the whole buy off a first trip that needs it", (WidgetTester tester) async { + // Week 1 needs 0.4 grams, so the shared split gives it none of the 600 grams. A week 1 recipe + // still needs the product, so the row must not send the user to the week 2 trip. + await _pumpIngredient( + tester, + remainingGrams: 600, + plannedTrips: [ + _trip(0, [_item(amount: 0.4)]), + _trip(1, [_item(amount: 600)]), + ], + ); + + expect(find.text("Buy 6 packs"), findsOneWidget); + expect(find.text("Buy 6 packs Week 2"), findsNothing); + }); + + testWidgets("names the earliest trip that needs the product when the rounding moves the buy to a later trip", (WidgetTester tester) async { + // The first trip buys only rice. Week 2 needs 0.4 grams, so the shared split gives the whole + // 600 grams to week 3. The row must name the week 2 trip, because a week 2 recipe needs it. + await _pumpIngredient( + tester, + remainingGrams: 600, + plannedTrips: [ + _trip(0, [_item(ingredientId: "rice", amount: 600)]), + _trip(1, [_item(amount: 0.4)]), + _trip(2, [_item(amount: 600)]), + ], + ); + + expect(find.text("Buy 6 packs Week 2"), findsOneWidget); + expect(find.text("Buy 6 packs Week 3"), findsNothing); + }); + testWidgets("an ingredient with products shows a per-product owned input, not the header input", (WidgetTester tester) async { double? reportedIndex; double? reportedCount; From 57759df59a63ced7b310c47ed88cfb7143239bbe Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Fri, 2 Oct 2026 19:42:57 +0200 Subject: [PATCH 04/10] fix: name the single later trip on equivalent-product rows Rows of an equivalent group (one-of-each cycle) always got an empty trip list, so they showed a plain total even when only a later trip buys the ingredient. The user then read "buy it now" for food that the plan buys on a later trip. When one later trip buys the whole cover of the group, each visible member row now gets one trip entry with its own cycled share, so it prints its count and the trip label. A 2+ trip split still gives the member rows the plain share, because a per-trip split of the solo count would contradict the one-of-each share. Co-Authored-By: Claude Opus 5.5 --- .../lib/shopping/shopping_ingredient.dart | 20 ++++-- .../test/shopping_ingredient_test.dart | 61 ++++++++++++++++++- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/menu_management/lib/shopping/shopping_ingredient.dart b/menu_management/lib/shopping/shopping_ingredient.dart index f1efe44..6a828a4 100644 --- a/menu_management/lib/shopping/shopping_ingredient.dart +++ b/menu_management/lib/shopping/shopping_ingredient.dart @@ -429,7 +429,8 @@ class _ShoppingIngredientState extends State { /// /// For an equivalent group of 2+ members the buy count is the cycled share: the group's /// solo cover ([_packsToBuyForProduct], computed from the still-needed amount so it reflects - /// owned stock) split one-of-each via [distributeEquivalentPacks]. Non-equivalent products + /// owned stock) split one-of-each via [distributeEquivalentPacks]. A member row gets no per-trip + /// split, but it names the trip when one later trip buys the whole cover. Non-equivalent products /// (different pack size, shelf life, ...) keep their solo count and the per-trip split. /// /// Every rendered row also hosts its own "Owned" input (per-product owned counts), including the @@ -464,6 +465,11 @@ class _ShoppingIngredientState extends State { List visibleMembers = isCombinedGroup ? _visibleMemberIndexes(group: group, cycledShares: cycledShares) : const []; + // The solo trip purchases of the group's cover. One entry means that one later trip buys the + // whole cover, so each member row names that trip. All members share one unit. + List groupTripPurchases = isCombinedGroup ? _tripPurchasesForProduct(group.first.value) : const []; + ProductTripPurchase? groupSingleTrip = groupTripPurchases.length == 1 ? groupTripPurchases.single : null; + bool isFirstVisibleInGroup = true; for (int memberIndex = 0; memberIndex < group.length; memberIndex++) { int productIndex = group[memberIndex].key; @@ -494,9 +500,15 @@ class _ShoppingIngredientState extends State { recommendation: recommendation, isBestOption: bestWaste != null && recommendation.totalWaste == bestWaste, packsToBuy: packsToBuy, - // The one-of-each cycle already splits an equivalent group; a per-trip split on top - // would show the wrong (solo) counts, so it is only used for standalone products. - tripPurchases: isCombinedGroup ? const [] : _tripPurchasesForProduct(product), + // The one-of-each cycle already splits an equivalent group. A per-trip split on top + // would show the solo counts, so a member row gets no 2+ split. When one later trip buys + // the whole cover, the member row gets one entry with its own cycled share, so the row + // names that trip. + tripPurchases: !isCombinedGroup + ? _tripPurchasesForProduct(product) + : groupSingleTrip != null && packsToBuy > 0 + ? [ProductTripPurchase(weekIndex: groupSingleTrip.weekIndex, packs: packsToBuy, isFirstTrip: groupSingleTrip.isFirstTrip)] + : const [], startDate: widget.startDate, ownedCount: widget.ownedProductCounts[productIndex] ?? 0, onOwnedCountChanged: (double count) => widget.onProductOwnedChanged(productIndex, count), diff --git a/menu_management/test/shopping_ingredient_test.dart b/menu_management/test/shopping_ingredient_test.dart index d3755b5..7c37f19 100644 --- a/menu_management/test/shopping_ingredient_test.dart +++ b/menu_management/test/shopping_ingredient_test.dart @@ -39,6 +39,7 @@ Future _pumpProducts( Map ownedProductCounts = const {}, double ownedAmount = 0, void Function(double amount, OwnedUnit unit)? onOwnedChanged, + List plannedTrips = const [], }) async { await tester.pumpWidget( MaterialApp( @@ -61,7 +62,7 @@ Future _pumpProducts( ownedProductCounts: ownedProductCounts, onProductOwnedChanged: (int productIndex, double count) {}, sources: const [], - plannedTrips: const [], + plannedTrips: plannedTrips, ), ), ), @@ -751,6 +752,64 @@ void main() { expect(find.text("and"), findsOneWidget); expect(find.text("or"), findsNothing); }); + + testWidgets("names the single later trip on each row of an equivalent group", (WidgetTester tester) async { + // Two equivalent 500 g variants, need 1000 g -> 2 packs total -> 1 pack each. The first trip + // buys only rice, so only the week 2 trip buys this ingredient. + await _pumpProducts( + tester, + products: [_equivProduct("a"), _equivProduct("b")], + remainingGrams: 1000, + plannedTrips: [ + _trip(0, [_item(ingredientId: "rice", amount: 1000)]), + _trip(1, [_item(ingredientId: "pizza", amount: 1000)]), + ], + ); + + expect(find.text("Buy 1 pack Week 2"), findsNWidgets(2)); + expect(find.text("Buy 1 pack"), findsNothing); + expect(find.text("and"), findsOneWidget); + }); + + testWidgets("keeps the plain cycled share on an equivalent group that 2 trips buy", (WidgetTester tester) async { + // Need 1000 g -> 1 pack each. Both trips buy the ingredient, and a per-trip split of the solo + // count would contradict the one-of-each share, so each row keeps the plain share. + await _pumpProducts( + tester, + products: [_equivProduct("a"), _equivProduct("b")], + remainingGrams: 1000, + plannedTrips: [ + _trip(0, [_item(ingredientId: "pizza", amount: 500)]), + _trip(1, [_item(ingredientId: "pizza", amount: 500)]), + ], + ); + + expect(find.text("Buy 1 pack"), findsNWidgets(2)); + expect(find.text("Buy 1 pack now"), findsNothing); + expect(find.text("+ 1 pack Week 2"), findsNothing); + }); + + testWidgets("buys one pack less on each single later-trip row of an equivalent group with an under-buy", (WidgetTester tester) async { + // Need 2000 g -> 4 packs total -> 2 packs each. Each recommendation drops one mostly-empty + // pack (packsNeeded 1), so each row buys 1 pack on the week 2 trip and shows the chip. + List products = [_equivProduct("a"), _equivProduct("b")]; + ProductRecommendation underBuy(Product product) => + ProductRecommendation(product: product, packsNeeded: 1, overBuyWaste: 0, expiryWaste: 0, isViable: true, underBuy: true, shortfall: 100); + await _pumpProducts( + tester, + products: products, + remainingGrams: 2000, + recommendations: [underBuy(products[0]), underBuy(products[1])], + plannedTrips: [ + _trip(0, [_item(ingredientId: "rice", amount: 2000)]), + _trip(1, [_item(ingredientId: "pizza", amount: 2000)]), + ], + ); + + expect(find.text("Buy 1 pack Week 2"), findsNWidgets(2)); + expect(find.text("Buy 2 packs Week 2"), findsNothing); + expect(find.text("100 grams short"), findsNWidgets(2)); + }); }); group("ShoppingIngredient freeze on arrival note", () { From dfcc070af37e5048a96907583cf34740d1ce2a38 Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Fri, 2 Oct 2026 19:46:11 +0200 Subject: [PATCH 05/10] test: pin the single later-trip label with exact outputs The checks for a missing trip label searched for "week", but labels write "Week", so they could never fail. They now name the exact wrong rows, such as "Buy 6 packs now" and "Buy 6 packs Week 2". New tests cover the real-date label of a single later trip, a count that owned stock lowers, and parity with the copied list: the row names the same trip that the copy puts the product under. Co-Authored-By: Claude Opus 5.5 --- .../test/shopping_ingredient_test.dart | 77 +++++++++++++++++-- 1 file changed, 71 insertions(+), 6 deletions(-) diff --git a/menu_management/test/shopping_ingredient_test.dart b/menu_management/test/shopping_ingredient_test.dart index 7c37f19..2625135 100644 --- a/menu_management/test/shopping_ingredient_test.dart +++ b/menu_management/test/shopping_ingredient_test.dart @@ -6,8 +6,10 @@ import "package:menu_management/ingredients/models/ingredient.dart"; import "package:menu_management/ingredients/models/product.dart"; import "package:menu_management/recipes/enums/unit.dart"; import "package:menu_management/recipes/models/quantity.dart"; +import "package:menu_management/menu/menu_dates.dart"; import "package:menu_management/shopping/multi_trip_planner.dart"; import "package:menu_management/shopping/owned_amount.dart"; +import "package:menu_management/shopping/shopping_copy_text.dart"; import "package:menu_management/shopping/shopping_ingredient.dart"; import "package:menu_management/shopping/waste_optimizer.dart"; @@ -81,6 +83,7 @@ Future _pumpIngredient( required double remainingGrams, required List plannedTrips, List combinationRecommendations = const [], + DateTime? startDate, }) async { await tester.pumpWidget( MaterialApp( @@ -98,6 +101,7 @@ Future _pumpIngredient( onProductOwnedChanged: (int productIndex, double count) {}, sources: const [], plannedTrips: plannedTrips, + startDate: startDate, ), ), ), @@ -230,8 +234,9 @@ void main() { ); expect(find.text("Buy 6 packs"), findsOneWidget); - expect(find.textContaining("week"), findsNothing); - expect(find.textContaining("now"), findsNothing); + // A trip label on the first trip would tell the user nothing new. + expect(find.text("Buy 6 packs now"), findsNothing); + expect(find.text("Buy 6 packs Week 1"), findsNothing); }); testWidgets("shows no split when 3 trips exist but only 1 buys the product", (WidgetTester tester) async { @@ -247,8 +252,9 @@ void main() { ); expect(find.text("Buy 6 packs"), findsOneWidget); - expect(find.textContaining("week"), findsNothing); - expect(find.textContaining("now"), findsNothing); + // A trip label on the first trip would tell the user nothing new. + expect(find.text("Buy 6 packs now"), findsNothing); + expect(find.text("Buy 6 packs Week 1"), findsNothing); }); testWidgets("names the later trip when it is the only trip that buys the product", (WidgetTester tester) async { @@ -266,6 +272,37 @@ void main() { expect(find.text("Buy 6 packs"), findsNothing); }); + testWidgets("names the shopping date of the single later trip when the menu has a start date", (WidgetTester tester) async { + // Menu day 0 is Wednesday 6 Aug 2025, so the week 2 trip falls on Tuesday 12 Aug. + await _pumpIngredient( + tester, + remainingGrams: 600, + startDate: DateTime(2025, 8, 6), + plannedTrips: [ + _trip(0, [_item(ingredientId: "rice", amount: 600)]), + _trip(1, [_item(amount: 600)]), + ], + ); + + expect(find.text("Buy 6 packs Tuesday 12 Aug"), findsOneWidget); + expect(find.text("Buy 6 packs Week 2"), findsNothing); + }); + + testWidgets("counts the single later trip from the remaining after owned stock", (WidgetTester tester) async { + // The recipes need 900 grams, and owned stock leaves 300 grams to buy, all on the week 2 trip. + await _pumpIngredient( + tester, + remainingGrams: 300, + plannedTrips: [ + _trip(0, [_item(ingredientId: "rice", amount: 600)]), + _trip(1, [_item(amount: 300)]), + ], + ); + + expect(find.text("Buy 3 packs Week 2"), findsOneWidget); + expect(find.text("Buy 9 packs Week 2"), findsNothing); + }); + testWidgets("shows the plain total when the rounding moves the whole buy off a first trip that needs it", (WidgetTester tester) async { // Week 1 needs 0.4 grams, so the shared split gives it none of the 600 grams. A week 1 recipe // still needs the product, so the row must not send the user to the week 2 trip. @@ -379,8 +416,9 @@ void main() { ); expect(find.text("Buy 6 packs"), findsOneWidget); - expect(find.textContaining("week"), findsNothing); - expect(find.textContaining("now"), findsNothing); + // Week 2 gets no grams, so the row must neither split nor name the week 2 trip. + expect(find.text("Buy 6 packs now"), findsNothing); + expect(find.text("Buy 6 packs Week 2"), findsNothing); }); testWidgets("splits the page total the same way the copied list splits it", (WidgetTester tester) async { @@ -398,6 +436,33 @@ void main() { expect(find.text("Buy 5 packs now"), findsOneWidget); expect(find.text("+ 1 pack Week 2"), findsOneWidget); }); + + testWidgets("names the same single later trip that the copied list puts the product under", (WidgetTester tester) async { + // Only the week 2 trip buys beans. The row and the copied list must both send the user to it. + // The copy names each trip with shoppingTripLabel, the same as the shopping page does. + List trips = [ + _trip(0, [_item(ingredientId: "rice", amount: 600)]), + _trip(1, [_item(amount: 600)]), + ]; + await _pumpIngredient(tester, remainingGrams: 600, plannedTrips: trips); + + String copied = buildMultiTripCopyText( + ingredients: [_ingredient()], + remainingByIngredientId: const { + "beans": [Quantity(amount: 600, unit: Unit.grams)], + }, + trips: trips, + tripLabel: (ShoppingTrip trip) => shoppingTripLabel( + startDate: null, + weekIndex: trip.weekIndex, + tripDay: trip.tripDay, + isFirstTrip: trip.weekIndex == trips.first.weekIndex, + ), + ); + + expect(find.text("Buy 6 packs Week 2"), findsOneWidget); + expect(copied.split("\n"), const ["Week 2", "------", "Beans", " 2x50grams: 6 packs"]); + }); }); group("ShoppingIngredient header owned input", () { From 30c3b8ba35f87a5c33a9698537e40c81b78caec9 Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Fri, 2 Oct 2026 19:48:02 +0200 Subject: [PATCH 06/10] docs: record the per-row trip feedback in ADR 0014 and the test count ADR 0014 still said that the banner and the copy are the only trip feedback. Product rows now show a per-trip split or the label of a single later trip, so the ADR says that, and why the label names the earliest trip that holds the ingredient. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- adr/0014-multi-trip-shopping-planner.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3361e33..88a1337 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/` | 1057 tests across 42 files | +| Run all tests | `cd menu_management && flutter test test/` | 1065 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 015a883..15340a0 100644 --- a/adr/0014-multi-trip-shopping-planner.md +++ b/adr/0014-multi-trip-shopping-planner.md @@ -74,5 +74,5 @@ One floating Export button replaced the floating copy button. It opens `showExpo - 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. - The copied per-trip amounts always equal the on-screen list: same unit, and per-trip lines that sum to the on-screen total with no rounding drift. The planner picks the trips; `distributeRemainingAcrossTrips` splits the page's number across them. If the planner and page ever disagree on which weeks buy an ingredient, the page total still wins because the copy is derived from it. - Owned amounts are tracked per product for ingredients that have products (one count per product), and as one number plus one selected unit for ingredients without products (see ADR 0011 and issue #24). They are not tracked per-trip. The planner subtracts the summed global owned amount from earliest events first, which usually means owned reduces what is bought on the earliest trip. There is no way today for the user to say "I have 100g of X but I want to use it on trip 2". If this comes up we can add per-event owned overrides. -- The on-screen list does not visually sectionize when the toggle is on. The banner under the AppBar is the only on-screen feedback besides the copy output. If users want section headers on screen we can iterate on the per-ingredient widget without changing the planner. +- The on-screen list has no trip sections. Each product row shows the trip feedback of its own product. When 2+ trips buy the product, the row shows one line per trip ("Buy 6 packs now" + "+ 3 packs Week 2"). When one later trip buys it, the row shows the total with the label of that trip ("Buy 3 packs Week 2"). That label names the earliest trip that holds the ingredient, because the largest-remainder split can round a small early need down to 0. A row of an equivalent-product group gets no per-trip split, because the split of the solo count would contradict the one-of-each share. It still gets the label of a single later trip. If users want section headers on screen we can iterate on the per-ingredient widget without changing the planner. - The `single-shopping-trip` assumption in ADR 0010's menu expiry warning is unchanged: that warning still assumes one purchase the day before menu day 0. Multi-trip mode is a property of the shopping list copy, not of the menu warning. Reconciling them (warning aware of trips) is possible later but not part of this change. The freeze-aware single-trip mode is documented in ADR 0015 and likewise does not change the menu warning's single-trip assumption. From f82719b0008444bd306790aeca4ddd3c27d2c25c Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Fri, 2 Oct 2026 19:54:41 +0200 Subject: [PATCH 07/10] refactor: extract the earliest later-trip lookup of an ingredient The single later-trip label needs the earliest trip that holds the ingredient. Equivalent-group rows need the same lookup next, so it moves into its own helper instead of a second copy. The comment that explained the lookup sat at the end of the early-return line. It now sits on the helper, and the comment above the early return says what 0 and 2+ purchases give. Co-Authored-By: Claude Opus 5.5 --- .../lib/shopping/shopping_ingredient.dart | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/menu_management/lib/shopping/shopping_ingredient.dart b/menu_management/lib/shopping/shopping_ingredient.dart index 6a828a4..762ca7d 100644 --- a/menu_management/lib/shopping/shopping_ingredient.dart +++ b/menu_management/lib/shopping/shopping_ingredient.dart @@ -293,16 +293,25 @@ class _ShoppingIngredientState extends State { if (packs <= 0) continue; purchases.add(ProductTripPurchase(weekIndex: allocation.weekIndex, packs: packs, isFirstTrip: allocation.weekIndex == firstWeek)); } - // A row is a "split" when 2+ trips buy this product. - if (purchases.length != 1) return purchases; // The split weighs each trip by the grams of all its items of this ingredient, whatever their - // unit, so the earliest trip is found by ingredient and not by the unit of this product. - // One purchase means that at least one trip holds this ingredient, so firstWhere finds a trip. - ShoppingTrip earliestTrip = widget.plannedTrips.firstWhere( + // No purchase gives the plain total, and 2+ purchases give the per-trip split. + if (purchases.length != 1) return purchases; + int? earliestLaterTripWeek = _earliestLaterTripWeekForIngredient(); + // The earliest trip of the plan alone gives the plain total, the same as a one-trip plan. + if (earliestLaterTripWeek == null) return const []; + return [ProductTripPurchase(weekIndex: earliestLaterTripWeek, packs: purchases.single.packs, isFirstTrip: false)]; + } + + /// Returns the week of the earliest planned trip that holds a [TripItem] of this ingredient. + /// + /// Returns null when that trip is the first trip of the plan, or when no trip holds the ingredient. + /// The split weighs each trip by the grams of all its items of this ingredient, whatever their + /// unit, so this method finds the trip by ingredient and not by the unit of a product. + int? _earliestLaterTripWeekForIngredient() { + ShoppingTrip? earliestTrip = widget.plannedTrips.firstWhereOrNull( (ShoppingTrip trip) => trip.items.any((TripItem item) => item.ingredientId == widget.ingredient.id), ); - // The earliest trip of the plan alone gives the plain total, the same as a one-trip plan. - if (earliestTrip.weekIndex == firstWeek) return const []; - return [ProductTripPurchase(weekIndex: earliestTrip.weekIndex, packs: purchases.single.packs, isFirstTrip: false)]; + if (earliestTrip == null || earliestTrip.weekIndex == widget.plannedTrips.first.weekIndex) return null; + return earliestTrip.weekIndex; } /// Returns the indexes inside [group] of the equivalent members whose row renders. From 3efa5f07c64869944653e5f0f568da6a77220ab4 Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Fri, 2 Oct 2026 19:54:54 +0200 Subject: [PATCH 08/10] fix: name the earliest later trip on equivalent-group rows An equivalent group got a trip label only when exactly one later trip bought its cover. When the first trip holds only other ingredients and 2+ later trips buy this one, each member row showed a plain "Buy 1 pack", which reads as "buy now" although no recipe needs it before the later trip. Each member row now names the earliest trip that holds the ingredient whenever that trip is not the first trip of the plan, with its own cycled share. The isFirstTrip flag is now a plain false, since a later trip is never the first one, and the packsToBuy > 0 guard goes away, because the row shows "Covered" for 0 packs before it reads the trip purchases. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- .../lib/shopping/shopping_ingredient.dart | 17 ++++++++-------- .../test/shopping_ingredient_test.dart | 20 +++++++++++++++++++ 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 88a1337..071ee34 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/` | 1065 tests across 42 files | +| Run all tests | `cd menu_management && flutter test test/` | 1066 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/menu_management/lib/shopping/shopping_ingredient.dart b/menu_management/lib/shopping/shopping_ingredient.dart index 762ca7d..e85b784 100644 --- a/menu_management/lib/shopping/shopping_ingredient.dart +++ b/menu_management/lib/shopping/shopping_ingredient.dart @@ -474,10 +474,9 @@ class _ShoppingIngredientState extends State { List visibleMembers = isCombinedGroup ? _visibleMemberIndexes(group: group, cycledShares: cycledShares) : const []; - // The solo trip purchases of the group's cover. One entry means that one later trip buys the - // whole cover, so each member row names that trip. All members share one unit. - List groupTripPurchases = isCombinedGroup ? _tripPurchasesForProduct(group.first.value) : const []; - ProductTripPurchase? groupSingleTrip = groupTripPurchases.length == 1 ? groupTripPurchases.single : null; + // When the first trip of the plan does not hold this ingredient, each member row names the + // earliest trip that holds it. Otherwise a plain share would read as "buy now". + int? groupEarliestLaterTripWeek = isCombinedGroup ? _earliestLaterTripWeekForIngredient() : null; bool isFirstVisibleInGroup = true; for (int memberIndex = 0; memberIndex < group.length; memberIndex++) { @@ -510,13 +509,13 @@ class _ShoppingIngredientState extends State { isBestOption: bestWaste != null && recommendation.totalWaste == bestWaste, packsToBuy: packsToBuy, // The one-of-each cycle already splits an equivalent group. A per-trip split on top - // would show the solo counts, so a member row gets no 2+ split. When one later trip buys - // the whole cover, the member row gets one entry with its own cycled share, so the row - // names that trip. + // would show the solo counts, so a member row gets no 2+ split. When the first trip of + // the plan does not hold the ingredient, the member row gets one entry with its own + // cycled share, so the row names the earliest trip that holds it. tripPurchases: !isCombinedGroup ? _tripPurchasesForProduct(product) - : groupSingleTrip != null && packsToBuy > 0 - ? [ProductTripPurchase(weekIndex: groupSingleTrip.weekIndex, packs: packsToBuy, isFirstTrip: groupSingleTrip.isFirstTrip)] + : groupEarliestLaterTripWeek != null + ? [ProductTripPurchase(weekIndex: groupEarliestLaterTripWeek, packs: packsToBuy, isFirstTrip: false)] : const [], startDate: widget.startDate, ownedCount: widget.ownedProductCounts[productIndex] ?? 0, diff --git a/menu_management/test/shopping_ingredient_test.dart b/menu_management/test/shopping_ingredient_test.dart index 2625135..79acc41 100644 --- a/menu_management/test/shopping_ingredient_test.dart +++ b/menu_management/test/shopping_ingredient_test.dart @@ -836,6 +836,26 @@ void main() { expect(find.text("and"), findsOneWidget); }); + testWidgets("names the earliest trip on each row of an equivalent group that 2 later trips buy", (WidgetTester tester) async { + // Need 1000 g -> 1 pack each. The first trip buys only rice, so a plain share would read as + // "buy now". Each row must name the week 2 trip, the earliest trip that holds the ingredient. + await _pumpProducts( + tester, + products: [_equivProduct("a"), _equivProduct("b")], + remainingGrams: 1000, + plannedTrips: [ + _trip(0, [_item(ingredientId: "rice", amount: 1000)]), + _trip(1, [_item(ingredientId: "pizza", amount: 500)]), + _trip(2, [_item(ingredientId: "pizza", amount: 500)]), + ], + ); + + expect(find.text("Buy 1 pack Week 2"), findsNWidgets(2)); + expect(find.text("Buy 1 pack"), findsNothing); + expect(find.text("Buy 1 pack Week 3"), findsNothing); + expect(find.text("and"), findsOneWidget); + }); + testWidgets("keeps the plain cycled share on an equivalent group that 2 trips buy", (WidgetTester tester) async { // Need 1000 g -> 1 pack each. Both trips buy the ingredient, and a per-trip split of the solo // count would contradict the one-of-each share, so each row keeps the plain share. From 0574f9f74e4a506feb16b74b86b7bc3b2d73f10c Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Fri, 2 Oct 2026 19:55:01 +0200 Subject: [PATCH 09/10] test: make the owned-stock trip test tell remaining from trip amount The trip item held 300 grams, the same as the remaining after owned stock, so a row that counted packs from the raw trip amount also passed. The trip item now holds 900 grams, so only a count from the 300 grams remaining gives "Buy 3 packs Week 2". The comment now names the grams that the test uses. Co-Authored-By: Claude Opus 5.5 --- menu_management/test/shopping_ingredient_test.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/menu_management/test/shopping_ingredient_test.dart b/menu_management/test/shopping_ingredient_test.dart index 79acc41..79eb95a 100644 --- a/menu_management/test/shopping_ingredient_test.dart +++ b/menu_management/test/shopping_ingredient_test.dart @@ -289,13 +289,14 @@ void main() { }); testWidgets("counts the single later trip from the remaining after owned stock", (WidgetTester tester) async { - // The recipes need 900 grams, and owned stock leaves 300 grams to buy, all on the week 2 trip. + // The week 2 trip item holds 900 grams, and owned stock leaves 300 grams to buy. The row must + // count the packs from the 300 grams, not from the 900 grams of the trip item. await _pumpIngredient( tester, remainingGrams: 300, plannedTrips: [ _trip(0, [_item(ingredientId: "rice", amount: 600)]), - _trip(1, [_item(amount: 300)]), + _trip(1, [_item(amount: 900)]), ], ); From 21a969d3d9a89ed0e885643858e7973d8d57839a Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Fri, 2 Oct 2026 19:55:01 +0200 Subject: [PATCH 10/10] docs: record in ADR 0014 when the row trip label differs from the copy The ADR said the copied per-trip amounts always equal the on-screen list. In the rounding case the row names an earlier trip than the copied section, on purpose, so the ADR now says so and points to issue #75 for the split. This keeps a later change from "fixing" one side back to match the other. It also records that equivalent-group rows use the same earliest-trip label, also when 2+ later trips buy the ingredient. Co-Authored-By: Claude Opus 5.5 --- adr/0014-multi-trip-shopping-planner.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/adr/0014-multi-trip-shopping-planner.md b/adr/0014-multi-trip-shopping-planner.md index 15340a0..9d3d8fe 100644 --- a/adr/0014-multi-trip-shopping-planner.md +++ b/adr/0014-multi-trip-shopping-planner.md @@ -72,7 +72,7 @@ One floating Export button replaced the floating copy button. It opens `showExpo - 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. -- The copied per-trip amounts always equal the on-screen list: same unit, and per-trip lines that sum to the on-screen total with no rounding drift. The planner picks the trips; `distributeRemainingAcrossTrips` splits the page's number across them. If the planner and page ever disagree on which weeks buy an ingredient, the page total still wins because the copy is derived from it. +- The copied per-trip amounts use the unit of the on-screen list, and they sum to the on-screen total with no rounding drift. The trip that a row names can differ from the copied section in one case (see the trip label below). The planner picks the trips; `distributeRemainingAcrossTrips` splits the page's number across them. If the planner and page ever disagree on which weeks buy an ingredient, the page total still wins because the copy is derived from it. - Owned amounts are tracked per product for ingredients that have products (one count per product), and as one number plus one selected unit for ingredients without products (see ADR 0011 and issue #24). They are not tracked per-trip. The planner subtracts the summed global owned amount from earliest events first, which usually means owned reduces what is bought on the earliest trip. There is no way today for the user to say "I have 100g of X but I want to use it on trip 2". If this comes up we can add per-event owned overrides. -- The on-screen list has no trip sections. Each product row shows the trip feedback of its own product. When 2+ trips buy the product, the row shows one line per trip ("Buy 6 packs now" + "+ 3 packs Week 2"). When one later trip buys it, the row shows the total with the label of that trip ("Buy 3 packs Week 2"). That label names the earliest trip that holds the ingredient, because the largest-remainder split can round a small early need down to 0. A row of an equivalent-product group gets no per-trip split, because the split of the solo count would contradict the one-of-each share. It still gets the label of a single later trip. If users want section headers on screen we can iterate on the per-ingredient widget without changing the planner. +- The on-screen list has no trip sections. Each product row shows the trip feedback of its own product. When 2+ trips buy the product, the row shows one line per trip ("Buy 6 packs now" + "+ 3 packs Week 2"). When one later trip buys it, the row shows the total with the label of that trip ("Buy 3 packs Week 2"). That label names the earliest trip that holds the ingredient, because the largest-remainder split can round a small early need down to 0. In that rounding case the row names an earlier trip than the copied section, on purpose: a buy on the earlier trip is safe, and a buy on the later trip comes too late for a recipe. Issue #75 tracks the split itself. A row of an equivalent-product group gets no per-trip split, because the split of the solo count would contradict the one-of-each share. When the first trip of the plan does not hold the ingredient, each member row shows its own share with the same earliest-trip label, also when 2+ later trips buy the ingredient. If users want section headers on screen we can iterate on the per-ingredient widget without changing the planner. - The `single-shopping-trip` assumption in ADR 0010's menu expiry warning is unchanged: that warning still assumes one purchase the day before menu day 0. Multi-trip mode is a property of the shopping list copy, not of the menu warning. Reconciling them (warning aware of trips) is possible later but not part of this change. The freeze-aware single-trip mode is documented in ADR 0015 and likewise does not change the menu warning's single-trip assumption.