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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Each kind of knowledge has one home. Write a change in the home that matches it;
| Code gen (watch) | `cd menu_management && dart run build_runner watch --delete-conflicting-outputs` | |
| Build release | `cd menu_management && flutter build windows` | |
| Build + copy to Desktop | `./build_and_copy.bat` | Run from repo root; it `cd`s into `menu_management` and runs `build_and_copy.ps1`. Windows only; copies portable build to Desktop |
| Run all tests | `cd menu_management && flutter test test/` | 1066 tests across 42 files |
| Run all tests | `cd menu_management && flutter test test/` | 1073 tests across 42 files |
| Run single test | `cd menu_management && flutter test test/<file>.dart` | |
| List devices | `flutter devices` | |
| Format check | `cd menu_management && find lib test -name "*.dart" ! -name "*.freezed.dart" ! -name "*.g.dart" -print0 \| xargs -0 dart format --set-exit-if-changed` | Bash/Git Bash; excludes generated files; fix drift by re-running without `--set-exit-if-changed` |
Expand Down
22 changes: 20 additions & 2 deletions adr/0014-multi-trip-shopping-planner.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,25 @@ For each cooking event (per ingredient, per unit, per day) the planner computes

Perishable events are sorted by `latestWeek` ascending and processed greedily: reuse a previously chosen trip if it falls within the event's window, otherwise add the event's `latestWeek` as a new chosen trip. This is the textbook minimum interval point cover.

Non-perishable events are processed afterwards: each is placed on the earliest already-chosen trip that is on or before the event's `latestWeek`. If none exists (e.g., no perishables at all), trip 0 is added. This biases non-perishables toward early purchase rather than late, which matches user expectation (buy the pantry stuff up front).
Non-perishable events are processed afterwards. When no chosen trip is on or before the `latestWeek` of such an event (e.g., no perishables at all), trip 0 is added. When the trip set is final, each non-perishable goes on the first trip. The first trip is always on or before the event, because the planner added trip 0 when no chosen trip was. This biases non-perishables toward early purchase rather than late, which matches user expectation (buy the pantry stuff up front).

Rejected alternative: give each non-perishable the earliest chosen trip before the trip set is final. The result then depends on the key order of the timeline. A pantry item can take a later trip before another pantry item adds trip 0, and that later trip can stay open for the pantry item alone.

### Trip of each perishable

The greedy pass only picks the trip weeks. Freezing-required and non-perishable events can still add trip 0 after it. When the trip set is final, the planner gives each perishable event its trip again:

- When the first trip is inside the window of the event, the event goes on the first trip.
- Otherwise the event goes on the latest trip inside its window.

The reason is how the user shops. The first trip is usually a home delivery, so it takes everything that it keeps fresh. The later trips are small trips to the shop, for the perishables that do not keep from the delivery. On those trips the user buys each item as close to its use as possible.

Rejected alternatives:

- **The first chosen trip inside the window** (the greedy result): it can pick an in-between trip. Example: trips in week 0, 1 and 2, and a yogurt for day 15 with a window of [1, 2]. The yogurt went on trip 1, but the user goes to the shop for trip 2 anyway, and the yogurt is fresher from there.
- **Always the latest trip inside the window**: it moves items off the home delivery onto the small trips to the shop. The user rejected it.

The assignment cannot add a trip, because each window already holds the trip that the greedy pass gave it. It can empty a trip in one case: trip 0 came after the greedy pass, and trip 0 is inside the window of every perishable event of the first greedy trip. The non-perishables of that trip also move to trip 0, because each non-perishable goes on the first trip. The plan then has one trip fewer. Only the first greedy trip can empty, because every later greedy trip holds the event that created it, and the window of that event does not hold an earlier trip.

When no trip can possibly serve a perishable event fresh (very short shelf life relative to cooking day), the planner falls back to the latest trip on or before the event day. The existing menu expiry warning (ADR 0010) continues to surface this to the user; the shopping list does not silently drop the item.

