Skip to content

feat(meal-prep): batch prep sessions, yield to inventory & prep→shopping (009) - #31

Merged
rghamilton3 merged 4 commits into
mainfrom
009-meal-prep
Aug 29, 2026
Merged

rghamilton3 merged 4 commits into
mainfrom
009-meal-prep

Conversation

@rghamilton3

Copy link
Copy Markdown
Contributor

Closes #16.

Builds the meal prep subsystem (SRS §3.10): batch-cook sessions that yield ready-to-eat portions into inventory, plus the shopping and planning integrations.

What this adds

  • meal_prep_sessions + meal_prep_session_recipes — pick recipes with servings, schedule a prep day (weekend default, overridable), and on completion yield PREP_SESSION-origin portions into prepped inventory with fridge/freezer shelf lives.
  • Prep → Shopping (REQ-PP-019..022) — aggregate a session's ingredients across its recipes, scale by servings, subtract pantry on hand, and generate a FROM_PREP list for the gap.
  • Planning (REQ-PP-023..026) — prepped inventory surfaced as a non-forcing suggestion; portions place onto the calendar via the HAVE_IT path.
  • /prep route — the sidebar entry was href: null, rendering muted with a "soon" badge while the feature was reachable only through the "Start a prep session" button on Pantry → Prepped. It now has its own surface and the nav entry points at it.

Out of scope (clarified in the spec): distribution — REQ-PP-005..009 (auto-generated meal plan, planning horizon, replan). A completed session yields portions; users place them onto days themselves.

Bug fix

addPreppedMeal() fired a positive INITIALIZED portion event that the client guard, the DB CHECK and INV-INV-011 all reject — so direct entry threw against any real backend. The mocked unit test hid it. The row insert already sets portions_remaining; the ledger records only consume/adjust deltas. Locked with non-mocked pgTAP coverage.

Provenance

This rebases a prior build that had completed all 34 tasks but was never committed, sitting in a stale worktree based three features behind main. Rebase repairs:

Problem Fix
Migrations 0009–0011 collided with main's meal_logs / reminders / security-fixes Renumbered to 0013–0015
ensureSession() removed by feature 008 → currentUser()
database.types.ts generated pre-0009 Regenerated against the full 0001–0015 schema
The standing security guard from #29 failed 4/5 — these migrations predate 0011/0012 Pinned search_path on detach_prep_shopping_lists, revoked EXECUTE on the SECURITY DEFINER ownership trigger, wrapped auth.uid() as (select auth.uid()) in both policies

The guard catching all four advisor regressions before they reached the dashboard is worth noting.

Verification

bun run check 0 errors (564 files) · 383 unit tests pass (42 files) · 155 pgTAP tests pass (9 files, security_advisors guard included) · production build clean · lint clean.

Manually exercised end-to-end in a browser against the local stack — session creation, shopping-list build, and completion:

  • Auth gate: /prep → /signin?next=%2Fprep → back to /prep
  • Session COMPLETED with completed_at set (INV-PL-008)
  • Shopping list FROM_PREP and linked to the session (INV-XD-004)
  • Quantities scaled exactly ×1.5 for 4→6 servings: 2→3 can, 1→1.5 can, 3→4.5 tbsp, 200→300 g
  • One prepped batch: 6/6 portions, FREEZER, expires +90 days (REQ-PP-012), on the "Eat this first" shelf with Defrost and "Eat 1" controls
  • portion_events = 0 rows — confirms the INITIALIZED fix against a real DB

https://claude.ai/code/session_01YZxPcM8tizytxMsXNf6QeL

…ing (009)

Rebases the 006-meal-prep build (34 tasks, never committed) onto current main
and brings it up to the conventions the three intervening features established.

Feature:
- meal_prep_sessions + meal_prep_session_recipes: pick recipes with servings,
  schedule a prep day, and on completion yield PREP_SESSION-origin portions
  into prepped inventory (INV-PL-007/008 enforced by CHECKs).
- Prep → Shopping (REQ-PP-019..022): aggregate a session's ingredients across
  its recipes, subtract pantry on hand, generate a FROM_PREP list for the gap.
- Planning (REQ-PP-023..026): prepped inventory surfaced as a non-forcing
  suggestion; prepped portions place onto the calendar via the HAVE_IT path.
