Files
Meal-Planner/fix-ui-audit.md
T
admin 2029e80c5a docs(review): Sprint 5 verification log + plan/handoff/audit updates
Sprint 5 (F5 + F2 + 0015 cast fix) is now documented across the project:

- Review/sprint5-verification.md: new deploy + smoke-check doc.
  Backend + frontend deploy (one batch with Sprints 2-4). Migration
  0015 MUST be run as part of this deploy (the cast fix is what
  makes it runnable). 7 smoke-check sections: A) curl tests for
  ?week_start=, B/C/D) URL week nav on Dashboard and Shopping List
  with query-key isolation, E) keyboard shortcut matrix, F) post-
  migration canonical-aisle verification query, G) Sprints 1-4
  regression spot-check. Rollback section covers reverts + the
  persist_aisle_backup recovery path.

- fix-ui-audit.md: new Sprint 5 section (S5.0 critical 0015 fix,
  S5.1 F5 implementation, S5.2 F2 implementation, S5.3 verification
  gate). 'Done when (overall)' block updated to 5 sprints + 9
  commits + 18 findings closed + the 0015 fix unblocks Sprint 2.

- Review/handoff-ui-audit.md: updated to a 5-sprint cycle. TL;DR
  table includes the d78bd18 + f740f40 rows with the CRITICAL 0015
  fix callout. file-list includes the new sprint5-verification doc.
  file-level diff summary gains 16 new rows (S5 backend + frontend +
  0015 + hooks/components). §Future list now strikethroughs F2 and
  F5. Quick-start deploy commands list Sprints 2-5 as a single
  batch (backup → migrate → rebuild backend + frontend).

- Review/ui-nielsen-audit.md: new Sprint 5 status block at the
  top. F5 + F2 + the 0015 fix all documented. Cross-ref to
  Review/sprint5-verification.md.

- docs/HANDOFF.md: Last-updated line bumped to 5 sprints / 9
  commits / 18 findings / with the 0015 fix CRITICAL callout.
  Header commit list gains the two Sprint 5 commits. New 'Sprint
  5' subsection in the 2026-06-04 session block. Commit table
  gained the d78bd18 + f740f40 rows. Files-modified list now
  includes all 5 sprints' changes. New 'Files added by Sprint 5'
  subsection for the 3 new files in hooks/ + components/.

No code changes; the 3 pre-existing WIP files (backend/app/api/
recipes.py, schemas/recipe.py, nginx/nginx.conf) are deliberately
not staged.
2026-06-04 12:42:54 -07:00

309 lines
30 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 5 — Power-user navigation (F5 + F2)
**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.
---
## 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.