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
2 changes: 1 addition & 1 deletion adr/0010-product-entity-store-products.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Product data is edited via a `ProductEditor` dialog accessible from the ingredie
- `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, 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.
- The shopping list tracks owned quantity per product for ingredients that have products (a count of each product the user owns), and in the ingredient's native unit for ingredients without products. The global owned amount is summed from the per-product counts times each pack quantity via the ingredient's conversions (see ADR 0014 and issue #24).
- 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.
- The single-shopping-trip assumption is intentionally strict for multi-week menus: a fresh-meat product with `shelfLifeDaysClosed = 2` will warn for any meal beyond Sunday of week 1. If this proves too noisy in practice, the assumption can be relaxed (e.g., one shopping trip per week) by adjusting the way `absoluteDayIndex` is computed before being passed to `mayBeExpiredOnDay`. The model rule itself stays unchanged. When `canBeFrozen` is true on a variant the warning still fires on the same day; only the severity rendered changes (see ADR 0015).
- Old `.tsr` files keep loading because of the `shelfLifeDays` -> `shelfLifeDaysOpened` migration; new saves never write the legacy key.
11 changes: 9 additions & 2 deletions adr/0014-multi-trip-shopping-planner.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,14 @@ Shelf life is read from the same-unit product variants of the ingredient. ADR 00

### Owned amounts

`planShoppingTrips` accepts an optional `ownedAmounts: Map<String, OwnedStock>`, where `OwnedStock` (in `owned_amount.dart`) is the user's stock as one amount plus one selected unit (or null for "packs"). Both the planner and the on-screen list draw the stock down through the shared `OwnedStockConsumer` (in `owned_amount.dart`): the stock is turned into a single grams pool (via `ownedAmountInUnit`) and consumed across the ingredient's needs, one need at a time, in chronological order. `ownedAmountInUnit` handles same-unit, cross-unit (grams <-> pieces via `gramsPerPiece`, weight <-> volume via `density`), and "packs" mode (the product whose unit matches the target unit). A need whose unit has no grams path, or an owned stock with no grams path at all (for example owned pieces with no `gramsPerPiece`), falls back to a per-unit subtraction. Because both callers use the same consumer, the copied trip amounts always equal the on-screen "Need" amounts.
`planShoppingTrips` accepts an optional `ownedAmounts: Map<String, OwnedStock>`, where `OwnedStock` (in `owned_amount.dart`) has two shapes (see ADR 0011 and issue #24):

- **Single-form** (`OwnedStock(amount, unit)`): one amount plus one selected unit (or null for "packs"). Used for ingredients with no products.
- **Per-product** (`OwnedStock.perProduct(countsByProductIndex)`): one owned count per product of the ingredient. The global owned amount is summed from each product's count times its pack quantity. Used for ingredients that have products, so the user can say "I have 3 of product A and 5 of product B".

Both shapes resolve to an amount in a target unit through `OwnedStock.amountInUnit(ingredient, targetUnit)`. Single-form delegates to the shared `ownedAmountInUnit`; per-product sums each product's contribution via `productOwnedAmountInUnit` (count times pack quantity, converted with the ingredient's `toGrams`/`fromGrams`). `ownedAmountInUnit` handles same-unit, cross-unit (grams <-> pieces via `gramsPerPiece`, weight <-> volume via `density`), and "packs" mode (the product whose unit matches the target unit).

Both the planner and the on-screen list then draw the stock down through the shared `OwnedStockConsumer` (in `owned_amount.dart`). The consumer takes the whole `OwnedStock`, so both shapes feed the same single pool: it reads the stock's grams total via `OwnedStock.amountInUnit(ingredient, grams)` (for per-product, the summed global grams from every owned product) into one grams pool, then consumes that pool across the ingredient's needs, one need at a time, in chronological order. A need whose unit has no grams path, or an owned stock with no grams path at all (for example owned pieces with no `gramsPerPiece`), falls back to a per-unit subtraction that likewise reads from `OwnedStock.amountInUnit`. Because both callers use the same consumer over the same `OwnedStock`, the copied trip amounts always equal the on-screen "Need" amounts. The on-screen list builds the same stock via `_ownedStockFor` in `shopping_page.dart` and runs it through `computeRemainingQuantities`, which is the same `OwnedStockConsumer`, so per-product owned is subtracted once, across units, exactly as the planner does. When no conversion path exists, nothing is subtracted.

Earlier this planner did no cross-unit conversion: it took owned as `Map<String, List<Quantity>>` and subtracted only when the owned unit exactly matched the event unit, and the shopping page converted "packs" using the ingredient's first product. That diverged from the on-screen list (which converted correctly), so the copied list could list a higher amount to buy than the page showed. Moving both to `ownedAmountInUnit` removed that divergence.

Expand All @@ -60,6 +67,6 @@ ADR 0015 supersedes this UI: the OFF mode (flat list, ignore shelf life) was dro
- 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.
- Owned amounts are still tracked at the ingredient level (one number, one selected unit), not per-trip. The planner subtracts owned 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.
- 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 `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.
6 changes: 3 additions & 3 deletions menu_management/lib/shopping/multi_trip_planner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,9 @@ List<_PlanEvent> _buildPlanEvents({
// and unit. This is the same single-grams-pool subtraction the on-screen list runs (see
// computeRemainingQuantities), so the planner and the page never disagree on how much is still
// needed. Consuming full owned stock per unit here would over-subtract and drop the ingredient.
OwnedStockConsumer? consumer = (owned == null || ingredient == null)
? null
: OwnedStockConsumer(ingredient: ingredient, ownedAmount: owned.amount, ownedUnit: owned.unit);
// The owned stock carries its shape (single-form or per-product, see issue #24); the consumer
// reads its grams total from OwnedStock.amountInUnit, so per-product counts feed the same pool.
OwnedStockConsumer? consumer = (owned == null || ingredient == null) ? null : OwnedStockConsumer(ingredient: ingredient, owned: owned);

for (CookingEvent event in events) {
for (Quantity quantity in event.quantities) {
Expand Down
103 changes: 77 additions & 26 deletions menu_management/lib/shopping/owned_amount.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,72 @@ import "package:menu_management/recipes/models/quantity.dart";

/// A user's owned stock of one ingredient, as entered on the shopping page.
///
/// [unit] is the unit the user picked in the "owned" dropdown. It is null when
/// the user picked "packs" (product-relative), matching [Unit]? null everywhere.
/// Two shapes exist:
/// - Single-form ([OwnedStock.new]): one [amount] plus one selected [unit]. Used for ingredients
/// with no products, where the user types a single number in the desired unit. [unit] is null
/// when the user picked "packs" (product-relative), matching [Unit]? null everywhere.
/// - Per-product ([OwnedStock.perProduct]): one owned count per product of the ingredient
/// ([countsByProductIndex] maps a product's index in [Ingredient.products] to how many of that
/// product the user owns). The global owned amount is summed from each product's count times its
/// pack quantity via the ingredient's conversions. Used for ingredients that have products.
///
/// Both shapes resolve to an amount in a target unit through [amountInUnit], so the on-screen list
/// and the multi-trip planner always subtract the same amount.
class OwnedStock {
const OwnedStock({required this.amount, required this.unit});
const OwnedStock({required this.amount, required this.unit}) : countsByProductIndex = null;

const OwnedStock.perProduct({required Map<int, double> this.countsByProductIndex}) : amount = 0, unit = null;

final double amount;

/// null means "packs".
final Unit? unit;

/// Per-product owned counts (product index in [Ingredient.products] -> owned count).
/// null for single-form stock.
final Map<int, double>? countsByProductIndex;

/// Whether the user owns anything at all. Lets callers skip empty stock.
bool get hasStock {
final Map<int, double>? counts = countsByProductIndex;
if (counts == null) return amount > 0;
return counts.values.any((double count) => count > 0);
}

/// The owned amount expressed in [targetUnit] for [ingredient], using the shared converters.
///
/// Single-form stock delegates to [ownedAmountInUnit]. Per-product stock sums each owned
/// product's contribution via [productOwnedAmountInUnit].
double amountInUnit({required Ingredient ingredient, required Unit targetUnit}) {
final Map<int, double>? counts = countsByProductIndex;
if (counts == null) {
return ownedAmountInUnit(ingredient: ingredient, ownedAmount: amount, ownedUnit: unit, targetUnit: targetUnit);
}
double total = 0;
for (MapEntry<int, double> entry in counts.entries) {
int index = entry.key;
if (index < 0 || index >= ingredient.products.length) continue;
total += productOwnedAmountInUnit(ingredient: ingredient, product: ingredient.products[index], count: entry.value, targetUnit: targetUnit);
}
return total;
}
}

/// Converts an owned [count] of a single [product] of [ingredient] into [targetUnit].
///
/// The count is a number of packs of that product. It is first turned into an amount in the
/// product's own unit (`count * totalQuantityPerPack`), then converted to [targetUnit] via the
/// ingredient's conversions (grams bridge through `density` for volume, `gramsPerPiece` for
/// pieces). Returns 0 when the count is non-positive or no conversion path exists.
double productOwnedAmountInUnit({required Ingredient ingredient, required Product product, required double count, required Unit targetUnit}) {
if (count <= 0) return 0;
double amountInProductUnit = count * product.totalQuantityPerPack;
if (product.unit == targetUnit) return amountInProductUnit;

double? grams = ingredient.toGrams(Quantity(amount: amountInProductUnit, unit: product.unit));
if (grams == null) return 0;
if (targetUnit == Unit.grams) return grams;
return ingredient.fromGrams(grams, targetUnit) ?? 0;
}

/// Converts a user's owned amount into [targetUnit] for an ingredient.
Expand Down Expand Up @@ -51,29 +108,30 @@ double ownedAmountInUnit({required Ingredient ingredient, required double ownedA

/// Draws down a user's owned stock across an ingredient's needs, one need at a time.
///
/// The stock is turned into a single shared grams pool (via [ownedAmountInUnit]) and consumed across
/// every need in the order [consumeRemaining] is called. This makes a single owned stock get
/// The stock is turned into a single shared grams pool (via [OwnedStock.amountInUnit]) and consumed
/// across every need in the order [consumeRemaining] is called. This makes a single owned stock get
/// subtracted only once, even when the ingredient is needed in more than one unit at the same time.
/// It is the single source of truth for owned-stock subtraction, shared by the on-screen shopping
/// list ([computeRemainingQuantities]) and the multi-trip planner (`multi_trip_planner.dart`), so the
/// two never disagree on how much is still needed.
///
/// The consumer takes an [OwnedStock], so both stock shapes flow through the same single pool:
/// single-form (one amount + unit) and per-product (one count per product, summed into a global
/// grams amount, see issue #24). Whichever shape the user entered, [OwnedStock.amountInUnit] gives
/// its grams total for the pool and its per-unit total for the fallback below.
///
/// Create one consumer per (ingredient, owned stock). A need whose unit cannot be related to grams
/// (no density and no gramsPerPiece), or an owned stock with no grams path at all (for example owned
/// pieces with no gramsPerPiece), falls back to a per-unit subtraction that is likewise consumed only
/// once per unit across calls.
class OwnedStockConsumer {
OwnedStockConsumer({required Ingredient ingredient, required double ownedAmount, required Unit? ownedUnit})
OwnedStockConsumer({required Ingredient ingredient, required OwnedStock owned})
: _ingredient = ingredient,
_ownedAmount = ownedAmount,
_ownedUnit = ownedUnit,
_gramsPool = ownedAmount <= 0
? 0
: ownedAmountInUnit(ingredient: ingredient, ownedAmount: ownedAmount, ownedUnit: ownedUnit, targetUnit: Unit.grams);
_owned = owned,
_gramsPool = owned.hasStock ? owned.amountInUnit(ingredient: ingredient, targetUnit: Unit.grams) : 0;

final Ingredient _ingredient;
final double _ownedAmount;
final Unit? _ownedUnit;
final OwnedStock _owned;

/// Remaining shared grams pool. Drawn down by each need; reaching 0 just means the stock is used up,
/// not that there is no grams path (that is fixed at construction, see [_ownedHasGramsPath]).
Expand All @@ -91,7 +149,7 @@ class OwnedStockConsumer {
/// Returns how much of [need] still has to be bought after applying the owned stock.
/// The result is raw (not rounded); callers that display whole units round it themselves.
double consumeRemaining(Quantity need) {
if (_ownedAmount <= 0) return need.amount;
if (!_owned.hasStock) return need.amount;

// No grams conversion path from the owned stock: subtract per unit directly.
if (!_ownedHasGramsPath) return _consumeFallback(need);
Expand All @@ -108,10 +166,7 @@ class OwnedStockConsumer {
}

double _consumeFallback(Quantity need) {
double owned = _fallbackOwnedByUnit.putIfAbsent(
need.unit,
() => ownedAmountInUnit(ingredient: _ingredient, ownedAmount: _ownedAmount, ownedUnit: _ownedUnit, targetUnit: need.unit),
);
double owned = _fallbackOwnedByUnit.putIfAbsent(need.unit, () => _owned.amountInUnit(ingredient: _ingredient, targetUnit: need.unit));
double consumed = min(owned, need.amount);
_fallbackOwnedByUnit[need.unit] = owned - consumed;
return need.amount - consumed;
Expand All @@ -125,13 +180,9 @@ class OwnedStockConsumer {
///
/// Delegates to [OwnedStockConsumer] so the stock is consumed a single time across all units. This
/// prevents the old bug where a single stock was fully converted into every unit and subtracted from
/// each, over-subtracting when an ingredient is needed in more than one unit at once.
List<Quantity> computeRemainingQuantities({
required Ingredient ingredient,
required List<Quantity> requiredQuantities,
required double ownedAmount,
required Unit? ownedUnit,
}) {
OwnedStockConsumer consumer = OwnedStockConsumer(ingredient: ingredient, ownedAmount: ownedAmount, ownedUnit: ownedUnit);
/// each, over-subtracting when an ingredient is needed in more than one unit at once. [owned] may be
/// either stock shape (single-form or per-product); both resolve through the same single pool.
List<Quantity> computeRemainingQuantities({required Ingredient ingredient, required List<Quantity> requiredQuantities, required OwnedStock owned}) {
OwnedStockConsumer consumer = OwnedStockConsumer(ingredient: ingredient, owned: owned);
return requiredQuantities.map((Quantity q) => Quantity(amount: max(0, consumer.consumeRemaining(q)).roundToDouble(), unit: q.unit)).toList();
}
Loading
Loading