- Entry point is the previously-disabled "Start a prep session" button in
  Pantry → Prepped. Distribution/replan (REQ-PP-005..009) stays out of scope.

Fixes a real bug the mocked unit test hid: addPreppedMeal() fired a positive
INITIALIZED portion event that the client guard, the DB CHECK and INV-INV-011
all reject, so direct entry threw against any real backend. The row insert
already sets portions_remaining; the ledger records only consume/adjust deltas.
Locked with non-mocked pgTAP coverage.

Rebase adjustments:
- Renumber migrations 0009-0011 → 0013-0015 (main took 0009-0012).
- ensureSession() → currentUser() per feature 008.
- Regenerate database.types.ts against the full 0001-0015 schema.
- Satisfy the 0011/0012 security guard the migrations predated: pin
  search_path on detach_prep_shopping_lists, revoke EXECUTE on the
  SECURITY DEFINER ownership trigger, and wrap auth.uid() as
  (select auth.uid()) in both policies.

bun run check: 0 errors · 383 unit tests pass · 155 pgTAP tests pass (9 files,
security_advisors guard included) · lint clean

Claude-Session: https://claude.ai/code/session_01YZxPcM8tizytxMsXNf6QeL
… nav

The sidebar "Meal prep" entry was href: null, rendering muted with a "soon"
badge, while the feature itself was only reachable through the "Start a prep
session" button on Pantry → Prepped. Add a /prep route hosting the session
overview and creation sheet (mirroring shopping/+page.svelte's sheet-overlay
and inert conventions) and point the nav entry at it.

The null-href branch stays as the scaffold for the next unbuilt surface.
Bottom tabs are unchanged — the five mobile slots are still spoken for, so
mobile reaches prep via Pantry → Prepped.

bun run check: 0 errors · 383 unit tests pass · build clean · lint clean

Claude-Session: https://claude.ai/code/session_01YZxPcM8tizytxMsXNf6QeL
Feature dir renamed 006 → 009, migration references renumbered 0009-0011 →
0013-0015, and ensureSession() → currentUser() in the quickstart, plan and
task list, so the artifacts describe what actually shipped.

Claude-Session: https://claude.ai/code/session_01YZxPcM8tizytxMsXNf6QeL
@github-actions

github-actions Bot commented Aug 2, 2026 •

Copy link
Copy Markdown

Claude finished @rghamilton3's task in 2m 25s —— View job


Claude finished the review

Task list

  • Gather context (PR body, changed files, CLAUDE.md)
  • Understand request — custom instructions specify /code-review:code-review on this PR
  • Review migrations, RLS, service/store layer, and UI components
  • Post review feedback

Summary

Well-structured feature with strong invariant discipline (RLS default-deny + owner-scoping, INV-XD-004/INV-PL-006/008 enforced at the DB layer, search_path pinned per the security-advisor guard). The addPreppedMeal() INITIALIZED-event fix is correct and now locked by a non-mocked pgTAP test. One real correctness bug found in the completion path; a couple of smaller UX gaps.


🟠 completeSession() can mark a session COMPLETED without yielding all recipes

src/lib/planning/mealPrep/mealPrepService.ts:260-304

The idempotency guard checks only whether any prepped_meals row is linked to the session:

const { data: existing } = await supabase
  .from('prepped_meals')
  .select('id')
  .eq('meal_prep_session_id', sessionId)
  .limit(1);
if ((existing ?? []).length > 0) {
  if (session.status !== 'COMPLETED') return markCompleted(sessionId);
  return session;
}

If addPreppedMeal() throws partway through the for (const recipe of session.recipes) loop (e.g. a transient network/DB error on the 2nd of 3 recipes), recipe 1 already has a portion linked to the session, but the exception propagates before markCompleted() runs, so the session stays PLANNED. On retry, the guard sees recipe 1's existing row, treats the whole session as "already yielded," skips the loop entirely, and calls markCompleted() — so recipes 2 and 3 never get a prepped portion, yet the session is now COMPLETED.

