diff --git a/.specswarm/features/009-meal-prep/checklists/requirements.md b/.specswarm/features/009-meal-prep/checklists/requirements.md new file mode 100644 index 0000000..ee5c4ba --- /dev/null +++ b/.specswarm/features/009-meal-prep/checklists/requirements.md @@ -0,0 +1,42 @@ +# Specification Quality Checklist: Meal Prep — Batch Sessions & Make-Ahead Integrations + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-06-13 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) — table/column names appear only in + Key Entities as domain entities, consistent with the in-repo spec house style (cf. 004) +- [x] Focused on user value and business needs (make-ahead loop, HAVE_IT) +- [x] Written for non-technical stakeholders (scenarios in plain language) +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain (two scope forks resolved in Clarifications) +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable (SC-001..006) +- [x] Success criteria are technology-agnostic +- [x] All acceptance scenarios are defined (4 scenarios) +- [x] Edge cases identified (cancel, re-completion idempotency, exhausted portions → MUST_ACQUIRE) +- [x] Scope clearly bounded (Included vs Excluded; distribution explicitly deferred) +- [x] Dependencies and assumptions identified (Assumptions section) + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows (batch cook, HAVE_IT, regression, cancel) +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Scope was materially reshaped during specification: investigation found the direct-entry prepped + stack already production-ready (feature 001) and HAVE_IT derivation already shipped (feature 004). + The two scope forks (batch-sessions+integrations; yield-to-inventory-only) were confirmed with the + user before writing. +- REQ-PP-005..009 (auto-distribution, planning horizon, replan) explicitly deferred — recorded in + Excluded with a note that the schema leaves room to add them later. +- One assumption to confirm in `/ss:clarify` if needed: recipe base-yield field availability for + ingredient scaling (FR-PP-020 / Assumptions). diff --git a/.specswarm/features/009-meal-prep/contracts/meal-prep-service.md b/.specswarm/features/009-meal-prep/contracts/meal-prep-service.md new file mode 100644 index 0000000..bcac39b --- /dev/null +++ b/.specswarm/features/009-meal-prep/contracts/meal-prep-service.md @@ -0,0 +1,91 @@ +# Contracts: Meal Prep Service + +SPA data access is client-side `supabase-js` against PostgREST (no REST endpoints of our own). The +"contracts" are therefore the client service signatures and the PostgREST operations they perform, +plus their invariants. All operations run under the caller's RLS context (owner-scoped). + +## `mealPrepService.ts` + +```ts +// CREATE — session + its recipes (>=1). INV-PL-006 enforced here (reject empty). +createSession(draft: NewMealPrepSession): Promise +// inserts meal_prep_sessions (status PLANNED) + meal_prep_session_recipes rows. +// throws if draft.recipes.length < 1 or any servingsToPrep <= 0 (INV-PL-007). + +// READ +getSessions(householdId?: string | null): Promise // PLANNED first, then by prep_day +getSession(id: string): Promise + +// UPDATE (PLANNED only) +addRecipe(sessionId: string, r: { recipeId; recipeName; servingsToPrep }): Promise +removeRecipe(sessionId: string, sessionRecipeId: string): Promise +// rejects removal of the last recipe (would violate INV-PL-006). +updateServings(sessionRecipeId: string, servingsToPrep: number): Promise // > 0 +setPrepDay(sessionId: string, prepDay: string): Promise // not in past + +// COMPLETE — yield to inventory (idempotent). FR-PP-014/015/016. +completeSession(sessionId: string, yields: YieldChoice[]): Promise +// guard: SELECT prepped_meals WHERE meal_prep_session_id = sessionId. +// if rows exist -> return session unchanged (already yielded). +// for each session recipe: addPreppedMeal({ +// origin: 'PREP_SESSION', recipe_id, recipe_name, meal_prep_session_id: sessionId, +// name: recipeName, original_portions: servingsToPrep, portions_remaining: servingsToPrep, +// storage_location, prepared_date: today, expiration_date: today + shelfLife(storage), +// defrost_state: storage === 'FREEZER' ? 'FROZEN' : 'NOT_APPLICABLE' }) +// then UPDATE meal_prep_sessions SET status='COMPLETED', completed_at=now(). +// NOTE: addPreppedMeal is being FIXED in this feature to NOT fire a positive INITIALIZED event +// (Design A) — the row insert is authoritative for the starting count (INV-INV-011/INV-CC-006). + +// CANCEL — PLANNED -> CANCELLED, no portions. FR-PP-017. +cancelSession(sessionId: string): Promise + +// PREP -> SHOPPING. FR-PP-020..023. +generatePrepShoppingList(sessionId: string): Promise<{ shoppingListId: string; itemCount: number }> +// loads each recipe's ingredients, scales by servingsToPrep/recipe.servings, +// computes gap vs pantry index, then writes shopping_lists(source_type='FROM_PREP', +// meal_prep_session_id=sessionId) + shopping_list_items (each item notes forRecipes). +// available while session is PLANNED (does not require completion). +``` + +### Errors +- `MealPrepServiceError(message, cause?)` — wraps supabase errors, mirrors `PreppedMealServiceError`. +- Yield reuses `PreppedMealServiceError` / `PortionLedgerError` from the prepped stack unchanged. + +## `prepShoppingList.ts` (pure — no I/O, node-testable) + +```ts +interface RecipeForPrep { + recipeId: string; + recipeName: string; + baseServings: number; // recipes.servings (>0) + servingsToPrep: number; // >0 + ingredients: { name: string; unit: string; quantity: number; isOptional: boolean }[]; +} + +// required = quantity * (servingsToPrep / baseServings); aggregate by (normName, unit); +// drop items whose normName is in pantryIndex; optional ingredients excluded from the gap. +computePrepShoppingGap( + recipes: RecipeForPrep[], + pantryIndex: Set // normalized names with quantity > 0 +): PrepShoppingGapItem[] + +// normName = name.trim().toLowerCase().replace(/\s+/g, ' ') (matches fulfillment.ts) +``` + +## `mealPrepStore.svelte.ts` (runes) +Exposes: `sessions()`, `sessionsLoading()`, `sessionsError()`, `loadSessions()`, +`optimisticCreateSession()`, `optimisticCompleteSession()`, `optimisticCancelSession()`, +`optimisticAddRecipe()`, `optimisticRemoveRecipe()`. Optimistic-update-then-resync, mirroring +`preppedMealStore.svelte.ts`. After `completeSession`, also triggers `loadPreppedMeals()` so the +Prepped tab reflects the new yield. + +## Invariant checklist (verified by tests) +- INV-PL-006: cannot create/leave a session with 0 recipes. +- INV-PL-007: `servings_to_prep > 0` (DB CHECK + service guard). +- INV-PL-008: `completed_at` set ⟺ `status='COMPLETED'` (DB CHECK). +- INV-INV-006: yielded portions have valid `recipe_id` + `recipe_name`. +- INV-INV-007/009: FREEZER yields are FROZEN; `expiration_date > prepared_date`. +- INV-CC-006/P14: portions written only via `portion_events` (no blind column write). +- INV-XD-004: `FROM_PREP` shopping list always references a valid session (DB CHECK). +- FR-PP-014: re-completion yields no duplicate portions (idempotency guard). +- P7: pgTAP asserts owner-only access + anon sees zero rows on both new tables. diff --git a/.specswarm/features/009-meal-prep/data-model.md b/.specswarm/features/009-meal-prep/data-model.md new file mode 100644 index 0000000..4bac0f8 --- /dev/null +++ b/.specswarm/features/009-meal-prep/data-model.md @@ -0,0 +1,162 @@ +# Data Model: Meal Prep — Batch Sessions + +## New tables (migration 0009) + +### `meal_prep_sessions` + +| Column | Type | Notes | +|--------|------|-------| +| `id` | `uuid` PK `DEFAULT gen_random_uuid()` | P8 | +| `owner_id` | `uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE` | RLS scope | +| `household_id` | `uuid` (nullable, no FK) | mirrors `prepped_meals`; Household feature later | +| `status` | `meal_prep_session_status NOT NULL DEFAULT 'PLANNED'` | enum below | +| `prep_day` | `date NOT NULL` | suggested/overridden (REQ-PP-003/004) | +| `completed_at` | `timestamptz` (nullable) | set iff `status='COMPLETED'` (INV-PL-008) | +| `created_at` | `timestamptz NOT NULL DEFAULT now()` | P9 | +| `updated_at` | `timestamptz NOT NULL DEFAULT now()` | P9 | + +Enum: `CREATE TYPE meal_prep_session_status AS ENUM ('PLANNED','COMPLETED','CANCELLED');` + +Constraint (INV-PL-008): +```sql +CONSTRAINT meal_prep_sessions_completed_has_timestamp + CHECK ((status = 'COMPLETED') = (completed_at IS NOT NULL)) +``` +Indexes: `(owner_id)`, `(household_id)`, `(prep_day)`. + +> INV-PL-006 (≥1 recipe per session) is a cross-row invariant — enforced in the service layer +> (a session is created together with its first recipe; a recipe-removal that would empty a session +> is rejected). Documented as not statically enforceable by a single-table CHECK, same pattern the +> recipes module uses for "≥1 ingredient" (0001_recipes.sql:8). + +### `meal_prep_session_recipes` + +| Column | Type | Notes | +|--------|------|-------| +| `id` | `uuid` PK `DEFAULT gen_random_uuid()` | P8 | +| `meal_prep_session_id` | `uuid NOT NULL REFERENCES meal_prep_sessions(id) ON DELETE CASCADE` | | +| `recipe_id` | `uuid NOT NULL REFERENCES recipes(id) ON DELETE CASCADE` | | +| `recipe_name` | `text NOT NULL CHECK (char_length(recipe_name) BETWEEN 1 AND 500)` | denormalized snapshot | +| `servings_to_prep` | `integer NOT NULL CHECK (servings_to_prep > 0)` | INV-PL-007 | +| `created_at` | `timestamptz NOT NULL DEFAULT now()` | | + +Constraint: `UNIQUE (meal_prep_session_id, recipe_id)` — a recipe appears at most once per session. +Index: `(meal_prep_session_id)`. + +### RLS (P7 — default-deny, owner-scoped) +```sql +ALTER TABLE meal_prep_sessions ENABLE ROW LEVEL SECURITY; +CREATE POLICY "meal_prep_sessions_owner_all" ON meal_prep_sessions + FOR ALL USING (owner_id = auth.uid()) WITH CHECK (owner_id = auth.uid()); + +ALTER TABLE meal_prep_session_recipes ENABLE ROW LEVEL SECURITY; +-- access gated through the parent session's ownership +CREATE POLICY "meal_prep_session_recipes_owner_all" ON meal_prep_session_recipes + FOR ALL USING (EXISTS (SELECT 1 FROM meal_prep_sessions s + WHERE s.id = meal_prep_session_id AND s.owner_id = auth.uid())) + WITH CHECK (EXISTS (SELECT 1 FROM meal_prep_sessions s + WHERE s.id = meal_prep_session_id AND s.owner_id = auth.uid())); +``` + +### Data API grants (per project memory — post-2026-05-30 grant flip) +```sql +GRANT SELECT, INSERT, UPDATE, DELETE ON meal_prep_sessions, meal_prep_session_recipes TO authenticated; +GRANT ALL ON meal_prep_sessions, meal_prep_session_recipes TO service_role; +GRANT SELECT ON meal_prep_sessions, meal_prep_session_recipes TO anon; -- RLS still yields 0 rows pre-login +``` +`updated_at` touch trigger on `meal_prep_sessions` matching the repo's existing convention. + +### Ownership guard (security review) +`check_session_recipe_ownership()` (SECURITY DEFINER, `BEFORE INSERT OR UPDATE OF recipe_id`) +rejects a session recipe whose `recipe_id` is not owned by `auth.uid()` — the bare FK would +otherwise be a cross-user existence oracle and could propagate a foreign `recipe_id` into yielded +`prepped_meals`. Mirrors 0007's `check_source_planned_meal_ownership`. + +## Altered table (migration 0010) — shopping FROM_PREP (INV-XD-004) + +```sql +-- Step 1 (own statement): extend the enum +ALTER TYPE shopping_list_source ADD VALUE IF NOT EXISTS 'FROM_PREP'; + +-- Step 2: link column + integrity +ALTER TABLE shopping_lists ADD COLUMN meal_prep_session_id uuid + REFERENCES meal_prep_sessions(id) ON DELETE SET NULL; + +ALTER TABLE shopping_lists ADD CONSTRAINT shopping_lists_from_prep_has_session + CHECK ( + (source_type = 'FROM_PREP' AND meal_prep_session_id IS NOT NULL) OR + (source_type <> 'FROM_PREP' AND meal_prep_session_id IS NULL) + ); +``` +Existing `FROM_PLAN` range CHECK (0007) is untouched. `FROM_PREP` lists carry no generated_range +(those remain NULL — the existing range CHECK only constrains FROM_PLAN). + +### Detach-on-delete (security review) +`detach_prep_shopping_lists()` (`BEFORE DELETE ON meal_prep_sessions`) flips any linked +`FROM_PREP` list to `MANUAL` and nulls its `meal_prep_session_id` before the FK's `SET NULL` +fires. Without it, deleting a session that generated a shopping list would null the FK on a row +still tagged `FROM_PREP`, violating the INV-XD-004 CHECK and aborting the delete (and the +`auth.users` delete cascade). The list is the user's work product and survives as a plain list. + +## Reused tables (no schema change) +- `prepped_meals` (0003): yield target — `origin='PREP_SESSION'`, `meal_prep_session_id` set, + `recipe_id`/`recipe_name` set (INV-INV-006). `meal_prep_session_id` column already exists. +- `portion_events` (0004): `INITIALIZED` event per yielded portion (via `addPreppedMeal()`). +- `recipes` / `recipe_ingredients` (0001): read for scaling & shopping aggregation. +- `pantry_items` (0002): read for gap detection. +- `planned_meals` (0006): PREPPED source already supports prepped portions (feature 004). + +## Client types (new — `src/lib/planning/mealPrep/types.ts`) + +```ts +export type MealPrepSessionStatus = 'PLANNED' | 'COMPLETED' | 'CANCELLED'; + +export interface MealPrepSessionRecipe { + id: string; + recipeId: string; + recipeName: string; + servingsToPrep: number; +} + +export interface MealPrepSession { + id: string; + status: MealPrepSessionStatus; + prepDay: string; // ISO date + completedAt: string | null; + recipes: MealPrepSessionRecipe[]; +} + +export interface NewMealPrepSession { + prepDay: string; + recipes: { recipeId: string; recipeName: string; servingsToPrep: number }[]; // length >= 1 +} + +/** Per-recipe storage choice captured at completion. */ +export interface YieldChoice { + sessionRecipeId: string; + storageLocation: 'FRIDGE' | 'FREEZER'; +} + +/** Pure prep→shopping output (prepShoppingList.ts). */ +export interface PrepShoppingGapItem { + name: string; + unit: string; + quantity: number; + forRecipes: string[]; // recipe names that contribute this item +} +``` + +## State transitions + +``` + create (>=1 recipe) + ∅ ───────────────────────────▶ PLANNED + │ complete() [guard: no existing yielded portions] + │ ├─ yield N prepped_meals (origin=PREP_SESSION, INITIALIZED events) + │ └─ set status=COMPLETED, completed_at=now() + ▼ + COMPLETED (terminal) + PLANNED ──cancel()──▶ CANCELLED (terminal; no portions created) +``` +Completion is idempotent: re-running `complete()` on a session that already has linked prepped +portions performs no further yield (FR-PP-014). diff --git a/.specswarm/features/009-meal-prep/plan.md b/.specswarm/features/009-meal-prep/plan.md new file mode 100644 index 0000000..bb198d4 --- /dev/null +++ b/.specswarm/features/009-meal-prep/plan.md @@ -0,0 +1,133 @@ +# Implementation Plan: Meal Prep — Batch Sessions & Make-Ahead Integrations + +**Feature**: 009-meal-prep +**Spec**: [spec.md](./spec.md) +**Created**: 2026-06-13 +**Status**: Planning complete + +## Summary + +Build the batch meal-prep **session** subsystem and its two integration seams, on top of the +already-shipped direct-entry prepped-meal stack (feature 001) and fulfillment derivation +(feature 004). A session selects recipes + servings, schedules a prep day, and on completion +**yields** prepped portions into inventory (origin `PREP_SESSION`) — which already read HAVE_IT. +Two integrations: prep→shopping list (`FROM_PREP`) and a passive prepped-inventory suggestion in +planning. Distribution into the meal plan is deferred (yield-to-inventory-only, per clarification). + +## Technical Context + +- **Language / runtime**: TypeScript 5 (strict), Svelte 5 runes, SvelteKit `adapter-static` SPA. +- **Data access**: client-side `supabase-js` against PostgREST; no server load functions (P1/P2). +- **Persistence**: PostgreSQL via Supabase CLI migrations (next: `0009`, `0010`). +- **State**: rune-based stores (`*.svelte.ts`) with optimistic update + re-sync, mirroring + `preppedMealStore.svelte.ts`. +- **Styling**: design tokens + `.nk-*` primitives (P4). +- **Testing**: vitest two-project split (server/node for services + pure logic; client/chromium for + components) + pgTAP for RLS. Per worktree-env memory, RLS verified via pgTAP + server project; the + browser project is not relied on in agent sandboxes. +- **New technologies**: **none** — entirely existing approved stack. + +## Constitution Check + +| Principle | Relevance | How satisfied | +|-----------|-----------|---------------| +| P1/P2 SPA only | data access | All session/shopping logic is client-side `supabase-js`; no `+server`/server load. | +| P3 Runes | new store/components | New store + components use runes only. | +| P4 Design tokens | new UI | Session UI uses `var(--token)` / `.nk-*`; no raw values. | +| P5 No unsanitized HTML | recipe/portion names | Render via Svelte escaping; no `{@html}`. | +| P7 RLS default-deny | 2 new tables | `meal_prep_sessions`, `meal_prep_session_recipes` get RLS enabled + owner policy **before** exposure; explicit Data API grants (per project memory on the 2026-05-30 grant flip). | +| P8 UUID PKs | new tables | `gen_random_uuid()` PKs. | +| P9 timestamptz UTC | timestamps | `created_at`/`updated_at`/`completed_at` are `timestamptz`. | +| P12 tests assert; server isolation | services | Pure logic + services in node project; every test asserts. | +| P13 No clinical labels | session UI copy | Shame-free, plain language. | +| P14 Append-only ledger | yield | Yield writes portions **only** via `portion_events` INITIALIZED (reuse `addPreppedMeal()`); never blind-writes `portions_remaining`. | +| P15 Online-first | completion | Server authoritative; completion idempotency guarded by querying existing yielded portions, not a local flag. | + +**Gate result: PASS** — no new tech, no prohibited patterns, no violations to justify. + +## Tech Stack Compliance Report + +### ✅ Approved Technologies (already in stack) +TypeScript 5, Svelte 5 (runes), SvelteKit adapter-static, supabase-js, PostgreSQL/Supabase, vitest, +pgTAP, Playwright. No Technical Context entry introduces a library absent from `.specswarm/tech-stack.md`. + +### ➕ New Technologies (auto-added) +None. + +### ⚠️ Conflicting Technologies +None. + +### ❌ Prohibited Technologies +None used. + +`tech-stack.md` version unchanged (no additions). + +## Architecture & Approach + +### Module layout +- `src/lib/planning/mealPrep/` — new submodule for the session aggregate (keeps it in the Planning + module per the logical architecture's module table; prepped portions live in Inventory and are + reused, not duplicated). + - `types.ts` — `MealPrepSession`, `MealPrepSessionRecipe`, drafts, row mappers. + - `mealPrepService.ts` — CRUD + `completeSession()` (yield) + `cancelSession()`. + - `mealPrepStore.svelte.ts` — rune store (list, create, complete, cancel; optimistic + re-sync). + - `prepShoppingList.ts` — pure ingredient aggregation/scaling + gap computation. +- `src/lib/components/planning/mealPrep/` — `MealPrepSessionForm.svelte` (recipe+servings picker, + prep-day), `MealPrepSessionCard.svelte`, `CompleteSessionDialog.svelte` (per-portion storage), + `MealPrepOverview.svelte`. +- Wire-in: enable the **disabled** "Start a prep session" button in `PreppedMealOverview.svelte` + (currently `aria-disabled`, "coming soon") to open the session flow. + +### Pre-req fix (discovered empirically — Design A) +`addPreppedMeal()` currently inserts the row **and** fires a positive `INITIALIZED` portion event, +which the client guard (`portionLedger.ts`), the DB CHECK, and INV-INV-011 all reject — so it throws +against any real backend (the unit test mocks the ledger, hiding it). Fix: **remove the +`INITIALIZED` event insertion** (and its now-unneeded re-fetch round-trip). The row insert already +sets `portions_remaining = original_portions`; the ledger records only consume/adjust deltas. This +repairs direct-entry and is the seam batch-yield reuses. Add non-mocked coverage (pgTAP + a service +test that does not mock `insertPortionEvent`). + +### Yield flow (completion) +1. Guard: load `prepped_meals` where `meal_prep_session_id = session.id`. If any exist → already + yielded; treat completion as idempotent no-op (FR-PP-014). +2. For each session recipe, call the (now-fixed) `addPreppedMeal()` with + `origin='PREP_SESSION'`, `recipe_id`, `recipe_name`, `meal_prep_session_id`, `original_portions = + servingsToPrep`, storage location + computed `expiration_date` from the storage-default shelf + life. This reuses the INITIALIZED-event seam (P14) verbatim — no new portion-writing path. +3. Update session `status='COMPLETED'`, `completed_at=now()`. +Order chosen so a mid-flow failure leaves a still-`PLANNED` session whose existing partial portions +are detected by the step-1 guard on retry (no double-yield). + +### Prep → shopping +- `prepShoppingList.ts` (pure): given session recipes + their ingredients + base servings + pantry + index, produce gap items annotated with contributing recipe names. Unit handling: aggregate per + (normalized-name, unit); presence-by-name drop against pantry (no unit math, matches fulfillment v1). +- `mealPrepService.generatePrepShoppingList()` writes a `shopping_lists` row (`source_type='FROM_PREP'`, + `meal_prep_session_id` set) + `shopping_list_items`, reusing the shopping service insert path. + +### Planning integration +- PREPPED placement / picker already exists (feature 004). This feature adds a **passive** prepped + preview (expiration-aware) at the planning surface and regression-guards REQ-PP-027 decrement for + `PREP_SESSION`-origin portions. No forcing logic (REQ-PP-026). + +## Phasing (maps to tasks.md) +1. **Slice 0 — Verify direct-entry → HAVE_IT** (small, "first"): regression tests only (FR-PP-001/002). +2. **Slice 1 — Schema**: migrations `0009` (sessions tables + RLS + grants), `0010` (shopping + `FROM_PREP` enum value + `meal_prep_session_id` + constraint). pgTAP RLS tests. `gen types`. +3. **Slice 2 — Session core**: types, service (CRUD + complete/cancel + idempotent yield), store, unit tests. +4. **Slice 3 — Session UI**: form, card, complete dialog, overview; enable the disabled button; component tests. +5. **Slice 4 — Prep→shopping**: pure aggregation + service write + tests. +6. **Slice 5 — Planning preview + decrement regression**: passive preview surface + tests. + +## Risks / Open follow-ups +- **Idempotent multi-insert without a transaction** (online-first, no server function): mitigated by + the existing-yield guard; acceptable for single-user MVP. Documented, not solved with a txn. +- **Ingredient unit heterogeneity** across recipes: v1 aggregates by (name, unit) and lists both if + units differ rather than converting. Recorded as a known simplification. +- **Pantry deduction on completion** deferred (Excluded in spec) — revisit with quantity-aware inventory. + +## Generated artifacts +- [research.md](./research.md) +- [data-model.md](./data-model.md) +- [contracts/meal-prep-service.md](./contracts/meal-prep-service.md) +- [quickstart.md](./quickstart.md) diff --git a/.specswarm/features/009-meal-prep/quickstart.md b/.specswarm/features/009-meal-prep/quickstart.md new file mode 100644 index 0000000..ea4a4dd --- /dev/null +++ b/.specswarm/features/009-meal-prep/quickstart.md @@ -0,0 +1,42 @@ +# Quickstart: Meal Prep — Batch Sessions + +## What this feature adds +Batch meal-prep **sessions**: pick recipes + servings, schedule a prep day, build a shopping list +for the gap, and on completion **yield** prepped portions into inventory — which then read HAVE_IT +on the planning calendar. Direct-entry prepped meals (already shipped) are left as-is; this closes +the make-ahead side with the batch path. + +## Try it (after implementation) +1. `bun install && bun run dev` +2. Sign in, go to **Pantry → Prepped**. The previously-disabled **"Start a prep session"** button + is now active — click it. +3. Add **two recipes** with servings (e.g. Chili ×6, Oats ×5). Keep the suggested prep day (next + weekend) or change it. +4. Click **Build shopping list** → a `FROM_PREP` list appears under Shopping, containing only items + not already in your pantry, each noting which recipe needs it. +5. Click **Mark prepped** → choose fridge/freezer per item → **11 portions** appear under + Pantry → Prepped with sensible eat-by dates. +6. Go to **Plan**, add a meal for any day, pick the prepped Chili portion → the day shows + **HAVE_IT**. Log it → the portion count drops. + +## Verify +```bash +bun run check # typecheck +bun run lint # prettier + eslint +bun run test:unit --run # services + pure logic + components (node/chromium split) +# RLS (server-side; per worktree-env memory, prefer pgTAP over the browser project in sandboxes): +supabase test db # pgTAP — meal_prep_sessions / _recipes owner-only + anon-zero-rows +``` + +## Migrations +- `0013_meal_prep_sessions.sql` — `meal_prep_sessions` + `meal_prep_session_recipes` (+ enum, RLS, grants). +- `0014_shopping_from_prep_enum.sql` / `0015_shopping_from_prep_link.sql` — adds `FROM_PREP` to `shopping_list_source` (the enum value must be committed before any DDL references it), then `shopping_lists.meal_prep_session_id` + CHECK. +After applying, regenerate types from this worktree: +```bash +supabase gen types typescript --local > src/lib/database.types.ts +``` + +## Scope reminders +- **Yield-to-inventory only.** Completing a session does NOT auto-build/extend the meal plan + (REQ-PP-005..009 deferred). Place portions onto days via the existing planning flow. +- **No pantry deduction** on completion in v1 (presence-by-name inventory model). diff --git a/.specswarm/features/009-meal-prep/research.md b/.specswarm/features/009-meal-prep/research.md new file mode 100644 index 0000000..984b46b --- /dev/null +++ b/.specswarm/features/009-meal-prep/research.md @@ -0,0 +1,69 @@ +# Research: Meal Prep — Batch Sessions + +All NEEDS CLARIFICATION items were resolved during /ss:specify and /ss:clarify (two scope forks + +one schema-resolved scaling question). This file records the design decisions and the alternatives +weighed. + +## D1 — Where does the session aggregate live? +**Decision**: Planning module (`src/lib/planning/mealPrep/`), reusing Inventory's prepped-meal stack +for yield. +**Rationale**: The logical architecture (§3 module table) lists `meal_prep_sessions` under Planning, +`prepped_meals`/`portion_events` under Inventory. Sessions *produce* prepped portions; they don't own +them. Keeping the boundary avoids duplicating the portion ledger. +**Alternatives**: Put sessions under `pantry/` next to prepped meals — rejected; conflates planning +intent with inventory state and crosses the documented module boundary. + +## D2 — How is yield made idempotent without a transaction? +**Decision**: Before yielding, query `prepped_meals` for rows with `meal_prep_session_id = session.id`. +If any exist, completion is a no-op. Yield portions first, then flip `status=COMPLETED`. +**Rationale**: SPA + online-first (P15, P1/P2): no server function, so no multi-statement DB +transaction from the client. The `meal_prep_session_id` linkage is itself the idempotency key — it +already exists on `prepped_meals` (migration 0003). A retry after partial failure detects the partial +portions and won't double-create. +**Alternatives**: (a) A local "yielded" boolean — rejected, not server-authoritative (P15). (b) An +Edge Function doing a transactional yield — viable but heavier; deferred, not needed for single-user +MVP. (c) Flip status first then yield — rejected; a failure after status-flip would strand a +"COMPLETED" session with no portions and no retry path. + +## D3 — Reuse `addPreppedMeal()` for yield vs. a bespoke insert? +**Decision**: Reuse `addPreppedMeal()` (preppedMealService.ts:35) once per session recipe. +**Rationale**: It already encapsulates the row-insert + `INITIALIZED` portion event + re-fetch, +honoring P14 (append-only ledger) and INV-CC-006. A bespoke path would duplicate and risk drifting +from the ledger invariant. +**Alternatives**: Direct multi-row insert + manual events — rejected; re-implements an invariant-bearing +seam. + +## D4 — Ingredient scaling & pantry gap for prep→shopping +**Decision**: required = `recipe_ingredient.quantity × (servingsToPrep / recipe.servings)`. Gap = +required ingredients whose normalized name is absent from the pantry-on-hand index (quantity > 0). +**Rationale**: `recipes.servings` is `NOT NULL CHECK 1..100` and `recipe_ingredients.quantity` is +`NOT NULL > 0` (0001_recipes.sql), so scaling is total. Presence-by-name gap detection matches +fulfillment v1 (`fulfillment.ts`) — consistent inventory semantics, no unit math in v1. +**Alternatives**: Unit-aware quantity reconciliation against pantry quantities — rejected for v1; +fulfillment itself does no quantity math, so introducing it only here would be inconsistent. + +## D5 — Shopping `FROM_PREP` schema shape +**Decision**: `ALTER TYPE shopping_list_source ADD VALUE 'FROM_PREP'`; add nullable +`meal_prep_session_id` to `shopping_lists` with a CHECK: `FROM_PREP ⇒ session id set`, and the +existing FROM_PLAN range CHECK left intact. INV-XD-004. +**Rationale**: The shopping migration (0007:13) explicitly deferred `FROM_PREP`; this is the planned +follow-up. `ALTER TYPE ... ADD VALUE` is the minimal, additive change. +**Gotcha**: Postgres historically disallowed using a newly-added enum value in the *same* transaction. +Put the `ADD VALUE` in its own migration step (or before the CHECK that references it) so a fresh +`supabase start` applies cleanly. The migration is authored to add the value first, then the column + +constraint. +**Alternatives**: A separate `shopping_list_prep_sources` link table — rejected; over-engineered for a +single nullable FK that mirrors the existing FROM_PLAN range columns' pattern. + +## D6 — Suggested prep day +**Decision**: default to the nearest upcoming weekend day (Sat/Sun; today if today is one), +user-overridable to any non-past date. +**Rationale**: REQ-PP-003 ("common prep days, e.g. weekends") + REQ-PP-004 (override). Pure date logic, +unit-testable, no timezone surprises (compare on local calendar date). + +## D7 — Per-portion vs per-session storage location at completion +**Decision**: capture a storage location **per yielded recipe** at completion, defaulting all to one +session-level choice the user can change per line. +**Rationale**: REQ-PP-016 (freezer vs fridge drives shelf life) needs per-portion granularity (some +items freeze, some don't), but a single default keeps the common case one tap. +**Alternatives**: Single session-wide location — rejected; forces all-or-nothing freezing. diff --git a/.specswarm/features/009-meal-prep/spec.md b/.specswarm/features/009-meal-prep/spec.md new file mode 100644 index 0000000..09eda9c --- /dev/null +++ b/.specswarm/features/009-meal-prep/spec.md @@ -0,0 +1,271 @@ +--- +parent_branch: worktree-feat+meal-prep +feature_number: 006 +status: In Progress +created_at: 2026-06-13T15:10:00-05:00 +references_consulted: + - nexus-kitchen-requirements.md + - nexus-kitchen-invariants.md + - nexus-kitchen-logical-architecture.md + - nexus-kitchen-differentiator.md + - nexus-kitchen-domain-specification.md +--- + +# Feature: Meal Prep — Batch Sessions & Make-Ahead Integrations + +## Overview + +This feature closes the **make-ahead side** of the product's core loop (per the Differentiator: +"move every requirement to *have it* before it's due"). Prepped portions are the canonical +**HAVE_IT** source — a planned meal sourced from a prepped portion with portions remaining > 0 +already reads HAVE_IT today (feature 004, INV-PL-017). What's missing is a way to *produce* +those portions in bulk and to feed the operations around them. + +The make-ahead capability is delivered in two slices, matching the user's framing: + +1. **Direct entry (small — already built, verified here).** Adding a ready-to-eat portion + directly to inventory (origin `DIRECT_ENTRY` / `STORE_BOUGHT`) is production-ready from + feature 001 (`PreppedMealForm`, portion ledger, `/pantry?tab=PREPPED`). This slice is a + **verification pass** that confirms direct-entry portions reach inventory and correctly + produce HAVE_IT end-to-end — no rebuild, regression coverage only, plus closing any small + gap in the PREPPED calendar picker. + +2. **Batch meal-prep sessions (bigger — the real build).** A session lets the user pick one or + more recipes with servings to prepare, schedule a prep day, and on completion **yield** + those servings into inventory as `PREP_SESSION`-origin prepped portions. This is the missing + subsystem (`meal_prep_sessions` does not exist; the "Start a prep session" button is disabled + with "coming soon"). It carries two integrations: + - **Prep → Shopping (REQ-PP-019..022):** aggregate the ingredients a session needs across all + its recipes, subtract what's on hand, and generate a shopping list for the gap. + - **Planning (REQ-PP-023..026):** prepped inventory is surfaced as a non-forcing suggestion + when planning, and prepped portions are placeable on the calendar (the HAVE_IT path). + +**Distribution is explicitly out of scope (clarified):** a completed session yields portions to +inventory only. It does **not** auto-generate or extend the meal plan, compute a planning +horizon, or replan (REQ-PP-005..009). Users place prepped portions onto days through the +existing planning flow, which already supports the PREPPED source. + +## Clarifications + +### Session 2026-06-13 + +- Q: Direct entry is already built and already feeds HAVE_IT. What should feature 006 deliver? + → A: **Batch sessions + integrations.** Treat direct-entry as done (verify, don't rebuild). + Build the batch meal-prep session subsystem, plus the prep→shopping-list integration + (REQ-PP-019..022) and the planning suggestion/picker integration (REQ-PP-023..026). +- Q: How far should the first cut of batch sessions go on distributing prepped portions into the + meal plan (REQ-PP-005..009)? + → A: **Yield to inventory only.** A completed session creates the prepped portions and drops + them into inventory. Auto-distribution, planning-horizon calculation, replan, and automatic + day assignment are deferred; users place portions via the existing planning flow. +- Q: Empirical testing found `addPreppedMeal()` (the only prepped-portion creation path) throws + against a real DB — it fires a positive `INITIALIZED` portion event that the client guard, + the DB CHECK, and INV-INV-011 all forbid; the unit test only passes because it mocks the + ledger. Direct-entry is therefore **not** actually working, and batch-yield would inherit the + bug. How to handle? + → A: **Fix it (Design A).** Remove the redundant positive `INITIALIZED` event — the + `prepped_meals` row insert already sets `portions_remaining = original_portions` directly, and + the ledger records only consume/adjust deltas (consistent with INV-INV-011 + INV-CC-006). + Add non-mocked coverage so it can't regress silently. Batch-yield then reuses the **fixed** + `addPreppedMeal()`. This makes the "direct-entry first (small)" slice a real, foundational fix. +- Q: How are ingredient requirements scaled for prep→shopping when servings-to-prepare differs + from the recipe's yield? *(Auto-resolved from schema, not asked.)* + → A: **Scale by base yield.** Required quantity = `recipe_ingredient.quantity × + (servingsToPrep / recipe.servings)`. Source: `0001_recipes.sql` — `recipes.servings` is + `integer NOT NULL CHECK (servings BETWEEN 1 AND 100)` and `recipe_ingredients.quantity` is + `numeric NOT NULL CHECK (quantity > 0)` with a `unit`. No base-yield ambiguity remains. + +## Scope + +**Included:** + +- **Verification slice (direct entry → HAVE_IT):** regression coverage proving a `DIRECT_ENTRY` + / `STORE_BOUGHT` portion with portions remaining > 0 produces HAVE_IT for a PREPPED planned + meal, and that the prepped portion is placeable on the calendar. Fix any gap found; no rebuild. +- **Batch meal-prep sessions:** + - Create a session by selecting **one or more recipes** (INV-PL-006) with a **positive + servings-to-prepare** per recipe (REQ-PP-001/002, INV-PL-007). + - A **suggested prep day** defaulting to the next common prep day (weekend), user-overridable + (REQ-PP-003/004). + - Session lifecycle `PLANNED → COMPLETED` (with completion timestamp, INV-PL-008) and + `PLANNED → CANCELLED` (discard before cooking). + - On **completion**, **yield** servings into inventory: one `prepped_meals` row per recipe with + `origin = PREP_SESSION`, `recipe_id` + denormalized `recipe_name` set (INV-INV-006), + `meal_prep_session_id` set, `portions_remaining = original_portions = servingsToPrep`, a + default shelf life applied by storage location (REQ-PP-012), and an `INITIALIZED` portion + event (INV-CC-006). Storage location and prepared date are captured at completion. +- **Prep → Shopping integration (REQ-PP-019..022):** + - Aggregate required ingredients across all selected recipes scaled by servings-to-prepare. + - Compare against current pantry on-hand (presence-by-name, consistent with fulfillment v1) + and drop satisfied items. + - Generate a `FROM_PREP` shopping list for the remaining gap, referencing the session + (INV-XD-004), with each item annotated by which recipe(s) require it (REQ-PP-022). +- **Planning integration (REQ-PP-023..026):** + - Prepped portions are surfaced as a **non-forcing suggestion** with expiration indicators when + planning (REQ-PP-025/026), and remain placeable on the calendar as the PREPPED source + (REQ-PP-023; verifies/extends the feature-004 picker). + - REQ-PP-027 (auto-decrement prepped inventory on meal logging) already exists; this feature + confirms it still holds for `PREP_SESSION`-origin portions. + +**Excluded (deferred):** + +- **Auto-distribution & planning horizon (REQ-PP-005..009):** generating/extending a meal plan + from a completed session, computing the default horizon `(new + existing servings) / meals per + day`, manual horizon override, one-action replan, and manual day reassignment. Deferred per the + "yield to inventory only" decision. The schema leaves room to add this later without rework. +- **Pantry quantity deduction on completion:** the logical architecture (§4.2) notes completion + could deduct pantry quantities. v1 keeps the presence-by-name inventory model (matching + fulfillment v1, which does no quantity math) and does **not** mutate pantry quantities on + completion. Captured as a known follow-up. +- **Step-by-step / timed cooking execution during a session.** A session tracks intent and yield, + not a guided cook-along. +- The full preference-driven *generation* prompt (REQ-PP-024) is meaningful only alongside + auto-distribution and is deferred with it; the prepped-inventory **preview** (REQ-PP-025) is + retained as a passive suggestion surface. + +## User Scenarios + +### Scenario 1 — Sunday batch cook (primary) +A user opens meal prep and starts a session. They add "Chili" (6 servings) and "Overnight oats" +(5 servings), keep the suggested prep day (next Sunday), and tap "Build shopping list." The app +shows the ingredients both recipes need, minus what's already in the pantry, as a single +`FROM_PREP` list — each line noting which recipe it's for. On Sunday after cooking, they open the +session and tap "Mark prepped," choosing fridge for the oats and freezer for half the chili. Eleven +prepped portions appear in `/pantry?tab=PREPPED`, each with a sensible eat-by date. + +### Scenario 2 — Portions become HAVE_IT +Later that week the user plans Tuesday dinner and picks the prepped chili portion. The Tuesday meal +card immediately shows **HAVE_IT** (no shopping, no cooking needed). Logging the meal decrements the +chili portions remaining; when it hits zero, future planned meals referencing it read MUST_ACQUIRE. + +### Scenario 3 — Verify direct entry still feeds HAVE_IT (regression) +A user adds a store-bought lasagna directly (2 portions, freezer) via the existing Prepped tab, +then places it on Thursday. Thursday reads HAVE_IT. (No new UI; this scenario guards the existing +path.) + +### Scenario 4 — Cancel before cooking +A user creates a session, builds the shopping list, then changes plans and cancels the session. No +prepped portions are created; the session is marked CANCELLED and leaves inventory untouched. + +## Functional Requirements + +### A. Verification slice — direct entry → HAVE_IT +- **FR-PP-001:** The system MUST have regression coverage proving a prepped portion of origin + `DIRECT_ENTRY` or `STORE_BOUGHT` with `portions_remaining > 0`, when referenced by a PREPPED + planned meal, derives **HAVE_IT** (guards feature 004 / INV-PL-017). +- **FR-PP-002:** A prepped portion with portions remaining MUST be selectable as the source when + placing a planned meal on the calendar (PREPPED source). If the feature-004 picker is missing or + regressed in this branch, this feature closes that gap; otherwise it is covered by tests only. + +### B. Batch meal-prep sessions +- **FR-PP-010:** Users MUST be able to create a meal-prep session containing **one or more** + recipes (a session with zero recipes cannot be created — INV-PL-006). +- **FR-PP-011:** Each recipe in a session MUST carry a **positive integer servings-to-prepare** + (INV-PL-007); non-positive values are rejected. +- **FR-PP-012:** The system MUST suggest a prep day defaulting to the next weekend day, and MUST + allow the user to override it to any date not in the past (REQ-PP-003/004). +- **FR-PP-013:** A session MUST have a status of `PLANNED`, `COMPLETED`, or `CANCELLED`. A new + session starts `PLANNED`. +- **FR-PP-014:** Completing a session MUST record a completion timestamp (INV-PL-008) and MUST be + idempotent — completing an already-completed session does not double-yield portions. +- **FR-PP-015:** On completion, the system MUST create exactly one prepped portion record per + session recipe, with `origin = PREP_SESSION`, `recipe_id` and denormalized `recipe_name` set + (INV-INV-006), `meal_prep_session_id` referencing the session, + `portions_remaining = original_portions = servingsToPrep`, and an `INITIALIZED` portion event of + the same magnitude (INV-CC-006). +- **FR-PP-016:** At completion the user MUST choose a **storage location** (fridge/freezer) per + yielded portion (or a session default), and the system MUST apply the storage-appropriate default + shelf life to compute `expiration_date` (> `prepared_date`, INV-INV-009; REQ-PP-012). Freezer + yields MUST be marked `FROZEN` (INV-INV-007). +- **FR-PP-017:** Cancelling a `PLANNED` session MUST NOT create any prepped portions and MUST leave + pantry and inventory unchanged. +- **FR-PP-018:** A session and its recipes MUST be owner-scoped under RLS (default-deny; owner + full access), consistent with `prepped_meals` and `pantry_items`. + +### C. Prep → Shopping integration (REQ-PP-019..022) +- **FR-PP-020:** From a session, the system MUST aggregate required ingredients across all its + recipes, scaling each recipe's ingredients by that recipe's servings-to-prepare relative to the + recipe's base yield. +- **FR-PP-021:** The aggregated requirement MUST be compared against current pantry on-hand + (normalized presence-by-name, quantity > 0) and satisfied items dropped, leaving only the gap. +- **FR-PP-022:** The system MUST generate a shopping list of source type `FROM_PREP` for the gap, + referencing the session (INV-XD-004), and each item MUST indicate which recipe(s) require it + (REQ-PP-022). +- **FR-PP-023:** Generating the prep shopping list MUST NOT require the session to be completed; it + is available while the session is `PLANNED`. + +### D. Planning integration (REQ-PP-023..026) +- **FR-PP-030:** When planning, the system MUST surface current prepped inventory as a passive + suggestion with expiration indicators (REQ-PP-025) without forcing its use (REQ-PP-026). +- **FR-PP-031:** A `PREP_SESSION`-origin portion MUST decrement through the same consume path as + any other origin (the CONSUMED portion-event ledger), and its planned-meal fulfillment MUST move + from HAVE_IT to MUST_ACQUIRE once exhausted. *Note:* REQ-PP-027's **automatic** decrement on + meal-**logging** is deferred with the meal-logging feature — no `meal_logs` table/flow exists yet; + this feature guarantees the consume mechanism works identically for session yields, not an + auto-on-log trigger. + +## Key Entities + +- **MealPrepSession** (`meal_prep_sessions`, new) — owner-scoped batch-cook intent. Fields: + id (uuid PK), owner_id, household_id (nullable, no FK yet), status + (`PLANNED`/`COMPLETED`/`CANCELLED`), prep_day (date), completed_at (timestamptz, set iff + COMPLETED — INV-PL-008), created_at, updated_at. Has ≥1 recipe (INV-PL-006). +- **MealPrepSessionRecipe** (`meal_prep_session_recipes`, new) — a recipe line within a session: + id, meal_prep_session_id (FK, ON DELETE CASCADE), recipe_id (FK → recipes), recipe_name + (denormalized snapshot), servings_to_prep (int > 0 — INV-PL-007). Unique (session, recipe). +- **PreppedMeal** (`prepped_meals`, existing) — yield target; `origin = PREP_SESSION`, + `meal_prep_session_id` set, `recipe_id`/`recipe_name` set (INV-INV-006). +- **PortionEvent** (`portion_events`, existing) — `INITIALIZED` event written per yielded portion. +- **ShoppingList** (`shopping_lists`, existing — altered) — gains `FROM_PREP` source value and a + nullable `meal_prep_session_id` (INV-XD-004); FROM_PLAN range constraint left intact. + +## Success Criteria + +- **SC-001:** A user can go from "no prepped food" to portions in inventory entirely through a + batch session — create → (optional) build shopping list → complete → portions visible in + `/pantry?tab=PREPPED` — without touching the direct-entry form. +- **SC-002:** A planned meal sourced from a session-yielded portion shows HAVE_IT within one screen + refresh of placement, with no shopping or cooking action required. +- **SC-003:** A session with N recipes yields exactly N prepped portion records on completion, each + with the correct servings count, recipe link, and a valid future eat-by date; re-completing yields + no duplicates. +- **SC-004:** A prep shopping list contains only ingredients not already on hand, and every line + names the recipe(s) that need it. +- **SC-005:** Cancelling a planned session leaves inventory and pantry byte-for-byte unchanged. +- **SC-006:** All existing direct-entry, fulfillment, planning, and shopping tests continue to pass + (no regression to the make-ahead path already shipped). + +## Assumptions + +- **Servings ⇒ portions 1:1.** One serving-to-prepare yields one prepped portion unit; no + per-recipe portion-size conversion in v1. +- **Ingredient scaling uses recipe base yield (confirmed against schema).** Required quantity = + `recipe_ingredient.quantity × (servingsToPrep / recipe.servings)`. `recipes.servings` is a + guaranteed non-null base yield (1..100) and `recipe_ingredients.quantity` is non-null > 0, so + no fallback or default-yield branch is needed. +- **Presence-by-name pantry match.** Prep→shopping gap detection uses the same normalized + name-presence rule as fulfillment v1 (`trim().toLowerCase().replace(/\s+/g,' ')`, quantity > 0); + no unit math. +- **Default shelf lives** follow REQ-PP-012 storage defaults already encoded for direct entry + (reuse the existing fridge/freezer defaults rather than inventing new ones). +- **Weekend = Saturday/Sunday** for the suggested prep day; "next weekend day" is the nearest + upcoming Sat or Sun (today if today is one). +- **No auto meal-plan generation** (deferred); `planning_horizon_days` is intentionally NOT added + to the schema in v1 to avoid an unused, constraint-bearing column (INV-PL-009 applies only once + distribution lands). +- Pantry quantities are **not** decremented on completion in v1 (see Excluded). + +## Sources + +This spec was generated by consulting the following references (per `.specswarm/references.md`): + +| Source | Sections informing this spec | +|--------|------------------------------| +| `nexus-kitchen-requirements.md` | §3.10.1 Session Planning (REQ-PP-001..009), §3.10.2 Prepped Inventory (REQ-PP-010..018), §3.10.3 Shopping integration (REQ-PP-019..022), §3.10.4 Planning integration (REQ-PP-023..027), §3.10.5 Direct Entry (REQ-PP-028/029), REQ-MP-012 | +| `nexus-kitchen-invariants.md` | INV-PL-006/007/008/009 (session rules), INV-XD-003/004 (consume + shopping-from-prep), INV-INV-006/007/009 (prepped recipe ref, freezer, expiration), INV-CC-006 (portion ledger), INV-PL-017 (fulfillment derived) | +| `nexus-kitchen-logical-architecture.md` | §4.2 Meal Prep Session flow (completion → create prepped_meals, deduct pantry, distribute), §3 module table (planning/inventory tables) | +| `nexus-kitchen-differentiator.md` | "move every requirement to *have it*" thesis; make-ahead framing | +| Feature 004 `spec.md` / `data-model.md` (in-repo) | Existing PREPPED placement + fulfillment derivation that this feature builds on | + +No requirement was fabricated without a corresponding source citation, an explicit Assumption, or a +deferral recorded in Excluded. diff --git a/.specswarm/features/009-meal-prep/tasks.md b/.specswarm/features/009-meal-prep/tasks.md new file mode 100644 index 0000000..ced3462 --- /dev/null +++ b/.specswarm/features/009-meal-prep/tasks.md @@ -0,0 +1,128 @@ +# Tasks: Meal Prep — Batch Sessions & Make-Ahead Integrations + + + + + +**Feature**: 009-meal-prep +**Plan**: [plan.md](./plan.md) · **Spec**: [spec.md](./spec.md) · **Data model**: [data-model.md](./data-model.md) + +Legend: `[P]` parallelizable (different files, no ordering dep) · `[US#]` user story. +MVP = User Story 1 (batch session core → yield → HAVE_IT). + +--- + +## Phase 1 — Setup & Schema (foundational; blocks all stories) + +- [X] T001 Write migration `supabase/migrations/0013_meal_prep_sessions.sql` — `meal_prep_session_status` enum; `meal_prep_sessions` + `meal_prep_session_recipes` tables with all columns, CHECKs (INV-PL-007, INV-PL-008 completed⟺completed_at, UNIQUE(session,recipe)), indexes, and `updated_at` touch trigger per data-model.md — supabase/migrations/0013_meal_prep_sessions.sql +- [X] T002 In the same migration, enable RLS (P7 default-deny) with owner-scoped policies for both tables and add explicit Data API grants (authenticated CRUD, service_role ALL, anon SELECT) per the project grant-flip convention — supabase/migrations/0013_meal_prep_sessions.sql +- [X] T003 Write the shopping `FROM_PREP` change as TWO migrations (the new enum value must be committed before any DDL references it): `0014_shopping_from_prep_enum.sql` (`ALTER TYPE shopping_list_source ADD VALUE IF NOT EXISTS 'FROM_PREP'`) and `0015_shopping_from_prep_link.sql` (`shopping_lists.meal_prep_session_id` FK ON DELETE SET NULL + `shopping_lists_from_prep_has_session` CHECK, INV-XD-004) — supabase/migrations/0014_shopping_from_prep_enum.sql, supabase/migrations/0015_shopping_from_prep_link.sql +- [X] T004 Apply migrations to the local stack and regenerate types from this worktree: `supabase gen types typescript --local > src/lib/database.types.ts` (verify the two new tables + `FROM_PREP`/`meal_prep_session_id` appear) — src/lib/database.types.ts + +**Checkpoint:** schema + types exist; PostgREST can see both tables under RLS. + +--- + +## Phase 2 — Direct-entry first (small): FIX portion creation + verify → HAVE_IT + +Empirical testing found `addPreppedMeal()` throws against a real DB (positive `INITIALIZED` event +forbidden by client guard + DB CHECK + INV-INV-011). Fix it (Design A) — this is the foundational +seam batch-yield reuses. Then verify the make-ahead path (FR-PP-001/002). + +- [X] T005 [US0] Fix `src/lib/pantry/preppedMealService.ts` `addPreppedMeal()` — remove the positive `INITIALIZED` `insertPortionEvent` call (and the now-redundant re-fetch); the row insert already sets `portions_remaining = original_portions`. Keep returning the inserted row mapped via `toPreppedMeal` (Design A; INV-INV-011/INV-CC-006) — src/lib/pantry/preppedMealService.ts +- [X] T006 [US0] Update `src/lib/pantry/preppedMealService.spec.ts` — replace the "calls insertPortionEvent with kind INITIALIZED" expectation with "does NOT fire a positive INITIALIZED event on add"; keep CONSUMED/ADJUSTED expectations — src/lib/pantry/preppedMealService.spec.ts +- [X] T007 [P] [US0] Add a **non-mocked** pgTAP test that creating a prepped portion (direct INSERT into `prepped_meals`) succeeds with `portions_remaining > 0` and that a positive `INITIALIZED` `portion_events` row is rejected by the constraint (locks the invariant the mock was hiding) — supabase/tests/portion_creation.test.sql +- [X] T008 [P] [US0] Regression test: a `DIRECT_ENTRY`/`STORE_BOUGHT` prepped portion with `portions_remaining > 0`, referenced by a PREPPED planned meal, derives `HAVE_IT` (`src/lib/planning/fulfillment.ts`) — src/lib/planning/fulfillment.haveit.spec.ts +- [X] T009 [P] [US0] Confirm a prepped portion is selectable as a PREPPED source when placing a planned meal (feature-004 picker); if missing in this branch, implement the minimal selector — src/lib/planning/preppedPicker.spec.ts + +**Checkpoint:** prepped-portion creation actually works against a real DB; direct-entry → HAVE_IT regression-locked. This unblocks batch-yield (which reuses the fixed seam). + +--- + +## Phase 3 — User Story 1 (P1): Batch session core → yield to inventory + +**Goal:** create a session of recipes+servings, schedule a prep day, complete it to yield +`PREP_SESSION` portions into inventory, or cancel it. **Independent test:** create session → complete → +N portions appear in Pantry→Prepped with valid eat-by dates → place one → day reads HAVE_IT. + +- [X] T010 [US1] Create `src/lib/planning/mealPrep/types.ts` — `MealPrepSession(Status)`, `MealPrepSessionRecipe`, `NewMealPrepSession`, `YieldChoice`, `PrepShoppingGapItem`, and row→type mappers per data-model.md — src/lib/planning/mealPrep/types.ts +- [X] T011 [US1] Implement `src/lib/planning/mealPrep/mealPrepService.ts` CRUD: `createSession` (rejects 0 recipes — INV-PL-006 — and servings ≤ 0 — INV-PL-007), `getSessions`/`getSession`, `addRecipe`/`removeRecipe` (reject removing last), `updateServings`, `setPrepDay` (no past date), `cancelSession`; `MealPrepServiceError` mirroring `PreppedMealServiceError` — src/lib/planning/mealPrep/mealPrepService.ts +- [X] T012 [US1] Implement `completeSession(sessionId, yields)` in the same service: idempotency guard (skip if `prepped_meals` already linked to the session), yield one portion per recipe via the **fixed** `addPreppedMeal()` (origin=PREP_SESSION, recipe_id/recipe_name, meal_prep_session_id, portions_remaining=original_portions=servingsToPrep, FROZEN for freezer, expiration from storage shelf-life), then set status=COMPLETED/completed_at (FR-PP-014/015/016, P14) — src/lib/planning/mealPrep/mealPrepService.ts +- [X] T013 [P] [US1] Add `src/lib/planning/mealPrep/prepDay.ts` — pure `suggestPrepDay(today)` returning the nearest upcoming Sat/Sun, and a `isNotPast(date)` guard (REQ-PP-003/004, research D6) — src/lib/planning/mealPrep/prepDay.ts +- [X] T014 [US1] Implement `src/lib/planning/mealPrep/mealPrepStore.svelte.ts` (runes) — `sessions()/sessionsLoading()/sessionsError()/loadSessions()` plus optimistic `create/complete/cancel/addRecipe/removeRecipe`; after complete, trigger `loadPreppedMeals()` so the Prepped tab refreshes — src/lib/planning/mealPrep/mealPrepStore.svelte.ts +- [X] T015 [P] [US1] Unit tests for `mealPrepService` (node project): create-with-0-recipes rejected, servings ≤ 0 rejected, remove-last-recipe rejected, complete yields N portions with correct fields, **re-complete yields no duplicates** (idempotency), cancel creates no portions — src/lib/planning/mealPrep/mealPrepService.spec.ts +- [X] T016 [P] [US1] Unit tests for `prepDay.ts` (weekend suggestion edge cases incl. today-is-weekend; past-date rejection) — src/lib/planning/mealPrep/prepDay.spec.ts +- [X] T017 [US1] Build `MealPrepSessionForm.svelte` — recipe multi-select with per-recipe servings stepper and prep-day picker (defaulted via `suggestPrepDay`); design tokens only (P4), runes (P3); validates ≥1 recipe & servings > 0 — src/lib/components/planning/mealPrep/MealPrepSessionForm.svelte +- [X] T018 [P] [US1] Build `MealPrepSessionCard.svelte` (status, prep day, recipe lines) and `MealPrepOverview.svelte` (list + empty state + "New session") — src/lib/components/planning/mealPrep/MealPrepSessionCard.svelte +- [X] T019 [US1] Build `CompleteSessionDialog.svelte` — per-recipe fridge/freezer choice (session-level default, per-line override) calling `optimisticCompleteSession`; shame-free copy (P13) — src/lib/components/planning/mealPrep/CompleteSessionDialog.svelte +- [X] T020 [US1] Enable the disabled "Start a prep session" button in `src/lib/components/pantry/PreppedMealOverview.svelte` to open the session flow (remove `aria-disabled`/"coming soon") — src/lib/components/pantry/PreppedMealOverview.svelte +- [X] T021 [P] [US1] Component tests (client/chromium project) for `MealPrepSessionForm` (validation) and `CompleteSessionDialog` (yield call shape) — src/lib/components/planning/mealPrep/MealPrepSessionForm.svelte.spec.ts +- [X] T022 [P] [US1] pgTAP RLS test: owner-only SELECT/INSERT/UPDATE/DELETE on `meal_prep_sessions` + `meal_prep_session_recipes`, and anon sees zero rows (P7) — supabase/tests/meal_prep_rls.test.sql + +**Checkpoint (MVP):** a user can batch-create, complete (yield), and cancel sessions; yielded portions read HAVE_IT. + +--- + +## Phase 4 — User Story 2 (P2): Prep → shopping list + +**Goal:** from a planned session, generate a `FROM_PREP` shopping list of the ingredient gap. +**Independent test:** session with 2 recipes → build list → only not-on-hand items, each annotated by recipe. + +- [X] T023 [P] [US2] Implement pure `src/lib/planning/mealPrep/prepShoppingList.ts` — `computePrepShoppingGap(recipes, pantryIndex)`: scale by `servingsToPrep/baseServings`, aggregate by (normName, unit), drop optional ingredients + pantry-present names, annotate `forRecipes` (research D4) — src/lib/planning/mealPrep/prepShoppingList.ts +- [X] T024 [P] [US2] Unit tests for `computePrepShoppingGap` — scaling math, multi-recipe aggregation, pantry-present drop, optional-ingredient exclusion, forRecipes annotation — src/lib/planning/mealPrep/prepShoppingList.spec.ts +- [X] T025 [US2] Add `generatePrepShoppingList(sessionId)` to `mealPrepService.ts` — load recipe ingredients + pantry index, call `computePrepShoppingGap`, write `shopping_lists` (source_type=FROM_PREP, meal_prep_session_id) + `shopping_list_items` reusing the shopping service insert path; available while PLANNED (FR-PP-020..023) — src/lib/planning/mealPrep/mealPrepService.ts +- [X] T026 [US2] Add a "Build shopping list" action to `MealPrepSessionCard`/overview wired to `generatePrepShoppingList`, surfacing the resulting list under Shopping — src/lib/components/planning/mealPrep/MealPrepSessionCard.svelte +- [X] T027 [P] [US2] Service test for `generatePrepShoppingList` — writes a FROM_PREP list referencing the session with the expected gap items (INV-XD-004) — src/lib/planning/mealPrep/prepShoppingList.service.spec.ts + +**Checkpoint:** prep sessions produce a correct shopping list for missing ingredients. + +--- + +## Phase 5 — User Story 3 (P3): Planning prepped preview + decrement regression + +**Goal:** surface prepped inventory as a passive, expiration-aware suggestion in planning (non-forcing) +and regression-guard auto-decrement for PREP_SESSION portions. **Independent test:** preview lists +prepped portions with expiry; logging a session-origin meal drops its portion count. + +- [X] T028 [P] [US3] Add a passive prepped-inventory preview (expiration indicators, non-forcing — REQ-PP-025/026) to the planning surface, reusing `preppedMealStore` data — src/lib/components/planning/PreppedSuggestionPreview.svelte +- [X] T029 [P] [US3] Regression test for the session-yield consume lifecycle: a `PREP_SESSION`-origin portion derives HAVE_IT while remaining > 0 and MUST_ACQUIRE once consumed to 0 (the consume/decrement seam; INV-XD-003). Auto-decrement-on-meal-log (REQ-PP-027) is deferred with the unbuilt meal-logging feature — src/lib/planning/preppedDecrement.spec.ts +- [X] T030 [P] [US3] Component test for `PreppedSuggestionPreview` (renders expiry, never auto-selects) — src/lib/components/planning/PreppedSuggestionPreview.svelte.spec.ts + +**Checkpoint:** planning shows prepped portions as a suggestion; decrement-on-log holds for session yields. + +--- + +## Phase 6 — Polish & cross-cutting + +- [X] T031 Run `bun run format` then `bun run lint` and fix all prettier/eslint issues in new files — (repo-wide) +- [X] T032 Run `bun run check` (svelte-kit sync + svelte-check) and resolve type errors against the regenerated `database.types.ts` — (repo-wide) +- [X] T033 Run `bun run test:unit --run` (node + chromium projects) and `supabase test db` (pgTAP); ensure all new + existing suites pass (SC-006, P12 every test asserts) — (repo-wide) +- [X] T034 [P] Export new public modules via `src/lib/index.ts` barrel where consistent with the existing pattern, and confirm the quickstart steps work end-to-end — src/lib/index.ts + +--- + +## Dependencies & ordering + +- **Phase 1 (T001–T004)** blocks everything (schema + types). +- **Phase 2 (T005–T009)** — the portion-creation fix (T005/T006/T007) is foundational and unblocks + batch-yield; the HAVE_IT/picker checks (T008/T009) depend only on existing code and can run + alongside Phase 1. +- **US1 (T010–T022)** depends on Phase 1 + the T005 fix. Within US1: T010 → T011 → T012; T013/T016 + parallel; store T014 after T011/T012; UI T017–T020 after types/store; tests T015/T021/T022 parallel + once their targets exist. +- **US2 (T023–T027)** depends on US1 service + Phase 1 schema (FROM_PREP). T023/T024 pure, parallel-first. +- **US3 (T028–T030)** depends on Phase 1 + existing prepped/planning stores; independent of US2. +- **Phase 6** last. + +## Parallel execution examples +- Kick off T008/T009 (Phase 2 checks) alongside T001–T003 (migrations) — different files. +- Within US1: T013 (prepDay) ‖ T015/T016 test scaffolds ‖ T018 (card/overview) once T010 lands. +- US2 pure logic T023 ‖ T024 can be written before T025 wires the service. + +## Implementation strategy +1. **Foundation = Phase 1 + the T005 portion-creation fix** — without it, no prepped portion can be + created at all (direct-entry or batch). +2. Ship **MVP = Phase 1 + Phase 2 + US1**: batch sessions yield portions that read HAVE_IT — the + make-ahead loop closes. +3. Add **US2** (prep→shopping) and **US3** (planning preview) as independent increments. +4. **Polish** (Phase 6) before `/ss:ship`. diff --git a/src/lib/components/pantry/PantryTab.svelte b/src/lib/components/pantry/PantryTab.svelte index 08b081f..3a7c79e 100644 --- a/src/lib/components/pantry/PantryTab.svelte +++ b/src/lib/components/pantry/PantryTab.svelte @@ -5,6 +5,13 @@ import PreppedMealForm from './PreppedMealForm.svelte'; import PreppedMealOverview from './PreppedMealOverview.svelte'; import PortionEditor from './PortionEditor.svelte'; + import MealPrepOverview from '$lib/components/planning/mealPrep/MealPrepOverview.svelte'; + import MealPrepSessionForm from '$lib/components/planning/mealPrep/MealPrepSessionForm.svelte'; + import { + buildPrepShoppingListAction, + loadSessions + } from '$lib/planning/mealPrep/mealPrepStore.svelte'; + import type { MealPrepSession } from '$lib/planning/mealPrep/types'; import type { PantryItem, PreppedMeal, StorageLocation } from '$lib/pantry/types'; type Tab = 'ALL' | StorageLocation | 'PREPPED'; @@ -47,6 +54,25 @@ function openPortionEditor(meal: PreppedMeal) { portionMeal = meal; } + + let showSessionForm = $state(false); + let buildNotice = $state(null); + + function openSessionForm() { + showSessionForm = true; + } + function closeSessionForm() { + showSessionForm = false; + void loadSessions(); + } + async function handleBuildShoppingList(session: MealPrepSession) { + buildNotice = null; + const { itemCount } = await buildPrepShoppingListAction(session.id); + buildNotice = + itemCount > 0 + ? `Added ${itemCount} item${itemCount === 1 ? '' : 's'} to a prep shopping list.` + : 'Everything for this session is already in your pantry.'; + }
@@ -77,7 +103,22 @@
{#if activeTab === 'PREPPED'} - +
+ + {#if buildNotice} +

+ {buildNotice} +

+ {/if} + +
{:else} {/if} + {#if showSessionForm} + + {/if} + {#if portionMeal}
void; onEditMeal?: (meal: PreppedMeal) => void; + onStartSession?: () => void; } - let { onAddPrepped, onEditMeal }: Props = $props(); + let { onAddPrepped, onEditMeal, onStartSession }: Props = $props(); // Nearest-expiry first; spread to avoid mutating the live $state array const sortedMeals = $derived( @@ -69,17 +70,23 @@ + Add a prepped meal I already have {/if} - - + {#if onStartSession} + + {:else} + + + {/if}
{:else} diff --git a/src/lib/components/planning/AddMealSheet.svelte b/src/lib/components/planning/AddMealSheet.svelte index dee853d..08d70c7 100644 --- a/src/lib/components/planning/AddMealSheet.svelte +++ b/src/lib/components/planning/AddMealSheet.svelte @@ -13,7 +13,11 @@ preppedMeals, preppedMealsLoading } from '$lib/pantry/preppedMealStore.svelte'; - import type { PreppedMeal, StorageLocation } from '$lib/pantry/types'; + import { + placeablePreppedPortions, + type PreppedMeal, + type StorageLocation + } from '$lib/pantry/types'; import { recipesRepository, type RecipeSummary } from '$lib/recipes'; interface Props { @@ -48,7 +52,7 @@ // Prepped tab (FR-FS-010) — only portions that still exist to eat let selectedPrepped = $state(null); let preppedRequested = $state(false); - const availablePortions = $derived(preppedMeals().filter((p) => p.portions_remaining > 0)); + const availablePortions = $derived(placeablePreppedPortions(preppedMeals())); const STORAGE_LABEL: Record = { PANTRY: 'Pantry', diff --git a/src/lib/components/planning/PreppedSuggestionPreview.svelte b/src/lib/components/planning/PreppedSuggestionPreview.svelte new file mode 100644 index 0000000..174e68b --- /dev/null +++ b/src/lib/components/planning/PreppedSuggestionPreview.svelte @@ -0,0 +1,68 @@ + + + +{#if sorted.length > 0} +
+

+ {heading} +

+
    + {#each sorted as portion (portion.id)} +
  • + + {portion.name} + + + {portion.portions_remaining} + portion{portion.portions_remaining === 1 ? '' : 's'} + + {#if portion.isExpired} + + Expired {formatDate(portion.expiration_date)} + + {:else if portion.isExpiringSoon} + + Eat by {formatDate(portion.expiration_date)} + + {:else} + + Good until {formatDate(portion.expiration_date)} + + {/if} +
  • + {/each} +
+
+{/if} diff --git a/src/lib/components/planning/PreppedSuggestionPreview.svelte.spec.ts b/src/lib/components/planning/PreppedSuggestionPreview.svelte.spec.ts new file mode 100644 index 0000000..b877651 --- /dev/null +++ b/src/lib/components/planning/PreppedSuggestionPreview.svelte.spec.ts @@ -0,0 +1,59 @@ +import { render } from 'vitest-browser-svelte'; +import { describe, expect, it } from 'vitest'; +import PreppedSuggestionPreview from './PreppedSuggestionPreview.svelte'; +import type { PreppedMeal } from '$lib/pantry/types'; + +function makePortion(overrides: Partial = {}): PreppedMeal { + return { + id: 'pp-1', + owner_id: 'user-1', + household_id: null, + origin: 'PREP_SESSION', + name: 'Chili', + recipe_id: 'r-1', + recipe_name: 'Chili', + meal_prep_session_id: 'mps-1', + portions_remaining: 3, + original_portions: 3, + storage_location: 'FREEZER', + container_label: null, + prepared_date: '2026-06-13', + expiration_date: '2026-06-20', + defrost_state: 'FROZEN', + defrost_started_at: null, + estimated_ready_at: null, + photo_url: null, + created_at: '2026-06-13T00:00:00Z', + updated_at: '2026-06-13T00:00:00Z', + isExpiringSoon: false, + isExpired: false, + isReadyToEat: false, + ...overrides + }; +} + +describe('PreppedSuggestionPreview (REQ-PP-025/026)', () => { + it('renders prepped portions with name and an expiry indicator', async () => { + expect.hasAssertions(); + const { getByText } = render(PreppedSuggestionPreview, { + portions: [makePortion({ isExpiringSoon: true })] + }); + expect(getByText('Chili')).toBeTruthy(); + expect(getByText(/3 portions/)).toBeTruthy(); + expect(getByText(/Eat by/)).toBeTruthy(); + }); + + it('is non-forcing: it renders no buttons or selection controls', async () => { + expect.hasAssertions(); + const { container } = render(PreppedSuggestionPreview, { portions: [makePortion()] }); + // A passive suggestion surface never auto-selects or offers an action (REQ-PP-026). + expect(container.querySelectorAll('button').length).toBe(0); + expect(container.querySelectorAll('input').length).toBe(0); + }); + + it('renders nothing when there is no prepped inventory', async () => { + expect.hasAssertions(); + const { container } = render(PreppedSuggestionPreview, { portions: [] }); + expect(container.querySelector('section')).toBeNull(); + }); +}); diff --git a/src/lib/components/planning/mealPrep/CompleteSessionDialog.svelte b/src/lib/components/planning/mealPrep/CompleteSessionDialog.svelte new file mode 100644 index 0000000..6482d5a --- /dev/null +++ b/src/lib/components/planning/mealPrep/CompleteSessionDialog.svelte @@ -0,0 +1,175 @@ + + +
+
+

+ Mark as prepped +

+

+ Tell us where each batch is going so we can track it. +

+
+ + +
+ + Set all to + +
+ + +
+
+ +
+ + +
    + {#each session.recipes as recipe (recipe.id)} +
  • + + {recipe.recipeName} + +
    + {#each TOGGLE as option (option.value)} + {@const active = (choices[recipe.id] ?? 'FRIDGE') === option.value} + + {/each} +
    +
  • + {/each} +
+ + {#if errorMsg} +

+ {errorMsg} +

+ {/if} + +
+ +
+ + +
+
diff --git a/src/lib/components/planning/mealPrep/MealPrepOverview.svelte b/src/lib/components/planning/mealPrep/MealPrepOverview.svelte new file mode 100644 index 0000000..150026d --- /dev/null +++ b/src/lib/components/planning/mealPrep/MealPrepOverview.svelte @@ -0,0 +1,139 @@ + + +{#if sessionsLoading() && !sessionsLoaded()} + +
+ {#each [0, 1] as skeletonIdx (skeletonIdx)} + + {/each} +
+{:else if sessionsError()} +
+

+ {sessionsError()} +

+ +
+{:else if sessions().length === 0} + +
+

+ No prep sessions yet +

+

+ Plan a batch-cook session to make a few meals at once and stock your week. +

+ {#if onNewSession} + + {/if} +
+{:else} +
+ {#if onNewSession} +
+ +
+ {/if} +
+ {#each ordered as session (session.id)} + (completing = s)} + onCancel={handleCancel} + {onBuildShoppingList} + {buildingShoppingList} + /> + {/each} +
+
+{/if} + +{#if completing} + +{/if} diff --git a/src/lib/components/planning/mealPrep/MealPrepSessionCard.svelte b/src/lib/components/planning/mealPrep/MealPrepSessionCard.svelte new file mode 100644 index 0000000..ecba713 --- /dev/null +++ b/src/lib/components/planning/mealPrep/MealPrepSessionCard.svelte @@ -0,0 +1,113 @@ + + +
+
+
+ Prep day + + {prepDayFormatted} + +
+ + {STATUS_LABEL[session.status]} + +
+ +
    + {#each session.recipes as recipe (recipe.id)} +
  • + + {recipe.recipeName} + + + {recipe.servingsToPrep} serving{recipe.servingsToPrep === 1 ? '' : 's'} + +
  • + {/each} +
+ + {#if isPlanned} +
+ + {#if onBuildShoppingList} + + {/if} + +
+ {/if} +
diff --git a/src/lib/components/planning/mealPrep/MealPrepSessionForm.svelte b/src/lib/components/planning/mealPrep/MealPrepSessionForm.svelte new file mode 100644 index 0000000..b3996d4 --- /dev/null +++ b/src/lib/components/planning/mealPrep/MealPrepSessionForm.svelte @@ -0,0 +1,337 @@ + + +
{ + e.preventDefault(); + handleSubmit(); + }} + novalidate + aria-label="Plan a prep session" + class="nk-stack" + style="gap: var(--space-5); padding: var(--space-6); background: var(--surface); border-radius: var(--radius-lg);" +> +
+
+

+ Plan a prep session +

+

+ Pick what you want to batch-cook and how much. +

+
+ +
+ + {#if formError} +

+ {formError} +

+ {/if} + + +
+ + What are you prepping? + + + + + {#if loadError} +

+ {loadError} +

+ {:else if !recipesLoaded} +

+ Loading recipes… +

+ {:else if filteredRecipes.length === 0} +

+ {recipes.length === 0 + ? 'No recipes yet — add one from the Recipes tab first.' + : 'No recipes match that search.'} +

+ {:else} +
    + {#each filteredRecipes as recipe (recipe.id)} +
  • + +
  • + {/each} +
+ {/if} + + {#if showValidation && chosen.length < 1} +

+ Pick at least one recipe to prep. +

+ {/if} +
+ + + {#if chosen.length > 0} +
+ + How many servings of each? + + {#each chosen as item (item.recipeId)} +
+ + {item.recipeName} + +
+ + setServings(item.recipeId, Number(e.currentTarget.value))} + style="width: 3.5rem; text-align: center; min-height: var(--tap-min); border: none; background: var(--surface); color: var(--text); font-size: var(--text-base); font-family: var(--font-sans); font-variant-numeric: tabular-nums;" + /> + +
+
+ {/each} +
+ {/if} + + +
+ + + {#if !prepDayValid} + + {/if} +
+ + +
+ + +
+
diff --git a/src/lib/components/planning/mealPrep/MealPrepSessionForm.svelte.spec.ts b/src/lib/components/planning/mealPrep/MealPrepSessionForm.svelte.spec.ts new file mode 100644 index 0000000..85ba024 --- /dev/null +++ b/src/lib/components/planning/mealPrep/MealPrepSessionForm.svelte.spec.ts @@ -0,0 +1,55 @@ +import { page } from 'vitest/browser'; +import { describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import MealPrepSessionForm from './MealPrepSessionForm.svelte'; +import type { RecipeSummary } from '$lib/recipes'; + +// Mock the recipes module: only listRecipes is used by the form. +const listRecipes = vi.fn<() => Promise>(async () => []); +vi.mock('$lib/recipes', () => ({ + recipesRepository: { + listRecipes: () => listRecipes() + } +})); + +// Mock the store action so no network/service is touched. +const createSessionAction = vi.fn(async () => ({ + id: 'session-1', + status: 'PLANNED' as const, + prepDay: '2026-06-20', + completedAt: null, + recipes: [] +})); +vi.mock('$lib/planning/mealPrep/mealPrepStore.svelte', () => ({ + createSessionAction: (...args: unknown[]) => + createSessionAction(...(args as Parameters)) +})); + +function makeRecipe(overrides: Partial = {}): RecipeSummary { + return { + id: 'recipe-1', + title: 'Chili', + servings: 4, + ...overrides + } as RecipeSummary; +} + +describe('MealPrepSessionForm.svelte', () => { + it('disables the submit button when no recipes are chosen', async () => { + listRecipes.mockResolvedValueOnce([makeRecipe()]); + render(MealPrepSessionForm, { onClose: () => {} }); + + const submit = page.getByRole('button', { name: 'Plan this session' }); + await expect.element(submit).toBeDisabled(); + }); + + it('shows a validation error for a prep day in the past', async () => { + listRecipes.mockResolvedValueOnce([makeRecipe()]); + render(MealPrepSessionForm, { onClose: () => {} }); + + const dateInput = page.getByLabelText('When will you prep?'); + await dateInput.fill('2000-01-01'); + + await expect.element(page.getByText('Pick today or a day in the future.')).toBeInTheDocument(); + }); +}); diff --git a/src/lib/database.types.ts b/src/lib/database.types.ts index d2494cc..02cf6a1 100644 --- a/src/lib/database.types.ts +++ b/src/lib/database.types.ts @@ -137,6 +137,81 @@ export type Database = { }; Relationships: []; }; + meal_prep_session_recipes: { + Row: { + created_at: string; + id: string; + meal_prep_session_id: string; + recipe_id: string; + recipe_name: string; + servings_to_prep: number; + }; + Insert: { + created_at?: string; + id?: string; + meal_prep_session_id: string; + recipe_id: string; + recipe_name: string; + servings_to_prep: number; + }; + Update: { + created_at?: string; + id?: string; + meal_prep_session_id?: string; + recipe_id?: string; + recipe_name?: string; + servings_to_prep?: number; + }; + Relationships: [ + { + foreignKeyName: 'meal_prep_session_recipes_meal_prep_session_id_fkey'; + columns: ['meal_prep_session_id']; + isOneToOne: false; + referencedRelation: 'meal_prep_sessions'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'meal_prep_session_recipes_recipe_id_fkey'; + columns: ['recipe_id']; + isOneToOne: false; + referencedRelation: 'recipes'; + referencedColumns: ['id']; + } + ]; + }; + meal_prep_sessions: { + Row: { + completed_at: string | null; + created_at: string; + household_id: string | null; + id: string; + owner_id: string; + prep_day: string; + status: Database['public']['Enums']['meal_prep_session_status']; + updated_at: string; + }; + Insert: { + completed_at?: string | null; + created_at?: string; + household_id?: string | null; + id?: string; + owner_id?: string; + prep_day: string; + status?: Database['public']['Enums']['meal_prep_session_status']; + updated_at?: string; + }; + Update: { + completed_at?: string | null; + created_at?: string; + household_id?: string | null; + id?: string; + owner_id?: string; + prep_day?: string; + status?: Database['public']['Enums']['meal_prep_session_status']; + updated_at?: string; + }; + Relationships: []; + }; meal_reminders: { Row: { created_at: string; @@ -755,6 +830,7 @@ export type Database = { generated_range_start: string | null; household_id: string | null; id: string; + meal_prep_session_id: string | null; name: string; owner_id: string; source_type: Database['public']['Enums']['shopping_list_source']; @@ -768,6 +844,7 @@ export type Database = { generated_range_start?: string | null; household_id?: string | null; id?: string; + meal_prep_session_id?: string | null; name: string; owner_id?: string; source_type?: Database['public']['Enums']['shopping_list_source']; @@ -781,13 +858,22 @@ export type Database = { generated_range_start?: string | null; household_id?: string | null; id?: string; + meal_prep_session_id?: string | null; name?: string; owner_id?: string; source_type?: Database['public']['Enums']['shopping_list_source']; status?: Database['public']['Enums']['shopping_list_status']; updated_at?: string; }; - Relationships: []; + Relationships: [ + { + foreignKeyName: 'shopping_lists_meal_prep_session_id_fkey'; + columns: ['meal_prep_session_id']; + isOneToOne: false; + referencedRelation: 'meal_prep_sessions'; + referencedColumns: ['id']; + } + ]; }; user_recipe_meta: { Row: { @@ -859,6 +945,7 @@ export type Database = { Enums: { defrost_state: 'NOT_APPLICABLE' | 'FROZEN' | 'DEFROSTING' | 'READY'; meal_log_type: 'FROM_PLAN' | 'FROM_RECIPE' | 'FROM_PREPPED' | 'QUICK_LOG' | 'CUSTOM'; + meal_prep_session_status: 'PLANNED' | 'COMPLETED' | 'CANCELLED'; meal_slot: 'BREAKFAST' | 'LUNCH' | 'DINNER' | 'SNACK'; meal_verdict: 'KEEP' | 'FINE' | 'REST'; planned_meal_source: 'RECIPE' | 'PREPPED' | 'STORE_BOUGHT' | 'QUICK'; @@ -878,7 +965,7 @@ export type Database = { | 'PANTRY_STAPLES' | 'OTHER'; shopping_item_status: 'PENDING' | 'CHECKED' | 'UNAVAILABLE' | 'REMOVED'; - shopping_list_source: 'MANUAL' | 'FROM_PLAN'; + shopping_list_source: 'MANUAL' | 'FROM_PLAN' | 'FROM_PREP'; shopping_list_status: 'ACTIVE' | 'SHOPPING' | 'COMPLETED' | 'ARCHIVED'; storage_location: 'PANTRY' | 'FRIDGE' | 'FREEZER' | 'OTHER'; }; @@ -1011,6 +1098,7 @@ export const Constants = { Enums: { defrost_state: ['NOT_APPLICABLE', 'FROZEN', 'DEFROSTING', 'READY'], meal_log_type: ['FROM_PLAN', 'FROM_RECIPE', 'FROM_PREPPED', 'QUICK_LOG', 'CUSTOM'], + meal_prep_session_status: ['PLANNED', 'COMPLETED', 'CANCELLED'], meal_slot: ['BREAKFAST', 'LUNCH', 'DINNER', 'SNACK'], meal_verdict: ['KEEP', 'FINE', 'REST'], planned_meal_source: ['RECIPE', 'PREPPED', 'STORE_BOUGHT', 'QUICK'], @@ -1031,7 +1119,7 @@ export const Constants = { 'OTHER' ], shopping_item_status: ['PENDING', 'CHECKED', 'UNAVAILABLE', 'REMOVED'], - shopping_list_source: ['MANUAL', 'FROM_PLAN'], + shopping_list_source: ['MANUAL', 'FROM_PLAN', 'FROM_PREP'], shopping_list_status: ['ACTIVE', 'SHOPPING', 'COMPLETED', 'ARCHIVED'], storage_location: ['PANTRY', 'FRIDGE', 'FREEZER', 'OTHER'] } diff --git a/src/lib/pantry/preppedMealService.spec.ts b/src/lib/pantry/preppedMealService.spec.ts index 70625bd..432ece9 100644 --- a/src/lib/pantry/preppedMealService.spec.ts +++ b/src/lib/pantry/preppedMealService.spec.ts @@ -107,15 +107,21 @@ describe('preppedMealService', () => { // ------------------------------------------------------------------------- describe('addPreppedMeal', () => { - it('inserts meal then calls insertPortionEvent with kind INITIALIZED', async () => { + it('inserts the meal and returns it WITHOUT firing a positive INITIALIZED event', async () => { + // The row INSERT is authoritative for the starting portion count. A positive + // INITIALIZED portion_event is forbidden by the ledger guard, the DB CHECK, and + // INV-INV-011 — emitting one made addPreppedMeal throw against a real backend. expect.hasAssertions(); - const row = makePreppedMealRow({ id: 'meal-new', original_portions: 6 }); - // Both the insert+single and the re-fetch+single return the same row + const row = makePreppedMealRow({ + id: 'meal-new', + original_portions: 6, + portions_remaining: 6 + }); const chain = makeChain({ data: row, error: null }); (supabase.from as Mock).mockReturnValue(chain); - await addPreppedMeal({ + const saved = await addPreppedMeal({ name: 'Chicken Curry', owner_id: 'user-1', origin: 'DIRECT_ENTRY', @@ -126,13 +132,10 @@ describe('preppedMealService', () => { expiration_date: '2026-06-05' }); - expect(insertPortionEvent).toHaveBeenCalledWith( - expect.objectContaining({ - preppedMealId: 'meal-new', - deltaPortions: 6, - kind: 'INITIALIZED' - }) - ); + expect(saved.id).toBe('meal-new'); + expect(saved.portions_remaining).toBe(6); + // No portion event on creation — the ledger records only later consume/adjust deltas. + expect(insertPortionEvent).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/pantry/preppedMealService.ts b/src/lib/pantry/preppedMealService.ts index 839b884..7d53efc 100644 --- a/src/lib/pantry/preppedMealService.ts +++ b/src/lib/pantry/preppedMealService.ts @@ -33,25 +33,15 @@ export async function getPreppedMeals(householdId?: string | null): Promise { + // The INSERT itself sets portions_remaining = original_portions (the authoritative + // starting count). We deliberately do NOT emit an INITIALIZED portion_event: a positive + // non-ADJUSTED delta is forbidden by the ledger guard, the DB CHECK, and INV-INV-011. + // The append-only ledger records only later CONSUMED/ADJUSTED *changes* (INV-CC-006); + // the initial count lives on the row. const { data, error } = await supabase.from('prepped_meals').insert(meal).select().single(); if (error) throw new PreppedMealServiceError('Failed to add prepped meal', error); - // Record the initial portion count as an INITIALIZED event - await insertPortionEvent({ - preppedMealId: data.id, - deltaPortions: data.original_portions, - kind: 'INITIALIZED' - }); - - // Re-fetch so portions_remaining reflects the trigger update - const { data: refreshed, error: refetchError } = await supabase - .from('prepped_meals') - .select('*') - .eq('id', data.id) - .single(); - if (refetchError) - throw new PreppedMealServiceError('Failed to fetch new prepped meal', refetchError); - return toPreppedMeal(refreshed); + return toPreppedMeal(data); } export async function updatePreppedMeal( diff --git a/src/lib/pantry/types.ts b/src/lib/pantry/types.ts index c904ff0..e9aeede 100644 --- a/src/lib/pantry/types.ts +++ b/src/lib/pantry/types.ts @@ -126,3 +126,14 @@ export function toPreppedMeal(row: PreppedMealRow): PreppedMeal { isReadyToEat: row.defrost_state === 'NOT_APPLICABLE' || row.defrost_state === 'READY' }; } + +/** + * Prepped portions that can be placed on the meal plan as a PREPPED source: only those + * with portions remaining > 0 (an exhausted portion would derive MUST_ACQUIRE, not HAVE_IT). + * Shared by the Add-meal picker (feature 004) and the make-ahead suggestion surfaces. + */ +export function placeablePreppedPortions( + portions: readonly T[] +): T[] { + return portions.filter((p) => p.portions_remaining > 0); +} diff --git a/src/lib/planning/fulfillment.haveit.spec.ts b/src/lib/planning/fulfillment.haveit.spec.ts new file mode 100644 index 0000000..9b09e14 --- /dev/null +++ b/src/lib/planning/fulfillment.haveit.spec.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { deriveFulfillment, inputsFromSnapshot } from './fulfillment'; +import type { PlannedMeal } from './types'; +import type { PreppedMeal, PreppedMealOrigin } from '$lib/pantry/types'; + +// --------------------------------------------------------------------------- +// Make-ahead guard (feature 006, FR-PP-001). +// +// A prepped portion is the canonical HAVE_IT source. This locks the guarantee that +// closing the make-ahead loop produces HAVE_IT *regardless of how the portion was +// created* — direct entry, store-bought, or a batch prep session — exercised through +// the documented inputsFromSnapshot seam (not by poking the derivation's internals). +// +// It also stands as a regression for the portion-creation fix (Design A): a portion that +// was successfully created with portions_remaining > 0 must read HAVE_IT. +// --------------------------------------------------------------------------- + +function plannedFromPrepped(preppedMealId: string): PlannedMeal { + return { + id: 'pm-1', + mealPlanId: 'plan-1', + date: '2026-06-16', + mealSlot: 'DINNER', + source: 'PREPPED', + recipeId: null, + recipeTitleSnapshot: null, + preppedMealId, + preppedNameSnapshot: 'Leftovers', + storeBoughtName: null, + quickMealName: null, + servings: 1, + status: 'PLANNED', + sortOrder: 0, + createdAt: '', + updatedAt: '' + }; +} + +function preppedPortion( + id: string, + origin: PreppedMealOrigin, + portionsRemaining: number +): PreppedMeal { + return { + id, + owner_id: 'user-1', + household_id: null, + origin, + name: 'Portion', + recipe_id: origin === 'PREP_SESSION' ? 'r-1' : null, + recipe_name: origin === 'PREP_SESSION' ? 'Chili' : null, + meal_prep_session_id: origin === 'PREP_SESSION' ? 'mps-1' : null, + portions_remaining: portionsRemaining, + original_portions: Math.max(portionsRemaining, 1), + storage_location: 'FRIDGE', + container_label: null, + prepared_date: '2026-06-13', + expiration_date: '2026-06-20', + defrost_state: 'NOT_APPLICABLE', + defrost_started_at: null, + estimated_ready_at: null, + photo_url: null, + created_at: '', + updated_at: '', + isExpiringSoon: false, + isExpired: false, + isReadyToEat: true + }; +} + +describe('make-ahead → HAVE_IT (origin-independent, FR-PP-001)', () => { + const origins: PreppedMealOrigin[] = ['DIRECT_ENTRY', 'STORE_BOUGHT', 'PREP_SESSION']; + + for (const origin of origins) { + it(`a ${origin} portion with portions remaining derives HAVE_IT`, () => { + const inputs = inputsFromSnapshot( + { pantryItems: [], preppedMeals: [preppedPortion('pp-1', origin, 2)] }, + new Map() + ); + expect(deriveFulfillment(plannedFromPrepped('pp-1'), inputs)).toEqual({ state: 'HAVE_IT' }); + }); + + it(`an exhausted ${origin} portion (0 remaining) derives MUST_ACQUIRE`, () => { + const inputs = inputsFromSnapshot( + { pantryItems: [], preppedMeals: [preppedPortion('pp-1', origin, 0)] }, + new Map() + ); + expect(deriveFulfillment(plannedFromPrepped('pp-1'), inputs)?.state).toBe('MUST_ACQUIRE'); + }); + } +}); diff --git a/src/lib/planning/index.ts b/src/lib/planning/index.ts index afba325..b42986c 100644 --- a/src/lib/planning/index.ts +++ b/src/lib/planning/index.ts @@ -45,6 +45,44 @@ export { updateMeal } from './planStore.svelte'; +// Meal-prep batch sessions (feature 006). +export { + toMealPrepSession, + toMealPrepSessionRecipe, + type MealPrepSession, + type MealPrepSessionRecipe, + type MealPrepSessionStatus, + type NewMealPrepSession, + type NewSessionRecipe, + type YieldChoice, + type PrepShoppingGapItem +} from './mealPrep/types'; + +export { MealPrepServiceError } from './mealPrep/mealPrepService'; + +export { isNotPast, isWeekend, suggestPrepDay } from './mealPrep/prepDay'; + +export { + computePrepShoppingGap, + type PrepRecipeIngredient, + type RecipeForPrep +} from './mealPrep/prepShoppingList'; + +export { + addRecipeAction, + buildPrepShoppingListAction, + cancelSessionAction, + completeSessionAction, + createSessionAction, + loadSessions, + plannedSessions, + removeRecipeAction, + sessions, + sessionsError, + sessionsLoaded, + sessionsLoading +} from './mealPrep/mealPrepStore.svelte'; + export { addDays, dayFullLabel, diff --git a/src/lib/planning/mealPrep/mealPrepService.spec.ts b/src/lib/planning/mealPrep/mealPrepService.spec.ts new file mode 100644 index 0000000..6c2aaa5 --- /dev/null +++ b/src/lib/planning/mealPrep/mealPrepService.spec.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mock } from 'vitest'; +import { + addRecipe, + cancelSession, + completeSession, + createSession, + MealPrepServiceError, + removeRecipe, + updateServings +} from './mealPrepService'; + +vi.mock('$lib/supabaseClient', () => ({ + supabase: { from: vi.fn() } +})); + +vi.mock('$lib/pantry/preppedMealService', () => ({ + addPreppedMeal: vi.fn().mockResolvedValue({ id: 'pp-new' }) +})); + +vi.mock('$lib/session/session.svelte', () => ({ + currentUser: vi.fn().mockResolvedValue({ id: 'user-1' }) +})); + +import { supabase } from '$lib/supabaseClient'; +import { addPreppedMeal } from '$lib/pantry/preppedMealService'; + +// --------------------------------------------------------------------------- +// Per-table mock dispatch. Each table maps to a thenable/terminal chain whose +// result can be set per test. Chains record nothing beyond returning configured data. +// --------------------------------------------------------------------------- + +type Result = { data: unknown; error: unknown }; + +function chainFor(result: Result) { + const chain: Record = {}; + for (const m of ['select', 'insert', 'update', 'delete', 'eq', 'order', 'limit']) { + chain[m] = vi.fn().mockReturnValue(chain); + } + chain.single = vi.fn().mockResolvedValue(result); + chain.maybeSingle = vi.fn().mockResolvedValue(result); + chain.then = (resolve: (v: unknown) => unknown) => Promise.resolve(result).then(resolve); + return chain; +} + +/** today + offset days, ISO date. */ +function isoDay(offset = 0): string { + const d = new Date(); + d.setHours(0, 0, 0, 0); + d.setDate(d.getDate() + offset); + return d.toISOString().slice(0, 10); +} + +function sessionRow(overrides: Record = {}) { + return { + id: 'sess-1', + status: 'PLANNED', + prep_day: isoDay(1), + completed_at: null, + meal_prep_session_recipes: [ + { id: 'sr-1', recipe_id: 'r-1', recipe_name: 'Chili', servings_to_prep: 3 }, + { id: 'sr-2', recipe_id: 'r-2', recipe_name: 'Oats', servings_to_prep: 2 } + ], + ...overrides + }; +} + +/** Configure supabase.from to return the given result for each table name. */ +function mockTables(byTable: Record) { + (supabase.from as Mock).mockImplementation((table: string) => + chainFor(byTable[table] ?? { data: null, error: null }) + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + (addPreppedMeal as Mock).mockResolvedValue({ id: 'pp-new' }); +}); + +describe('createSession — validation (INV-PL-006/007)', () => { + it('rejects a session with zero recipes', async () => { + await expect(createSession({ prepDay: isoDay(1), recipes: [] })).rejects.toBeInstanceOf( + MealPrepServiceError + ); + expect(supabase.from).not.toHaveBeenCalled(); + }); + + it('rejects non-positive servings', async () => { + await expect( + createSession({ + prepDay: isoDay(1), + recipes: [{ recipeId: 'r-1', recipeName: 'Chili', servingsToPrep: 0 }] + }) + ).rejects.toThrow(/greater than zero/); + }); + + it('rejects a prep day in the past', async () => { + await expect( + createSession({ + prepDay: isoDay(-1), + recipes: [{ recipeId: 'r-1', recipeName: 'Chili', servingsToPrep: 3 }] + }) + ).rejects.toThrow(/past/); + }); +}); + +describe('addRecipe / updateServings — validation', () => { + it('addRecipe rejects non-positive servings', async () => { + await expect( + addRecipe('sess-1', { recipeId: 'r-9', recipeName: 'Soup', servingsToPrep: -1 }) + ).rejects.toThrow(/greater than zero/); + }); + + it('updateServings rejects non-positive servings', async () => { + await expect(updateServings('sr-1', 0)).rejects.toThrow(/greater than zero/); + }); +}); + +describe('removeRecipe — keeps at least one recipe (INV-PL-006)', () => { + it('rejects removing the last recipe', async () => { + mockTables({ + meal_prep_sessions: { + data: sessionRow({ + meal_prep_session_recipes: [ + { id: 'sr-1', recipe_id: 'r-1', recipe_name: 'Chili', servings_to_prep: 3 } + ] + }), + error: null + } + }); + await expect(removeRecipe('sess-1', 'sr-1')).rejects.toThrow(/at least one recipe/); + }); +}); + +describe('completeSession — yield to inventory (FR-PP-014/015)', () => { + it('yields one prepped portion per recipe then marks the session complete', async () => { + mockTables({ + meal_prep_sessions: { data: sessionRow(), error: null }, + prepped_meals: { data: [], error: null } // no existing yield + }); + + await completeSession('sess-1', [ + { sessionRecipeId: 'sr-1', storageLocation: 'FREEZER' }, + { sessionRecipeId: 'sr-2', storageLocation: 'FRIDGE' } + ]); + + expect(addPreppedMeal).toHaveBeenCalledTimes(2); + // Chili → freezer, FROZEN, portions from servings_to_prep + expect(addPreppedMeal).toHaveBeenCalledWith( + expect.objectContaining({ + origin: 'PREP_SESSION', + recipe_id: 'r-1', + recipe_name: 'Chili', + meal_prep_session_id: 'sess-1', + original_portions: 3, + portions_remaining: 3, + storage_location: 'FREEZER', + defrost_state: 'FROZEN' + }) + ); + // Oats → fridge, not applicable + expect(addPreppedMeal).toHaveBeenCalledWith( + expect.objectContaining({ + recipe_name: 'Oats', + storage_location: 'FRIDGE', + defrost_state: 'NOT_APPLICABLE', + original_portions: 2 + }) + ); + }); + + it('is idempotent — does not yield again when both recipes already have portions', async () => { + mockTables({ + meal_prep_sessions: { + data: sessionRow({ status: 'COMPLETED', completed_at: isoDay() }), + error: null + }, + // both session recipes already yielded + prepped_meals: { data: [{ recipe_id: 'r-1' }, { recipe_id: 'r-2' }], error: null } + }); + + await completeSession('sess-1'); + + expect(addPreppedMeal).not.toHaveBeenCalled(); + }); + + it('retries only the recipes still missing a portion after a partial failure', async () => { + // r-1 already has a linked portion (a prior attempt yielded it before throwing); + // r-2 does not yet. + mockTables({ + meal_prep_sessions: { data: sessionRow(), error: null }, + prepped_meals: { data: [{ recipe_id: 'r-1' }], error: null } + }); + + await completeSession('sess-1', [{ sessionRecipeId: 'sr-2', storageLocation: 'FRIDGE' }]); + + expect(addPreppedMeal).toHaveBeenCalledTimes(1); + expect(addPreppedMeal).toHaveBeenCalledWith( + expect.objectContaining({ recipe_id: 'r-2', recipe_name: 'Oats' }) + ); + }); +}); + +describe('cancelSession', () => { + it('does not create any prepped portions', async () => { + mockTables({ meal_prep_sessions: { data: sessionRow({ status: 'CANCELLED' }), error: null } }); + await cancelSession('sess-1'); + expect(addPreppedMeal).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/planning/mealPrep/mealPrepService.ts b/src/lib/planning/mealPrep/mealPrepService.ts new file mode 100644 index 0000000..40c9cbb --- /dev/null +++ b/src/lib/planning/mealPrep/mealPrepService.ts @@ -0,0 +1,377 @@ +import { supabase } from '$lib/supabaseClient'; +import { currentUser } from '$lib/session/session.svelte'; +import { addPreppedMeal } from '$lib/pantry/preppedMealService'; +import { getPantryItems } from '$lib/pantry/pantryService'; +import { PREPPED_SHELF_LIFE_DAYS, type StorageLocation } from '$lib/pantry/types'; +import { buildPantryNameIndex } from '../fulfillment'; +import { recipesRepository } from '$lib/recipes'; +import { addItems } from '$lib/shopping/shoppingService'; +import type { NewShoppingItem } from '$lib/shopping/types'; +import { computePrepShoppingGap, type RecipeForPrep } from './prepShoppingList'; +import { + toMealPrepSession, + type MealPrepSession, + type MealPrepSessionRecipeRow, + type MealPrepSessionRow, + type NewMealPrepSession, + type NewSessionRecipe, + type YieldChoice +} from './types'; + +// PostgREST embedded-resource select: a session plus its recipe lines in one round-trip. +const SESSION_SELECT = '*, meal_prep_session_recipes(*)'; + +type SessionWithRecipes = MealPrepSessionRow & { + meal_prep_session_recipes: MealPrepSessionRecipeRow[]; +}; + +function mapRow(row: SessionWithRecipes): MealPrepSession { + return toMealPrepSession(row, row.meal_prep_session_recipes ?? []); +} + +// --------------------------------------------------------------------------- +// Read +// --------------------------------------------------------------------------- + +export async function getSessions(): Promise { + const { data, error } = await supabase + .from('meal_prep_sessions') + .select(SESSION_SELECT) + // PLANNED first (so the actionable ones lead), then soonest prep day. + .order('status', { ascending: true }) + .order('prep_day', { ascending: true }); + if (error) throw new MealPrepServiceError('Failed to load meal prep sessions', error); + return (data ?? []).map((r) => mapRow(r as SessionWithRecipes)); +} + +export async function getSession(id: string): Promise { + const { data, error } = await supabase + .from('meal_prep_sessions') + .select(SESSION_SELECT) + .eq('id', id) + .maybeSingle(); + if (error) throw new MealPrepServiceError('Failed to load meal prep session', error); + return data ? mapRow(data as SessionWithRecipes) : null; +} + +// --------------------------------------------------------------------------- +// Create +// --------------------------------------------------------------------------- + +export async function createSession(draft: NewMealPrepSession): Promise { + assertRecipes(draft.recipes); + assertNotPast(draft.prepDay); + + // owner_id defaults to auth.uid() (migration 0009); status defaults to PLANNED. + const { data: session, error } = await supabase + .from('meal_prep_sessions') + .insert({ prep_day: draft.prepDay }) + .select() + .single(); + if (error) throw new MealPrepServiceError('Failed to create meal prep session', error); + + const recipeRows = draft.recipes.map((r) => ({ + meal_prep_session_id: session.id, + recipe_id: r.recipeId, + recipe_name: r.recipeName, + servings_to_prep: r.servingsToPrep + })); + const { error: recipeError } = await supabase + .from('meal_prep_session_recipes') + .insert(recipeRows); + if (recipeError) { + // Roll back the orphaned session so an empty session can never exist (INV-PL-006). + await supabase.from('meal_prep_sessions').delete().eq('id', session.id); + throw new MealPrepServiceError('Failed to add recipes to the session', recipeError); + } + + const created = await getSession(session.id); + if (!created) throw new MealPrepServiceError('Session vanished immediately after creation'); + return created; +} + +// --------------------------------------------------------------------------- +// Update (PLANNED sessions) +// --------------------------------------------------------------------------- + +export async function addRecipe( + sessionId: string, + recipe: NewSessionRecipe +): Promise { + if (recipe.servingsToPrep <= 0) { + throw new MealPrepServiceError('Servings to prepare must be greater than zero'); + } + const { error } = await supabase.from('meal_prep_session_recipes').insert({ + meal_prep_session_id: sessionId, + recipe_id: recipe.recipeId, + recipe_name: recipe.recipeName, + servings_to_prep: recipe.servingsToPrep + }); + if (error) throw new MealPrepServiceError('Failed to add recipe to the session', error); + return requireSession(sessionId); +} + +export async function removeRecipe( + sessionId: string, + sessionRecipeId: string +): Promise { + const session = await requireSession(sessionId); + if (session.recipes.length <= 1) { + // A session must keep at least one recipe (INV-PL-006). + throw new MealPrepServiceError('A prep session needs at least one recipe'); + } + const { error } = await supabase + .from('meal_prep_session_recipes') + .delete() + .eq('id', sessionRecipeId); + if (error) throw new MealPrepServiceError('Failed to remove recipe from the session', error); + return requireSession(sessionId); +} + +export async function updateServings( + sessionRecipeId: string, + servingsToPrep: number +): Promise { + if (servingsToPrep <= 0) { + throw new MealPrepServiceError('Servings to prepare must be greater than zero'); + } + const { error } = await supabase + .from('meal_prep_session_recipes') + .update({ servings_to_prep: servingsToPrep }) + .eq('id', sessionRecipeId); + if (error) throw new MealPrepServiceError('Failed to update servings', error); +} + +export async function setPrepDay(sessionId: string, prepDay: string): Promise { + assertNotPast(prepDay); + const { error } = await supabase + .from('meal_prep_sessions') + .update({ prep_day: prepDay }) + .eq('id', sessionId); + if (error) throw new MealPrepServiceError('Failed to update the prep day', error); +} + +// --------------------------------------------------------------------------- +// Cancel (PLANNED -> CANCELLED; no portions created) +// --------------------------------------------------------------------------- + +export async function cancelSession(sessionId: string): Promise { + const { error } = await supabase + .from('meal_prep_sessions') + .update({ status: 'CANCELLED' }) + .eq('id', sessionId); + if (error) throw new MealPrepServiceError('Failed to cancel the session', error); + return requireSession(sessionId); +} + +// --------------------------------------------------------------------------- +// Prep → shopping list (REQ-PP-019..022, INV-XD-004) — FR-PP-020..023 +// --------------------------------------------------------------------------- + +/** + * Generate a FROM_PREP shopping list of the ingredient gap for a session: aggregate the + * ingredients its recipes need (scaled by servings), subtract what's on hand in the pantry, + * and write a shopping list referencing the session. Available while the session is PLANNED; + * does not require completion (FR-PP-023). Each item records which recipe(s) need it. + */ +export async function generatePrepShoppingList( + sessionId: string +): Promise<{ shoppingListId: string; itemCount: number }> { + const session = await requireSession(sessionId); + + // Load each recipe's ingredients + base yield, scaled by the session's servings. + const nameToRecipeId = new Map(); + const recipesForPrep: RecipeForPrep[] = []; + for (const line of session.recipes) { + nameToRecipeId.set(line.recipeName, line.recipeId); + const detail = await recipesRepository.getRecipe(line.recipeId); + recipesForPrep.push({ + recipeId: line.recipeId, + recipeName: line.recipeName, + baseServings: detail.servings, + servingsToPrep: line.servingsToPrep, + ingredients: detail.ingredients.map((ing) => ({ + name: ing.name, + unit: ing.unit, + quantity: ing.quantity, + isOptional: ing.isOptional + })) + }); + } + + const pantryIndex = buildPantryNameIndex(await getPantryItems()); + const gap = computePrepShoppingGap(recipesForPrep, pantryIndex); + + // Create the FROM_PREP list (owner_id defaults to auth.uid(); the CHECK requires the + // session reference for FROM_PREP — INV-XD-004). + const listName = `Prep — ${session.prepDay}`; + const { data: list, error } = await supabase + .from('shopping_lists') + .insert({ + name: listName, + source_type: 'FROM_PREP', + meal_prep_session_id: sessionId + }) + .select() + .single(); + if (error) throw new MealPrepServiceError('Failed to create the prep shopping list', error); + + const items: NewShoppingItem[] = gap.map((g, idx) => ({ + name: g.name, + quantity: g.quantity, + unit: g.unit, + category: 'OTHER', + sortOrder: idx, + neededFor: g.forRecipes.map((title) => ({ + recipeId: nameToRecipeId.get(title) ?? null, + title + })) + })); + + if (items.length > 0) { + try { + await addItems(list.id, items); + } catch (itemError) { + // Don't strand a list whose items failed — remove it and surface the failure. + await supabase.from('shopping_lists').delete().eq('id', list.id); + throw new MealPrepServiceError('Failed to add items to the prep shopping list', itemError); + } + } + + return { shoppingListId: list.id, itemCount: items.length }; +} + +// --------------------------------------------------------------------------- +// Complete (yield to inventory) — FR-PP-014/015/016 +// --------------------------------------------------------------------------- + +const DEFAULT_YIELD_STORAGE: StorageLocation = 'FRIDGE'; + +/** + * Complete a session: yield one PREP_SESSION-origin prepped portion per recipe into + * inventory, then mark the session COMPLETED. Idempotent — keyed off which recipe_ids + * already have a portion linked to this session (not just "does any exist"), so a retry + * after a partial failure (addPreppedMeal throwing partway through the loop below) yields + * only the recipes still missing a portion instead of skipping straight to markCompleted + * and silently dropping the rest (FR-PP-014/015). Distribution into the meal plan is out + * of scope (yield-to-inventory-only). + */ +export async function completeSession( + sessionId: string, + yields: readonly YieldChoice[] = [] +): Promise { + const session = await requireSession(sessionId); + + // Idempotency guard: the session<->portion link is the key, not a local flag. A recipe + // appears at most once per session (meal_prep_session_recipes_unique), so recipe_id + // uniquely identifies which session recipes have already been yielded. + const { data: existing, error: existingError } = await supabase + .from('prepped_meals') + .select('recipe_id') + .eq('meal_prep_session_id', sessionId); + if (existingError) { + throw new MealPrepServiceError('Failed to check existing yield', existingError); + } + const alreadyYielded = new Set((existing ?? []).map((row) => row.recipe_id)); + const recipesToYield = session.recipes.filter((r) => !alreadyYielded.has(r.recipeId)); + + if (recipesToYield.length === 0) { + // Already yielded — make sure the status reflects completion and return. + if (session.status !== 'COMPLETED') return markCompleted(sessionId); + return session; + } + + // prepped_meals.owner_id has no DB default (unlike meal_prep_sessions), so set it explicitly. + const user = await currentUser(); + if (!user) throw new MealPrepServiceError('You need to be signed in to mark a session prepped'); + + const storageByRecipe = new Map(yields.map((y) => [y.sessionRecipeId, y.storageLocation])); + const preparedDate = todayIso(); + + for (const recipe of recipesToYield) { + const storage = storageByRecipe.get(recipe.id) ?? DEFAULT_YIELD_STORAGE; + await addPreppedMeal({ + owner_id: user.id, + origin: 'PREP_SESSION', + name: recipe.recipeName, + recipe_id: recipe.recipeId, + recipe_name: recipe.recipeName, + meal_prep_session_id: sessionId, + original_portions: recipe.servingsToPrep, + portions_remaining: recipe.servingsToPrep, + storage_location: storage, + prepared_date: preparedDate, + expiration_date: expirationFor(preparedDate, storage), + // INV-INV-007: freezer items are FROZEN; fridge items are immediately ready. + defrost_state: storage === 'FREEZER' ? 'FROZEN' : 'NOT_APPLICABLE' + }); + } + + return markCompleted(sessionId); +} + +async function markCompleted(sessionId: string): Promise { + const { error } = await supabase + .from('meal_prep_sessions') + .update({ status: 'COMPLETED', completed_at: new Date().toISOString() }) + .eq('id', sessionId); + if (error) throw new MealPrepServiceError('Failed to mark the session complete', error); + return requireSession(sessionId); +} + +function todayIso(): string { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return d.toISOString().slice(0, 10); +} + +/** prepared_date + the storage-appropriate default shelf life (REQ-PP-012, INV-INV-009). */ +function expirationFor(preparedDate: string, storage: StorageLocation): string { + const days = PREPPED_SHELF_LIFE_DAYS[storage] ?? PREPPED_SHELF_LIFE_DAYS.FRIDGE ?? 4; + const d = new Date(`${preparedDate}T00:00:00`); + d.setDate(d.getDate() + days); + return d.toISOString().slice(0, 10); +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +async function requireSession(sessionId: string): Promise { + const session = await getSession(sessionId); + if (!session) throw new MealPrepServiceError('That prep session could not be found'); + return session; +} + +function assertRecipes(recipes: readonly NewSessionRecipe[]): void { + if (recipes.length < 1) { + throw new MealPrepServiceError('A prep session needs at least one recipe'); + } + if (recipes.some((r) => r.servingsToPrep <= 0)) { + throw new MealPrepServiceError('Servings to prepare must be greater than zero'); + } +} + +/** Reject prep days in the past (compared on the local calendar date). */ +function assertNotPast(prepDay: string): void { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const target = new Date(`${prepDay}T00:00:00`); + if (target.getTime() < today.getTime()) { + throw new MealPrepServiceError('The prep day cannot be in the past'); + } +} + +// --------------------------------------------------------------------------- +// Error type +// --------------------------------------------------------------------------- + +export class MealPrepServiceError extends Error { + constructor( + message: string, + public readonly cause?: unknown + ) { + super(message); + this.name = 'MealPrepServiceError'; + } +} diff --git a/src/lib/planning/mealPrep/mealPrepStore.svelte.ts b/src/lib/planning/mealPrep/mealPrepStore.svelte.ts new file mode 100644 index 0000000..d7dc6d6 --- /dev/null +++ b/src/lib/planning/mealPrep/mealPrepStore.svelte.ts @@ -0,0 +1,116 @@ +import { loadPreppedMeals } from '$lib/pantry/preppedMealStore.svelte'; +import { + addRecipe, + cancelSession, + completeSession, + createSession, + generatePrepShoppingList, + getSessions, + removeRecipe +} from './mealPrepService'; +import type { MealPrepSession, NewMealPrepSession, NewSessionRecipe, YieldChoice } from './types'; + +// --------------------------------------------------------------------------- +// Module-level reactive state (Svelte 5 runes) +// --------------------------------------------------------------------------- + +let _sessions = $state([]); +let _loading = $state(false); +let _loaded = $state(false); +let _error = $state(null); + +// --------------------------------------------------------------------------- +// Public accessors +// --------------------------------------------------------------------------- + +export function sessions(): MealPrepSession[] { + return _sessions; +} +export function sessionsLoading(): boolean { + return _loading; +} +export function sessionsLoaded(): boolean { + return _loaded; +} +export function sessionsError(): string | null { + return _error; +} + +// Getter (a $derived cannot be exported from a .svelte.ts module); consumers wrap in $derived. +export function plannedSessions(): MealPrepSession[] { + return _sessions.filter((s) => s.status === 'PLANNED'); +} + +// --------------------------------------------------------------------------- +// Load +// --------------------------------------------------------------------------- + +export async function loadSessions(): Promise { + _loading = true; + _error = null; + try { + _sessions = await getSessions(); + _loaded = true; + } catch (err) { + _error = err instanceof Error ? err.message : 'Failed to load meal prep sessions'; + } finally { + _loading = false; + } +} + +// --------------------------------------------------------------------------- +// Mutations — server is authoritative; we re-sync the affected session from its +// service result rather than guessing, keeping the optimistic surface honest. +// --------------------------------------------------------------------------- + +export async function createSessionAction(draft: NewMealPrepSession): Promise { + const created = await createSession(draft); + _sessions = [created, ..._sessions]; + return created; +} + +export async function addRecipeAction( + sessionId: string, + recipe: NewSessionRecipe +): Promise { + const updated = await addRecipe(sessionId, recipe); + replaceSession(updated); + return updated; +} + +export async function removeRecipeAction( + sessionId: string, + sessionRecipeId: string +): Promise { + const updated = await removeRecipe(sessionId, sessionRecipeId); + replaceSession(updated); + return updated; +} + +export async function completeSessionAction( + sessionId: string, + yields: readonly YieldChoice[] = [] +): Promise { + const updated = await completeSession(sessionId, yields); + replaceSession(updated); + // Yield landed new portions — refresh the prepped inventory so the Prepped tab reflects it. + await loadPreppedMeals(); + return updated; +} + +export async function cancelSessionAction(sessionId: string): Promise { + const updated = await cancelSession(sessionId); + replaceSession(updated); + return updated; +} + +/** Build a FROM_PREP shopping list for the session's ingredient gap (REQ-PP-019..022). */ +export async function buildPrepShoppingListAction( + sessionId: string +): Promise<{ shoppingListId: string; itemCount: number }> { + return generatePrepShoppingList(sessionId); +} + +function replaceSession(updated: MealPrepSession): void { + _sessions = _sessions.map((s) => (s.id === updated.id ? updated : s)); +} diff --git a/src/lib/planning/mealPrep/prepDay.spec.ts b/src/lib/planning/mealPrep/prepDay.spec.ts new file mode 100644 index 0000000..0e54552 --- /dev/null +++ b/src/lib/planning/mealPrep/prepDay.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { isNotPast, isWeekend, suggestPrepDay } from './prepDay'; + +// Local-midnight Date for a given ISO date, so tests don't depend on the runner's clock. +const at = (iso: string) => new Date(`${iso}T09:00:00`); + +describe('isWeekend', () => { + it('is true for Saturday and Sunday', () => { + expect(isWeekend(at('2026-06-13'))).toBe(true); // Sat + expect(isWeekend(at('2026-06-14'))).toBe(true); // Sun + }); + + it('is false for weekdays', () => { + expect(isWeekend(at('2026-06-15'))).toBe(false); // Mon + expect(isWeekend(at('2026-06-11'))).toBe(false); // Thu + }); +}); + +describe('suggestPrepDay (REQ-PP-003)', () => { + it('returns today when today is already a weekend day', () => { + expect(suggestPrepDay(at('2026-06-13'))).toBe('2026-06-13'); // Sat → Sat + expect(suggestPrepDay(at('2026-06-14'))).toBe('2026-06-14'); // Sun → Sun + }); + + it('returns the nearest upcoming Saturday from a weekday', () => { + expect(suggestPrepDay(at('2026-06-10'))).toBe('2026-06-13'); // Wed → Sat + expect(suggestPrepDay(at('2026-06-12'))).toBe('2026-06-13'); // Fri → Sat + }); + + it('returns Sunday when today is Monday-after-Saturday is further than Sunday', () => { + expect(suggestPrepDay(at('2026-06-15'))).toBe('2026-06-20'); // Mon → next Sat + }); +}); + +describe('isNotPast (REQ-PP-004)', () => { + const today = at('2026-06-13'); + + it('accepts today and future dates', () => { + expect(isNotPast('2026-06-13', today)).toBe(true); + expect(isNotPast('2026-06-20', today)).toBe(true); + }); + + it('rejects past dates', () => { + expect(isNotPast('2026-06-12', today)).toBe(false); + }); + + it('rejects malformed date strings', () => { + expect(isNotPast('not-a-date', today)).toBe(false); + }); +}); diff --git a/src/lib/planning/mealPrep/prepDay.ts b/src/lib/planning/mealPrep/prepDay.ts new file mode 100644 index 0000000..d6c594d --- /dev/null +++ b/src/lib/planning/mealPrep/prepDay.ts @@ -0,0 +1,49 @@ +// Pure prep-day helpers (REQ-PP-003/004, research D6). No I/O, no globals — `today` is +// always passed in so the suggestion is deterministic and unit-testable. + +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; + +function toLocalMidnight(date: Date): Date { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d; +} + +function toIsoDate(date: Date): string { + const d = toLocalMidnight(date); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +/** True for Saturday (6) and Sunday (0) — the default "common prep days" (REQ-PP-003). */ +export function isWeekend(date: Date): boolean { + const dow = toLocalMidnight(date).getDay(); + return dow === 0 || dow === 6; +} + +/** + * Suggest the next common prep day: the nearest upcoming weekend day (Sat/Sun), or today + * if today is already a weekend day. Returns an ISO date string (REQ-PP-003). + */ +export function suggestPrepDay(today: Date): string { + const start = toLocalMidnight(today); + for (let offset = 0; offset < 7; offset++) { + const candidate = new Date(start); + candidate.setDate(start.getDate() + offset); + if (isWeekend(candidate)) return toIsoDate(candidate); + } + // Unreachable (a weekend day occurs within any 7-day window), but keep total. + return toIsoDate(start); +} + +/** + * Guard for the user-chosen prep day (REQ-PP-004): a prep day may be today or later, never + * in the past. `prepDay` is an ISO date; `today` is compared on the local calendar date. + */ +export function isNotPast(prepDay: string, today: Date): boolean { + if (!ISO_DATE.test(prepDay)) return false; + const target = new Date(`${prepDay}T00:00:00`); + return target.getTime() >= toLocalMidnight(today).getTime(); +} diff --git a/src/lib/planning/mealPrep/prepShoppingList.service.spec.ts b/src/lib/planning/mealPrep/prepShoppingList.service.spec.ts new file mode 100644 index 0000000..e365dd4 --- /dev/null +++ b/src/lib/planning/mealPrep/prepShoppingList.service.spec.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mock } from 'vitest'; +import { generatePrepShoppingList } from './mealPrepService'; + +vi.mock('$lib/supabaseClient', () => ({ supabase: { from: vi.fn() } })); +vi.mock('$lib/session/session.svelte', () => ({ + currentUser: vi.fn().mockResolvedValue({ id: 'user-1' }) +})); +vi.mock('$lib/pantry/preppedMealService', () => ({ addPreppedMeal: vi.fn() })); +vi.mock('$lib/pantry/pantryService', () => ({ getPantryItems: vi.fn().mockResolvedValue([]) })); +vi.mock('$lib/recipes', () => ({ recipesRepository: { getRecipe: vi.fn() } })); +vi.mock('$lib/shopping/shoppingService', () => ({ addItems: vi.fn().mockResolvedValue([]) })); + +import { supabase } from '$lib/supabaseClient'; +import { getPantryItems } from '$lib/pantry/pantryService'; +import { recipesRepository } from '$lib/recipes'; +import { addItems } from '$lib/shopping/shoppingService'; + +type Result = { data: unknown; error: unknown }; + +function chainFor(result: Result) { + const chain: Record = {}; + for (const m of ['select', 'insert', 'update', 'delete', 'eq', 'order', 'limit']) { + chain[m] = vi.fn().mockReturnValue(chain); + } + chain.single = vi.fn().mockResolvedValue(result); + chain.maybeSingle = vi.fn().mockResolvedValue(result); + chain.then = (resolve: (v: unknown) => unknown) => Promise.resolve(result).then(resolve); + return chain; +} + +const sessionRow = { + id: 'sess-1', + status: 'PLANNED', + prep_day: '2026-06-20', + completed_at: null, + meal_prep_session_recipes: [ + { id: 'sr-1', recipe_id: 'r-1', recipe_name: 'Chili', servings_to_prep: 8 } + ] +}; + +let shoppingInsert: Mock; + +beforeEach(() => { + vi.clearAllMocks(); + (getPantryItems as Mock).mockResolvedValue([{ name: 'Onion', quantity: 5 }]); // onion on hand + (recipesRepository.getRecipe as Mock).mockResolvedValue({ + id: 'r-1', + servings: 4, + ingredients: [ + { name: 'Kidney beans', unit: 'g', quantity: 400, isOptional: false }, + { name: 'Onion', unit: 'whole', quantity: 1, isOptional: false }, // on hand → dropped + { name: 'Coriander', unit: 'g', quantity: 10, isOptional: true } // optional → dropped + ] + }); + + (supabase.from as Mock).mockImplementation((table: string) => { + if (table === 'meal_prep_sessions') return chainFor({ data: sessionRow, error: null }); + if (table === 'shopping_lists') { + const chain = chainFor({ data: { id: 'list-1' }, error: null }); + shoppingInsert = chain.insert as Mock; + return chain; + } + return chainFor({ data: null, error: null }); + }); +}); + +describe('generatePrepShoppingList (REQ-PP-019..022, INV-XD-004)', () => { + it('creates a FROM_PREP list referencing the session', async () => { + const result = await generatePrepShoppingList('sess-1'); + + expect(result.shoppingListId).toBe('list-1'); + expect(shoppingInsert).toHaveBeenCalledWith( + expect.objectContaining({ source_type: 'FROM_PREP', meal_prep_session_id: 'sess-1' }) + ); + }); + + it('adds only the gap items (on-hand + optional dropped), scaled, with recipe attribution', async () => { + const result = await generatePrepShoppingList('sess-1'); + + expect(result.itemCount).toBe(1); + expect(addItems).toHaveBeenCalledWith('list-1', [ + expect.objectContaining({ + name: 'Kidney beans', + quantity: 800, // 400 × (8/4) + unit: 'g', + neededFor: [{ recipeId: 'r-1', title: 'Chili' }] + }) + ]); + }); +}); diff --git a/src/lib/planning/mealPrep/prepShoppingList.spec.ts b/src/lib/planning/mealPrep/prepShoppingList.spec.ts new file mode 100644 index 0000000..dcbd359 --- /dev/null +++ b/src/lib/planning/mealPrep/prepShoppingList.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { computePrepShoppingGap, type RecipeForPrep } from './prepShoppingList'; + +const chili: RecipeForPrep = { + recipeId: 'r-1', + recipeName: 'Chili', + baseServings: 4, + servingsToPrep: 8, // 2× scale + ingredients: [ + { name: 'Kidney beans', unit: 'g', quantity: 400, isOptional: false }, + { name: 'Onion', unit: 'whole', quantity: 1, isOptional: false }, + { name: 'Coriander', unit: 'g', quantity: 10, isOptional: true } // optional → never in gap + ] +}; + +const oats: RecipeForPrep = { + recipeId: 'r-2', + recipeName: 'Oats', + baseServings: 2, + servingsToPrep: 2, // 1× scale + ingredients: [ + { name: 'Oats', unit: 'g', quantity: 100, isOptional: false }, + { name: 'Onion', unit: 'whole', quantity: 1, isOptional: false } // contrived shared item + ] +}; + +describe('computePrepShoppingGap (REQ-PP-019..022)', () => { + it('scales ingredient quantities by servingsToPrep / baseServings', () => { + const gap = computePrepShoppingGap([chili], new Set()); + const beans = gap.find((g) => g.name === 'Kidney beans'); + expect(beans?.quantity).toBe(800); // 400 × (8/4) + }); + + it('aggregates the same (name, unit) across recipes and records contributing recipes', () => { + const gap = computePrepShoppingGap([chili, oats], new Set()); + const onion = gap.find((g) => g.name === 'Onion'); + expect(onion?.quantity).toBe(3); // chili 1×2 + oats 1×1 + expect(onion?.forRecipes.sort()).toEqual(['Chili', 'Oats']); + }); + + it('drops ingredients already on hand in the pantry (presence-by-name)', () => { + const gap = computePrepShoppingGap([chili], new Set(['kidney beans'])); + expect(gap.find((g) => g.name === 'Kidney beans')).toBeUndefined(); + expect(gap.find((g) => g.name === 'Onion')).toBeDefined(); + }); + + it('excludes optional ingredients from the gap', () => { + const gap = computePrepShoppingGap([chili], new Set()); + expect(gap.find((g) => g.name === 'Coriander')).toBeUndefined(); + }); + + it('matches pantry names case- and whitespace-insensitively', () => { + const gap = computePrepShoppingGap([oats], new Set(['oats'])); + // "Oats" on hand → only the shared Onion remains + expect(gap.map((g) => g.name)).toEqual(['Onion']); + }); + + it('returns an empty gap when everything is on hand', () => { + const gap = computePrepShoppingGap([oats], new Set(['oats', 'onion'])); + expect(gap).toEqual([]); + }); +}); diff --git a/src/lib/planning/mealPrep/prepShoppingList.ts b/src/lib/planning/mealPrep/prepShoppingList.ts new file mode 100644 index 0000000..e9e23ea --- /dev/null +++ b/src/lib/planning/mealPrep/prepShoppingList.ts @@ -0,0 +1,84 @@ +// Pure prep→shopping aggregation (REQ-PP-019..022, research D4). No I/O — the service layer +// loads recipe ingredients + the pantry index and hands them here. Gap detection uses the same +// presence-by-name rule as fulfillment v1 (no unit math): an ingredient already on hand by name +// drops out; what remains is what the prep session needs to buy. + +import { normalizeName } from '../fulfillment'; +import type { PrepShoppingGapItem } from './types'; + +export interface PrepRecipeIngredient { + name: string; + unit: string; + quantity: number; + isOptional: boolean; +} + +export interface RecipeForPrep { + recipeId: string; + recipeName: string; + /** recipes.servings — the recipe's base yield (> 0). */ + baseServings: number; + /** servings to prepare in this session (> 0). */ + servingsToPrep: number; + ingredients: PrepRecipeIngredient[]; +} + +interface Aggregate { + name: string; // first-seen display name + unit: string; + quantity: number; + forRecipes: Set; +} + +/** + * Aggregate the ingredients a session needs across all its recipes (scaled by servings), + * drop anything already on hand in the pantry, and annotate each remaining item with the + * recipe(s) that need it. Optional ingredients never enter the gap. + */ +export function computePrepShoppingGap( + recipes: readonly RecipeForPrep[], + pantryIndex: ReadonlySet +): PrepShoppingGapItem[] { + const byKey = new Map(); + + for (const recipe of recipes) { + const factor = recipe.baseServings > 0 ? recipe.servingsToPrep / recipe.baseServings : 0; + for (const ing of recipe.ingredients) { + if (ing.isOptional) continue; + const norm = normalizeName(ing.name); + // Aggregate per (name, unit) — different units for the same name stay separate + // rather than being silently summed across incompatible measures. + const key = `${norm}|${normalizeName(ing.unit)}`; + const existing = byKey.get(key); + if (existing) { + existing.quantity += ing.quantity * factor; + existing.forRecipes.add(recipe.recipeName); + } else { + byKey.set(key, { + name: ing.name.trim(), + unit: ing.unit, + quantity: ing.quantity * factor, + forRecipes: new Set([recipe.recipeName]) + }); + } + } + } + + const gap: PrepShoppingGapItem[] = []; + for (const agg of byKey.values()) { + // Already on hand (presence-by-name) → not a gap. + if (pantryIndex.has(normalizeName(agg.name))) continue; + gap.push({ + name: agg.name, + unit: agg.unit, + quantity: roundQuantity(agg.quantity), + forRecipes: [...agg.forRecipes] + }); + } + return gap; +} + +/** Round scaled quantities to a sane precision (avoids 0.30000000000000004 in lists). */ +function roundQuantity(value: number): number { + return Math.round(value * 1000) / 1000; +} diff --git a/src/lib/planning/mealPrep/types.ts b/src/lib/planning/mealPrep/types.ts new file mode 100644 index 0000000..af14e70 --- /dev/null +++ b/src/lib/planning/mealPrep/types.ts @@ -0,0 +1,83 @@ +// Domain types for the meal-prep batch-session feature (006). CamelCase application shapes +// mapped from the snake_case database rows. The service layer returns these. +// +// Canonical shapes: Domain Specification §2.x (Planning Context, Meal Prep Session). +// A session selects recipes + servings and, on completion, yields PREP_SESSION-origin +// prepped portions into inventory (yield-to-inventory-only; distribution deferred). + +import type { Enums, Tables, TablesInsert } from '$lib/database.types'; +import type { StorageLocation } from '$lib/pantry/types'; + +export type MealPrepSessionStatus = Enums<'meal_prep_session_status'>; + +export type MealPrepSessionRow = Tables<'meal_prep_sessions'>; +export type MealPrepSessionRecipeRow = Tables<'meal_prep_session_recipes'>; +export type MealPrepSessionInsert = TablesInsert<'meal_prep_sessions'>; +export type MealPrepSessionRecipeInsert = TablesInsert<'meal_prep_session_recipes'>; + +export interface MealPrepSessionRecipe { + id: string; + recipeId: string; + recipeName: string; + servingsToPrep: number; +} + +export interface MealPrepSession { + id: string; + status: MealPrepSessionStatus; + prepDay: string; // ISO date + completedAt: string | null; + recipes: MealPrepSessionRecipe[]; +} + +/** Input for creating a session together with its first recipes (INV-PL-006: length >= 1). */ +export interface NewMealPrepSession { + prepDay: string; + recipes: NewSessionRecipe[]; +} + +export interface NewSessionRecipe { + recipeId: string; + recipeName: string; + servingsToPrep: number; +} + +/** Per-recipe storage choice captured at completion (drives shelf life / freezer state). */ +export interface YieldChoice { + sessionRecipeId: string; + storageLocation: StorageLocation; +} + +/** Pure prep→shopping output (prepShoppingList.ts). */ +export interface PrepShoppingGapItem { + name: string; + unit: string; + quantity: number; + forRecipes: string[]; // recipe names that contribute this item +} + +// --------------------------------------------------------------------------- +// Row → application-shape mappers +// --------------------------------------------------------------------------- + +export function toMealPrepSessionRecipe(row: MealPrepSessionRecipeRow): MealPrepSessionRecipe { + return { + id: row.id, + recipeId: row.recipe_id, + recipeName: row.recipe_name, + servingsToPrep: row.servings_to_prep + }; +} + +export function toMealPrepSession( + row: MealPrepSessionRow, + recipeRows: readonly MealPrepSessionRecipeRow[] +): MealPrepSession { + return { + id: row.id, + status: row.status, + prepDay: row.prep_day, + completedAt: row.completed_at, + recipes: recipeRows.map(toMealPrepSessionRecipe) + }; +} diff --git a/src/lib/planning/preppedDecrement.spec.ts b/src/lib/planning/preppedDecrement.spec.ts new file mode 100644 index 0000000..ec1f286 --- /dev/null +++ b/src/lib/planning/preppedDecrement.spec.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { deriveFulfillment, inputsFromSnapshot } from './fulfillment'; +import type { PlannedMeal } from './types'; +import type { PreppedMeal } from '$lib/pantry/types'; + +// --------------------------------------------------------------------------- +// Session-yield consume lifecycle (feature 006, FR-PP-031 / REQ-PP-027 / INV-XD-003). +// +// A PREP_SESSION-origin portion decrements through the same CONSUMED portion-event ledger +// as any other origin (proven at the DB level in supabase/tests/portion_creation.test.sql). +// Here we lock the user-facing consequence: while the portion has servings, the planned meal +// reads HAVE_IT; once consumed to 0 it reads MUST_ACQUIRE — closing the make-ahead loop. +// +// NOTE: the *automatic* decrement on meal-LOGGING (REQ-PP-027) is deferred together with the +// meal-logging feature — there is no meal_logs table/flow yet. This guards the consume +// mechanism for session yields, not an auto-on-log trigger. +// --------------------------------------------------------------------------- + +function sessionPortion(portionsRemaining: number): PreppedMeal { + return { + id: 'pp-sess', + owner_id: 'user-1', + household_id: null, + origin: 'PREP_SESSION', + name: 'Chili', + recipe_id: 'r-1', + recipe_name: 'Chili', + meal_prep_session_id: 'mps-1', + portions_remaining: portionsRemaining, + original_portions: 3, + storage_location: 'FREEZER', + container_label: null, + prepared_date: '2026-06-13', + expiration_date: '2026-09-11', + defrost_state: 'FROZEN', + defrost_started_at: null, + estimated_ready_at: null, + photo_url: null, + created_at: '', + updated_at: '', + isExpiringSoon: false, + isExpired: false, + isReadyToEat: false + }; +} + +const plannedFromSession: PlannedMeal = { + id: 'pm-1', + mealPlanId: 'plan-1', + date: '2026-06-16', + mealSlot: 'DINNER', + source: 'PREPPED', + recipeId: null, + recipeTitleSnapshot: null, + preppedMealId: 'pp-sess', + preppedNameSnapshot: 'Chili', + storeBoughtName: null, + quickMealName: null, + servings: 1, + status: 'PLANNED', + sortOrder: 0, + createdAt: '', + updatedAt: '' +}; + +function fulfillmentFor(portionsRemaining: number) { + const inputs = inputsFromSnapshot( + { pantryItems: [], preppedMeals: [sessionPortion(portionsRemaining)] }, + new Map() + ); + return deriveFulfillment(plannedFromSession, inputs); +} + +describe('PREP_SESSION portion consume lifecycle (FR-PP-031)', () => { + it('reads HAVE_IT while the yielded portion has servings remaining', () => { + expect(fulfillmentFor(3)).toEqual({ state: 'HAVE_IT' }); + expect(fulfillmentFor(1)).toEqual({ state: 'HAVE_IT' }); + }); + + it('flips to MUST_ACQUIRE once the portion is consumed to zero', () => { + expect(fulfillmentFor(0)?.state).toBe('MUST_ACQUIRE'); + }); +}); diff --git a/src/lib/planning/preppedPicker.spec.ts b/src/lib/planning/preppedPicker.spec.ts new file mode 100644 index 0000000..2a9e077 --- /dev/null +++ b/src/lib/planning/preppedPicker.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { placeablePreppedPortions } from '$lib/pantry/types'; + +// --------------------------------------------------------------------------- +// PREPPED picker placeability contract (feature 006, FR-PP-002). +// +// The Add-meal sheet (AddMealSheet.svelte, feature 004) lets a user place a prepped +// portion as a PREPPED-source planned meal — the path that reads HAVE_IT. The one +// picker-specific rule (only portions with portions remaining > 0 are offerable) was +// inline in the component; it now lives in `placeablePreppedPortions` and is locked here. +// The PREPPED draft → planned-meal mapping itself is covered in planningService.spec.ts. +// --------------------------------------------------------------------------- + +const portion = (id: string, remaining: number) => ({ id, portions_remaining: remaining }); + +describe('placeablePreppedPortions (FR-PP-002)', () => { + it('offers only portions with remaining > 0', () => { + const result = placeablePreppedPortions([portion('a', 3), portion('b', 0), portion('c', 1)]); + expect(result.map((p) => p.id)).toEqual(['a', 'c']); + }); + + it('excludes exhausted portions (they would derive MUST_ACQUIRE, not HAVE_IT)', () => { + expect(placeablePreppedPortions([portion('x', 0)])).toEqual([]); + }); + + it('returns an empty list when there is no prepped inventory', () => { + expect(placeablePreppedPortions([])).toEqual([]); + }); +}); diff --git a/src/lib/shopping/types.spec.ts b/src/lib/shopping/types.spec.ts index 59c80fc..97a3e78 100644 --- a/src/lib/shopping/types.spec.ts +++ b/src/lib/shopping/types.spec.ts @@ -22,6 +22,7 @@ function listRow(overrides: Partial = {}): ShoppingListRow { source_type: 'MANUAL', generated_range_start: null, generated_range_end: null, + meal_prep_session_id: null, status: 'ACTIVE', completed_at: null, created_at: '2026-06-11T00:00:00Z', diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 15737d3..93dad84 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -76,8 +76,9 @@ } }); - // Per design/web-*.html: full nav skeleton; unbuilt surfaces render muted (readme: - // "Other surfaces are scaffolded in the nav but not yet designed"). + // Per design/web-*.html: full nav skeleton. Every surface is live as of 009; the + // null-href branch below stays as the scaffold for the next unbuilt one (household + // sharing, energy-aware suggestions), which renders muted rather than linking. const NAV = [ { label: 'Today', icon: 'ph-house', href: resolve('/today') }, { label: 'Plan', icon: 'ph-calendar-dots', href: resolve('/plan') }, @@ -85,12 +86,12 @@ { label: 'Pantry', icon: 'ph-jar', href: resolve('/pantry') }, { label: 'Shopping', icon: 'ph-shopping-cart-simple', href: resolve('/shopping') }, { label: 'Nudges', icon: 'ph-bell-simple-ringing', href: resolve('/reminders') }, - { label: 'Meal prep', icon: 'ph-cooking-pot', href: null } + { label: 'Meal prep', icon: 'ph-cooking-pot', href: resolve('/prep') } ] as const; - // Bottom tabs (mobile) — five slots per design/mobile-*.html. Shopping is live now, - // so it takes the fifth slot from the still-unbuilt Meal prep surface. Nudges is - // reachable on mobile from the Today header bell instead of a tab. + // Bottom tabs (mobile) — five slots per design/mobile-*.html. Shopping holds the + // fifth slot; Meal prep and Nudges are reachable on mobile from the Pantry → + // Prepped shelf and the Today header bell respectively. const TABS = NAV.filter((n) => n.label !== 'Meal prep' && n.label !== 'Nudges'); function tabLabel(label: string): string { diff --git a/src/routes/prep/+page.svelte b/src/routes/prep/+page.svelte new file mode 100644 index 0000000..f9685f2 --- /dev/null +++ b/src/routes/prep/+page.svelte @@ -0,0 +1,109 @@ + + + + +Meal prep — Nexus Kitchen + + +
+
+

+ Meal prep +

+
+ + {#if buildError} + + {:else if buildNotice} +

+ {buildNotice} +

+ {/if} + + (showSessionForm = true)} + onBuildShoppingList={handleBuildShoppingList} + buildingShoppingList={building} + /> +
+ +{#if showSessionForm} + +{/if} + + diff --git a/supabase/migrations/0013_meal_prep_sessions.sql b/supabase/migrations/0013_meal_prep_sessions.sql new file mode 100644 index 0000000..b69111e --- /dev/null +++ b/supabase/migrations/0013_meal_prep_sessions.sql @@ -0,0 +1,144 @@ +-- Migration: meal_prep_sessions +-- Feature: 006-meal-prep +-- Batch meal-prep sessions: select recipes + servings, schedule a prep day, and on +-- completion yield PREP_SESSION-origin prepped_meals into inventory. Distribution into the +-- meal plan is deferred (yield-to-inventory-only). See features/006-meal-prep/data-model.md. +-- +-- Note: household_id is a plain nullable uuid (no FK) until the Identity feature lands, +-- mirroring prepped_meals (migration 0003). +-- Note: INV-PL-006 (a session has >= 1 recipe) is a cross-row rule enforced in the service +-- layer (a session is created with its first recipe; removing the last recipe is +-- rejected) — a single-table CHECK cannot express it, same pattern as recipes (0001). + +-- --------------------------------------------------------------------------- +-- Enum +-- --------------------------------------------------------------------------- +CREATE TYPE meal_prep_session_status AS ENUM ('PLANNED', 'COMPLETED', 'CANCELLED'); + +-- --------------------------------------------------------------------------- +-- Table: meal_prep_sessions +-- --------------------------------------------------------------------------- +CREATE TABLE meal_prep_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id uuid NOT NULL DEFAULT auth.uid() REFERENCES auth.users(id) ON DELETE CASCADE, + household_id uuid, -- no FK until Household feature + + status meal_prep_session_status NOT NULL DEFAULT 'PLANNED', + prep_day date NOT NULL, -- suggested/overridden (REQ-PP-003/004) + completed_at timestamptz, -- set iff status = COMPLETED + + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + -- INV-PL-008: a completed session has a completion timestamp, and only a completed one does + CONSTRAINT meal_prep_sessions_completed_has_timestamp + CHECK ((status = 'COMPLETED') = (completed_at IS NOT NULL)) +); + +CREATE INDEX meal_prep_sessions_owner_id_idx ON meal_prep_sessions (owner_id); +CREATE INDEX meal_prep_sessions_household_id_idx ON meal_prep_sessions (household_id); +CREATE INDEX meal_prep_sessions_prep_day_idx ON meal_prep_sessions (prep_day); + +-- --------------------------------------------------------------------------- +-- Table: meal_prep_session_recipes +-- --------------------------------------------------------------------------- +CREATE TABLE meal_prep_session_recipes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + meal_prep_session_id uuid NOT NULL REFERENCES meal_prep_sessions(id) ON DELETE CASCADE, + recipe_id uuid NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + recipe_name text NOT NULL CHECK (char_length(recipe_name) BETWEEN 1 AND 500), -- denormalized snapshot + servings_to_prep integer NOT NULL CHECK (servings_to_prep > 0), -- INV-PL-007 + created_at timestamptz NOT NULL DEFAULT now(), + + -- a recipe appears at most once per session + CONSTRAINT meal_prep_session_recipes_unique UNIQUE (meal_prep_session_id, recipe_id) +); + +CREATE INDEX meal_prep_session_recipes_session_id_idx ON meal_prep_session_recipes (meal_prep_session_id); + +-- --------------------------------------------------------------------------- +-- updated_at trigger (reuses set_updated_at() from 0002_pantry_items.sql) +-- --------------------------------------------------------------------------- +CREATE TRIGGER trg_meal_prep_sessions_updated_at + BEFORE UPDATE ON meal_prep_sessions + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- recipe_id ownership guard (same pattern as 0007 check_source_planned_meal_ownership). +-- The recipe_id FK alone would let a session reference another user's recipe (a cross-user +-- existence oracle, and a foreign recipe_id that would propagate into yielded prepped_meals). +-- RLS gates the row via its parent session, but not the foreign recipe reference — so guard it. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.check_session_recipe_ownership() +RETURNS TRIGGER +LANGUAGE plpgsql SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + IF NEW.recipe_id IS NOT NULL THEN + IF NOT EXISTS ( + SELECT 1 FROM recipes r + WHERE r.id = NEW.recipe_id AND r.owner_id = auth.uid() + ) THEN + RAISE EXCEPTION 'recipe_id must reference one of your own recipes' + USING ERRCODE = 'insufficient_privilege'; + END IF; + END IF; + RETURN NEW; +END; +$$; + +-- A SECURITY DEFINER function inherits the implicit PUBLIC EXECUTE grant, which +-- publishes it at /rest/v1/rpc/ running as its owner (lints 0028/0029). +-- The trigger fires regardless of EXECUTE grants, so revoking costs nothing. +REVOKE ALL ON FUNCTION public.check_session_recipe_ownership() + FROM PUBLIC, anon, authenticated, service_role; + +CREATE TRIGGER trg_session_recipes_recipe_ownership + BEFORE INSERT OR UPDATE OF recipe_id ON meal_prep_session_recipes + FOR EACH ROW EXECUTE FUNCTION check_session_recipe_ownership(); + +-- --------------------------------------------------------------------------- +-- Row-Level Security (P7 — default-deny, owner-scoped) +-- --------------------------------------------------------------------------- +ALTER TABLE meal_prep_sessions ENABLE ROW LEVEL SECURITY; + +-- auth.uid() is wrapped as `(select auth.uid())` so the planner hoists it into an +-- InitPlan evaluated once per statement rather than once per row (perf lint 0003, +-- see 0012_rls_initplan). +CREATE POLICY "meal_prep_sessions_owner_all" + ON meal_prep_sessions FOR ALL + USING (owner_id = (select auth.uid())) + WITH CHECK (owner_id = (select auth.uid())); + +ALTER TABLE meal_prep_session_recipes ENABLE ROW LEVEL SECURITY; + +-- access gated through the parent session's ownership +CREATE POLICY "meal_prep_session_recipes_owner_all" + ON meal_prep_session_recipes FOR ALL + USING ( + EXISTS ( + SELECT 1 FROM meal_prep_sessions s + WHERE s.id = meal_prep_session_id AND s.owner_id = (select auth.uid()) + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM meal_prep_sessions s + WHERE s.id = meal_prep_session_id AND s.owner_id = (select auth.uid()) + ) + ); + +-- TODO (Identity feature): add household-member policies once household_members exists. + +-- --------------------------------------------------------------------------- +-- Data API grants +-- The CLI's implicit auto_expose_new_tables default flipped to false on 2026-05-30 +-- (see supabase/config.toml + migration 0008), so new public tables need explicit grants +-- or PostgREST returns "permission denied" before RLS is consulted. Authorization remains +-- RLS (P7) — these grants are the coarse capability layer only. +-- --------------------------------------------------------------------------- +GRANT SELECT, INSERT, UPDATE, DELETE ON meal_prep_sessions, meal_prep_session_recipes TO authenticated; +GRANT ALL ON meal_prep_sessions, meal_prep_session_recipes TO service_role; +-- anon gets SELECT capability only; RLS still yields zero rows pre-login +GRANT SELECT ON meal_prep_sessions, meal_prep_session_recipes TO anon; diff --git a/supabase/migrations/0014_shopping_from_prep_enum.sql b/supabase/migrations/0014_shopping_from_prep_enum.sql new file mode 100644 index 0000000..9a7ecd3 --- /dev/null +++ b/supabase/migrations/0014_shopping_from_prep_enum.sql @@ -0,0 +1,11 @@ +-- Migration: shopping_list_source += FROM_PREP (enum value only) +-- Feature: 006-meal-prep +-- Prep sessions can generate a shopping list for the ingredient gap (REQ-PP-019..022, +-- INV-XD-004). Migration 0007 deferred FROM_PREP; this restores it. +-- +-- The enum value is added in its OWN migration, separate from the column + CHECK that +-- reference it (migration 0011). PostgreSQL forbids using a newly-added enum value in the +-- same transaction it was added ("unsafe use of new value"), and migration runners may wrap +-- each file in a transaction — so the value must be committed before any DDL references it. + +ALTER TYPE shopping_list_source ADD VALUE IF NOT EXISTS 'FROM_PREP'; diff --git a/supabase/migrations/0015_shopping_from_prep_link.sql b/supabase/migrations/0015_shopping_from_prep_link.sql new file mode 100644 index 0000000..609dc78 --- /dev/null +++ b/supabase/migrations/0015_shopping_from_prep_link.sql @@ -0,0 +1,46 @@ +-- Migration: shopping_lists.meal_prep_session_id + FROM_PREP integrity +-- Feature: 006-meal-prep +-- Adds the session link for FROM_PREP shopping lists and enforces INV-XD-004 +-- (a FROM_PREP list references a valid meal_prep_session; non-FROM_PREP lists do not). +-- The 'FROM_PREP' enum value is added separately in migration 0010 (committed first). + +ALTER TABLE shopping_lists + ADD COLUMN meal_prep_session_id uuid REFERENCES meal_prep_sessions(id) ON DELETE SET NULL; + +CREATE INDEX shopping_lists_meal_prep_session_id_idx + ON shopping_lists (meal_prep_session_id); + +-- INV-XD-004: FROM_PREP ⇔ session reference present (explicit OR form so NULL passes +-- through and a non-FROM_PREP list can never carry a stray session id). +ALTER TABLE shopping_lists + ADD CONSTRAINT shopping_lists_from_prep_has_session CHECK ( + (source_type = 'FROM_PREP' AND meal_prep_session_id IS NOT NULL) OR + (source_type <> 'FROM_PREP' AND meal_prep_session_id IS NULL) + ); + +-- --------------------------------------------------------------------------- +-- Detach-on-session-delete. +-- The FK above is ON DELETE SET NULL, but nulling meal_prep_session_id on a row that is +-- still source_type='FROM_PREP' would violate the CHECK above — so deleting a session that +-- generated a shopping list would otherwise abort (and break the auth.users delete cascade). +-- A FROM_PREP list is the user's work product and should outlive its planning session, so on +-- session delete we detach the list to a plain MANUAL list (BEFORE the FK action fires). +-- --------------------------------------------------------------------------- +-- search_path is pinned per lint 0011 (see 0011_security_advisor_fixes): CREATE OR +-- REPLACE silently discards any attribute the new definition omits, so it must be +-- restated here rather than inherited. +CREATE OR REPLACE FUNCTION public.detach_prep_shopping_lists() +RETURNS TRIGGER LANGUAGE plpgsql +SET search_path = public +AS $$ +BEGIN + UPDATE shopping_lists + SET source_type = 'MANUAL', meal_prep_session_id = NULL + WHERE meal_prep_session_id = OLD.id; + RETURN OLD; +END; +$$; + +CREATE TRIGGER trg_detach_prep_shopping_lists + BEFORE DELETE ON meal_prep_sessions + FOR EACH ROW EXECUTE FUNCTION detach_prep_shopping_lists(); diff --git a/supabase/tests/meal_prep_rls.test.sql b/supabase/tests/meal_prep_rls.test.sql new file mode 100644 index 0000000..ab8bd97 --- /dev/null +++ b/supabase/tests/meal_prep_rls.test.sql @@ -0,0 +1,182 @@ +-- RLS isolation tests for the meal-prep batch-session tables +-- (meal_prep_sessions, meal_prep_session_recipes). +-- Covers P7 (default-deny RLS, owner-scoped) and the child table's parent-ownership gating. +-- Requires migrations 0009 applied. + +begin; +select plan(16); + +-- Two test users + a recipe each owns (FK target for session recipes). +insert into auth.users (id, email) +values + ('11111111-1111-1111-1111-111111111111', 'alice@test.local'), + ('22222222-2222-2222-2222-222222222222', 'bob@test.local') +on conflict (id) do nothing; + +insert into public.recipes (id, owner_id, title, servings) +values + ('aaaa1111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'Alice Chili', 4), + ('bbbb2222-2222-2222-2222-222222222222', '22222222-2222-2222-2222-222222222222', 'Bob Stew', 4) +on conflict (id) do nothing; + +-- ── Act as Alice ────────────────────────────────────────────────────────────── +set local role authenticated; +select set_config( + 'request.jwt.claims', + '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', + true +); + +insert into public.meal_prep_sessions (id, prep_day) +values ('cccc1111-1111-1111-1111-111111111111', current_date + 2); + +insert into public.meal_prep_session_recipes + (id, meal_prep_session_id, recipe_id, recipe_name, servings_to_prep) +values + ('dddd1111-1111-1111-1111-111111111111', + 'cccc1111-1111-1111-1111-111111111111', + 'aaaa1111-1111-1111-1111-111111111111', 'Alice Chili', 6); + +select is( + (select count(*) from public.meal_prep_sessions), + 1::bigint, + 'Alice sees her own session' +); +select is( + (select count(*) from public.meal_prep_session_recipes), + 1::bigint, + 'Alice sees her own session recipe' +); +select is( + (select owner_id from public.meal_prep_sessions where id = 'cccc1111-1111-1111-1111-111111111111'), + '11111111-1111-1111-1111-111111111111'::uuid, + 'owner_id defaulted to auth.uid()' +); + +-- ── Act as Bob ──────────────────────────────────────────────────────────────── +set local role authenticated; +select set_config( + 'request.jwt.claims', + '{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated"}', + true +); + +select is( + (select count(*) from public.meal_prep_sessions), + 0::bigint, + 'Bob cannot see Alice''s session' +); +select is( + (select count(*) from public.meal_prep_session_recipes), + 0::bigint, + 'Bob cannot see Alice''s session recipe (parent-gated)' +); + +-- Bob cannot UPDATE or DELETE Alice's session (0 rows affected — invisible under RLS). +with upd as ( + update public.meal_prep_sessions set status = 'CANCELLED' + where id = 'cccc1111-1111-1111-1111-111111111111' + returning 1 +) +select is((select count(*) from upd), 0::bigint, 'Bob cannot UPDATE Alice''s session'); + +with del as ( + delete from public.meal_prep_sessions + where id = 'cccc1111-1111-1111-1111-111111111111' + returning 1 +) +select is((select count(*) from del), 0::bigint, 'Bob cannot DELETE Alice''s session'); + +-- Bob cannot INSERT a session recipe into Alice's session (parent not owned → WITH CHECK fails). +select throws_ok( + $$insert into public.meal_prep_session_recipes + (meal_prep_session_id, recipe_id, recipe_name, servings_to_prep) + values ('cccc1111-1111-1111-1111-111111111111', + 'bbbb2222-2222-2222-2222-222222222222', 'Sneaky', 1)$$, + '42501', + NULL, + 'Bob cannot INSERT a recipe into Alice''s session' +); + +-- Bob's own session works and is isolated. +insert into public.meal_prep_sessions (id, prep_day) +values ('cccc2222-2222-2222-2222-222222222222', current_date + 3); +select is( + (select count(*) from public.meal_prep_sessions), + 1::bigint, + 'Bob sees only his own session' +); + +-- ── Constraint checks (independent of RLS) ──────────────────────────────────── +-- servings_to_prep must be positive (INV-PL-007). +select throws_ok( + $$insert into public.meal_prep_session_recipes + (meal_prep_session_id, recipe_id, recipe_name, servings_to_prep) + values ('cccc2222-2222-2222-2222-222222222222', + 'bbbb2222-2222-2222-2222-222222222222', 'Bob Stew', 0)$$, + '23514', + NULL, + 'servings_to_prep must be > 0 (INV-PL-007)' +); + +-- A recipe appears at most once per session (UNIQUE). +insert into public.meal_prep_session_recipes + (meal_prep_session_id, recipe_id, recipe_name, servings_to_prep) +values ('cccc2222-2222-2222-2222-222222222222', + 'bbbb2222-2222-2222-2222-222222222222', 'Bob Stew', 4); +select throws_ok( + $$insert into public.meal_prep_session_recipes + (meal_prep_session_id, recipe_id, recipe_name, servings_to_prep) + values ('cccc2222-2222-2222-2222-222222222222', + 'bbbb2222-2222-2222-2222-222222222222', 'Bob Stew', 2)$$, + '23505', + NULL, + 'a recipe appears at most once per session (UNIQUE)' +); + +-- Bob cannot reference Alice's recipe in his own session (ownership guard, not just FK). +select throws_ok( + $$insert into public.meal_prep_session_recipes + (meal_prep_session_id, recipe_id, recipe_name, servings_to_prep) + values ('cccc2222-2222-2222-2222-222222222222', + 'aaaa1111-1111-1111-1111-111111111111', 'Alice Chili', 2)$$, + '42501', + NULL, + 'Bob cannot reference Alice''s recipe in his session (recipe ownership guard)' +); + +-- ── Detach-on-delete: a FROM_PREP list survives session deletion as MANUAL (INV-XD-004) ── +insert into public.shopping_lists (name, source_type, meal_prep_session_id) +values ('Bob prep list', 'FROM_PREP', 'cccc2222-2222-2222-2222-222222222222'); + +-- Deleting the session must NOT abort on the FROM_PREP CHECK; the list is detached. +delete from public.meal_prep_sessions where id = 'cccc2222-2222-2222-2222-222222222222'; + +select is( + (select source_type::text from public.shopping_lists where name = 'Bob prep list'), + 'MANUAL', + 'deleting a session detaches its FROM_PREP list to MANUAL (no CHECK violation)' +); +select is( + (select meal_prep_session_id from public.shopping_lists where name = 'Bob prep list'), + NULL::uuid, + 'the detached list no longer references the deleted session' +); + +-- ── Anon role — no access at all ────────────────────────────────────────────── +set local role anon; +select set_config('request.jwt.claims', '{}', true); + +select is( + (select count(*) from public.meal_prep_sessions), + 0::bigint, + 'anon sees no meal_prep_sessions' +); +select is( + (select count(*) from public.meal_prep_session_recipes), + 0::bigint, + 'anon sees no meal_prep_session_recipes' +); + +select * from finish(); +rollback; diff --git a/supabase/tests/portion_creation.test.sql b/supabase/tests/portion_creation.test.sql new file mode 100644 index 0000000..8cff005 --- /dev/null +++ b/supabase/tests/portion_creation.test.sql @@ -0,0 +1,76 @@ +-- Non-mocked regression for prepped-portion CREATION (feature 006, Design A). +-- +-- The app's addPreppedMeal() used to INSERT the prepped_meals row and THEN emit a positive +-- INITIALIZED portion_event. That event is rejected by INV-INV-011's CHECK +-- (portion_events_positive_delta_only_adjusted: positive delta only for ADJUSTED), so the +-- creation path threw against any real backend. The unit test mocked the ledger and never +-- caught it. This suite locks the real DB behavior: +-- 1. A direct INSERT into prepped_meals is the authoritative starting count (works). +-- 2. A positive INITIALIZED portion_event is rejected by the constraint. +-- 3. A CONSUMED delta still flows through the trigger (the ledger records *changes* only). +-- Requires migrations 0003–0004 applied. + +begin; +select plan(4); + +insert into auth.users (id, email) +values ('33333333-3333-3333-3333-333333333333', 'carol@test.local') +on conflict (id) do nothing; + +set local role authenticated; +select set_config( + 'request.jwt.claims', + '{"sub":"33333333-3333-3333-3333-333333333333","role":"authenticated"}', + true +); + +-- 1. Creating a prepped portion via direct INSERT succeeds with portions_remaining > 0. +insert into public.prepped_meals ( + id, owner_id, origin, name, + portions_remaining, original_portions, + storage_location, prepared_date, expiration_date, defrost_state +) values ( + 'dddddddd-dddd-dddd-dddd-dddddddddddd', + '33333333-3333-3333-3333-333333333333', + 'DIRECT_ENTRY', 'Carol Lentil Soup', + 5, 5, + 'FRIDGE', current_date, current_date + 4, 'NOT_APPLICABLE' +); + +select is( + (select portions_remaining from public.prepped_meals + where id = 'dddddddd-dddd-dddd-dddd-dddddddddddd'), + 5, + 'direct INSERT sets the authoritative starting portion count (no INITIALIZED event needed)' +); + +-- 2. A positive INITIALIZED portion_event is rejected (INV-INV-011) — this is exactly the +-- write the old addPreppedMeal attempted. It MUST fail, by the CHECK constraint (23514). +select throws_ok( + $$insert into public.portion_events (prepped_meal_id, delta_portions, kind) + values ('dddddddd-dddd-dddd-dddd-dddddddddddd', 5, 'INITIALIZED')$$, + '23514', + NULL, + 'a positive INITIALIZED portion_event is rejected by the constraint (INV-INV-011)' +); + +-- 3. A CONSUMED delta flows through the trigger and decrements remaining. +insert into public.portion_events (prepped_meal_id, delta_portions, kind) +values ('dddddddd-dddd-dddd-dddd-dddddddddddd', -2, 'CONSUMED'); + +select is( + (select portions_remaining from public.prepped_meals + where id = 'dddddddd-dddd-dddd-dddd-dddddddddddd'), + 3, + 'CONSUMED event decrements portions_remaining via the trigger' +); + +select is( + (select original_portions from public.prepped_meals + where id = 'dddddddd-dddd-dddd-dddd-dddddddddddd'), + 5, + 'original_portions is unchanged by a CONSUMED event' +); + +select * from finish(); +rollback;