Files
Meal-Planner/fix-ui-audit.md
T
admin 0668d40444 docs: Sprint 15 — seed 50 recipes + Sprint 12 latent-bug fix across all 6 running docs
Sprint 15 (commit a3c89bf) does two things: (1) reorders
backend/app/main.py so recipe_search_api.router mounts BEFORE
the WIP recipes_api.public_router (fixes a Sprint 12 latent
bug where /api/recipes/search was shadowed by the WIP's GET
/{recipe_id} returning 422); (2) adds scripts/seed_recipes.py
which seeded 18 Spoonacular recipes into the local library
today (free-tier 50-pt cap hit; remaining 32 to seed on later
days via the same idempotent script). DB went 31 -> 49 total
recipes.

This commit updates the 6 running docs that track sprints:

- .agent/plan.md — Sprint 15 section (S15.1-S15.4 + Done
  when + Out of scope) added after Sprint 14's out-of-scope.
- .agent/context.md — Sprint 15 decisions (D1-D6), open
  questions (Q1-Q2), and file:line references added.
- Review/sprint15-verification.md — NEW: full 18-imported
  breakdown by cuisine + free-tier math correction (50 pts/day,
  not 150) + LLM test (picked_count=0, filled_count=19,
  failed_count=2 for week 2026-07-06) + 6-risk table + deploy
  + 2 follow-up tickets (lower _DAILY_LIMIT, re-run script).
- Review/ui-nielsen-audit.md — Sprint 15 status block
  (T8.1-T8.3) added after the Sprint 14 block. Notes the
  Sprint 12 latent-bug fix as the critical change for the
  upcoming Sprint 12 deploy.
- fix-ui-audit.md — Sprint 15 section (T8.1-T8.5) added after
  the Sprint 14 section. T8.1 documents the main.py mount
  order fix in detail. T8.5 surfaces 2 follow-up tickets.
- Review/handoff-ui-audit.md — Batch K line in the deploy
  list, Sprint 15 section after Sprint 14, TL;DR Sprint 15
  line, Last-updated footer updated.
- docs/HANDOFF.md — Sprint 15 section after Sprint 14, Last-
  updated footer updated. Notes the corrected free-tier math
  and the 2 follow-up tickets.

All 6 docs now reflect Sprint 15. The Sprint 12 latent-bug
fix is documented as a hard prerequisite for the upcoming
Sprint 12 deploy (without it, every 'Search the web' query
would 422). Re-running scripts/seed_recipes.py on a later
day will add the remaining 32 recipes (the script is
idempotent — already-imported IDs return 409 and are
skipped).
2026-06-06 14:12:25 -07:00

851 lines
77 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 13 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.