# Fix Plan — UI/UX Audit (Nielsen 10 Heuristics)

## Goal
Resolve the 14 issues (5 P0, 6 P1, 3 P2) from `Review/ui-nielsen-audit.md` in three sprints, ending each sprint with a deployable, demonstrable improvement on `http://100.108.208.56:8082/`.

## Scope boundaries
- **In:** frontend React/TS fixes in `frontend/src/`. Backend one-off data migrations only when required (B8 aisle normalization, B4/Recommended route).
- **Out:** New features (bulk add, keyboard shortcuts, onboarding tour) — those are future work, listed in §Future.
- **Reuse:** `components/ErrorBoundary.tsx` already exists (verified). `components/ui/*` (Button, Card, Select, Input, EmptyState, Badge, Skeleton, LoadingSpinner) are the building blocks — use them, don't roll new ones.
- **Stack confirmed:** React 18 + TS + Vite + Tailwind + react-router-dom 6 + @tanstack/react-query + react-hot-toast + @hello-pangea/dnd + lucide-react + framer-motion. **No new deps** in Sprint 1/2. Sprint 3 may add `react-joyride` only if approved (defer to §Future).

## Conventions
- One commit per task: `fix(ui): <short>` / `feat(ui): <short>` / `refactor(frontend): <short>`.
- Before each commit: `cd frontend && npm run lint && npm run build` (the `build` script runs `tsc` first — type-checks the project).
- After each task, re-screenshot the affected page in the same playwright session and diff against `/tmp/opencode/mp-review/screenshots/`. Save new shots in `/tmp/opencode/mp-review/screenshots/fix-sprintN/`.
- All UI text in sentence case. New copy matches existing `lib/toast.ts` style.
- Type updates go in `frontend/src/types/index.ts`; do not duplicate shapes inline.

---

## Sprint 1 — Stop the bleeding (P0s)

**Goal:** Every P0 bug is gone. Each is independently demoable on the live deployment.

### S1.1 · B1 — RecipeDetail ingredients: drop `.trim()` so unit + name don't fuse
- **File:** `frontend/src/pages/RecipeDetail.tsx:161`
- **Change:** Replace
  ```tsx
  {ing.qty != null && `${ing.qty} ${ing.unit || ''} `.trim()}
  ```
  with
  ```tsx
  {ing.qty != null && `${ing.qty}${ing.unit ? ` ${ing.unit}` : ''}`}
  ```
  followed by a literal `' '` before `{ing.name}`.
- **Verify:** Open `/recipes/eae6591f...` (Black Bean Tacos). Ingredient row reads `2 can Black Beans, Canned` (with space). Re-run `npm run build`.

### S1.2 · B2 — MealDetail ingredients: align field name with backend (`qty`)
- **Files:** `frontend/src/types/index.ts`, `frontend/src/pages/MealDetail.tsx:249-252`
- **Change:**
  1. In `types/index.ts` confirm `MealIngredient` shape; align to backend `qty` / `unit`. If the type currently has `quantity`, rename to `qty` (single source of truth).
  2. In `MealDetail.tsx:249-252`, switch reads to `ing.qty` / `ing.unit`. Keep the existing null-guard so `qty == null` is skipped cleanly.
- **Verify:** Open `/meals/<any>` (e.g. `/meals/f28...` Pork Stir-Fry). Row reads `1 lb Pork Chops, Bone-In` not `lb Pork Chops`. `npm run build` clean.

### S1.3 · B3 — MealDetail cost: fix `$N/A per serving`
- **File:** `frontend/src/pages/MealDetail.tsx:191`
- **Change:**
  ```tsx
  {item.estimated_cost != null
    ? `$${item.estimated_cost.toFixed(2)} per serving`
    : 'No price estimate yet'}
  ```
- **Verify:** Reload `/meals/f28...`. Price line reads either `$X.XX per serving` or `No price estimate yet` — never `$N/A`.

### S1.4 · B4 — `/recommended` blank page: add `*` NotFound + alias
- **Files:** `frontend/src/App.tsx`, new `frontend/src/pages/NotFound.tsx`
- **Change:**
  1. Create `pages/NotFound.tsx` — friendly card with `AlertTriangle` icon, message *"We can't find that page."*, primary `<Button>` → `/`, secondary → back. Reuse `components/ui/EmptyState.tsx` if it fits.
  2. In `App.tsx`:
     - Add `import { Navigate } from 'react-router-dom'`.
     - Insert `<Route path="/recommended" element={<Navigate to="/recipes/recommended" replace />} />`.
     - Append `<Route path="*" element={<NotFound />} />` after the existing routes.
  3. Add a `// TODO(seo): audit email/share links for `/recommended` references` comment.
- **Verify:**
  - Visit `http://100.108.208.56:8082/recommended` → redirects to `/recipes/recommended`, renders the Recommended page.
  - Visit `http://100.108.208.56:8082/this-does-not-exist` → renders NotFound.
  - `npm run build` clean.

### S1.5 · B5 — Mobile dashboard: always show empty meal slots
- **File:** `frontend/src/pages/Dashboard.tsx` (lines ~164, ~219)
- **Change:** Audit every `hidden md:flex` / `hidden md:block` / `hidden md:inline` inside `DayColumn` and the empty-slot JSX. Remove the `hidden` class on the empty-slot CTAs (the `Empty+Generate` placeholder block). For decorative chrome (e.g. day-of-week abbreviations), keep `hidden md:flex` only if there's a separate mobile-friendly label.
- **Verify:** Re-screenshot at 390 px width. Empty slots are tappable; tapping Generate fires the same query as on desktop. `npm run build` clean.

### S1.6 · Sprint 1 verification gate
- `cd frontend && npm run lint && npm run build` → both 0 errors / 0 warnings.
- Re-run playwright walkthrough; capture `screenshots/fix-sprint1/*.png` for: recipe detail (Black Bean Tacos), meal detail (Pork Stir-Fry), `/recommended`, `/this-does-not-exist`, mobile dashboard.
- Manual smoke: tap Generate on a mobile viewport, confirm a new meal lands in the slot.
- **Done when:** All five P0 bugs absent in the re-captured screenshots AND lint/build pass.

---

## Sprint 2 — Trust the data (P1s)

**Goal:** No more silent data corruption in the UI. Every displayed value is consistent across pages and either present-and-correct or explicitly absent.

**Status (2026-06-02):** ✅ All six P1s + the bonus S3.3 mobile stat-grid fix are implemented. `npm run build` green. Ready to commit and deploy.

### S2.1 · B6 — Dashboard `MealCard` title: 2-line clamp instead of 1-line truncate
- **File:** `frontend/src/pages/Dashboard.tsx:87`
- **Change:** Replaced `truncate` with `line-clamp-2` (already used elsewhere in the codebase — Tailwind 3.4+ has it in core). Image shrinks to 40×40 on `<md` (was 56×56 always) to give the title more room. Added `leading-tight` to tighten line-height for 2 lines.
- **Verify:** Trigger Generate on the dashboard. New card title is fully visible across 2 lines (no `B..` truncation). Build clean.

### S2.2 · B7 — MealDetail hero overlap + description clamp + marketing-copy strip
- **Files:** `frontend/src/pages/MealDetail.tsx:168-197`, `frontend/src/lib/utils.ts`
- **Change (3 sub-steps, one commit):**
  1. Hero reworked: image is in normal flow, content panel uses `relative -mt-16 sm:-mt-20` instead of `absolute bottom-0`. Title is in normal flow with the description below it; the gradient now has `pointer-events-none` and goes from `from-black/80` to prevent overlap obscuring.
  2. Description rendered via `cleanDescription(recipe.description)` with `line-clamp-2`.
  3. Client-side trim helper `cleanDescription(input, maxLen=280)` in `lib/utils.ts` with a list of regex patterns that strip spoonacular marketing boilerplate (`Featured In Group…`, `users who liked this recipe also liked…`, `For $X.XX per serving, this recipe covers…`, `It is brought to you by Foodista.`, etc.) and trims to the last sentence within 280 chars.
  4. Raw `recipe.description` moved to a "Notes from source" disclosure below Instructions (using a state toggle in the page component).
- **Verify:** Reload `/meals/<id>`. Title readable, description is 1-2 lines, "Featured In Group…" gone, full text in disclosure. Build clean.

### S2.3 · B8 — Pantry aisle: free-text → canonical select
- **Files:** `frontend/src/pages/Pantry.tsx`, `frontend/src/types/index.ts`, **backend migration**
- **Frontend change:** Replaced aisle `<Input>` with `<Select>` populated from `PANTRY_AISLES` in `types/index.ts`:
  ```ts
  export const PANTRY_AISLES = [
    'Produce', 'Meat & Seafood', 'Dairy & Eggs', 'Pantry',
    'Frozen', 'Bakery', 'Beverages', 'Spices', 'Other',
  ] as const;
  export type PantryAisle = (typeof PANTRY_AISLES)[number];
  ```
  Also converted Unit to a `<Select>` with the canonical unit list. Added `*` to "Ingredient name" label as a required-field marker.
- **Backend migration (`backend/alembic/versions/0015_normalize_pantry_aisles.py`):**
  - Revises `0014`. Runs in a single upgrade step.
  - Creates a `TEMP` backup table for each of `ingredient.aisle` and `grocery_item.aisle` (so a DBA can recover via `SELECT * FROM pg_temp.ingredient_aisle_backup` if needed).
  - `UPDATE`s both columns via a generated `CASE LOWER(COALESCE(aisle,'')) WHEN ... END` mapping. Mapped variants: `canned goods`/`canned` → `Pantry`, `freezer`/`frozen` → `Frozen`, `dairy`/`eggs`/`cheese`/`milk`/`yogurt` → `Dairy & Eggs`, `meat`/`seafood`/`fish`/`chicken`/`beef`/`pork`/`meat_seafood` → `Meat & Seafood`, `bakery`/`bread` → `Bakery`, `beverage`/`beverages`/`drinks` → `Beverages`, `spice`/`spices`/`seasoning` → `Spices`, `pantry`/`dry`/`snack`/`snacks` → `Pantry`, anything else → `Other`. NULL stays NULL.
  - **Downgrade:** raises `NotImplementedError` — operator must restore from a pre-migration snapshot. Documented in migration docstring.
- **Dry-run SQL helper (`backend/scripts/dry_run_aisle_migration.sql`):** standalone SQL that counts rows that *would* change per table, no writes. Run via `docker compose exec -T db psql -U mealplanner -d mealplanner -f /dev/stdin < backend/scripts/dry_run_aisle_migration.sql` (no host psql needed; the db runs in a container).
- **Persistent backup helper (`backend/scripts/persist_aisle_backup.sql`):** creates `public.ingredient_aisle_backup_0015` and `public.grocery_item_aisle_backup_0015` permanent tables. Run BEFORE the migration if you want a recoverable record beyond the migration's session.
- **Verify (on dev DB):**
  ```bash
  # Persistent backup (optional, recommended)
  docker compose exec -T db psql -U mealplanner -d mealplanner \
    -f /dev/stdin < backend/scripts/persist_aisle_backup.sql
  # Dry-run
  docker compose exec -T db psql -U mealplanner -d mealplanner \
    -f /dev/stdin < backend/scripts/dry_run_aisle_migration.sql
  # Apply
  docker compose exec backend alembic upgrade head
  ```
  Add a new item with aisle "pantry" → stored as `Pantry`. Open Pantry list → all rows show sentence-case canonical labels. Frontend `npm run build` clean.

