From 8afb6aa61a5fb38e0108304b5fdc1d0773d88175 Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Wed, 22 Jul 2026 22:47:26 +0200 Subject: [PATCH 1/2] feat(shopping): buy one pack less when per-recipe shortfall is small The waste optimizer always rounded pack counts up so the shopper bought enough. This could leave a mostly-empty extra pack as over-buy surplus (for example 3 packs of 400g for a 900g recipe: the 3rd pack is 75% wasted). Now, when buying one pack less removes the over-buy surplus AND leaves every affected cooking event short by no more than 20% of that recipe's own need, the optimizer recommends one pack less. The threshold is checked PER RECIPE (per cooking event), not on the ingredient total: the shortfall is allocated to the latest cooking events first, so a small total shortfall that lands entirely on one small recipe does not trigger the reduction. Without a reduction, behavior is unchanged. ProductRecommendation gains an `underBuy` flag and the `shortfall` amount. The shopping product row shows an amber "buying less than the recipes calculate" warning chip and drops the on-screen single-total buy line by one. The threshold is a named constant (underBuyMaxRecipeShortfallFraction, 20%). ADR 0010 records the exception to the round-up rule. Closes #29 Co-Authored-By: Claude Opus 4.8 --- adr/0010-product-entity-store-products.md | 3 +- .../lib/shopping/shopping_product_row.dart | 24 ++- .../lib/shopping/waste_optimizer.dart | 91 ++++++++++- .../test/shopping_product_row_test.dart | 31 +++- .../test/waste_optimizer_test.dart | 153 ++++++++++++++++-- 5 files changed, 287 insertions(+), 15 deletions(-) diff --git a/adr/0010-product-entity-store-products.md b/adr/0010-product-entity-store-products.md index 21e3a61..2c09c7c 100644 --- a/adr/0010-product-entity-store-products.md +++ b/adr/0010-product-entity-store-products.md @@ -24,6 +24,7 @@ The model lives at `lib/ingredients/models/product.dart`. Because `Product` is a The two shelf-life fields drive separate features: - `shelfLifeDaysOpened` is consumed by `lib/shopping/waste_optimizer.dart`. The optimizer simulates sequential container consumption across cooking days and only counts elapsed time once a container is opened. This is what allows the shopping list to prefer two small packs over one big pack when cooking events are far apart in the menu. + - **Under-buy exception to the round-up rule** (issue #29): normally the optimizer rounds up so the shopper always buys enough. As an exception, when buying one pack less removes the over-buy surplus AND leaves every affected cooking event short by no more than `underBuyMaxRecipeShortfallFraction` (20%) of that recipe's own need, the optimizer recommends one pack less and flags the recommendation with `underBuy` plus the `shortfall` amount. The threshold is checked PER RECIPE (per cooking event), not on the ingredient total: the shortfall is allocated to the latest cooking events first (they run short first under sequential consumption), so a small total shortfall that lands entirely on one small recipe does not trigger the reduction. The shopping product row shows an amber "buying less than recipes calculate" warning chip and drops the on-screen single-total buy line by one. The clipboard/trip-split copy is unaffected and still shows the full round-up count. - `shelfLifeDaysClosed` is consumed by `lib/menu/expiry_warnings.dart`. For each meal at a given absolute day index, the helper inspects every ingredient used by the meal's recipe and warns when *every* product variant of that ingredient may already be expired by that day (when at least one variant survives, the user can buy that one, so no warning fires). The menu page renders an `Icons.warning_rounded` icon in `colorScheme.error` next to the recipe name with a tooltip listing affected ingredients. Leftover sub-meals (`Cooking.yield == 0`) are skipped: their raw ingredients were already consumed on the original cook day, so they introduce no new raw-ingredient shelf-life risk on the day the leftover is eaten. Cooked-dish storage is a separate concern tracked by `Recipe.maxStorageDays` (see ADR 0012), used for yield/leftover validity rather than ingredient expiry warnings. When at least one of the expired variants has `canBeFrozen` set, the warning is downgraded to a blue "freeze on arrival" severity instead of the red impossibility warning; see ADR 0015. ### JSON backward compatibility @@ -39,7 +40,7 @@ Product data is edited via a `ProductEditor` dialog accessible from the ingredie - Shoppers see actionable pack counts in the shopping list for any ingredient with a product attached. - The raw-amount fallback means existing ingredients without products continue to work without migration. - `Product` data is persisted inside the existing `.tsr` format with no format version bump; old `.tsr` files without product data load correctly because `products` defaults to an empty list. -- Pack counts are rounded up (ceiling) so the shopper always buys enough. +- Pack counts are rounded up (ceiling) so the shopper always buys enough, except for the opt-in under-buy case above (issue #29) where the optimizer recommends one pack less and warns. - The shopping list currently uses only the first product for display. Future work (issue #3) will use multiple products to recommend optimal pack-size combinations per cooking event. - The shopping list does not track owned packs -- it continues to track owned quantities in the ingredient's native unit. The pack display is presentation-only. - Splitting shelf life into `shelfLifeDaysOpened` and `shelfLifeDaysClosed` lets the two waste/expiry features share data without coupling: the optimizer never reads the closed value and the menu warning never reads the opened value. diff --git a/menu_management/lib/shopping/shopping_product_row.dart b/menu_management/lib/shopping/shopping_product_row.dart index 7b3ff54..8ef8652 100644 --- a/menu_management/lib/shopping/shopping_product_row.dart +++ b/menu_management/lib/shopping/shopping_product_row.dart @@ -61,6 +61,25 @@ class ShoppingProductRow extends StatelessWidget { double totalWaste = recommendation.totalWaste; String unit = product.unit.name; + // Under-buy: one pack less than the recipes calculate. Amber warning chip. + // Label stays compact (like the waste chips); the full wording lives in the tooltip. + if (recommendation.underBuy) { + ColorScheme amber = ColorScheme.fromSeed(seedColor: Colors.amber, brightness: Theme.of(context).brightness); + return Tooltip( + message: + "Buying less than the recipes calculate.\n" + "Dropped one mostly-empty pack; recipes will be about ${recommendation.shortfall.toFormattedAmount()} $unit short.", + child: Chip( + avatar: Icon(Icons.warning_amber_rounded, size: 16, color: amber.onPrimaryContainer), + label: Text("${recommendation.shortfall.toFormattedAmount()} $unit short"), + backgroundColor: amber.primaryContainer, + labelStyle: TextStyle(color: amber.onPrimaryContainer, fontSize: 12), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + ), + ); + } + // No waste: green chip if (totalWaste == 0) { return Tooltip( @@ -112,6 +131,9 @@ class ShoppingProductRow extends StatelessWidget { String? packLabel = product.packLabel(); String totalLabel = "${product.totalQuantityPerPack.toFormattedAmount()} ${product.unit.name}/pack"; bool covered = packsToBuy <= 0; + // When the recommendation is an under-buy, buy one pack less on the single-total line + // (the warning chip explains why). Guarded so it never drops below one pack. + int effectivePacksToBuy = recommendation.underBuy && packsToBuy >= 2 ? packsToBuy - 1 : packsToBuy; return FilledCard( outlined: true, @@ -178,7 +200,7 @@ class ShoppingProductRow extends StatelessWidget { ], ) : Text( - "Buy $packsToBuy ${_packWord(packsToBuy)}", + "Buy $effectivePacksToBuy ${_packWord(effectivePacksToBuy)}", style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold), textAlign: TextAlign.right, ), diff --git a/menu_management/lib/shopping/waste_optimizer.dart b/menu_management/lib/shopping/waste_optimizer.dart index 52d6215..042ce1d 100644 --- a/menu_management/lib/shopping/waste_optimizer.dart +++ b/menu_management/lib/shopping/waste_optimizer.dart @@ -5,6 +5,15 @@ import "package:menu_management/ingredients/models/product.dart"; import "package:menu_management/recipes/models/quantity.dart"; import "package:menu_management/shopping/cooking_timeline.dart"; +/// Maximum share of a single recipe's need that may go unmet when buying one pack less. +/// +/// When dropping the last (mostly-empty) pack would leave every affected cooking event short +/// by no more than this fraction of its own need, the optimizer recommends buying one pack +/// less and flags the recommendation as [ProductRecommendation.underBuy]. Evaluated PER RECIPE +/// (per cooking event), not on the ingredient total, so a small total shortfall that lands +/// entirely on one small recipe does not trigger the reduction. Starting point: 20%. +const double underBuyMaxRecipeShortfallFraction = 0.20; + class ProductRecommendation { const ProductRecommendation({ required this.product, @@ -12,6 +21,8 @@ class ProductRecommendation { required this.overBuyWaste, required this.expiryWaste, required this.isViable, + this.underBuy = false, + this.shortfall = 0, }); final Product product; @@ -20,6 +31,15 @@ class ProductRecommendation { final double expiryWaste; final bool isViable; + /// True when [packsNeeded] was reduced by one pack below what fully covers the recipes, + /// trading a small per-recipe shortfall for removing the over-buy surplus. The UI shows a + /// "buying less than recipes calculate" warning in this case. + final bool underBuy; + + /// Amount (in the product's unit) by which the recipes fall short when [underBuy] is true; + /// zero otherwise. + final double shortfall; + double get totalWaste => overBuyWaste + expiryWaste; } @@ -66,7 +86,14 @@ ProductRecommendation _simulateProduct({ if (normalizedEvents.isEmpty || shelfLife == null) { int packs = product.packsNeeded(totalNeeded); double bought = packs * product.totalQuantityPerPack; - return ProductRecommendation(product: product, packsNeeded: packs, overBuyWaste: bought - totalNeeded, expiryWaste: 0, isViable: true); + return _considerBuyingOnePackLess( + product: product, + packsNeeded: packs, + overBuyWaste: bought - totalNeeded, + expiryWaste: 0, + events: normalizedEvents, + totalNeeded: totalNeeded, + ); } // Simulate sequential consumption @@ -116,12 +143,72 @@ ProductRecommendation _simulateProduct({ overBuyWaste = openRemaining; } - return ProductRecommendation( + return _considerBuyingOnePackLess( product: product, packsNeeded: packsNeeded, overBuyWaste: overBuyWaste, expiryWaste: expiryWaste, + events: normalizedEvents, + totalNeeded: totalNeeded, + ); +} + +/// Builds the recommendation, optionally reducing it by one pack when buying one pack less +/// removes the over-buy surplus while keeping every affected recipe's shortfall under +/// [underBuyMaxRecipeShortfallFraction]. +/// +/// The shortfall from dropping one pack is `totalQuantityPerPack - overBuyWaste`. It is +/// allocated to cooking events from the latest day backward (later recipes run short first, +/// matching the sequential consumption simulation). The reduction applies only when every +/// affected event stays within the per-recipe threshold. When there are no events (fallback +/// path), the whole need is treated as a single recipe. +ProductRecommendation _considerBuyingOnePackLess({ + required Product product, + required int packsNeeded, + required double overBuyWaste, + required double expiryWaste, + required List<_NormalizedEvent> events, + required double totalNeeded, +}) { + ProductRecommendation fullBuy = ProductRecommendation( + product: product, + packsNeeded: packsNeeded, + overBuyWaste: overBuyWaste, + expiryWaste: expiryWaste, + isViable: expiryWaste <= 0, + ); + + double packQuantity = product.totalQuantityPerPack; + + // Only reduce viable, over-buying recommendations that keep at least one pack after the drop. + if (expiryWaste > 0 || overBuyWaste <= 0 || packsNeeded < 2 || packQuantity <= 0) return fullBuy; + + // Amount the recipes fall short if we buy one pack less. + double shortfall = packQuantity - overBuyWaste; + if (shortfall <= 0) return fullBuy; + + // Per-recipe check: the shortfall lands on the latest cooking events first. + List<_NormalizedEvent> recipeEvents = events.isNotEmpty ? events : [_NormalizedEvent(dayIndex: 0, amount: totalNeeded)]; + double remaining = shortfall; + for (int i = recipeEvents.length - 1; i >= 0 && remaining > 1e-9; i--) { + double eventNeed = recipeEvents[i].amount; + if (eventNeed <= 0) continue; + double eventShortfall = min(remaining, eventNeed); + // Reject when this recipe would be short by more than the allowed fraction of its own need. + if (eventShortfall > underBuyMaxRecipeShortfallFraction * eventNeed + 1e-9) return fullBuy; + remaining -= eventShortfall; + } + // Reject when the shortfall exceeds everything the recipes need (nothing left to absorb it). + if (remaining > 1e-9) return fullBuy; + + return ProductRecommendation( + product: product, + packsNeeded: packsNeeded - 1, + overBuyWaste: 0, + expiryWaste: expiryWaste, isViable: expiryWaste <= 0, + underBuy: true, + shortfall: shortfall, ); } diff --git a/menu_management/test/shopping_product_row_test.dart b/menu_management/test/shopping_product_row_test.dart index 04e87c5..bba70e3 100644 --- a/menu_management/test/shopping_product_row_test.dart +++ b/menu_management/test/shopping_product_row_test.dart @@ -15,13 +15,14 @@ Future _pumpRow( required Product product, required int packsToBuy, List tripPurchases = const [], + ProductRecommendation? recommendation, }) async { await tester.pumpWidget( MaterialApp( home: Scaffold( body: ShoppingProductRow( product: product, - recommendation: _recommendation(product), + recommendation: recommendation ?? _recommendation(product), isBestOption: true, packsToBuy: packsToBuy, tripPurchases: tripPurchases, @@ -82,4 +83,32 @@ void main() { expect(find.text("+ 1 piece week 3"), findsOneWidget); }); }); + + group("ShoppingProductRow under-buy warning", () { + testWidgets("shows the 'buying less than recipes' warning chip and buys one pack less", (WidgetTester tester) async { + Product product = _packProduct(); + ProductRecommendation underBuy = ProductRecommendation( + product: product, + packsNeeded: 2, + overBuyWaste: 0, + expiryWaste: 0, + isViable: true, + underBuy: true, + shortfall: 100, + ); + + await _pumpRow(tester, product: product, packsToBuy: 3, recommendation: underBuy); + + // Warning chip shows the shortfall amount and flags the under-buy. + expect(find.text("100 grams short"), findsOneWidget); + // Its tooltip carries the full "buying less than the recipes calculate" wording. + bool hasWarningTooltip = tester + .widgetList(find.byType(Tooltip)) + .any((Tooltip t) => (t.message ?? "").contains("Buying less than the recipes calculate")); + expect(hasWarningTooltip, isTrue); + // The single-total buy line drops by one pack. + expect(find.text("Buy 2 packs"), findsOneWidget); + expect(find.text("Buy 3 packs"), findsNothing); + }); + }); } diff --git a/menu_management/test/waste_optimizer_test.dart b/menu_management/test/waste_optimizer_test.dart index 82f93ae..e883009 100644 --- a/menu_management/test/waste_optimizer_test.dart +++ b/menu_management/test/waste_optimizer_test.dart @@ -65,39 +65,44 @@ void main() { }); test("handles multiple packs needed", () { - // Need 1200g. Product: 1x500g -> 3 packs = 1500g, 300g waste + // Need 1300g. Product: 1x500g -> 3 packs = 1500g, 200g surplus. + // The 3rd pack is 60% used (300g of 500g), so dropping it would leave the recipe + // 300g / 1300g = 23% short, above the 20% threshold. Keep all 3 packs (no under-buy). Product product = _product(quantityPerItem: 500); List result = rankProducts( - totalNeeded: 1200, - events: [_event(amount: 1200)], + totalNeeded: 1300, + events: [_event(amount: 1300)], ingredient: _ingredient(), products: [product], ); expect(result.first.packsNeeded, 3); - expect(result.first.overBuyWaste, closeTo(300, 0.01)); + expect(result.first.overBuyWaste, closeTo(200, 0.01)); + expect(result.first.underBuy, isFalse); }); }); group("event-based expiry simulation", () { test("single cooking event consuming all at once: no expiry waste", () { - // Tomate Triturado scenario: need 900g in one cooking event. - // 400g pack, 5-day shelf life. Opens 3 packs on cooking day. - // Leftover 300g is over-buy, NOT expiry. + // Need 1100g in one cooking event. 400g pack, 5-day shelf life. Opens 3 packs = 1200g. + // Leftover 100g is over-buy, NOT expiry. The 3rd pack is 75% used (300g of 400g), + // so dropping it would leave the recipe 300g / 1100g = 27% short, above the 20% + // threshold: keep all 3 packs (no under-buy). Product product = _product(quantityPerItem: 400, shelfLifeDays: 5); List result = rankProducts( - totalNeeded: 900, - events: [_event(amount: 900)], + totalNeeded: 1100, + events: [_event(amount: 1100)], ingredient: _ingredient(), products: [product], ); expect(result.first.packsNeeded, 3); - expect(result.first.overBuyWaste, closeTo(300, 0.01)); + expect(result.first.overBuyWaste, closeTo(100, 0.01)); expect(result.first.expiryWaste, closeTo(0, 0.01)); expect(result.first.isViable, isTrue); + expect(result.first.underBuy, isFalse); }); test("two events within shelf life: no expiry waste", () { @@ -383,5 +388,133 @@ void main() { expect(result.first.isViable, isFalse); }); }); + + group("buy one pack less (under-buy)", () { + test("drops one pack when the per-recipe shortfall is under the threshold and it removes waste", () { + // Need 1040g in one cooking event. Pack 100g, no shelf life. + // Full buy: 11 packs = 1100g, 60g surplus (the 11th pack is almost empty). + // Dropping one pack: 10 packs = 1000g, 40g short = 40 / 1040 = 3.8% < 20%. Buy 10 and warn. + Product product = _product(quantityPerItem: 100, itemsPerPack: 1); + + List result = rankProducts( + totalNeeded: 1040, + events: [_event(amount: 1040)], + ingredient: _ingredient(), + products: [product], + ); + + expect(result.first.packsNeeded, 10); + expect(result.first.underBuy, isTrue); + expect(result.first.shortfall, closeTo(40, 0.01)); + expect(result.first.overBuyWaste, closeTo(0, 0.01)); + expect(result.first.isViable, isTrue); + }); + + test("drops one pack in the shelf-life simulation path when the shortfall is small", () { + // Tomate Triturado scenario: need 900g in one cooking event. Pack 400g, 5-day shelf life. + // The simulation opens 3 containers = 3 packs = 1200g, 300g surplus (the 3rd pack is 75% empty). + // Dropping one pack: 2 packs = 800g, 100g short = 100 / 900 = 11.1% < 20%. Buy 2 and warn. + Product product = _product(quantityPerItem: 400, shelfLifeDays: 5); + + List result = rankProducts( + totalNeeded: 900, + events: [_event(amount: 900)], + ingredient: _ingredient(), + products: [product], + ); + + expect(result.first.packsNeeded, 2); + expect(result.first.underBuy, isTrue); + expect(result.first.shortfall, closeTo(100, 0.01)); + expect(result.first.overBuyWaste, closeTo(0, 0.01)); + expect(result.first.expiryWaste, closeTo(0, 0.01)); + expect(result.first.isViable, isTrue); + }); + + test("does not drop a pack when one recipe is short beyond the threshold, even if the total shortfall is small", () { + // Two cooking events: day 0 needs 1000g, day 5 needs 40g. Pack 100g, no shelf life. + // Full buy: 11 packs = 1100g, 60g surplus. + // Dropping one pack removes 40g, which falls on the last (small) recipe: 40 / 40 = 100% short. + // The total shortfall 40 / 1040 = 3.8% looks fine, but the per-recipe check blocks the drop. + Product product = _product(quantityPerItem: 100, itemsPerPack: 1); + + List result = rankProducts( + totalNeeded: 1040, + events: [_event(day: 0, amount: 1000), _event(day: 5, amount: 40)], + ingredient: _ingredient(), + products: [product], + ); + + expect(result.first.packsNeeded, 11); + expect(result.first.underBuy, isFalse); + expect(result.first.overBuyWaste, closeTo(60, 0.01)); + }); + + test("does not drop a pack when the shortfall exceeds the threshold", () { + // Need 700g in one event. Pack 500g, no shelf life. Full buy 2 packs = 1000g, 300g surplus. + // Dropping one pack: 1 pack = 500g, 200g short = 200 / 700 = 28.6% > 20%. Keep 2 packs. + Product product = _product(quantityPerItem: 500, itemsPerPack: 1); + + List result = rankProducts( + totalNeeded: 700, + events: [_event(amount: 700)], + ingredient: _ingredient(), + products: [product], + ); + + expect(result.first.packsNeeded, 2); + expect(result.first.underBuy, isFalse); + expect(result.first.overBuyWaste, closeTo(300, 0.01)); + }); + + test("does not drop a pack when there is no over-buy waste to remove", () { + // Need 1000g in one event. Pack 500g. Full buy 2 packs = 1000g exactly, no surplus. Keep 2. + Product product = _product(quantityPerItem: 500, itemsPerPack: 1); + + List result = rankProducts( + totalNeeded: 1000, + events: [_event(amount: 1000)], + ingredient: _ingredient(), + products: [product], + ); + + expect(result.first.packsNeeded, 2); + expect(result.first.underBuy, isFalse); + expect(result.first.overBuyWaste, closeTo(0, 0.01)); + }); + + test("drops one multi-item pack when the per-recipe shortfall stays under the threshold", () { + // 4x200g pack (800g/pack), no shelf life. Need 2900g in one event. + // Full buy 4 packs = 3200g, 300g surplus. Drop one pack: 3 packs = 2400g, + // 500g short = 500 / 2900 = 17.2% < 20%. Buy 3 and warn. + Product product = _product(quantityPerItem: 200, itemsPerPack: 4); + + List result = rankProducts( + totalNeeded: 2900, + events: [_event(amount: 2900)], + ingredient: _ingredient(), + products: [product], + ); + + expect(result.first.packsNeeded, 3); + expect(result.first.underBuy, isTrue); + expect(result.first.shortfall, closeTo(500, 0.01)); + }); + + test("does not set the under-buy flag when only one pack is needed", () { + // Need 500g, 6x100g pack. Full buy 1 pack = 600g, 100g surplus, but dropping it buys nothing. + Product product = _product(quantityPerItem: 100, itemsPerPack: 6); + + List result = rankProducts( + totalNeeded: 500, + events: [_event(amount: 500)], + ingredient: _ingredient(), + products: [product], + ); + + expect(result.first.packsNeeded, 1); + expect(result.first.underBuy, isFalse); + }); + }); }); } From 00f3056aa03d4c9c4855bec0b0107ceb3926c71e Mon Sep 17 00:00:00 2001 From: Guillem Poy Date: Wed, 22 Jul 2026 23:11:22 +0200 Subject: [PATCH 2/2] fix(shopping): correct under-buy reduction, chip, and ranking PR #41 review found three problems with the "buy one pack less" feature. 1. The reduction and the "N short" chip were driven by the DESIRED-need analysis (rankProducts) but applied to the REMAINING-need buy count (after owned stock). When the user owned part of an ingredient the two diverged, so the "-1" and the chip were wrong. The row now applies the reduction and shows the chip only when the actual buy count matches the analysis (packsToBuy == recommendation.packsNeeded + 1). 2. The chip showed in the multi-trip split even though the per-trip lines render the full round-up, contradicting the "buying less" warning. The chip is now suppressed when the trip-split layout is active. 3. The reduced recommendation set overBuyWaste to 0, so its totalWaste became 0. Since ranking and the "best option" marker sort by totalWaste, an under-buyer could outrank a product that fully covers the need with small waste. The under-buy recommendation now keeps its full-pack-buy waste; only packsNeeded and shortfall reflect the reduction. Red-green TDD: added a ranking test (fully-covering beats under-buyer), a widget test for owned>0 divergence, and a widget test for the trip-split chip suppression. Updated ADR 0010 to document all three. Co-Authored-By: Claude Opus 4.8 --- adr/0010-product-entity-store-products.md | 2 +- .../lib/shopping/shopping_product_row.dart | 19 +++++-- .../lib/shopping/waste_optimizer.dart | 9 ++- .../test/shopping_product_row_test.dart | 57 +++++++++++++++++++ .../test/waste_optimizer_test.dart | 30 +++++++++- 5 files changed, 109 insertions(+), 8 deletions(-) diff --git a/adr/0010-product-entity-store-products.md b/adr/0010-product-entity-store-products.md index 2c09c7c..3abf846 100644 --- a/adr/0010-product-entity-store-products.md +++ b/adr/0010-product-entity-store-products.md @@ -24,7 +24,7 @@ The model lives at `lib/ingredients/models/product.dart`. Because `Product` is a The two shelf-life fields drive separate features: - `shelfLifeDaysOpened` is consumed by `lib/shopping/waste_optimizer.dart`. The optimizer simulates sequential container consumption across cooking days and only counts elapsed time once a container is opened. This is what allows the shopping list to prefer two small packs over one big pack when cooking events are far apart in the menu. - - **Under-buy exception to the round-up rule** (issue #29): normally the optimizer rounds up so the shopper always buys enough. As an exception, when buying one pack less removes the over-buy surplus AND leaves every affected cooking event short by no more than `underBuyMaxRecipeShortfallFraction` (20%) of that recipe's own need, the optimizer recommends one pack less and flags the recommendation with `underBuy` plus the `shortfall` amount. The threshold is checked PER RECIPE (per cooking event), not on the ingredient total: the shortfall is allocated to the latest cooking events first (they run short first under sequential consumption), so a small total shortfall that lands entirely on one small recipe does not trigger the reduction. The shopping product row shows an amber "buying less than recipes calculate" warning chip and drops the on-screen single-total buy line by one. The clipboard/trip-split copy is unaffected and still shows the full round-up count. + - **Under-buy exception to the round-up rule** (issue #29): normally the optimizer rounds up so the shopper always buys enough. As an exception, when buying one pack less removes the over-buy surplus AND leaves every affected cooking event short by no more than `underBuyMaxRecipeShortfallFraction` (20%) of that recipe's own need, the optimizer recommends one pack less and flags the recommendation with `underBuy` plus the `shortfall` amount. The threshold is checked PER RECIPE (per cooking event), not on the ingredient total: the shortfall is allocated to the latest cooking events first (they run short first under sequential consumption), so a small total shortfall that lands entirely on one small recipe does not trigger the reduction. An under-buy recommendation keeps its FULL-pack-buy waste values (`overBuyWaste`/`totalWaste`); only `packsNeeded` and `shortfall` reflect the reduction. This is so product ranking and the "best option" marker (both driven by `totalWaste`) still compare every product on its full-buy waste, and an under-buyer is never marked "best option" over a product that fully covers the need with small waste. The shopping product row shows an amber "buying less than recipes calculate" warning chip and drops the on-screen single-total buy line by one, but only when the reduction is valid for what is actually bought: `rankProducts` computes the under-buy on the DESIRED need, while the on-screen buy count is the REMAINING need after owned stock, so the row applies the reduction and the chip only when owned stock did not change the count (`packsToBuy == recommendation.packsNeeded + 1`) and when the multi-trip split is not active (that layout shows full per-trip round-ups). The clipboard/trip-split copy is unaffected and still shows the full round-up count. - `shelfLifeDaysClosed` is consumed by `lib/menu/expiry_warnings.dart`. For each meal at a given absolute day index, the helper inspects every ingredient used by the meal's recipe and warns when *every* product variant of that ingredient may already be expired by that day (when at least one variant survives, the user can buy that one, so no warning fires). The menu page renders an `Icons.warning_rounded` icon in `colorScheme.error` next to the recipe name with a tooltip listing affected ingredients. Leftover sub-meals (`Cooking.yield == 0`) are skipped: their raw ingredients were already consumed on the original cook day, so they introduce no new raw-ingredient shelf-life risk on the day the leftover is eaten. Cooked-dish storage is a separate concern tracked by `Recipe.maxStorageDays` (see ADR 0012), used for yield/leftover validity rather than ingredient expiry warnings. When at least one of the expired variants has `canBeFrozen` set, the warning is downgraded to a blue "freeze on arrival" severity instead of the red impossibility warning; see ADR 0015. ### JSON backward compatibility diff --git a/menu_management/lib/shopping/shopping_product_row.dart b/menu_management/lib/shopping/shopping_product_row.dart index 8ef8652..7cfc272 100644 --- a/menu_management/lib/shopping/shopping_product_row.dart +++ b/menu_management/lib/shopping/shopping_product_row.dart @@ -40,6 +40,16 @@ class ShoppingProductRow extends StatelessWidget { /// Singular/plural unit word: pieces for single-item packs, packs otherwise. String _packWord(int count) => product.itemsPerPack == 1 ? (count == 1 ? "piece" : "pieces") : (count == 1 ? "pack" : "packs"); + /// Whether to actually buy one pack less and show the under-buy warning. + /// + /// [recommendation] is computed by `rankProducts` on the DESIRED need, but [packsToBuy] is the + /// REMAINING need after owned stock. The desired-based under-buy analysis is only valid for what + /// 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. + bool get _appliesUnderBuy => recommendation.underBuy && tripPurchases.length < 2 && packsToBuy == recommendation.packsNeeded + 1; + String _tripPurchaseLabel(ProductTripPurchase purchase, {required bool isFirstLine}) { String prefix = isFirstLine ? "Buy" : "+"; String when = purchase.isFirstTrip ? "now" : "week ${purchase.weekIndex + 1}"; @@ -63,7 +73,8 @@ class ShoppingProductRow extends StatelessWidget { // Under-buy: one pack less than the recipes calculate. Amber warning chip. // Label stays compact (like the waste chips); the full wording lives in the tooltip. - if (recommendation.underBuy) { + // Only shown when the reduction actually applies (see [_appliesUnderBuy]). + if (_appliesUnderBuy) { ColorScheme amber = ColorScheme.fromSeed(seedColor: Colors.amber, brightness: Theme.of(context).brightness); return Tooltip( message: @@ -131,9 +142,9 @@ class ShoppingProductRow extends StatelessWidget { String? packLabel = product.packLabel(); String totalLabel = "${product.totalQuantityPerPack.toFormattedAmount()} ${product.unit.name}/pack"; bool covered = packsToBuy <= 0; - // When the recommendation is an under-buy, buy one pack less on the single-total line - // (the warning chip explains why). Guarded so it never drops below one pack. - int effectivePacksToBuy = recommendation.underBuy && packsToBuy >= 2 ? packsToBuy - 1 : packsToBuy; + // When the under-buy recommendation applies, buy one pack less on the single-total line + // (the warning chip explains why). See [_appliesUnderBuy] for when it applies. + int effectivePacksToBuy = _appliesUnderBuy ? packsToBuy - 1 : packsToBuy; return FilledCard( outlined: true, diff --git a/menu_management/lib/shopping/waste_optimizer.dart b/menu_management/lib/shopping/waste_optimizer.dart index 042ce1d..ec2db68 100644 --- a/menu_management/lib/shopping/waste_optimizer.dart +++ b/menu_management/lib/shopping/waste_optimizer.dart @@ -34,6 +34,11 @@ class ProductRecommendation { /// True when [packsNeeded] was reduced by one pack below what fully covers the recipes, /// trading a small per-recipe shortfall for removing the over-buy surplus. The UI shows a /// "buying less than recipes calculate" warning in this case. + /// + /// When true, [overBuyWaste]/[expiryWaste]/[totalWaste] keep the FULL-pack-buy values (the waste + /// you would get buying the non-reduced count). This keeps ranking and the best-option marker + /// comparing every product on its full-buy waste; only [packsNeeded] and [shortfall] reflect the + /// reduction. See [rankProducts]. final bool underBuy; /// Amount (in the product's unit) by which the recipes fall short when [underBuy] is true; @@ -201,10 +206,12 @@ ProductRecommendation _considerBuyingOnePackLess({ // Reject when the shortfall exceeds everything the recipes need (nothing left to absorb it). if (remaining > 1e-9) return fullBuy; + // Keep the full-pack-buy waste so ranking and the best-option marker do not favor this reduced + // recommendation over a product that fully covers the need with small waste (see [underBuy]). return ProductRecommendation( product: product, packsNeeded: packsNeeded - 1, - overBuyWaste: 0, + overBuyWaste: overBuyWaste, expiryWaste: expiryWaste, isViable: expiryWaste <= 0, underBuy: true, diff --git a/menu_management/test/shopping_product_row_test.dart b/menu_management/test/shopping_product_row_test.dart index bba70e3..d2e32d9 100644 --- a/menu_management/test/shopping_product_row_test.dart +++ b/menu_management/test/shopping_product_row_test.dart @@ -110,5 +110,62 @@ void main() { expect(find.text("Buy 2 packs"), findsOneWidget); expect(find.text("Buy 3 packs"), findsNothing); }); + + testWidgets("does not reduce or warn when owned stock changed the buy count", (WidgetTester tester) async { + Product product = _packProduct(); + // rankProducts computed the under-buy on the DESIRED need: the full buy is 3 packs (packsNeeded + // holds the reduced 2). But owned stock left only 2 packs to actually buy, so the desired-based + // analysis no longer matches (2 != 2 + 1). The row must buy the full 2 packs and NOT warn. + ProductRecommendation underBuy = ProductRecommendation( + product: product, + packsNeeded: 2, + overBuyWaste: 60, + expiryWaste: 0, + isViable: true, + underBuy: true, + shortfall: 100, + ); + + await _pumpRow(tester, product: product, packsToBuy: 2, recommendation: underBuy); + + // Full count, no bogus "-1". + expect(find.text("Buy 2 packs"), findsOneWidget); + expect(find.text("Buy 1 pack"), findsNothing); + // No under-buy chip and no under-buy tooltip. + expect(find.textContaining("short"), findsNothing); + bool hasWarningTooltip = tester + .widgetList(find.byType(Tooltip)) + .any((Tooltip t) => (t.message ?? "").contains("Buying less than the recipes calculate")); + expect(hasWarningTooltip, isFalse); + }); + + testWidgets("suppresses the under-buy warning chip when the trip-split layout is active", (WidgetTester tester) async { + Product product = _packProduct(); + ProductRecommendation underBuy = ProductRecommendation( + product: product, + packsNeeded: 2, + overBuyWaste: 60, + expiryWaste: 0, + isViable: true, + underBuy: true, + shortfall: 100, + ); + + await _pumpRow( + tester, + product: product, + packsToBuy: 3, + recommendation: underBuy, + tripPurchases: const [ + ProductTripPurchase(weekIndex: 0, packs: 2, isFirstTrip: true), + ProductTripPurchase(weekIndex: 1, packs: 1, isFirstTrip: false), + ], + ); + + // Per-trip lines show the FULL round-up; the "N short" chip must not appear alongside them. + expect(find.text("Buy 2 packs now"), findsOneWidget); + expect(find.text("+ 1 pack week 2"), findsOneWidget); + expect(find.textContaining("short"), findsNothing); + }); }); } diff --git a/menu_management/test/waste_optimizer_test.dart b/menu_management/test/waste_optimizer_test.dart index e883009..6c35c11 100644 --- a/menu_management/test/waste_optimizer_test.dart +++ b/menu_management/test/waste_optimizer_test.dart @@ -406,7 +406,9 @@ void main() { expect(result.first.packsNeeded, 10); expect(result.first.underBuy, isTrue); expect(result.first.shortfall, closeTo(40, 0.01)); - expect(result.first.overBuyWaste, closeTo(0, 0.01)); + // The waste fields keep the FULL-pack-buy surplus (60g here) so ranking and best-option + // compare fairly against products that fully cover; only packsNeeded/shortfall are reduced. + expect(result.first.overBuyWaste, closeTo(60, 0.01)); expect(result.first.isViable, isTrue); }); @@ -426,7 +428,8 @@ void main() { expect(result.first.packsNeeded, 2); expect(result.first.underBuy, isTrue); expect(result.first.shortfall, closeTo(100, 0.01)); - expect(result.first.overBuyWaste, closeTo(0, 0.01)); + // Full-pack-buy surplus kept for ranking (300g); only packsNeeded/shortfall are reduced. + expect(result.first.overBuyWaste, closeTo(300, 0.01)); expect(result.first.expiryWaste, closeTo(0, 0.01)); expect(result.first.isViable, isTrue); }); @@ -515,6 +518,29 @@ void main() { expect(result.first.packsNeeded, 1); expect(result.first.underBuy, isFalse); }); + + test("ranks a fully-covering low-waste product above one that would under-buy", () { + // Need 1040g in one event, no shelf life. + // Covering: 1x1060g pack -> 1 pack, 20g surplus, cannot under-buy (only one pack). Waste 20g. + // Under-buyer: 1x100g packs -> full 11 packs = 1100g, 60g surplus; drops to 10 packs (40g short). + // Its full-pack-buy waste is 60g. The under-buy reduction must NOT zero this for ranking, + // or the under-buyer would wrongly sort first (0 < 20) and be flagged the "best option". + Product covering = _product(quantityPerItem: 1060, itemsPerPack: 1); + Product underBuyer = _product(quantityPerItem: 100, itemsPerPack: 1); + + List result = rankProducts( + totalNeeded: 1040, + events: [_event(amount: 1040)], + ingredient: _ingredient(), + products: [underBuyer, covering], + ); + + expect(result.first.product, covering); + expect(result.first.totalWaste, closeTo(20, 0.01)); + ProductRecommendation underBuyRec = result.firstWhere((ProductRecommendation r) => r.product == underBuyer); + expect(underBuyRec.underBuy, isTrue); + expect(underBuyRec.totalWaste, greaterThan(result.first.totalWaste)); + }); }); }); }