Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion adr/0010-product-entity-store-products.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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
Expand All @@ -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.
Expand Down
35 changes: 34 additions & 1 deletion menu_management/lib/shopping/shopping_product_row.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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}";
Expand All @@ -61,6 +71,26 @@ 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.
// 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:
"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(
Expand Down Expand Up @@ -112,6 +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 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,
Expand Down Expand Up @@ -178,7 +211,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,
),
Expand Down
98 changes: 96 additions & 2 deletions menu_management/lib/shopping/waste_optimizer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,24 @@ 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,
required this.packsNeeded,
required this.overBuyWaste,
required this.expiryWaste,
required this.isViable,
this.underBuy = false,
this.shortfall = 0,
});

final Product product;
Expand All @@ -20,6 +31,20 @@ 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.
///
/// 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;
/// zero otherwise.
final double shortfall;

double get totalWaste => overBuyWaste + expiryWaste;
}

Expand Down Expand Up @@ -66,7 +91,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
Expand Down Expand Up @@ -116,13 +148,75 @@ 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;

// 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: overBuyWaste,
expiryWaste: expiryWaste,
isViable: expiryWaste <= 0,
underBuy: true,
shortfall: shortfall,
);
}

class _NormalizedEvent {
Expand Down
88 changes: 87 additions & 1 deletion menu_management/test/shopping_product_row_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@ Future<void> _pumpRow(
required Product product,
required int packsToBuy,
List<ProductTripPurchase> 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,
Expand Down Expand Up @@ -82,4 +83,89 @@ 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<Tooltip>(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);
});

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<Tooltip>(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);
});
});
}
Loading
Loading