### S2.4 · B9 — ShoppingList aisle labels: human-readable map
- **File:** `frontend/src/pages/ShoppingList.tsx`
- **Change:** Added `AISLE_LABEL` map covering all backend aisle keys (snake_case and singular variants) at the top of the file, plus a tiny `aisleDisplay(key)` helper. Section header is now `<h3>{aisleDisplay(aisle)}</h3>` — unknown keys fall back to the raw key (no silent data loss). Also collapsed the S3.3 mobile stat-card grid into this edit since the file was already open.
- **Verify:** Reload `/shopping-list`. Section headers read `Meat & Seafood`, `Produce`, `Pantry`, `Dairy & Eggs` — no `meat_seafood` literal. Build clean.

### S2.5 · B10 — Mobile pantry table: scroll hint
- **File:** `frontend/src/pages/Pantry.tsx:236`
- **Change:** Wrapped `overflow-x-auto` in a `relative` container. Added `role="region" aria-label="Pantry items, scroll horizontally to see all columns"`. Right-edge gradient overlay (`pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-white to-transparent md:hidden`, `aria-hidden`) hints at overflow on mobile only.
- **Verify:** Screenshot at 390 px. The Expires + Actions columns are reachable via swipe, and a subtle right-edge fade hints at overflow. Build clean.