This violates FR-PP-015 (.specswarm/features/009-meal-prep/spec.md:171: "create exactly one prepped portion record per session recipe"). The code comment at line 253 ("if portions linked to this session already exist ... no further yield happens") documents this as intentional, but it only satisfies the narrower FR-PP-014 (no double-yield on a fully-completed session) at the cost of silently dropping portions on a partial failure. mealPrepService.spec.ts:172 only tests the fully-completed idempotency case, not partial failure.

Suggested fix: key the idempotency check off the set of recipe_ids already yielded for the session, and only create portions for session recipes not yet present, rather than short-circuiting on "at least one exists."

Fix this →

🟡 handleBuildShoppingList has no error handling

src/routes/prep/+page.svelte:22-29

async function handleBuildShoppingList(session: MealPrepSession) {
	buildNotice = null;
	const { itemCount } = await buildPrepShoppingListAction(session.id);
	...
}

If generatePrepShoppingList() throws (recipe fetch failure, network error, etc.) this becomes an unhandled rejection — the "Build shopping list" button just does nothing, with no errorMsg/role="alert" feedback and no saving/disabled state to prevent double-clicks (which would create duplicate FROM_PREP lists for the same session, since generatePrepShoppingList isn't itself idempotent). Every other mutation entry point in this feature (MealPrepSessionForm, CompleteSessionDialog) wraps its call in try/catch with a visible error message — this one is the outlier. Given the project's ADHD-friendly "no dead ends" design ethos, this is worth aligning with the others.

Fix this →

Minor / non-blocking

  • generatePrepShoppingList() (mealPrepService.ts:183-186) keys nameToRecipeId by recipeName, so if a session has two different recipes that happen to share the same display name, the neededFor.recipeId attribution on shopping items would collide (last one wins). Cosmetic only — display purposes.
  • supabase/migrations/0015_shopping_from_prep_link.sql:32-42: detach_prep_shopping_lists() isn't SECURITY DEFINER, which is correct here (session and its shopping list share an owner, so invoker rights suffice) — flagging only to confirm this was a deliberate choice given the file's own comments elsewhere are careful about definer/invoker semantics.

What looks solid

  • Migrations 0013–0015: correct enum-then-reference split for FROM_PREP (avoids the "unsafe use of new value in same transaction" footgun), INV-XD-004 CHECK constraint is a tight XOR, detach_prep_shopping_lists trigger correctly preempts the FK's ON DELETE SET NULL from violating the CHECK.
  • check_session_recipe_ownership() trigger closes the cross-user recipe reference gap RLS alone can't cover, with EXECUTE properly revoked from the SECURITY DEFINER function (matches the guard's expectations from fix(db): clear all 26 Supabase advisor warnings (0011, 0012) + standing pgTAP guard #29).
  • computePrepShoppingGap — pure, well-tested aggregation with sensible optional-ingredient/unit-key handling and rounding.
  • addPreppedMeal() fix is correct and directly verified against a real DB in portion_creation.test.sql (no mocks) — good regression coverage for exactly the bug described.
  • RLS is default-deny + owner-scoped on both new tables, consistent with the rest of the schema.
  • assertNotPast / isNotPast / suggestPrepDay are pure and well unit-tested (prepDay.spec.ts).

…pping-build error handling

Addresses Claude review feedback on PR #31:
- completeSession() previously short-circuited on "any prepped_meals row
  linked to this session," so a retry after addPreppedMeal() threw partway
  through the yield loop would skip straight to markCompleted() and silently
  drop the remaining recipes' portions (FR-PP-015). Now keys the idempotency
  check off the set of already-yielded recipe_ids and only yields the
  recipes still missing a portion.
- handleBuildShoppingList in src/routes/prep/+page.svelte had no try/catch,
  so a failed generatePrepShoppingList() call was an unhandled rejection
  with no user feedback and no guard against a double-click creating
  duplicate FROM_PREP lists. Brought it in line with the other mutation
  entry points in this feature (visible role="alert" error + disabled/busy
  button state).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBm5fpRZTqVqzTAdhsizHF
@rghamilton3
rghamilton3 merged commit e2e9ca5 into main Aug 29, 2026
3 checks passed
@rghamilton3
rghamilton3 deleted the 009-meal-prep branch August 29, 2026 22:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: meal prep sessions (SRS §3.10)

1 participant