Expand Down Expand Up @@ -68,7 +86,7 @@ One floating Export button replaced the floating copy button. It opens `showExpo
## Consequences

- The user can now produce a "buy these things on this trip" plan with one click, without having to mentally split the menu themselves.
- The greedy is optimal for minimum trip count under the week-boundary trip schedule: standard interval point cover. There is no smaller set of trips that respects every event's freshness window.
- The greedy is optimal for the freshness windows of the perishables alone: standard interval point cover. No smaller set of trips holds a trip inside every perishable window. A trip 0 that non-perishables or freezing events add later can make the plan bigger than needed. The final assignment recovers one trip in the case that "Trip of each perishable" describes. The planner does not search for the smallest plan overall.
- 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.
Expand Down
4 changes: 2 additions & 2 deletions adr/0015-freezable-products-and-freezer-aware-trips.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Red trumps blue: a meal with one impossible ingredient and one freeze-required i

The previous shopping page toggle (`_ensureFreshness`) had two states: off = single flat list ignoring shelf life, on = multi-trip splitting by sealed shelf life. The "ignore shelf life" mode is dropped entirely. The new toggle is labeled **"Try to make one trip"** in the AppBar (internal flag `_useFreezerStrategy`). The two states are:

- **Off (Multi-trip mode)**: identical to ADR 0014's "on" behavior. The planner picks a minimum set of weekly trips so every event is within sealed shelf life. Freezing flags are ignored. Tooltip: "shop multiple times so nothing expires before cooking".
- **Off (Multi-trip mode)**: identical to ADR 0014's "on" behavior. The planner picks weekly trips so every event is within sealed shelf life (ADR 0014 says when the set is the smallest). Freezing flags are ignored. Tooltip: "shop multiple times so nothing expires before cooking".
- **On (One-trip mode)**: every event whose matching product is freezable (any-match across same-unit variants) is treated as non-perishable for trip assignment AND pinned to trip 0. Such an event is also tagged `freezeOnArrival = true` when its sealed shelf life would not have covered the gap from trip 0 to the cooking day (i.e. the user actually has to freeze it). Non-freezable perishables continue to flow through the original interval-cover algorithm and may still force later trips when their shelf life is exceeded. Tooltip: "shop once and freeze items that would otherwise expire".

The detailed export (and the on-screen banner) reflect the active mode, and the detailed export is sectioned by trip in both modes. When `freezeOnArrival = true` for an aggregated `TripItem`, the corresponding line gets a `(freeze on arrival)` suffix. The simplified export writes no trip section, but it keeps that same suffix: without it the one-trip plan cannot be followed safely, because the plan assumes the user freezes those items on the day of the trip. `computeFreezeOnArrivalIngredientIds` in `shopping_copy_text.dart` names the ingredients to freeze, and both formats read it from the same trip plan. It holds one flag per ingredient: one trip that freezes an ingredient marks every line of that ingredient in the simplified text, because the reader of that text buys every batch on day one.
Expand All @@ -63,4 +63,4 @@ Aggregation collapses events with the same `(ingredient, unit, trip)` and OR-com
- Red-trumps-blue tooltip rendering may surprise users when a meal has both impossible and freeze-required ingredients. The tooltip mentions the freeze-required ones explicitly so the user can still tell which ones are recoverable. A two-icon cluster (red + blue side by side) was considered but not adopted, to avoid clutter on small meal cards.
- Shopping copy lines for ingredients with multiple product variants get the `(freeze on arrival)` suffix at the ingredient header, not per pack-size sub-line. This matches the per-`TripItem` granularity of the planner output.
- The single-shopping-trip assumption documented in ADR 0010's last paragraph still holds for the menu warning's `mayBeExpiredOnDay` calculation. Freezing changes the warning's color, not when it fires.
- The planner's interval-cover greedy is unchanged for non-freezable perishables, so all existing ADR 0014 properties (minimum trips for the freshness constraint, weekly-only trip schedule, fallback to closest trip on or before the event when no trip can serve it fresh) remain true in the off mode.
- The planner's interval-cover greedy is unchanged for non-freezable perishables, so all existing ADR 0014 properties (minimum trips for the freshness windows of the perishables, weekly-only trip schedule, fallback to closest trip on or before the event when no trip can serve it fresh) remain true in the off mode. In the on mode, the trip 0 that freezing events pin can make the plan bigger than needed. The final assignment of ADR 0014 then moves to trip 0 each perishable whose window holds trip 0, and every non-perishable, so the plan can lose one trip.
67 changes: 42 additions & 25 deletions menu_management/lib/shopping/multi_trip_planner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,22 +44,28 @@ class ShoppingTrip {
static int dayForWeek(int weekIndex) => weekIndex * 7 - 1;
}

/// Plans a minimal set of shopping trips that respects sealed shelf life.
/// Plans the shopping trips of a menu, with few trips, and respects sealed shelf life.
///
/// One trip is scheduled the day before each week it covers. For every cooking
/// event, the planner finds the latest trip that still gets the item fresh
/// (using the matching product's [Product.shelfLifeDaysClosed]). A greedy
/// interval point cover then picks the minimum number of trips that covers
/// every event.
/// event, the planner computes a window of trips `[earliestWeek, latestWeek]`.
/// `earliestWeek` is the earliest trip that keeps the item fresh (from the matching
/// product's [Product.shelfLifeDaysClosed]). `latestWeek` is the latest trip on or before the use.
/// A greedy interval point cover then picks the trips for the perishable events,
/// so that each window of a perishable event holds a chosen trip.
///
/// After the trip set is final, each perishable event goes on the first trip when the first trip
/// is inside the window of the event. Otherwise it goes on the latest trip inside the window,
/// so the user buys the item as close to its use as possible.
///
/// Items whose unit has no matching product, or whose matching product has
/// [Product.shelfLifeDaysClosed] = null, are treated as non-perishable: they
/// have no upper expiry constraint and ride along on the trips already chosen
/// by perishable items, only adding trip 0 if no other trip exists.
/// have no upper expiry constraint. The planner adds trip 0 when no chosen trip is on or before
/// the `latestWeek` of such an event. When the trip set is final, each non-perishable goes on the first trip.
///
/// If an event cannot be served fresh by any prior trip (very short shelf life
/// relative to the cooking day), the planner falls back to the latest trip on
/// or before the event day. The matching menu warning surfaces this to the user.
/// or before the event day. The window then holds only that trip, which does not keep the item fresh.
/// 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 drawn down via the shared [OwnedStockConsumer] (a single grams pool)
Expand Down Expand Up @@ -129,24 +135,24 @@ List<ShoppingTrip> planShoppingTrips({
event.assignedTrip = 0;
}

// Non-perishables ride along on the earliest chosen trip that is on or before
// their event day. If no such trip exists, add trip 0 (so we never store a
// non-perishable beyond its event by accident, and so single-non-perishable
// menus end up with a single trip 0).
// A non-perishable needs a trip on or before its event. When no chosen trip is that early, add trip 0.
// A menu with only non-perishables therefore ends up with trip 0 alone.
bool nonPerishableNeedsTripZero = nonPerishable.any((_PlanEvent event) => chosenTrips.every((int trip) => trip > event.latestWeek));
if (nonPerishableNeedsTripZero && !chosenTrips.contains(0)) chosenTrips.add(0);

// The trip set is now final. The greedy pass only picked the trips, so give each perishable its trip again:
// the first trip (usually a home delivery) when the first trip is inside the window of the event.
// Otherwise the perishable goes on the latest trip inside the window, to buy the item as close to its use as possible.
chosenTrips.sort();
for (_PlanEvent event in perishable) {
event.assignedTrip = _tripForPerishable(event, chosenTrips: chosenTrips);
}

// Every non-perishable goes on the first trip, so a later trip never stays open for a pantry item alone.
// The first trip is on or before the event of each non-perishable, because the check above added trip 0 when needed.
// When a freezing event exists, trip 0 is in the set, so the first trip is trip 0.
for (_PlanEvent event in nonPerishable) {
chosenTrips.sort();
int? assigned;
for (int trip in chosenTrips) {
if (trip <= event.latestWeek) {
assigned = trip;
break;
}
}
if (assigned == null) {
assigned = 0;
if (!chosenTrips.contains(0)) chosenTrips.add(0);
}
event.assignedTrip = assigned;
event.assignedTrip = chosenTrips.first;
}

return _aggregate(events: events, ingredientsById: ingredientsById);
Expand Down Expand Up @@ -265,6 +271,17 @@ void _attachTripWindow(_PlanEvent event, {required int maxWeekIndex}) {
event.latestWeek = latestWeek;
}

/// The trip of a perishable [event], picked from [chosenTrips] (sorted ascending).
///
/// Returns the first trip when it is inside the window of the event. Otherwise returns the latest trip inside the window.
/// The greedy pass put a trip inside every window, so a trip always matches.
int _tripForPerishable(_PlanEvent event, {required List<int> chosenTrips}) {
bool isInWindow(int trip) => trip >= event.earliestWeek && trip <= event.latestWeek;
int firstTrip = chosenTrips.first;
if (isInWindow(firstTrip)) return firstTrip;
return chosenTrips.lastWhere(isInWindow);
}

int _compareForGreedy(_PlanEvent a, _PlanEvent b) {
int byLatest = a.latestWeek.compareTo(b.latestWeek);
if (byLatest != 0) return byLatest;
Expand Down
Loading
Loading