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
13 changes: 11 additions & 2 deletions adr/0014-multi-trip-shopping-planner.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,17 @@ 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"). For each cooking event the planner converts that stock into the event's unit via the shared `ownedAmountInUnit` (in `owned_amount.dart`), then consumes it against the matching-unit events in chronological order before trips are computed. `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). The on-screen shopping list (`_ownedInUnit` in `shopping_page.dart`) calls the same function, so the copied trip amounts always equal the on-screen "Need" amounts. When no conversion path exists (for example owned pieces with no `gramsPerPiece`), nothing is subtracted.
`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.

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. The shared `ownedAmountInUnit` removed the divergence.
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.

A later divergence came from converting the same owned stock into each unit independently. When one ingredient is needed in two units at once (for example grams in one recipe and pieces in another, with a pieces product so the normalizer keeps both), subtracting the full stock from each unit over-subtracts. On the page this only inflated how much was marked as owned; in the planner it zeroed every event and dropped the ingredient from the trip list entirely, so the copy was missing an ingredient the page still showed as needed. The single-grams-pool `OwnedStockConsumer` fixes both: it is the one source of truth for owned-stock subtraction, so a single stock is never subtracted more than once and the planner never drops an ingredient the page still needs.

### Matching the copied amounts to the on-screen list

The on-screen list normalizes units before display (`quantity_normalizer.dart`: pieces to grams via `gramsPerPiece`, volume to grams via `density`) and rounds each ingredient's total once. The planner instead works on the raw, per-day cooking timeline in the recipe's own units. Feeding the planner's raw per-trip amounts straight into the copied text made the copy diverge from the page in two ways: a different unit (planner "4 pieces" vs page "20 g"), and per-trip lines that rounded separately and summed to one more or less than the page total.

The copy no longer prints the planner's raw amounts. For each ingredient it takes the on-screen remaining (already normalized, owned-subtracted, and rounded) and calls `distributeRemainingAcrossTrips` (in `trip_amount_distributor.dart`), which spreads that remaining across the weeks the planner chose. Each week's share is weighted by that week's raw need expressed in grams (so pieces and volume compare on one scale), and a largest-remainder split keeps every per-trip line a whole number while guaranteeing the lines sum to exactly the on-screen amount, in the on-screen unit. The planner still owns trip assignment (which weeks, respecting shelf life); the distributor only decides how the page's number is split across those weeks. The on-screen per-trip pack split (`_tripPurchasesForProduct` in `shopping_ingredient.dart`) still rounds each trip's raw amount to compute packs; it agrees with the copy at pack granularity and is left unchanged.

### UI

Expand All @@ -51,6 +59,7 @@ ADR 0015 supersedes this UI: the OFF mode (flat list, ignore shelf life) was dro
- 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.
- 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.
- 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.
36 changes: 14 additions & 22 deletions menu_management/lib/shopping/multi_trip_planner.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import "dart:math";

import "package:menu_management/ingredients/models/ingredient.dart";
import "package:menu_management/ingredients/models/product.dart";
import "package:menu_management/recipes/enums/unit.dart";
Expand Down Expand Up @@ -53,9 +51,9 @@ class ShoppingTrip {
/// or before the event day. The matching menu warning surfaces this to the user.
///
/// [ownedAmounts] holds the user's owned stock per ingredient (one amount + one selected
/// unit, or "packs"). It is converted into each event's unit via the shared
/// [ownedAmountInUnit] and consumed against the earliest events first, so the planner
/// subtracts exactly what the on-screen shopping list subtracts.
/// unit, or "packs"). It is drawn down via the shared [OwnedStockConsumer] (a single grams pool)
/// against the earliest events first, the same subtraction the on-screen shopping list runs, so the
/// planner subtracts exactly what the page subtracts and never drops an ingredient the page still needs.
///
/// When [assumeFreezerForFreezable] is true, every event whose matching product has
/// [Product.canBeFrozen] set is treated as non-perishable for trip assignment, so freezable
Expand Down Expand Up @@ -144,8 +142,9 @@ List<ShoppingTrip> planShoppingTrips({
}

/// Builds per-(ingredient, unit) plan events from the timeline, after applying
/// owned amounts chronologically against each event. Owned stock is converted into
/// the event's unit via the shared [ownedAmountInUnit] the first time that unit is seen.
/// owned amounts chronologically against each event. Owned stock is drawn down via the shared
/// [OwnedStockConsumer], the same single-grams-pool logic the on-screen list uses, so a single
/// stock is subtracted only once even when the ingredient is needed in more than one unit.
///
/// When [assumeFreezerForFreezable] is true and the matching product is freezable,
/// the event's effective shelf life is null (treated as non-perishable for trip assignment).
Expand All @@ -165,24 +164,17 @@ List<_PlanEvent> _buildPlanEvents({
Ingredient? ingredient = ingredientsById[ingredientId];
OwnedStock? owned = ownedAmounts[ingredientId];

// Remaining owned per event unit, converted from the user's stock via the shared
// converter the first time each unit appears, then consumed chronologically.
Map<Unit, double> ownedRemainingByUnit = {};
// One shared owned-stock pool for this ingredient, consumed chronologically across every event
// 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);

for (CookingEvent event in events) {
for (Quantity quantity in event.quantities) {
double remainingNeed = quantity.amount;
double ownedRemaining = ownedRemainingByUnit.putIfAbsent(
quantity.unit,
() => (owned == null || ingredient == null)
? 0
: ownedAmountInUnit(ingredient: ingredient, ownedAmount: owned.amount, ownedUnit: owned.unit, targetUnit: quantity.unit),
);
if (ownedRemaining > 0 && remainingNeed > 0) {
double consumed = min(ownedRemaining, remainingNeed);
ownedRemainingByUnit[quantity.unit] = ownedRemaining - consumed;
remainingNeed -= consumed;
}
double remainingNeed = consumer == null ? quantity.amount : consumer.consumeRemaining(quantity);
if (remainingNeed <= 0) continue;

// Any-match across same-unit variants: the user can pick the longest-shelf-life
Expand Down
89 changes: 89 additions & 0 deletions menu_management/lib/shopping/owned_amount.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import "dart:math";

import "package:menu_management/ingredients/models/ingredient.dart";
import "package:menu_management/ingredients/models/product.dart";
import "package:menu_management/recipes/enums/unit.dart";
Expand Down Expand Up @@ -46,3 +48,90 @@ double ownedAmountInUnit({required Ingredient ingredient, required double ownedA

return 0;
}

/// 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
/// 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.
///
/// 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})
: _ingredient = ingredient,
_ownedAmount = ownedAmount,
_ownedUnit = ownedUnit,
_gramsPool = ownedAmount <= 0
? 0
: ownedAmountInUnit(ingredient: ingredient, ownedAmount: ownedAmount, ownedUnit: ownedUnit, targetUnit: Unit.grams);

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

/// 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]).
double _gramsPool;

/// Whether the owned stock can be expressed in grams at all. Fixed at construction from the initial
/// pool. The branch must key off this, not the live pool level: once the pool drains to 0, later
/// needs must still stay on the grams path (subtracting nothing more), not fall back to a per-unit
/// conversion that would re-subtract the full owned stock.
late final bool _ownedHasGramsPath = _gramsPool > 0;

/// Per-unit remaining owned for needs with no grams path, converted on first use of each unit.
final Map<Unit, double> _fallbackOwnedByUnit = {};

/// 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;

// No grams conversion path from the owned stock: subtract per unit directly.
if (!_ownedHasGramsPath) return _consumeFallback(need);

double? needGrams = _ingredient.toGrams(need);
// This need's unit cannot be expressed in grams; only a same-unit owned stock can reduce it.
if (needGrams == null) return _consumeFallback(need);

double consumed = min(_gramsPool, needGrams);
_gramsPool -= consumed;
double remainingGrams = needGrams - consumed;
double? remainingInUnit = _ingredient.fromGrams(remainingGrams, need.unit);
return remainingInUnit ?? need.amount;
}

double _consumeFallback(Quantity need) {
double owned = _fallbackOwnedByUnit.putIfAbsent(
need.unit,
() => ownedAmountInUnit(ingredient: _ingredient, ownedAmount: _ownedAmount, ownedUnit: _ownedUnit, targetUnit: need.unit),
);
double consumed = min(owned, need.amount);
_fallbackOwnedByUnit[need.unit] = owned - consumed;
return need.amount - consumed;
}
}

/// Subtracts the user's owned stock from an ingredient's required amounts and rounds each to a
/// whole unit, producing the "remaining to buy" the on-screen shopping list shows.
///
/// [requiredQuantities] must already be normalized (the output of `normalizeQuantities`).
///
/// 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);
return requiredQuantities.map((Quantity q) => Quantity(amount: max(0, consumer.consumeRemaining(q)).roundToDouble(), unit: q.unit)).toList();
}
Loading
Loading