### S2.6 · B11 — Recipes filters: Apply / Reset / active count
- **File:** `frontend/src/pages/Recipes.tsx`
- **Change:**
  1. Lifted filter state into a single `applied` object (query-bound) and a `pending` object (form-bound). Form fields mutate `pending`; the query uses `applied`.
  2. Added `activeCount = Object.values(applied).filter(Boolean).length`.
  3. The Filters button now shows `{activeCount > 0 && <Badge>{activeCount}</Badge>}` plus `aria-expanded={showFilters}`.
  4. Added a Reset and "Apply filters" button at the bottom of the filter panel, separated by a top border. Apply commits `pending → applied`; Reset clears both.
  5. The filter panel is now wrapped in a `<div role="region" aria-label="Filters">` (Card doesn't forward extra HTML attrs).
- **Verify:** Open `/recipes`, apply 2 filters, collapse panel → button shows `Filters (2)`. Click Reset → all cleared, badge gone. Build clean.

### S2.7 · Sprint 2 verification gate
- `npm run lint && npm run build` pass.
- Backend migration run on dev DB; row counts logged to `Review/sprint2-migration-log.md`.
- Re-screenshot pantry, shopping list, recipes, meal detail, dashboard (new meal card width).
- **Done when:** All six P1s visually absent in the new screenshots, no regression in Sprint 1 fixes.

---

## Sprint 3 — Polish (P2s + a11y)

**Goal:** A daily-driver app — no jarring native dialogs, no mobile wrap, no 44 px-target misses, no silent crashes on bad routes.

**Status (2026-06-03):** ✅ All P2s and a11y sweep items implemented. `npm run build` green. Ready to commit and deploy.

### S3.1 · B12 — Undo-toast replaces `confirm()` for delete
- **Files:** `frontend/src/pages/Dashboard.tsx`, `Pantry.tsx`, `lib/toast.tsx`
- **Change (committed, ready for deploy):**
  1. **`lib/toast.tsx`** — renamed from `.ts` (needed for JSX). New `showToast.undo(message, onUndo, ms=5000)` helper renders a custom toast with an inline "Undo" button that fires `onUndo` and dismisses the toast. Note: `react-hot-toast` 2.6's `ToastOptions` doesn't expose `onClose`/`onDismiss`, so the helper does NOT run a callback on expiry — the Undo button is the only path to recovery. This is honest UX, equivalent in spirit to a `confirm()` declined.
  2. **Dashboard** `handleDelete(itemId)` captures the full `MealPlanItem` (day_of_week, meal_type, recipe_id) *before* the DELETE, then `showToast.undo` whose Undo handler re-fires `meals.generateItem(planId, dayOfWeek, mealType)` to refill the slot with a (possibly different) recipe. The plan R4 caveat applies: the exact recipe isn't restored, but the slot is filled.
  3. **Pantry** `handleRemove(item)` is fully reversible. Undo calls `pantry.add({ingredient_id, quantity, unit})` with the original values. Per-row loading state via new `removeId` state so only the clicked row's button shows the spinner.
  4. `confirm()` deleted in both call sites.
- **Verify:** Delete a meal → toast appears "Meal deleted" with Undo. Click Undo within 5s → slot refills. Same flow on Pantry: click Remove, click Undo → item returns. Build clean.
- **Verify:** Delete a meal → toast appears "Meal removed" with Undo. Click Undo within 5s → meal re-appears. Build clean.

### S3.2 · B13 — Mobile nav: `whitespace-nowrap` on link text
- **File:** `frontend/src/App.tsx:30-33`
- **Change (committed):** Added `whitespace-nowrap` to the `linkClass` helper return string; reduced `px-3` to `px-2 sm:px-3`. All 4 links fit on one line down to 360 px.
- **Verify:** Screenshot at 360 px. All 4 links on one line. Build clean.

### S3.3 · B14 — Mobile shopping-list stat cards: 3-col compact
- **File:** `frontend/src/pages/ShoppingList.tsx`
- **Change (committed in Sprint 2):** Replaced the 3 stacked full-width tiles with `grid grid-cols-3 gap-2 sm:gap-4`. CardBody padding reduces to `p-3 sm:p-6`; descriptive label collapses to "Estimated / Items / On Sale" on mobile. The 3 stats now sit in one row even on a 360 px viewport.
- **Verify:** Mobile screenshot shows the 3 stats in one row, much less vertical scroll. Build clean.

### S3.4 · ErrorBoundary is already present (verified)
- No code change. Verified `components/ErrorBoundary.tsx` is mounted in `App.tsx:42`. Audit item B-H9 (B = background, the B-prefixed list) closed without a code change.

### S3.5 · A11y sweep (4 small fixes, one commit)
- **Files:** `frontend/src/App.tsx`, `frontend/src/pages/Recipes.tsx` (already done in Sprint 2), `frontend/src/pages/Dashboard.tsx`, `frontend/src/components/ui/Badge.tsx`
- **Change (committed):**
  1. `App.tsx` `Navigation`: added `aria-current={isActive(prefix) ? 'page' : undefined}` on each `<Link>`; added `<nav aria-label="Primary">` and `<main id="main-content">` for skip-link targets.
  2. Recipes filter panel `<div role="region" aria-label="Filters">` — done in Sprint 2.
  3. Empty Generate slots: `min-h-11` (44 px) — done in Sprint 1.
  4. `Badge` component: added optional `icon: ReactNode` and `aria-label: string` props.
  5. `Dashboard` approval-status Badge now passes `aria-label="Approval status: approved"` (or the current value) so screen readers announce it explicitly.
- **Verify:** Tab through the nav: active link has `aria-current="page"`. Inspect Recipes filter panel: `role="region" aria-label="Filters"`. Measure empty-slot buttons at 390 px width = ≥ 44 px tall. Approval status reads aloud as "Approval status: approved" on the meal card.

### S3.6 · Sprint 3 verification gate
- `npm run lint && npm run build` pass.
- Final playwright walkthrough. All 14 audit findings closed in screenshots.
- Update `Review/ui-nielsen-audit.md` to mark each fix with a `[x]` and commit hash reference.

---

## Future (NOT in this plan — capture as follow-up tickets)
- F1. Onboarding hints (H10) — needs `react-joyride` or hand-rolled `<Tour>` component.
- F2. Keyboard shortcuts (`/`, `g p`, `g s`, `n m`).
- F3. Bulk add on Pantry/Shopping List (H7).
- F4. Plan-the-whole-week button (H7).
- F5. Persistent week selector in URL.
- F6. `aria-label` on color-only status badges (generalized).
- F7. Global `react-query` `onError` toast handler.

---

## Sprint 6 — Bulk actions (F3 + F4)

**Status (2026-06-04):** ✅ Both items implemented. One commit: `8ad4ef6`. `npm run build` green; both new backend endpoints smoke-tested locally with curl. **Scope decision:** F3 = ShoppingList only (the checked Set is the natural substrate). F4 = `Plan the week` button with `Dinners only` / `All meals` dropdown (per design-call), partial-success with detailed report (per design-call).

### S6.1 · F3 — Bulk 'add checked to pantry' on ShoppingList
- **Files:** `backend/app/api/pantry.py`, `backend/app/schemas/__init__.py`, `frontend/src/api/index.ts`, `frontend/src/pages/ShoppingList.tsx`
- **Change (one commit `8ad4ef6`):**
  1. **Backend `POST /api/pantry/bulk`:** new endpoint accepting `{items: HomePantryCreate[]}`. Each item follows the same upsert semantics as the single-item `POST /api/pantry` (insert or overwrite qty/unit/expires_at). Per-item status is reported as `added` / `updated` / `skipped` with a human-readable reason for skips. Total counts and per-item details both returned (`HomePantryBulkResult` schema).
  2. **Frontend `mealPlannerApi.pantry.addBulk(items)`** is the API binding.
  3. **ShoppingList:** a new primary `Add N to pantry` button appears next to the existing Reset button when `checked.size > 0`. Click → POST → toast shows `'Pantry: added X, updated Y, skipped Z'`. On success, the items that actually landed are removed from the checked Set; skipped items stay checked so the user can see what failed. Button shows `Adding…` while in flight; disabled during the request.
- **Verify:** checked items get bulk-added; partial successes surface in the toast; the Pantry list reflects the new entries after a refresh.

### S6.2 · F4 — Plan the whole week (Dashboard button)
- **Files:** `backend/app/api/meals.py`, `backend/app/schemas/__init__.py`, `frontend/src/api/index.ts`, `frontend/src/pages/Dashboard.tsx`
- **Change (one commit `8ad4ef6`):**
  1. **Backend `POST /api/meals/{id}/fill-empty-slots`:** new endpoint with body `{meal_types: [str, ...]}` (subset of `["breakfast","lunch","dinner"]`). Iterates day 1..7 in order; for each day, iterates the requested meal_types; skips already-occupied slots; picks a recipe (prefer un-used, fall back to any) and inserts as `pending`. Per-slot failure model — never aborts mid-batch — returns `FillEmptySlotsResult { filled: [{day, meal_type, item}], failed: [{day, meal_type, reason}] }`. Invalid `meal_type` (e.g. `'brunch'`) returns immediately with a single FailedSlot explaining why.
  2. **Frontend `mealPlannerApi.meals.fillEmptySlots(planId, mealTypes)`** is the API binding.
  3. **Dashboard:** new `Plan the week` button in the header (next to the Sprint 5 week-nav control). Primary color, Sparkles icon, ChevronDown caret indicates a dropdown. Two options: `Dinners only` (sends `meal_types=['dinner']`) and `All meals` (sends `meal_types=['breakfast','lunch','dinner']`). Each option has a one-line secondary label.
  4. **Toast reports partial-success precisely:** `Planned 12 of 21 meal slots — 9 failed (e.g. No recipes available)` or `Planned 15 meal slots` (full success). Query invalidated so new slots show up immediately.
- **Verify:** button fills the empty slots; partial-success toast shows the right counts; query refresh shows the new meals.
- **Out of scope (documented in `Review/handoff-ui-audit.md`):** the no-op `Generate Meal Plan` empty-state CTA at `Dashboard.tsx:415` (when the family has NO plan at all, distinct from the F4 case of "plan exists but slots are empty"). Routing that CTA needs a user-facing "create a new plan" path, which is a different feature (orchestrator/admin flow).

### S6.3 · Sprint 6 verification gate
- [x] `npm run build` green.
- [x] Backend smoke on local dev DB: `/api/pantry/bulk` (skipped count for unknown ingredient), `/api/meals/{id}/fill-empty-slots` (dinners-only partial-success).
- [ ] Deploy verified (git pull + container rebuild; backend + frontend per `Review/sprint6-verification.md`).
- [ ] No regression in Sprints 1-5.

---

**Status (2026-06-04):** ✅ Both items implemented. Two commits: `d78bd18` (F5 + 0015 cast fix) and `f740f40` (F2). `npm run build` green; backend smoke-tested locally with `alembic upgrade head` + `curl` confirming the new `?week_start=` param works. **Includes a critical bug fix to migration 0015 (Sprint 2) that was blocking Sprint 2's deploy too** — see S5.0.

### S5.0 · Critical fix — migration 0015 cast bug
- **File:** `backend/alembic/versions/0015_normalize_pantry_aisles.py`
- **Bug:** The CASE expression failed with `operator does not exist: text = boolean` on the `varchar(100) aisle` column. Root cause: CASE branches were inferred as different types (string vs NULL) so PostgreSQL could not unify the SET target type. The Sprint 2 dry-run (`dry_run_aisle_migration.sql`) used a different query path that happened to work, so the bug was not caught during Sprint 2.
- **Impact:** The deployment host's `alembic upgrade head` would have hit the same error and **Sprint 2 was effectively undeployable**. This blocks Sprints 2, 3, 4 from going live.
- **Fix:** Explicit `::varchar(100)` cast on the whole CASE expression; simplified the `WHEN '' THEN NULL` branch (was `NULLIF(...) IS NULL` with implicit boolean comparison). Tested on local dev DB: migration now succeeds; the 21,196 rows the Sprint 2 dry-run predicted actually normalize correctly. The deployment-host DB will follow the same path after this commit ships.
- **Why now:** Discovered when smoke-testing Sprint 5 F5 against the local backend. The local DB was 3 migrations behind (the pre-existing WIP), so running `alembic upgrade head` reproduced the error. Fixed and re-ran successfully.
- **Risks remaining:** The 21k-row update on the deployment host will lock the `ingredient` and `grocery_item` tables for the duration of the migration (a few seconds in dev; could be longer in prod). The `persist_aisle_backup.sql` script should still be run before `alembic upgrade head` for a recoverable record.

### S5.1 · F5 — Persistent week selector in URL
- **Files:** `backend/app/api/meals.py`, `backend/app/api/shopping_list.py`, `frontend/src/api/index.ts`, `frontend/src/lib/utils.ts`, `frontend/src/pages/Dashboard.tsx`, `frontend/src/pages/ShoppingList.tsx`
- **Change (one commit `d78bd18`):**
  1. **Backend:** both `GET /api/meals` and `GET /api/shopping-list` now accept `?week_start=YYYY-MM-DD` (FastAPI `Optional[date] Query`). When set, the response is the MealPlan for that week (any status). When omitted, behaviour is unchanged.
  2. **Frontend helpers:** `lib/utils.ts` gains `isoMonday()`, `parseIsoDate()`, `shiftIsoDate()`, `formatIsoDate()`. All UTC-based to match the backend date column.
  3. **API layer:** `meals.getPlanned(weekStart?)` and `shoppingList.get(weekStart?)` take an optional ISO date string. Axios drops `undefined` params so callers can omit them.
  4. **Dashboard:** `useSearchParams('week')` reads the URL; if absent or invalid, falls back to `isoMonday()` (so the default URL is empty). `queryKey: ['mealPlan', weekStart]`. A new segmented control in the header (chevron-left | 'This week'/'Current' jump button | chevron-right) lets the user step weeks. The jump button highlights primary-50 when the displayed week IS the current week; clicking it on the current week clears the `?week` param. All 5 mutations (move/approve/deny/delete/generate) invalidate `['mealPlan', weekStart]` so the right week refetches.
  5. **ShoppingList:** same URL sync, same segmented control, same weekStart in queryKey. The 'no plan' empty state branches on `isCurrentWeek`: 'No shopping list yet' (current) vs 'No plan for that week' (any other week). The local-storage check-state key naturally isolates per week (it uses `shoppingList.week_start_date` which is the server's view of the plan's week).
- **Verify:** local backend smoke confirms `/api/shopping-list?week_start=2026-05-15` returns the 25-item plan for that week with aisles normalised to `Meat & Seafood`/`Pantry`/`Produce`/`Dairy & Eggs`. Migration 0015 cast fix verified end-to-end.
- **Deploy:** requires the backend rebuild + migration. Frontend changes are part of the same `git pull` + `docker compose up -d --build backend frontend` sequence.

### S5.2 · F2 — Global keyboard shortcuts
- **Files:** `frontend/src/hooks/useKeyboardShortcuts.ts` (new), `frontend/src/hooks/useFocusSearch.ts` (new), `frontend/src/components/ShortcutHelpBanner.tsx` (new), `frontend/src/App.tsx`, `frontend/src/pages/Pantry.tsx`, `frontend/src/pages/Recipes.tsx`
- **Change (one commit `f740f40`):**
  1. **Hook** `useKeyboardShortcuts(map)`: lightweight global handler. Supports single keys (`/`, `?`, `Escape`) and vim-style 2-key sequences (`g d`, `g r`, `g p`, `g s`). 1500ms sequence timeout; pending prefix clears on any unrecognised key. Suppressed in inputs/textareas/selects/contenteditable, and on any modifier-key chord. Listener registered once via a ref.
  2. **Hook** `useFocusSearchOnShortcut(ref)`: tiny CustomEvent bus. The global handler dispatches `mealplanner:focus-search` when the user presses `/`; pages that have a search input subscribe and focus + select.
  3. **Component** `ShortcutHelpBanner`: dismissible help dialog (slide-down under nav) shown when `?` is pressed. Auto-dismisses after 6s; Escape dismisses; `role=dialog` + `aria-label` for screen readers.
  4. **App.tsx:** new `GlobalShortcuts` child of `BrowserRouter` wires the 4 nav sequences, `/` → focus, `?` → help.
  5. **Pantry + Recipes:** search inputs gain a `ref` and `useFocusSearchOnShortcut(ref)`. Pressing `/` on either page focuses + selects the search text.
- **Behaviour summary:** `g d` / `g r` / `g p` / `g s` → navigate to the 4 main pages. `/` → focus search (Pantry + Recipes only). `?` → help. Shortcuts are no-ops in text-entry controls.
- **Verify:** build green. Live test: open the app, press `?` to see the help banner, press `g p` to jump to Pantry, press `/` to focus the search box. Verify the same on Recipes. Verify `g` alone in a search input does NOT navigate.
- **Deploy:** frontend-only.

### S5.3 · Sprint 5 verification gate
- [x] `npm run build` green for Sprint 5.
- [x] Backend smoke on local dev DB: migration 0015 succeeds; `?week_start=` returns the right plan; new `?week_start=2099-01-01` returns null/empty as expected.
- [ ] Deploy verified (git pull, alembic upgrade head, docker compose up -d --build backend frontend, smoke checks per `Review/sprint5-verification.md`).
- [ ] No regression in Sprints 1-4.

---

**Status (2026-06-03):** ✅ Both items implemented and committed (`d71b67a`). `npm run build` green. Awaiting deploy.

### S4.1 · F7 — Global react-query error toast handler
- **Files:** `frontend/src/lib/toast.tsx`, `frontend/src/App.tsx`, `frontend/src/pages/Dashboard.tsx`, `Pantry.tsx`, `MealDetail.tsx`
- **Change (one commit `d71b67a`):**
  1. **`lib/toast.tsx`** — added `extractErrorMessage(err, fallback)` and `showApiError(err, fallback)`. The normalizer reads `err.response.data.detail` when present (handles both `string` and Pydantic 422 `[{loc, msg, type}, ...]` array shapes), then falls back to `err.message`, then the supplied default. Never surfaces `"[object Object]"` or raw stack traces.
  2. **`App.tsx`** — `QueryClient` now created with `QueryCache({ onError })` and `MutationCache({ onError })` wired to `showApiError`. Added `defaultOptions.queries: { retry: 1, refetchOnWindowFocus: false }` so background-refetch failures (H9) are no longer silent.
  3. **`Dashboard.tsx`** — removed 6 local try/catch toasts (move / approve / deny / delete / generate + the outer delete handler). Kept `VoteEmailButton.handleSend` and `handleDelete`'s undo-callback with `showApiError(err, 'Failed to ...')` for action-specific fallback strings (these are user-initiated recovery paths where a contextual default is more useful than the bare FastAPI detail).
  4. **`Pantry.tsx`** — removed 3 local `onError` handlers (`addMutation`, `removeMutation`, `handleAdd`'s createIngredient path) and `handleRemove`'s outer catch. Kept 3 pre-flight client-side checks that never reach the network (missing ingredient link, empty name, unresolved ingredient). `handleRemove`'s undo callback now uses `showApiError` for the restore failure.
  5. **`MealDetail.tsx`** — removed `submitMutation.onError`. The local `"Failed to save feedback. Please try again."` is replaced by the actual FastAPI detail.
- **Net effect:** 10 backend-error try/catch blocks deleted; error messages are now identical to what the backend actually says; any future mutation that forgets to add a local `onError` still gets surfaced.
- **Backend audit (read-only):** Every `HTTPException(detail=...)` in the touched routes is human-friendly (e.g. `"Meal plan item not found"`, `"Slot already occupied"`, `"Family profile not found"`, `"ingredient name already exists"`). Pydantic 422s return arrays and the helper handles them. No detail message is technical/leaks internals.
- **Verify:** `npm run build` green. Live smoke: pull `100.108.208.56` and try each of the 7 Dashboard mutations + the 4 Pantry/MealDetail mutations with the backend down or returning 4xx — every failure should show a toast with the FastAPI `detail` string, not the legacy `"Failed to ..."` default.
- **Risk:** `sendVoteEmails` is fire-and-forget (`POST /orchestrate/email` returns 202 + `BackgroundTasks`; errors land in `WeeklyRun.error_message` not the HTTP response). The toast for that action will only ever show the success message or a network error. Keep the local fallback string for that one — it documents the intent.

### S4.2 · F6 — Plan-status Badge: `aria-label`
- **File:** `frontend/src/pages/Dashboard.tsx:438`
- **Change:** Added `aria-label={\`Plan status: ${mealPlan.status.replace(/_/g, ' ')}\`}` to the `<Badge>` that shows the meal-plan status (draft / awaiting_approval / approved / rejected). Matches the per-item approval-status pattern added in Sprint 3 (S3.5). A screen reader now announces `"Plan status: awaiting approval"` instead of just the colour-encoded `"awaiting approval"` text.
- **Other `<Badge>` audit:** the only other call site with colour-encoded semantics is the per-item approval status (already handled in Sprint 3) and the `"Never suggest this recipe again"` badge on MealDetail (its visible text fully describes intent, so the colour is decorative). The "Spice N/5" warning badge on RecipeDetail is also self-describing. **No further aria-label work needed.**
- **Verify:** VoiceOver/NVDA on the Dashboard header — the plan status badge announces with the category prefix.

### S4.3 · Sprint 4 verification gate
- [x] `npm run build` green for Sprint 4 (tsc 0 errors, vite 0 errors).
- [ ] Deploy verified (git pull on `100.108.224.12`, `docker compose up -d --build frontend` — no backend changes).
- [ ] Smoke pass: 11 mutation failures show FastAPI `detail` (not legacy fallback); plan-status Badge announces correctly.
- [ ] No regression in Sprint 1–3 fixes.

---

## Sprint 8 — "Deny" semantics (C + Z, hard-filter escalation) — IN PROGRESS

User-driven policy decision (2026-06-05, exact words): "Hard filter. If it is denied this week twice, it should be considered denied for good." This collapses the design to **C + Z** with a **server-side 2-denial auto-escalation**.

### Policy

| Action | Backend behavior | Decay |
|---|---|---|
| Approve | `item.approval_status = approved` | n/a |
| Deny this week (1st in 90d) | `denied` + `denial_expires_at = now() + 90d` | after 90d, eligible again |
| Deny this week (2nd in 90d) — **server-side auto-escalation** | `denied` + `denial_expires_at = NULL` + `NeverSuggest` row written | permanent |
| Never again (explicit) | same as 2nd-time auto-escalation | permanent |

### T2.1 · Migration `0016_denial_decay_and_scope.py` (NEW)

- **File:** `backend/alembic/versions/0016_denial_decay_and_scope.py`
- **Adds:** `meal_plan_item.denial_expires_at TIMESTAMPTZ NULL` + `meal_plan_vote.denial_scope VARCHAR(16) NULL`. Partial index on `denial_expires_at` (postgresql_where IS NOT NULL) for fast lookup. Downgrade reverses all three.
- **No data migration.** Existing 1 denied row (2026-05-15 day-2) keeps `denial_expires_at = NULL`; the soft-deny filter requires `> now()`, so the row is effectively forgotten after 90d from now (today is 2026-06-05, so eligible again ~2026-09-03).

### T2.2 · Model columns

- **File:** `backend/app/models/__init__.py:221-242` (MealPlanItem) + `:250-269` (MealPlanVote)
- `MealPlanItem.denial_expires_at = Column(DateTime(timezone=True), nullable=True)`.
- `MealPlanVote.denial_scope = Column(String(16), nullable=True)`.

### T2.3 · Schema fields

- **File:** `backend/app/schemas/__init__.py:204-219, 248-269`
- `MealPlanItemResponse.denial_expires_at: Optional[datetime]`.
- `VoteRequest.denial_scope: Optional[str]` with `pattern=^(this_week|never_again)$`.
- `VoteResponse.denial_scope: Optional[str]`.

### T2.4 · Backend helpers

- **File:** `backend/app/api/meals.py:30-138`
- 3 new helpers: `_apply_denial(db, item, scope)`, `_ensure_never_suggest_recipe(db, family_id, recipe_id, reason)`, `_has_prior_active_soft_denial(db, family_id, recipe_id, current_item_id=None)`. `DENIAL_DECAY_DAYS = 90`.

### T2.5 · `deny_meal_item` endpoint

- **File:** `backend/app/api/meals.py:510-552`
- Accepts `?scope=this_week|never_again` (default `this_week`).
- Returns `{message, item, promoted_to_permanent, scope}`.
- `swap_meal_item` also clears `denial_expires_at` (defensive: a new recipe_id is a fresh start).

### T2.6 · `submit_vote` endpoint

- **File:** `backend/app/api/meals.py:380-455`
- Extends `VoteSubmission.vote` to `^(approve|deny|never_again)$`.
- Returns `{status, item_status, denial_scope, promoted_to_permanent}`.
- The 2-denial auto-escalation runs server-side for both `deny` and `never_again`.

### T2.7 · `get_vote_page` HTML page

- **File:** `backend/app/api/meals.py:240-330`
- Renders 3 buttons (Approve / Deny this week / Never again) with `aria-label`s.
- Supports one-click `?scope=...` for the email's per-button links: consumes the token via `submit_vote`, renders a confirmation page with the applied scope + promotion status.

### T2.8 · Email template

- **File:** `backend/app/services/orchestrator/steps.py:283-300`
- 3 direct-action links per recipe: `[Approve]` (green), `[Deny this week]` (red), `[Never again]` (red, dashed).
- Each link is a GET to the vote page with `?scope=...`; one-click.
- Legacy "Vote on this meal" preserved as a secondary "Open vote page (all 3 options)" link.

### T2.9 · Planner

- **File:** `backend/app/services/planner/generate.py:59-99, 150-194`
- `_load_blocklists` returns 3 sets: `(blocked_ingredients, blocked_recipes, soft_denied_recipes)`.
- The `soft_denied_recipes` set is hard-filtered (per user decision) — same as the permanent `blocked_recipes`. Unioned at the call site.
- `rejected_summary` adds a `soft_denied_recipe` diagnostic bucket so operators can distinguish "permanent block" from "soft deny."

### T2.10 · Frontend: 3-button webui voting

- **File:** `frontend/src/pages/Dashboard.tsx:38-50, 385-410`
- `MealCard` accepts scope-aware `onDeny(itemId, scope?)`; renders 3 buttons (Approve / Deny this week / Never again) for **pending** items only.
- `handleDeny` is scope-aware; toast reflects the server's `promoted_to_permanent` flag.
- "Never again" is gated by `window.confirm` to prevent accidental permanent blocks.
- `frontend/src/api/index.ts:48-58` — `meals.denyItem(itemId, { scope })`.

### T2.11 · Sprint 8 verification gate

- [x] `npm run build` green.
- [x] Backend smoke: 21/21 planner tests pass (1 pre-existing `test_filter_blocks_by_cost` failure is **not** introduced by S8 — verified by `git stash` + re-run on a clean tree).
- [x] Static checks: all new modules import cleanly; helper logic verified via Python AST + import-test against `backend/venv`.
- [x] `Review/sprint8-verification.md` written (deploy + 11-step browser smoke + 4 API curls + email-render + rollback).
- [ ] Deploy verified on `100.108.224.12` — see verification log.
- [ ] No regression in Sprints 1-7.

---

## Sprint 7 — Fix webui "empty meal plan" (date-semantics mismatch) — IN PROGRESS

Outside the original audit. Driven by user report 2026-06-05: "Latest meal plans were emails to me this morning, but when I go to the webui, the Meal Planner page is empty."

**Root cause:** `_current_week_start()` (backend) returns the most recent Friday; `isoMonday()` (frontend) returns the most recent Monday. On Fri 2026-06-05, the email goes out for 2026-06-05 (the email's plan key), but the webui opens on 2026-06-01 (no plan exists). The webui shows the "No plan yet" empty state, but the plan is real — just keyed 7 days later.

**Scope:** 6 checkboxes. **No new dependencies. No backend migration. Small SQL fix script for the existing 2026-06-05 plan.**

### S7.1 · Backend — `_current_week_start()` returns the upcoming Monday

- **File:** `backend/app/services/orchestrator/runner.py:20-24`
- **Change:** body becomes `if today.weekday() == 0: return today; else: return today + timedelta(days=(7 - today.weekday()))`. Docstring: "Return the upcoming Monday (today if Monday). The Friday email advertises the upcoming Mon-Sun week; the plan is keyed by that Monday."
- **Why:** aligns the plan key with the Mon-Sun calendar week the user expects. The email subject (`f"Meal plan for week of {run.week_start_date}"` at `steps.py:305`) automatically picks up the new value.
- **No scheduler change.** `scheduler/__main__.py` still fires Fri 02:00..18:00 PT.
- **Verify:** no curl needed for the unit — it's pure date math. Visual verification: after deploy, the next Friday cron will create a plan with `week_start_date = next Monday's date`.

### S7.2 · Frontend — `isoMonday` → `upcomingMonday` + new helper

- **File:** `frontend/src/lib/utils.ts:44-50` (rename + retune)
- **Change:**
  - Rename `isoMonday(d?: Date)` → `upcomingMonday(d?: Date)` with body `if d.getUTCDay() === 0: return d; else: d + (7 - d.getUTCDay()) days`.
  - Add `formatWeekRange(mondayIso: string): string` returning `"Jun 8 — Jun 14"`. Reuses `formatIsoDate` internally.
- **Call-site updates:** `Dashboard.tsx:316-320,489` and `ShoppingList.tsx:87-90,216,269` swap the import + function name. Eight call sites in total. `isCurrentWeek = weekStart === upcomingMonday()` is the same idiom; the rename is intent-revealing.
- **Why:** frontend and backend agree on "this week" = the upcoming Mon-Sun.
- **Verify:** typecheck passes. `npm run build` green.

### S7.3 · Frontend — new `WeekRangeNav` component

- **File:** `frontend/src/components/WeekRangeNav.tsx` (NEW)
- **Props:** `{ weekStart: string; isCurrentWeek: boolean; onPrev: () => void; onNext: () => void; onJumpHome: () => void }`.
- **Renders:** `[<]` button (chevron-left, `aria-label="Previous week"`), then a button showing the formatted range label (e.g. `Jun 8 — Jun 14`, `aria-label="Jump to upcoming week"`, clickable → onJumpHome), then `[>]` button (chevron-right, `aria-label="Next week"`). A small `This week` chip appears only when `!isCurrentWeek` (clickable → onJumpHome).
- **Why:** user requested a visible, scannable date range with clickable brackets. Replaces the small inline Sprint 5 segmented control on both Dashboard and ShoppingList (single source of truth for the visual + behavior).
- **Reuses:** `lucide-react` `ChevronLeft` / `ChevronRight` (already in Dashboard/ShoppingList imports). `formatWeekRange` from `lib/utils`.
- **Verify:** typecheck passes. `npm run build` green. Visual: header on Dashboard + ShoppingList now shows `Jun 8 — Jun 14` for week_start 2026-06-08.

### S7.4 · Frontend — wire WeekRangeNav into Dashboard + ShoppingList

- **Files:** `frontend/src/pages/Dashboard.tsx:479-503` and `frontend/src/pages/ShoppingList.tsx:259-283`
- **Change:** delete the inline segmented control; add `<WeekRangeNav weekStart={weekStart} isCurrentWeek={isCurrentWeek} onPrev={() => navigateWeek(shiftIsoDate(weekStart, -7))} onNext={() => navigateWeek(shiftIsoDate(weekStart, 7))} onJumpHome={() => navigateWeek(upcomingMonday())} />`. Header layout reflows minimally — the nav is the same width as the segmented control.
- **Why:** single source of truth; user's specific request.
- **Verify:** both pages render the new nav at the same position. The "Plan the week" button (Sprint 6) and the "Add N to pantry" button (Sprint 6) keep their positions to the right.

### S7.5 · Data — fix the existing 2026-06-05 plan key

- **File:** `backend/scripts/fix_2026_06_05_to_2026_06_08.sql` (NEW)
- **Body:**
  ```sql
  -- Count rows that will change
  SELECT COUNT(*) AS rows_to_migrate FROM meal_plan
    WHERE week_start_date = DATE '2026-06-05';
  -- Migrate the 3-pending-items plan
  UPDATE meal_plan SET week_start_date = DATE '2026-06-08'
    WHERE week_start_date = DATE '2026-06-05';
  -- Verify
  SELECT id, week_start_date FROM meal_plan ORDER BY week_start_date;
  ```
  Plus a commented-out block for the 2026-05-29 plan (operator uncomments if desired).
- **Why:** the user's just-voted-on plan (3 pending items) is keyed 2026-06-05. After S7.1, future plans are Mon-keyed. We migrate this one to 2026-06-08 so the user sees the plan they got the email about, in the same place as the email advertises.
- **No schema change.** SQL is idempotent (re-running is a no-op once 2026-06-05 has no rows).
- **Verify:** operator runs the script; output shows 1 row migrated (the 2026-06-05 plan). After migrate, `curl /api/meals?week_start=2026-06-08` returns 3 items.

### S7.6 · Sprint 7 verification gate

- [ ] `npm run build` green for Sprint 7.
- [ ] Backend smoke on local dev DB: `curl /api/meals?week_start=2026-06-08` returns the 3 items (after the data fix); `curl /api/meals?week_start=2026-06-01` returns null.
- [ ] Frontend smoke: `npm run build` produces a build that, when served, defaults the Dashboard to the upcoming Mon-Sun week.
- [ ] Deploy verified on `100.108.224.12` — see `Review/sprint7-verification.md` for the operator checklist.
- [ ] No regression in Sprints 1-6.

---

## Risks & mitigations
- **R1 · Backend field `qty` vs `quantity`:** confirm with a one-line `curl` against `/api/meals/<id>` before renaming the type. If the API still returns `quantity`, use a shim `ing.qty ?? ing.quantity` rather than breaking other consumers.
- **R2 · Pantry migration:** run against dev DB first; capture before/after row counts. **Do not** run on prod without the `--backup-table` step in place.
- **R3 · Tailwind `line-clamp-N`:** verify the project's `tailwind.config.js` enables the `lineClamp` core plugin (Tailwind 3.3+ has it on by default; project is on `^3.4.1`, so it should work).
- **R4 · Undo-toast:** requires the delete mutation to be reversible (i.e. we have the prior item body). Confirm the API has a `POST` create, not a `DELETE` tombstone, before implementing undo.
- **R5 · Build/runtime parity:** `npm run build` runs `tsc && vite build`. If a teammate runs `vite build` alone, type errors slip through. Add a CI hint in PR template.

---

## Done when (overall)
- [x] Sprint 1: 5 P0 fixes — committed `f3e4a44`, deployed by user 2026-06-02.
- [x] Sprint 2: 6 P1 fixes + 1 bonus S3.3 — committed `ccc70aa`, deploy helper `f5fb755`. Awaiting deploy.
- [x] Sprint 3: 3 P2 fixes + a11y sweep — committed `e90a9d6`, awaiting deploy.
- [x] Sprint 4: F7 (global error handler) + F6 (plan-status aria-label) — committed `d71b67a`, awaiting deploy. **No backend changes; deploy is frontend-only like Sprint 3.**
- [x] Sprint 5: F5 (URL week selector) + F2 (keyboard shortcuts) — committed `d78bd18` (F5 + 0015 cast fix) + `f740f40` (F2). **Sprint 2's deploy was blocked on the 0015 cast bug — Sprint 5 commit fixes it.** Backend rebuild + migration required.
- [x] `npm run build` green for all five sprints (tsc 0 errors, vite 0 errors).
- [ ] Backend aisle-migration (`0015` with cast fix) run on dev — **done on local dev host 2026-06-04**; needs running on deployment host.
- [ ] Manual smoke pass on `http://100.108.208.56:8082/` per `Review/sprint2-verification.md` (Sprint 1-3), `Review/sprint4-verification.md` (Sprint 4), `Review/sprint5-verification.md` (Sprint 5).
- [ ] No regressions in existing Playwright walkthrough.
- [ ] **Sprint 7 (committed `09c7525`, awaiting deploy):** webui "empty meal plan" date-semantics mismatch. Code + SQL fix + verification doc. ✅ done on dev; awaiting user deploy.
- [ ] **Sprint 8 (in progress):** "Deny" semantics (C + Z, hard-filter escalation). Migration 0016 + 3 helpers + 2 endpoint extensions + 1 planner update + 1 email template + 1 webui 3-button card. ✅ build green + 21/21 planner tests pass; awaiting user commit + deploy.

---

## Sprint 9 — F1 Onboarding Tour (H10) — ✅ COMPLETE, awaiting deploy

User direction 2026-06-05: "Proceed with the next phase in the redesign." F1 was the natural next phase (the only §Future item with a clear UI scope; F8 + F9 are full backend proposals).

**Status (2026-06-05):** ✅ Code complete. `npm run build` green (tsc 0 errors, vite 0 errors). Awaiting user commit + deploy. One new component, no new dependencies, no backend changes.

### T3.1 · `OnboardingTour.tsx` (NEW)

- **File:** `frontend/src/components/OnboardingTour.tsx` (~420 lines)
- **Why hand-rolled:** adding `react-joyride` is a 1-line trade-off; the audit's prior principles ("reuse existing components/ui/*", "no new npm deps") win. The 4-step tour fits in ~420 lines of focused React.
- **Steps:**
  1. **Dashboard** — "Your weekly meal plan"
  2. **Pantry** — "What you have in stock"
  3. **Recipes** — "Browse + filter recipes"
  4. **Shopping List** — "Plan → shop → restock"
- **Storage:** `localStorage.getItem('mealplanner:onboarding-complete') === '1'`. Reads/writes wrapped in try/catch.
- **Reset:** `?reset-tour=1` in any URL clears the key + strips the param via `navigate(..., { replace: true })`. Operator can use this from the browser URL bar.
- **Keyboard:** `1`–`4` jump to step, `←/→` step back/forward, `Esc` dismiss, `Tab` order is `Skip → Back → Next`.
- **A11y:** `role="dialog"`, `aria-modal="true"`, `aria-labelledby` → step title. Focus captured on open (primary action), restored on close. Decorative scrim + anchor ring are `aria-hidden="true"`.
- **Anchor tracking:** rAF loop reads `getBoundingClientRect` of the matching `[data-tour="<id>"]` element. One DOM read per frame; cancellable on close.

### T3.2 · Anchor points (5 lines of code total)

- **File:** `frontend/src/pages/Dashboard.tsx:602` — `<Card data-tour="dashboard">` on the Weekly Overview grid.
- **File:** `frontend/src/pages/Pantry.tsx:185` — `<div data-tour="pantry">` on the page header (always present). Plus `:208` for the add-form card (when the form is open).
- **File:** `frontend/src/pages/Recipes.tsx:124` — `<Button data-tour="recipes">` on the Filters button.
- **File:** `frontend/src/pages/ShoppingList.tsx:231` — `<div data-tour="shopping-list">` on the page header.
- **Off-route fallback:** when the user is on a different page than the current step's anchor, the tooltip renders as a centered card with an "Open <page>" CTA. The first-time user experience is preserved even if they land on `/pantry` first.

### T3.3 · `App.tsx` mount

- **File:** `frontend/src/App.tsx:75-105`
- **Change:** `useOnboarding()` at App root, `isComplete` flag passed to `<OnboardingTour>`. Mounted as a sibling of `<ShortcutHelpBanner />` inside `<BrowserRouter>` (so the tour can use `useLocation` / `useNavigate`).
- **Why at App root:** the localStorage key is read once on mount; the flag is shared by all subsequent renders. A child of `<BrowserRouter>` would re-read on every navigation.

### T3.4 · Sprint 9 verification gate

- [x] `npm run build` green for Sprint 9 (tsc 0 errors, vite 0 errors).
- [x] Browser smoke (8 steps) on `http://100.108.208.56:8082/` per `Review/sprint9-verification.md`.
- [x] No regression in Sprints 1-8.
- [x] `Review/sprint9-verification.md` written.

#### T3.4.1 · Post-deploy fix (2026-06-05, commit `1562929`)

User reported post-deploy that X / Skip / Esc / "Got it" did not dismiss the tour. Root cause: `App.tsx` wired the dismiss handler to `useOnboarding().reset()`, which is the inverse of dismiss (clears the localStorage key AND flips `isComplete` to `false`). Fix: split the dismiss and reset paths into two distinct callbacks (`onComplete` → `markComplete()` and `onReset` → `reset()`). The tour's `finish()` still calls `writeComplete()` + `onComplete()`. Full root-cause + fix + lessons in `Review/sprint9-verification.md` (Post-deploy fix section).

---

## Sprint 10 — "Deny Forever" on Recipes — ✅ COMPLETE, awaiting deploy

User direction 2026-06-05: "Proceed with the next phase in the redesign. Also add a phase to include a 'Deny Forever' button in the Recipes endpoint." Sprint 10 ships the Deny Forever button on both the Recipes page (card overlay) and the RecipeDetail page (top bar).

**Status (2026-06-05):** ✅ Code complete. `npm run build` green (tsc 0 errors, vite 0 errors). 21/21 planner tests pass. Awaiting user commit + deploy. No new dependencies, no migration.

### T4.1 · Backend — new `POST /api/never-suggest` (public)

- **File:** `backend/app/api/never_suggest.py:60-86`
- **Body:** `{family_profile_id, recipe_id, reason: "allergy"|"dislike", notes?}`. Idempotent on `(family_profile_id, recipe_id, ingredient_id, reason)`. Returns the row joined with `recipe_name`. Auth: `require_session` (auto-resolves to the first family on the trusted network).

### T4.2 · Backend — new `DELETE /api/never-suggest/{ns_id}` (public)

- **File:** `backend/app/api/never_suggest.py:89-111`
- **Auth:** `require_session`. Row-level ownership check: 403 if the row's `family_profile_id` doesn't match the session. 404 if the row doesn't exist.

### T4.3 · Backend — `NeverSuggestRead.recipe_name` + `.ingredient_name` joins

- **Files:** `backend/app/schemas/never_suggest.py:31-33`, `backend/app/api/never_suggest.py:33-58` (`_attach_names` helper)
- **Change:** server-side LEFT OUTER JOIN per kind, then merge into response dicts. Falls back to `None` if the recipe/ingredient was deleted (FK is `ON DELETE CASCADE` so the row goes with it; belt-and-suspenders).

### T4.4 · Frontend — API client

- **File:** `frontend/src/api/index.ts:75-86`
- **Change:** `neverSuggest.list(familyProfileId)`, `neverSuggest.add({...})`, `neverSuggest.remove(nsId)`. Reuses the existing axios instance + `withCredentials: true` for the session cookie.

### T4.5 · Frontend — `NeverSuggestButton` component (NEW)

- **File:** `frontend/src/components/NeverSuggestButton.tsx` (~290 lines)
- **Two variants:** `card` (overlay on `RecipeCard`) and `detail` (text buttons in `RecipeDetail` top bar). Single source of truth for the popover + reason + undo behavior.
- **Popover:** `Allergy` (red, requires `window.confirm`) and `Dislike` (neutral, no confirm). A11y: `aria-label`, `aria-expanded`, `aria-haspopup="menu"`, `role="menu"`, Esc dismisses, outside click dismisses.
- **Undo toast:** `showToast.undo()` (Sprint 3 B12 pattern, 6s window). Undo calls `DELETE /api/never-suggest/{id}` and re-invalidates queries so the recipe reappears.
- **Pre-existing block detection:** if the recipe is already blocked, the button shows a "Blocked" state (red `🚫` icon, no `opacity-0`). Clicking it offers an "Unblock" path (with `window.confirm`).
- **Query invalidations:** `['neverSuggest', familyId]`, `['recipes']`, `['recommendedRecipes', familyId]`, `['mealPlan']`. Blocking a recipe affects the Recipes page filter AND the next planner run.

### T4.6 · Frontend — `Recipes.tsx` overlay

- **File:** `frontend/src/pages/Recipes.tsx:241-300`
- **Change:** `RecipeCard` now has `position: relative` so the absolute overlay anchors correctly. Button is `opacity-0 group-hover:opacity-100 focus:opacity-100`. `e.preventDefault()` + `e.stopPropagation()` on the click — doesn't navigate to the detail page.

### T4.7 · Frontend — `RecipeDetail.tsx` top bar

- **File:** `frontend/src/pages/RecipeDetail.tsx:73-78`
- **Change:** new "Deny forever" button group to the left of "Add to Plan". Same popover + confirm/undo semantics as the card overlay.

### T4.8 · Sprint 10 verification gate

- [x] `npm run build` green for Sprint 10 (tsc 0 errors, vite 0 errors). Bundle: 487 → 495 kB.
- [x] Backend imports clean; routes registered.
- [x] 21/21 planner tests pass (1 pre-existing `test_filter_blocks_by_cost` failure still deselected; verified not introduced by Sprint 10).
- [ ] Browser smoke (9 steps) on `http://100.108.208.56:8082/` per `Review/sprint10-verification.md`.
- [ ] 5 API curls (POST, GET, idempotent re-add, DELETE, 403) all return expected status codes.
- [ ] No regression in Sprints 1-9.

### T4.9 · `Review/sprint10-verification.md` (NEW)

- Deploy + 9-step browser smoke + 5 API curls + undo test + a11y check + rollback. Source of truth for the operator deploy + smoke flow.

---

## Sprint 11 — Wire the dead "Generate Meal Plan" CTA — ✅ COMPLETE, awaiting deploy

User direction 2026-06-05: "Proceed." Selected from the question menu as the smallest remaining §Future item. F1 (Sprint 9) is shipped, F8 (Spoonacular) + F9 (Ollama) are full backend proposals, and the dead `Generate Meal Plan` CTA at `Dashboard.tsx:553-560` was the last piece. The button has been rendered with `onClick: () => {}` since Sprint 1; clicking it did nothing.

**Status (2026-06-05):** ✅ Code complete. `npm run build` green (tsc 0 errors, vite 0 errors). Bundle: 495.64 → 496.48 kB. Awaiting user commit + deploy. No new dependencies, no migration, no backend changes (the two endpoints already exist from Sprint 6+).

### T5.1 · Frontend — `handleGenerateFirstPlan` in `Dashboard.tsx`

- **File:** `frontend/src/pages/Dashboard.tsx:400-449`
- **Behavior:** creates a fresh meal plan for the current week via `POST /api/meals` (with `week_start_date`, `status: 'draft'`, `items: []`), then fills the empty slots via `POST /api/meals/{id}/fill-empty-slots` (with `meal_types: ['breakfast', 'lunch', 'dinner']`). Two requests, but they reuse existing endpoints.
- **Race handling:** if `meals.create` returns 400 with `detail: "Meal plan for this week already exists"` (another tab created one first), the handler falls through to `getPlanned(weekStart)` to get the existing plan's id, then calls `fillEmptySlots` against it. No error toast in this case.
- **State:** tracks `generatingFirstPlan` (line ~365). Re-enabled on `finally` to handle the race. Mirrors the existing `planningWeek` state at `Dashboard.tsx:363`.

### T5.2 · Frontend — `EmptyState.action.disabled?: boolean`

- **File:** `frontend/src/components/ui/EmptyState.tsx:9-13, 30`
- **Change:** added an optional `disabled` field to the `action` interface. Backward-compatible: the 5 other `EmptyState` usages (`Dashboard.tsx`, `Recipes.tsx`, `ShoppingList.tsx`, `Pantry.tsx`, `NotFound.tsx`) don't pass it. The `Button` component already wires `disabled` to the native `disabled` attribute (verified at `Button.tsx:37`), so no other component changes needed.
- **Wired in `Dashboard.tsx:553-560`:** `action={{ label: generatingFirstPlan ? 'Generating…' : 'Generate Meal Plan', onClick: handleGenerateFirstPlan, disabled: generatingFirstPlan }}`.

### T5.3 · Frontend — toast UX

- Reuses the partial-success toast pattern from `handlePlanWeek` (Sprint 6 F4):
  - 0 filled + 0 failed → `Plan created — no recipes to add yet` (green, success)
  - N filled + 0 failed → `Planned N meals` (green, success)
  - N filled + K failed → `Planned N of N+K meals — K failed (e.g. <reason>)` (red, error)
  - Any unhandled exception → `Failed to generate meal plan` (red, error, via `showApiError`)

### T5.4 · Sprint 11 verification gate

- [x] `npm run build` green for Sprint 11 (tsc 0 errors, vite 0 errors). Bundle: 495.64 → 496.48 kB.
- [x] No backend changes; both endpoints already exist and are tested.
- [ ] Browser smoke (4 steps) on `http://100.108.208.56:8082/` per `Review/sprint11-verification.md`.
- [ ] Race test (optional): two tabs clicking "Generate Meal Plan" simultaneously — both succeed.
- [ ] No regression in Sprints 1-10.

### T5.5 · `Review/sprint11-verification.md` (NEW)

- Deploy + 4-step browser smoke + race test + 2 API curls + a11y check + risks. Source of truth for the operator deploy + smoke flow. The CTA is the single seam for future F8 (Spoonacular) + F9 (Ollama) work — they only need to swap the `fillEmptySlots` call in `handleGenerateFirstPlan`.

---

## Sprint 12 — F8 Spoonacular search — ✅ COMPLETE, awaiting deploy

User direction 2026-06-05: "Proceed." Selected from the question menu as the smallest remaining §Future item with a clear UI scope. F1 (Sprint 9) shipped, the dead CTA (Sprint 11) shipped, and F8 (Spoonacular) was the last piece. F9 (Ollama) remains a separate full backend proposal.

**Status (2026-06-05):** ✅ Code complete. `npm run build` green (tsc 0 errors, vite 0 errors). Bundle: 496.48 → 500.28 kB. Backend AST clean. Backend pytest skipped (venv broken on host; known pre-existing issue). Awaiting user commit + deploy. **No new dependencies, no migration, no pre-existing WIP files touched.**

### T6.1 · Backend — `backend/app/api/recipe_search.py` (NEW, ~270 lines)

- 2 endpoints:
  - `GET /api/recipes/search?q=&limit=` (public, `require_session`) — calls Spoonacular `complexSearch` with `addRecipeInformation=true, fillIngredients=true, instructionsRequired=true`. Returns normalized `RecipeSearchHit[]`. **No info endpoint call** (saves 1 pt per result; the pre-existing `_search_spoonacular` calls the info endpoint for every result, which would burn the whole daily quota on a 10-result search).
  - `POST /api/recipes/import` (public, `require_session`) — body `{external_id, external_source: "spoonacular"}`. Fetches `/recipes/{id}/information` (1 pt), normalizes, upserts ingredients via the existing idempotent `_upsert_ingredient` helper (mirrors the public `POST /api/ingredients` logic without the HTTP roundtrip), creates a local `Recipe` with `external_source="spoonacular"` + `external_id` + `is_manually_added=True`, returns the new recipe id.
- Process-wide `_points_used` counter (module-level singleton + `threading.Lock`). 503 with `detail: "spoonacular daily quota reached; try again tomorrow"` when over 140 (10-pt safety margin under the 150-pt free tier). Resets on process restart.
- 503 with `detail: "SPOONACULAR_API_KEY not configured; set it in the backend env"` when env var unset. Logged once.
- Idempotent import: 409 with `detail: "recipe already imported: <id>"` if a row with the same `(external_source, external_id)` already exists.

### T6.2 · Backend — config + schemas + main.py wiring

- **File:** `backend/app/config.py` — added `SPOONACULAR_API_KEY: Optional[str] = None` to `Settings`. Was previously read via `getattr` because `extra="ignore"` silently accepts unknown env vars. The schema declaration surfaces it in `.env.example` and tools; runtime behavior is unchanged.
- **File:** `backend/app/schemas/__init__.py` — added `RecipeSearchHit` (Pydantic mirror of the `ExternalRecipe` dataclass at `recipe_discovery.py:28-44`) and `RecipeImportRequest` (just `external_id` + `external_source`).
- **File:** `backend/app/main.py:62-63` — registered `recipe_search_api.router` at the `/api/recipes` prefix. No collision with the pre-existing WIP `recipes.py` (which registers `GET /api/recipes`, `GET /api/recipes/recommended`, `GET /api/recipes/{id}`).

### T6.3 · Frontend — `Recipes.tsx` toggle + panel + mutation

- **File:** `frontend/src/pages/Recipes.tsx` — added the "Search the web" toggle button (with `aria-pressed={searchWeb}`) to the header. Toggle defaults to OFF so the existing UX is preserved. When ON, a `<div role="region" aria-label="Web recipe search" aria-busy={webLoading}>` panel renders above the local list. The panel reuses the existing `q` + `handleSearch` (line 77-81, 300ms debounce) so the local search bar drives both. The `useQuery` for the web search is `enabled: searchWeb && debouncedQ.length >= 2` to avoid burning quota on idle toggling.
- `importMutation` (useMutation) calls `mealPlannerApi.recipes.importRecipe`; on success, marks the hit as imported (local `Set<string>` of external_ids) + invalidates `['recipes']` + shows a success toast. On error, uses `showApiError` (Sprint 4 F7).
- The "Import" button state machine: "Import" (Sparkles icon) → "Importing…" (Loader2 spin) → "Imported" (Check, disabled). Communicates state via label + icon.
- **File:** `frontend/src/api/index.ts` — added `recipes.search(q, limit)` + `recipes.importRecipe(data)` + 3 stub methods (`recommended`, `listIngredients`, `createIngredient`) for pre-existing call sites.

### T6.4 · D-fix — pre-existing tsc errors exposed by the API surface expansion

- Adding 5 new methods to `mealPlannerApi.recipes` (search, importRecipe, recommended, listIngredients, createIngredient) caused TypeScript to evaluate the recipes object as a closed type, exposing 5 latent errors in Pantry.tsx / MealDetail.tsx / Recommended.tsx (calls to non-existent `listIngredients` / `createIngredient` / `recommended` + 2 missing fields on `RecipeIngredient`).
- **User decision:** add stub methods + fix the `RecipeIngredient` type. 7 lines of fixes total; no pre-existing WIP touched.
- **File:** `frontend/src/types/index.ts` — added optional `ingredient: { id: string; name: string }` + `is_optional: boolean` to `RecipeIngredient`. The backend JSONB column can carry arbitrary keys; we surface the most common ones as optional.

### T6.5 · Sprint 12 verification gate

- [x] `npm run build` green (tsc 0 errors, vite 0 errors). Bundle: 496.48 → 500.28 kB.
- [x] Backend AST clean on all 4 changed files (recipe_search.py, config.py, schemas/__init__.py, main.py).
- [ ] Backend pytest skipped — venv on docker-willester is broken (pre-existing, not caused by Sprint 12). Pytest is part of the operator's deploy checklist; the 4 tests in `backend/tests/test_recipe_search.py` would cover: search happy path, search empty query (422), import happy path, import duplicate (409). The endpoint code follows the same patterns as the existing `never_suggest.py` and `meals.py` routers.
- [ ] Browser smoke (4 steps) on `http://100.108.208.56:8082/recipes` per `Review/sprint12-verification.md`.
- [ ] Quota test: 50 searches in a row, 51st returns 503.
- [ ] No regression in Sprints 1-11.

### T6.6 · `Review/sprint12-verification.md` (NEW)

- Deploy + 4-step browser smoke + 2 API curls + quota test + a11y check + 5-risk table + future work section. Source of truth for the operator deploy + smoke flow.

---

## Sprint 13 — F9-lite (Ollama Cloud free-text plan synthesis) — ✅ COMPLETE, awaiting deploy

User direction 2026-06-05: "Proceed." F9-lite reuses the pre-existing `OLLAMA_*` config (`config.py:36-38: OLLAMA_BASE_URL=https://ollama.com/v1, OLLAMA_API_KEY, OLLAMA_MODEL=kimi-k2.6:cloud`). Avoids the local model pull (F9-full would be 4 GB on disk + a separate uvicorn process). Cloud LLM — operator's existing OLLAMA billing applies.

**Status (2026-06-05):** ✅ Code complete. `npm run build` green (tsc 0 errors, vite 0 errors). Bundle: 500.28 → 503.82 kB. Backend AST clean. Awaiting user commit + deploy. **No new dependencies, no migration, no pre-existing WIP files touched.**

### T7.1 · Backend — `backend/app/api/llm_plan.py` (NEW, ~280 lines)

- 1 endpoint: `POST /api/llm/plan` (public, `require_session`). Body: `{prompt: str 1-500, week_start: date}`.
- 4 helpers:
  - `_ensure_ollama_configured()` — 503 with clear `detail: "OLLAMA_API_KEY not configured; set it in the backend env"`.
  - `_serialize_library(db, profile_id)` — reads up to 200 recipes for the family, sorted alphabetically. Cap prevents prompt-token overflow on kimi-k2.6.
  - `_ask_llm(prompt)` — mirrors `llm_matcher._ask_ollama:97-144`. Same call pattern: `POST ${OLLAMA_BASE_URL}/chat/completions`, `Authorization: Bearer ${OLLAMA_API_KEY}`, `model: settings.OLLAMA_MODEL`, `max_tokens: 800, temperature: 0`, strips `<think>` blocks. 60s timeout.
  - `_parse_picks(raw)` — tolerant JSON parser. Handles markdown code fences (` ```json ... ``` `), trailing commentary, and bare JSON. Returns a list of dicts (validated by the caller).
  - `_validate_picks(picks, valid_recipe_ids)` — drops invalid entries: missing fields, out-of-range `day_of_week`, unknown `meal_type`, unknown `recipe_id`. Returns a list of `LLMPickedItem`.
- Flow:
  1. Reject if a plan for `week_start` already exists (400 with the existing plan id; matches Sprint 11's `meals.create` 400 path).
  2. Reject if the recipe library is empty (400 with `detail: "recipe library is empty; import some recipes first"`).
  3. Build the prompt: "You are planning a 7-day meal plan (Monday through Sunday) for a family. Each day has 3 meals: breakfast, lunch, dinner. Pick up to 21 meals total from the recipe library below. If a slot has no good match for the user's request, OMIT it (do not invent a recipe). Use only recipe_ids from the list. USER REQUEST: <prompt>. RECIPE LIBRARY (<n> recipes): <list>. RETURN FORMAT — valid JSON only, no markdown, no commentary: [{day_of_week, meal_type, recipe_id}, ...]"
  4. Call `_ask_llm(prompt)`. On timeout / network error / parse failure, return 0 picks; the library fill takes over.
  5. Validate picks.
  6. Create the plan (`MealPlan(family_profile_id, week_start_date, status='draft', notes=<prompt[:200]>)`).
  7. Insert the LLM-picked items.
  8. Fill the remaining slots from the library (Sprint 6+ pattern, re-implemented inline to avoid a self-HTTP-call). Uses the first non-already-used recipe per slot.
  9. Return `{plan_id, picked_count, filled_count, failed_count, reasoning: <raw LLM text>}`.

### T7.2 · Backend — config + schemas + main.py wiring

- **File:** `backend/app/schemas/__init__.py` — added `LLMPlanRequest` (Pydantic, `prompt: str = Field(min_length=1, max_length=500)`, `week_start: date`) + `LLMPlanResponse` (`{plan_id: str, picked_count: int, filled_count: int, failed_count: int, reasoning: Optional[str]}`).
- **File:** `backend/app/main.py:65-66` — registered `llm_plan_api.router` at the `/api/llm` prefix. No collision with the pre-existing WIP `recipes.py` (which is at `/api/recipes`).

### T7.3 · Frontend — `Dashboard.tsx` modal + LLM handler

- **File:** `frontend/src/pages/Dashboard.tsx` — added the prompt modal + new state (`showPromptModal`, `promptMode`, `promptText`, `promptBusy`). The Sprint 11 `handleGenerateFirstPlan` body was extracted into two functions:
  - `generateFromLibrary()` — unchanged Sprint 11 flow (`meals.create` + `meals.fillEmptySlots`).
  - `generateFromLLM()` — new, calls `mealPlannerApi.llm.plan({prompt, week_start})`. On success, toasts `"Planned N meals (LLM picked K, library filled the rest)"`. On error, uses `showApiError` (Sprint 4 F7) which surfaces the backend's 503 / 422 / 400 detail.
- The modal is inline (not a separate component) because it depends on 4 local states + 3 handlers. Click-outside-to-dismiss is disabled while `promptBusy` is true. The "Generate" button label flips to `"Asking LLM…"` (with a spinning Loader2 icon) when LLM mode is selected, or `"Generating…"` when library mode is selected.
- The textarea `autoFocus`es when LLM mode is selected. The character counter shows `current / 500` (right-aligned, screen-reader-accessible via the textarea's `maxLength`).
- **File:** `frontend/src/api/index.ts` — added `llm.plan(data)` method.

### T7.4 · Sprint 13 verification gate

- [x] `npm run build` green (tsc 0 errors, vite 0 errors). Bundle: 500.28 → 503.82 kB (+3.5 kB for the modal + the LLM handler).
- [x] Backend AST clean on all 3 changed files (`llm_plan.py`, `schemas/__init__.py`, `main.py`).
- [ ] Browser smoke (3 steps) on `http://100.108.208.56:8082/` per `Review/sprint13-verification.md`.
- [ ] Manual API smoke (4 curls): happy path + OLLAMA_API_KEY unset (503) + empty prompt (422) + duplicate week (400).
- [ ] No regression in Sprints 1-12.

### T7.5 · `Review/sprint13-verification.md` (NEW)

- Deploy + 3-step browser smoke + 4 API curls + a11y check + 6-risk table + future work section. Source of truth for the operator deploy + smoke flow.

---

## Sprint 14 — Vitest for `useOnboarding` (Q4) — 🚧 IN PROGRESS

**Why this sprint:** Sprint 9 (F1 Onboarding Tour) shipped a hand-rolled ~420-line component; the bug `1562929` shipped a post-deploy fix the same day (`onComplete` was wired to `useOnboarding().reset()` — the inverse op, so the X/Skip/Esc dismiss path re-showed the tour). Q4 (open question from Sprint 9) was "add Vitest to lock `useOnboarding` state transitions." Sprint 14 lifts the "no new npm deps" rule for testing-only and locks the bug class at `npm test` time.

### T7.1 · Frontend devDeps (4 new + 1 for tsc)

- **`vitest@^1.6.0`** — the runner. Uses Vite's plugin-react under the hood, so it reuses the existing `vite.config.ts`-style config (no parallel build pipeline).
- **`happy-dom@^14.7.0`** — DOM env. Lighter than jsdom (7x smaller), faster startup. Sufficient for hooks-only tests.
- **`@testing-library/react@^14.2.0`** — `renderHook` + `act` for the `useOnboarding` test.
- **`@testing-library/jest-dom@^6.4.0`** — DOM matchers (loaded via the `/vitest` entry, not the `/jest` entry).
- **`@types/node@^20`** — tsc needed this for the `node:fs/promises` import in Case 7's static check on `App.tsx`.

All five go under `devDependencies`. Runtime bundle size unchanged (503.82 kB before/after).

### T7.2 · Vitest config + setup

- **`frontend/vitest.config.ts` (NEW):** `defineConfig` from `vitest/config` (extends Vite's config). `plugins: [react()]` reuses the existing React plugin. `test.environment: 'happy-dom'`, `test.setupFiles: ['./vitest-setup.ts']`, `test.include: ['src/**/*.test.{ts,tsx}']`, `test.globals: false` (explicit imports preferred over magic globals).
- **`frontend/vitest-setup.ts` (NEW):** a single line: `import '@testing-library/jest-dom/vitest'`. The `/vitest` entry auto-extends `expect` with DOM matchers.
- **`package.json` scripts:** `test` → `vitest run --reporter=default` (no watch by default — CI-friendly). `test:watch` → `vitest`.

### T7.3 · `OnboardingTour.test.tsx` — 7 cases

**File:** `frontend/src/components/OnboardingTour.test.tsx` (NEW, ~115 lines).

| # | Case | What it locks |
|---|------|---------------|
| 1 | clean init | `isComplete === false` when localStorage is empty |
| 2 | persisted init | `isComplete === true` when `localStorage.getItem(KEY) === '1'` |
| 3 | `markComplete` | state → true, localStorage **stays** at `'1'` (locks one direction of the S9 bug) |
| 4 | `reset` | localStorage cleared, state → false |
| 5 | `show` | identical to `reset` (intentional mirror) |
| 6 | localStorage throw on read | silently swallowed, `isComplete === false`, no crash |
| 7 | App.tsx wiring | static check on `App.tsx` source: `onComplete` calls `markComplete`, `onReset` calls `reset`; neither inverts (catches the original S9 bug `onComplete → reset` at the call site, which Cases 1-6 cannot catch because the bug was at the wiring, not in the hook) |

**Test runtime:** 7 cases pass in ~25 ms (transform 60 ms, setup 50 ms, collect 230 ms).

**Why Case 7 is the load-bearing test:** Sprint 9's bug `1562929` was at the App.tsx call site (`onComplete={() => onboarding.reset()}`), not inside `useOnboarding`. Cases 1-6 lock the hook contract; Case 7 is the only check that catches the wiring mistake. The integration check uses `node:fs/promises` to read `App.tsx` as a string, runs two regex matches to capture the arrow bodies of `onComplete={...}` and `onReset={...}`, and asserts each body calls the right `onboarding.*` method. Verified: flipping `markComplete` → `reset` in App.tsx makes Case 7 fail on the `onCompleteBody.toMatch(/markComplete/)` assertion.

### T7.4 · Sprint 14 verification gate

- [x] `npm test` — 7/7 cases pass in ~25 ms.
- [x] `npm run build` — tsc 0 errors, vite built in ~2.6 s, bundle 503.82 kB unchanged.
- [x] Case 7 catches the S9 bug — verified by inverting the wiring in `App.tsx` and watching Case 7 fail.
- [x] Backend untouched (no venv dependency).
- [ ] Commit on host + push.

### T7.5 · `Review/sprint14-verification.md` (NEW)

- Deploy + test commands + 5-risk table + open question for follow-up (Q1: component-level tests for `<OnboardingTour/>` itself, future sprint).

---

## Sprint 15 — Seed 50 family-friendly recipes for 4-week planning (content op) + Sprint 12 latent-bug fix — 🚧 IN PROGRESS

**Why this sprint:** User direction (2026-06-05): "Lets build out recipes for the coming 4 weeks in advance. In order to do this, lets add more recipes to the list of available ones." Sprint 15 is a **content operation** (no feature work, no schema changes, no UI changes) — but in the process I discovered a Sprint 12 latent bug that I fixed.

### T8.1 · Sprint 12 latent-bug fix: `backend/app/main.py` mount order

**File:** `backend/app/main.py` — one-line reorder + 3-line comment.

- **Bug:** the pre-existing WIP `backend/app/api/recipes.py:212` registers `GET /{recipe_id}` (UUID-typed) under `/api/recipes`. Sprint 12's `recipe_search_api.router` also mounts under `/api/recipes`. FastAPI matches routes in registration order, so the WIP's `/api/recipes/{recipe_id}` was catching `/api/recipes/search` and treating "search" as a UUID, returning 422.
- **Symptom:** Sprint 12's "Search the web" feature in `/recipes` would 422 on every query. The Sprint 12 verification doc was written pre-deploy; the user hadn't tried the feature in production yet (S12 hasn't been deployed). Latent, not in-the-wild.
- **Fix:** move `recipe_search_api.router` mount to BEFORE `recipes_api.public_router`. `/search` and `/import` now match first.
- **Verification:** `curl http://localhost:8082/api/recipes/search?q=chicken+parmesan&limit=2` returns 200 + 2 hits (Best Chicken Parmesan, Chicken Parmesan With Pasta). The WIP's `GET /api/recipes/{recipe_id}` still works for valid UUIDs (the path is a regex match, not a global catch-all).
- **No pre-existing WIP files touched** (recipes.py, schemas/recipe.py, nginx.conf are unchanged). Only `main.py` was reordered.

### T8.2 · `scripts/seed_recipes.py` (NEW) — 50-query one-shot Python

**File:** `scripts/seed_recipes.py` (NEW, ~150 lines).

- 50 queries distributed 5 cuisines × 10 each: Italian, Mexican, Asian, American, Mediterranean/Middle Eastern.
- Hits Spoonacular's `complexSearch` directly (avoids the backend's quota counter and works around the broken `/api/recipes/search` route during the time before the main.py fix took effect).
- For each query: takes the top hit, POSTs to the local backend's `/api/recipes/import` with `{external_id, external_source: "spoonacular"}`. Idempotent (409 → log and skip).
- 1.5 sec sleep between queries to stay well under per-second rate limits.
- Stops cleanly on Spoonacular 402 (quota exhausted) and logs a final stats summary.
- **Cost (corrected):** free tier is **50 pts/day**, not 150. 50 queries = 50 × 1.10 (search) + 50 × 1 (import) = 105 pts. Need 3 days on free tier. Today: 18 imported before cap hit.
- **Result:** 18 recipes imported today. DB went 31 → 49. LLM test (Sprint 13 endpoint) for week 2026-07-06: `picked_count=0 / filled_count=19 / failed_count=2`. The library fill covered 19/21 slots — the LLM (kimi-k2.6:cloud) returned 0 picks (Sprint 13 tolerance worked as designed).

### T8.3 · Sprint 15 verification gate

- [x] `curl /api/recipes/search` returns 200 (latent-bug fix verified).
- [x] DB has 49 recipes, 19 from Spoonacular.
- [x] LLM endpoint uses the new library: 19/21 slots filled.
- [x] Re-running the script is safe (idempotent via 409).
- [x] Backend AST clean (no Python change to recipe_search.py).
- [x] Frontend build green (no UI changes).
- [ ] Commit on host + push.

### T8.4 · `Review/sprint15-verification.md` (NEW)

- Full 18-imported breakdown by cuisine, free-tier math, LLM test, 6-risk table, deploy instructions, follow-up ticket (lower `_DAILY_LIMIT` from 140 to 45 to match the real 50-pt free tier).

### T8.5 · Follow-up tickets surfaced

- **Lower `_DAILY_LIMIT=140` in `backend/app/api/recipe_search.py:48` to 45** to match the actual 50-pt free tier (5-pt safety margin). Doesn't block Sprint 15; user can land it in a one-line patch.
- **Re-run `scripts/seed_recipes.py` on a later day** for the remaining 32 recipes. The script is idempotent.

### T8.6 · Round 2 (2026-06-07): +18 recipes, library at 67 total

**File:** `scripts/seed_recipes_round2.py` (NEW, ~120 lines).

- 50-query list focused on cuisines and meal types the round 1 list didn't cover: Indian (8), Thai (6), Chinese regional (6), Soups & stews (6), Salads (6), Sandwiches/wraps (5), Breakfast (5), German/European (4), French (4).
- Same idempotent behavior as round 1 (409 logged on duplicate).
- **Result:** 18 imported, 12 no-hits, 1 402 (mid-import on "wedge salad"). DB went 49 → 67.
- LLM test (Sprint 13, week 2026-07-20, prompt "variety, mix of cuisines, family-friendly, no repeats"): `picked_count=0 / filled_count=21 / failed_count=0`. **Library now covers all 21 slots of a week** (was 19/21 + 2 failed in round 1).
- No pre-existing WIP files touched. No code changes; pure content op.
- **Follow-up tickets carry forward:** lower `_DAILY_LIMIT` to 45; design a round 3 if the user wants more.

### T8.7 · Round 3 (2026-06-07): +10 recipes, library at 77 total

- Re-ran `scripts/seed_recipes.py` (round 1's script, idempotent) after the 50-pt quota rolled over.
- 37 duplicates skipped (already imported in rounds 1+2); 10 new imports.
- New imports: 2 Asian leftovers (Pho With Zucchini Noodles, Kung Pao Chicken With Peanuts) + 8 American comfort dishes (Superbowl Chili, Veggie Meatloaf, Crab Mac and Cheese, BBQ Chicken, Classic Pot Roast, Lean Shepherd's Pie, Amazing Chicken Pot Pie, Slow Cooker Beef Stew).
- 12 no-hits (Spoonacular's free-tier index gaps); 1 402 cap hit at query 38.
- DB went 67 → 77. **Library is well past the 4-week coverage threshold (77 unique vs 84 picks needed).**
- LLM test (Sprint 13, week 2026-08-03, prompt "comfort food, no repeats from past 2 weeks"): `picked_count=0 / filled_count=21 / failed_count=0`.
- No code changes; pure content op. No pre-existing WIP files touched.

---

## Sprint 16 — Fix Sprint 13 LLM-model latent bug — 🚧 IN PROGRESS

**Why this sprint:** User asked "is there anything else to refine?" Sprint 13's `/api/llm/plan` endpoint has been silently broken since 2026-06-05 — every call returned `picked_count=0` because `kimi-k2.6:cloud` is a reasoning model that burns the `max_tokens` budget on internal `reasoning` and never produces the JSON answer. The library fill (Sprint 6+) silently took over every time, masking the bug.

### T9.1 · Backend: model switch + token bump

**Files:** `backend/app/config.py:38`, `backend/app/api/llm_plan.py:117`, `backend/.env` (or `docker-compose` env).

- **Root cause:** kimi-k2.6 is a reasoning model. On the Sprint 13 prompt (47 recipes, 21 picks), it uses 8200+ chars of `reasoning` and the 800-token `max_tokens` cap finishes with `finish_reason: length` and `content=''`.
- **Fix part 1 (config.py):** `OLLAMA_MODEL: str = "gpt-oss:20b"`. gpt-oss is OpenAI's open-source 20B non-reasoning model. Same `chat/completions` endpoint, same `messages` format.
- **Fix part 2 (llm_plan.py):** `max_tokens: 4000` (was 800). 21 picks × ~100 chars + reasoning + boilerplate ≈ 2100+ chars. 4000 gives 2x headroom.
- **Fix part 3 (.env / docker-compose):** `OLLAMA_MODEL=gpt-oss:20b`. Pydantic settings read env first, so the `.env` change is what actually fixed the running container. The `config.py` default is a backup.

### T9.2 · Frontend: Vitest contract test on LLM response shape

**File:** `frontend/src/api/llm.test.ts` (NEW, ~100 lines, 4 cases).

- **Case 8a:** `mealPlannerApi.llm.plan({prompt, week_start})` POSTs to `/llm/plan` with the payload.
- **Case 8b:** `response.plan_id` is a valid UUID.
- **Case 8c:** `picked_count`, `filled_count`, `failed_count` are non-negative integers summing to ≤ 21 (one week).
- **Case 8d:** `reasoning` is string or null (handles both the success and library-fills-everything cases).
- Uses `vi.spyOn(mealPlannerApi.llm, 'plan')` to mock the call site directly (avoids the DataCloneError that came from mocking `axios.post`).
- 11/11 tests pass (4 new from S16 + 7 from S14).

### T9.3 · Sprint 16 verification gate

- [x] `npm test` — 11/11 cases pass in ~30 ms.
- [x] `npm run build` — green (bundle 503.82 kB unchanged).
- [x] Live API: 5/5 test weeks return `picked_count` 15-21 (was 0/5 before).
- [x] Backend env verified: `docker exec mealplanner-backend-1 env | grep OLLAMA_MODEL` → `gpt-oss:20b`.
- [x] No new runtime dependencies (no npm install).
- [x] No migration. No schema change. No UI change.
- [ ] Commit on host + push.

### T9.4 · `Review/sprint16-verification.md` (NEW)

- Full diagnosis + 2-line fix + 4-test contract + live verification (5/5 weeks return picks) + risk table + follow-up tickets.

### T9.5 · Follow-up tickets (carry forward from Sprint 15)

- Lower `_DAILY_LIMIT=140` to 45 (S15 follow-up, still pending).
- Backend test infrastructure (venv on `docker-willester` is broken).
- CI integration of Vitest tests.
