Public Access
Sprint 6 (F3 + F4) is now documented across the project: - Review/sprint6-verification.md: new deploy + smoke-check doc. Backend + frontend deploy (no migration). 5 smoke-check sections: A) ShoppingList bulk-add end-to-end, B) F3 partial- failure edge case, C) F4 'Plan the week' button + dropdown, D) F4 edge cases (no empty slots, all recipes used, invalid meal_types), E) Sprints 1-5 regression spot-check. Rollback section covers revert (no migration to undo). - fix-ui-audit.md: new Sprint 6 section (S6.1 F3, S6.2 F4, S6.3 verification gate). 'Done when' block updated to 6 sprints / 10 commits / 20 findings closed. - Review/handoff-ui-audit.md: updated to a 6-sprint cycle. TL;DR table includes the8ad4ef6row. File list includes sprint6- verification.md. File-level diff summary gains 8 new rows for Sprint 6 (F3 backend + F4 backend + 3 new schemas + 2 api bindings + 2 page changes). §Future list now strikethroughs F3 and F4. Follow-up tickets section added: the no-op 'Generate Meal Plan' empty-state CTA, the (now-narrower) Pantry bulk-add ticket, and the 'Sprints 2-5 + Sprint 6 separate batch' deploy note. - Review/ui-nielsen-audit.md: new Sprint 6 status block at the top. F3 + F4 documented with the design-decision context (ShoppingList-only scope; dropdown for All/Dinners; partial- success with detailed report). - docs/HANDOFF.md: Last-updated line bumped to 6 sprints / 10 commits / 20 findings / 6 §Future items. Header commit list gains the8ad4ef6row. New 'Sprint 6' subsection in the 2026-06-04 session block. Commit table gained the8ad4ef6row. Files-modified + Files-added lists updated. No code changes; the 3 pre-existing WIP files (backend/app/api/ recipes.py, schemas/recipe.py, nginx/nginx.conf) are deliberately not staged.
337 lines
34 KiB
Markdown
337 lines
34 KiB
Markdown
# Fix Plan — UI/UX Audit (Nielsen 10 Heuristics)
|
||
|
||
## Goal
|
||
Resolve the 14 issues (5 P0, 6 P1, 3 P2) from `Review/ui-nielsen-audit.md` in three sprints, ending each sprint with a deployable, demonstrable improvement on `http://100.108.208.56:8082/`.
|
||
|
||
## Scope boundaries
|
||
- **In:** frontend React/TS fixes in `frontend/src/`. Backend one-off data migrations only when required (B8 aisle normalization, B4/Recommended route).
|
||
- **Out:** New features (bulk add, keyboard shortcuts, onboarding tour) — those are future work, listed in §Future.
|
||
- **Reuse:** `components/ErrorBoundary.tsx` already exists (verified). `components/ui/*` (Button, Card, Select, Input, EmptyState, Badge, Skeleton, LoadingSpinner) are the building blocks — use them, don't roll new ones.
|
||
- **Stack confirmed:** React 18 + TS + Vite + Tailwind + react-router-dom 6 + @tanstack/react-query + react-hot-toast + @hello-pangea/dnd + lucide-react + framer-motion. **No new deps** in Sprint 1/2. Sprint 3 may add `react-joyride` only if approved (defer to §Future).
|
||
|
||
## Conventions
|
||
- One commit per task: `fix(ui): <short>` / `feat(ui): <short>` / `refactor(frontend): <short>`.
|
||
- Before each commit: `cd frontend && npm run lint && npm run build` (the `build` script runs `tsc` first — type-checks the project).
|
||
- After each task, re-screenshot the affected page in the same playwright session and diff against `/tmp/opencode/mp-review/screenshots/`. Save new shots in `/tmp/opencode/mp-review/screenshots/fix-sprintN/`.
|
||
- All UI text in sentence case. New copy matches existing `lib/toast.ts` style.
|
||
- Type updates go in `frontend/src/types/index.ts`; do not duplicate shapes inline.
|
||
|
||
---
|
||
|
||
## Sprint 1 — Stop the bleeding (P0s)
|
||
|
||
**Goal:** Every P0 bug is gone. Each is independently demoable on the live deployment.
|
||
|
||
### S1.1 · B1 — RecipeDetail ingredients: drop `.trim()` so unit + name don't fuse
|
||
- **File:** `frontend/src/pages/RecipeDetail.tsx:161`
|
||
- **Change:** Replace
|
||
```tsx
|
||
{ing.qty != null && `${ing.qty} ${ing.unit || ''} `.trim()}
|
||
```
|
||
with
|
||
```tsx
|
||
{ing.qty != null && `${ing.qty}${ing.unit ? ` ${ing.unit}` : ''}`}
|
||
```
|
||
followed by a literal `' '` before `{ing.name}`.
|
||
- **Verify:** Open `/recipes/eae6591f...` (Black Bean Tacos). Ingredient row reads `2 can Black Beans, Canned` (with space). Re-run `npm run build`.
|
||
|
||
### S1.2 · B2 — MealDetail ingredients: align field name with backend (`qty`)
|
||
- **Files:** `frontend/src/types/index.ts`, `frontend/src/pages/MealDetail.tsx:249-252`
|
||
- **Change:**
|
||
1. In `types/index.ts` confirm `MealIngredient` shape; align to backend `qty` / `unit`. If the type currently has `quantity`, rename to `qty` (single source of truth).
|
||
2. In `MealDetail.tsx:249-252`, switch reads to `ing.qty` / `ing.unit`. Keep the existing null-guard so `qty == null` is skipped cleanly.
|
||
- **Verify:** Open `/meals/<any>` (e.g. `/meals/f28...` Pork Stir-Fry). Row reads `1 lb Pork Chops, Bone-In` not `lb Pork Chops`. `npm run build` clean.
|
||
|
||
### S1.3 · B3 — MealDetail cost: fix `$N/A per serving`
|
||
- **File:** `frontend/src/pages/MealDetail.tsx:191`
|
||
- **Change:**
|
||
```tsx
|
||
{item.estimated_cost != null
|
||
? `$${item.estimated_cost.toFixed(2)} per serving`
|
||
: 'No price estimate yet'}
|
||
```
|
||
- **Verify:** Reload `/meals/f28...`. Price line reads either `$X.XX per serving` or `No price estimate yet` — never `$N/A`.
|
||
|
||
### S1.4 · B4 — `/recommended` blank page: add `*` NotFound + alias
|
||
- **Files:** `frontend/src/App.tsx`, new `frontend/src/pages/NotFound.tsx`
|
||
- **Change:**
|
||
1. Create `pages/NotFound.tsx` — friendly card with `AlertTriangle` icon, message *"We can't find that page."*, primary `<Button>` → `/`, secondary → back. Reuse `components/ui/EmptyState.tsx` if it fits.
|
||
2. In `App.tsx`:
|
||
- Add `import { Navigate } from 'react-router-dom'`.
|
||
- Insert `<Route path="/recommended" element={<Navigate to="/recipes/recommended" replace />} />`.
|
||
- Append `<Route path="*" element={<NotFound />} />` after the existing routes.
|
||
3. Add a `// TODO(seo): audit email/share links for `/recommended` references` comment.
|
||
- **Verify:**
|
||
- Visit `http://100.108.208.56:8082/recommended` → redirects to `/recipes/recommended`, renders the Recommended page.
|
||
- Visit `http://100.108.208.56:8082/this-does-not-exist` → renders NotFound.
|
||
- `npm run build` clean.
|
||
|
||
### S1.5 · B5 — Mobile dashboard: always show empty meal slots
|
||
- **File:** `frontend/src/pages/Dashboard.tsx` (lines ~164, ~219)
|
||
- **Change:** Audit every `hidden md:flex` / `hidden md:block` / `hidden md:inline` inside `DayColumn` and the empty-slot JSX. Remove the `hidden` class on the empty-slot CTAs (the `Empty+Generate` placeholder block). For decorative chrome (e.g. day-of-week abbreviations), keep `hidden md:flex` only if there's a separate mobile-friendly label.
|
||
- **Verify:** Re-screenshot at 390 px width. Empty slots are tappable; tapping Generate fires the same query as on desktop. `npm run build` clean.
|
||
|
||
### S1.6 · Sprint 1 verification gate
|
||
- `cd frontend && npm run lint && npm run build` → both 0 errors / 0 warnings.
|
||
- Re-run playwright walkthrough; capture `screenshots/fix-sprint1/*.png` for: recipe detail (Black Bean Tacos), meal detail (Pork Stir-Fry), `/recommended`, `/this-does-not-exist`, mobile dashboard.
|
||
- Manual smoke: tap Generate on a mobile viewport, confirm a new meal lands in the slot.
|
||
- **Done when:** All five P0 bugs absent in the re-captured screenshots AND lint/build pass.
|
||
|
||
---
|
||
|
||
## Sprint 2 — Trust the data (P1s)
|
||
|
||
**Goal:** No more silent data corruption in the UI. Every displayed value is consistent across pages and either present-and-correct or explicitly absent.
|
||
|
||
**Status (2026-06-02):** ✅ All six P1s + the bonus S3.3 mobile stat-grid fix are implemented. `npm run build` green. Ready to commit and deploy.
|
||
|
||
### S2.1 · B6 — Dashboard `MealCard` title: 2-line clamp instead of 1-line truncate
|
||
- **File:** `frontend/src/pages/Dashboard.tsx:87`
|
||
- **Change:** Replaced `truncate` with `line-clamp-2` (already used elsewhere in the codebase — Tailwind 3.4+ has it in core). Image shrinks to 40×40 on `<md` (was 56×56 always) to give the title more room. Added `leading-tight` to tighten line-height for 2 lines.
|
||
- **Verify:** Trigger Generate on the dashboard. New card title is fully visible across 2 lines (no `B..` truncation). Build clean.
|
||
|
||
### S2.2 · B7 — MealDetail hero overlap + description clamp + marketing-copy strip
|
||
- **Files:** `frontend/src/pages/MealDetail.tsx:168-197`, `frontend/src/lib/utils.ts`
|
||
- **Change (3 sub-steps, one commit):**
|
||
1. Hero reworked: image is in normal flow, content panel uses `relative -mt-16 sm:-mt-20` instead of `absolute bottom-0`. Title is in normal flow with the description below it; the gradient now has `pointer-events-none` and goes from `from-black/80` to prevent overlap obscuring.
|
||
2. Description rendered via `cleanDescription(recipe.description)` with `line-clamp-2`.
|
||
3. Client-side trim helper `cleanDescription(input, maxLen=280)` in `lib/utils.ts` with a list of regex patterns that strip spoonacular marketing boilerplate (`Featured In Group…`, `users who liked this recipe also liked…`, `For $X.XX per serving, this recipe covers…`, `It is brought to you by Foodista.`, etc.) and trims to the last sentence within 280 chars.
|
||
4. Raw `recipe.description` moved to a "Notes from source" disclosure below Instructions (using a state toggle in the page component).
|
||
- **Verify:** Reload `/meals/<id>`. Title readable, description is 1-2 lines, "Featured In Group…" gone, full text in disclosure. Build clean.
|
||
|
||
### S2.3 · B8 — Pantry aisle: free-text → canonical select
|
||
- **Files:** `frontend/src/pages/Pantry.tsx`, `frontend/src/types/index.ts`, **backend migration**
|
||
- **Frontend change:** Replaced aisle `<Input>` with `<Select>` populated from `PANTRY_AISLES` in `types/index.ts`:
|
||
```ts
|
||
export const PANTRY_AISLES = [
|
||
'Produce', 'Meat & Seafood', 'Dairy & Eggs', 'Pantry',
|
||
'Frozen', 'Bakery', 'Beverages', 'Spices', 'Other',
|
||
] as const;
|
||
export type PantryAisle = (typeof PANTRY_AISLES)[number];
|
||
```
|
||
Also converted Unit to a `<Select>` with the canonical unit list. Added `*` to "Ingredient name" label as a required-field marker.
|
||
- **Backend migration (`backend/alembic/versions/0015_normalize_pantry_aisles.py`):**
|
||
- Revises `0014`. Runs in a single upgrade step.
|
||
- Creates a `TEMP` backup table for each of `ingredient.aisle` and `grocery_item.aisle` (so a DBA can recover via `SELECT * FROM pg_temp.ingredient_aisle_backup` if needed).
|
||
- `UPDATE`s both columns via a generated `CASE LOWER(COALESCE(aisle,'')) WHEN ... END` mapping. Mapped variants: `canned goods`/`canned` → `Pantry`, `freezer`/`frozen` → `Frozen`, `dairy`/`eggs`/`cheese`/`milk`/`yogurt` → `Dairy & Eggs`, `meat`/`seafood`/`fish`/`chicken`/`beef`/`pork`/`meat_seafood` → `Meat & Seafood`, `bakery`/`bread` → `Bakery`, `beverage`/`beverages`/`drinks` → `Beverages`, `spice`/`spices`/`seasoning` → `Spices`, `pantry`/`dry`/`snack`/`snacks` → `Pantry`, anything else → `Other`. NULL stays NULL.
|
||
- **Downgrade:** raises `NotImplementedError` — operator must restore from a pre-migration snapshot. Documented in migration docstring.
|
||
- **Dry-run SQL helper (`backend/scripts/dry_run_aisle_migration.sql`):** standalone SQL that counts rows that *would* change per table, no writes. Run via `docker compose exec -T db psql -U mealplanner -d mealplanner -f /dev/stdin < backend/scripts/dry_run_aisle_migration.sql` (no host psql needed; the db runs in a container).
|
||
- **Persistent backup helper (`backend/scripts/persist_aisle_backup.sql`):** creates `public.ingredient_aisle_backup_0015` and `public.grocery_item_aisle_backup_0015` permanent tables. Run BEFORE the migration if you want a recoverable record beyond the migration's session.
|
||
- **Verify (on dev DB):**
|
||
```bash
|
||
# Persistent backup (optional, recommended)
|
||
docker compose exec -T db psql -U mealplanner -d mealplanner \
|
||
-f /dev/stdin < backend/scripts/persist_aisle_backup.sql
|
||
# Dry-run
|
||
docker compose exec -T db psql -U mealplanner -d mealplanner \
|
||
-f /dev/stdin < backend/scripts/dry_run_aisle_migration.sql
|
||
# Apply
|
||
docker compose exec backend alembic upgrade head
|
||
```
|
||
Add a new item with aisle "pantry" → stored as `Pantry`. Open Pantry list → all rows show sentence-case canonical labels. Frontend `npm run build` clean.
|
||
|
||
### S2.4 · B9 — ShoppingList aisle labels: human-readable map
|
||
- **File:** `frontend/src/pages/ShoppingList.tsx`
|
||
- **Change:** Added `AISLE_LABEL` map covering all backend aisle keys (snake_case and singular variants) at the top of the file, plus a tiny `aisleDisplay(key)` helper. Section header is now `<h3>{aisleDisplay(aisle)}</h3>` — unknown keys fall back to the raw key (no silent data loss). Also collapsed the S3.3 mobile stat-card grid into this edit since the file was already open.
|
||
- **Verify:** Reload `/shopping-list`. Section headers read `Meat & Seafood`, `Produce`, `Pantry`, `Dairy & Eggs` — no `meat_seafood` literal. Build clean.
|
||
|
||
### S2.5 · B10 — Mobile pantry table: scroll hint
|
||
- **File:** `frontend/src/pages/Pantry.tsx:236`
|
||
- **Change:** Wrapped `overflow-x-auto` in a `relative` container. Added `role="region" aria-label="Pantry items, scroll horizontally to see all columns"`. Right-edge gradient overlay (`pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-white to-transparent md:hidden`, `aria-hidden`) hints at overflow on mobile only.
|
||
- **Verify:** Screenshot at 390 px. The Expires + Actions columns are reachable via swipe, and a subtle right-edge fade hints at overflow. Build clean.
|
||
|
||
### S2.6 · B11 — Recipes filters: Apply / Reset / active count
|
||
- **File:** `frontend/src/pages/Recipes.tsx`
|
||
- **Change:**
|
||
1. Lifted filter state into a single `applied` object (query-bound) and a `pending` object (form-bound). Form fields mutate `pending`; the query uses `applied`.
|
||
2. Added `activeCount = Object.values(applied).filter(Boolean).length`.
|
||
3. The Filters button now shows `{activeCount > 0 && <Badge>{activeCount}</Badge>}` plus `aria-expanded={showFilters}`.
|
||
4. Added a Reset and "Apply filters" button at the bottom of the filter panel, separated by a top border. Apply commits `pending → applied`; Reset clears both.
|
||
5. The filter panel is now wrapped in a `<div role="region" aria-label="Filters">` (Card doesn't forward extra HTML attrs).
|
||
- **Verify:** Open `/recipes`, apply 2 filters, collapse panel → button shows `Filters (2)`. Click Reset → all cleared, badge gone. Build clean.
|
||
|
||
### S2.7 · Sprint 2 verification gate
|
||
- `npm run lint && npm run build` pass.
|
||
- Backend migration run on dev DB; row counts logged to `Review/sprint2-migration-log.md`.
|
||
- Re-screenshot pantry, shopping list, recipes, meal detail, dashboard (new meal card width).
|
||
- **Done when:** All six P1s visually absent in the new screenshots, no regression in Sprint 1 fixes.
|
||
|
||
---
|
||
|
||
## Sprint 3 — Polish (P2s + a11y)
|
||
|
||
**Goal:** A daily-driver app — no jarring native dialogs, no mobile wrap, no 44 px-target misses, no silent crashes on bad routes.
|
||
|
||
**Status (2026-06-03):** ✅ All P2s and a11y sweep items implemented. `npm run build` green. Ready to commit and deploy.
|
||
|
||
### S3.1 · B12 — Undo-toast replaces `confirm()` for delete
|
||
- **Files:** `frontend/src/pages/Dashboard.tsx`, `Pantry.tsx`, `lib/toast.tsx`
|
||
- **Change (committed, ready for deploy):**
|
||
1. **`lib/toast.tsx`** — renamed from `.ts` (needed for JSX). New `showToast.undo(message, onUndo, ms=5000)` helper renders a custom toast with an inline "Undo" button that fires `onUndo` and dismisses the toast. Note: `react-hot-toast` 2.6's `ToastOptions` doesn't expose `onClose`/`onDismiss`, so the helper does NOT run a callback on expiry — the Undo button is the only path to recovery. This is honest UX, equivalent in spirit to a `confirm()` declined.
|
||
2. **Dashboard** `handleDelete(itemId)` captures the full `MealPlanItem` (day_of_week, meal_type, recipe_id) *before* the DELETE, then `showToast.undo` whose Undo handler re-fires `meals.generateItem(planId, dayOfWeek, mealType)` to refill the slot with a (possibly different) recipe. The plan R4 caveat applies: the exact recipe isn't restored, but the slot is filled.
|
||
3. **Pantry** `handleRemove(item)` is fully reversible. Undo calls `pantry.add({ingredient_id, quantity, unit})` with the original values. Per-row loading state via new `removeId` state so only the clicked row's button shows the spinner.
|
||
4. `confirm()` deleted in both call sites.
|
||
- **Verify:** Delete a meal → toast appears "Meal deleted" with Undo. Click Undo within 5s → slot refills. Same flow on Pantry: click Remove, click Undo → item returns. Build clean.
|
||
- **Verify:** Delete a meal → toast appears "Meal removed" with Undo. Click Undo within 5s → meal re-appears. Build clean.
|
||
|
||
### S3.2 · B13 — Mobile nav: `whitespace-nowrap` on link text
|
||
- **File:** `frontend/src/App.tsx:30-33`
|
||
- **Change (committed):** Added `whitespace-nowrap` to the `linkClass` helper return string; reduced `px-3` to `px-2 sm:px-3`. All 4 links fit on one line down to 360 px.
|
||
- **Verify:** Screenshot at 360 px. All 4 links on one line. Build clean.
|
||
|
||
### S3.3 · B14 — Mobile shopping-list stat cards: 3-col compact
|
||
- **File:** `frontend/src/pages/ShoppingList.tsx`
|
||
- **Change (committed in Sprint 2):** Replaced the 3 stacked full-width tiles with `grid grid-cols-3 gap-2 sm:gap-4`. CardBody padding reduces to `p-3 sm:p-6`; descriptive label collapses to "Estimated / Items / On Sale" on mobile. The 3 stats now sit in one row even on a 360 px viewport.
|
||
- **Verify:** Mobile screenshot shows the 3 stats in one row, much less vertical scroll. Build clean.
|
||
|
||
### S3.4 · ErrorBoundary is already present (verified)
|
||
- No code change. Verified `components/ErrorBoundary.tsx` is mounted in `App.tsx:42`. Audit item B-H9 (B = background, the B-prefixed list) closed without a code change.
|
||
|
||
### S3.5 · A11y sweep (4 small fixes, one commit)
|
||
- **Files:** `frontend/src/App.tsx`, `frontend/src/pages/Recipes.tsx` (already done in Sprint 2), `frontend/src/pages/Dashboard.tsx`, `frontend/src/components/ui/Badge.tsx`
|
||
- **Change (committed):**
|
||
1. `App.tsx` `Navigation`: added `aria-current={isActive(prefix) ? 'page' : undefined}` on each `<Link>`; added `<nav aria-label="Primary">` and `<main id="main-content">` for skip-link targets.
|
||
2. Recipes filter panel `<div role="region" aria-label="Filters">` — done in Sprint 2.
|
||
3. Empty Generate slots: `min-h-11` (44 px) — done in Sprint 1.
|
||
4. `Badge` component: added optional `icon: ReactNode` and `aria-label: string` props.
|
||
5. `Dashboard` approval-status Badge now passes `aria-label="Approval status: approved"` (or the current value) so screen readers announce it explicitly.
|
||
- **Verify:** Tab through the nav: active link has `aria-current="page"`. Inspect Recipes filter panel: `role="region" aria-label="Filters"`. Measure empty-slot buttons at 390 px width = ≥ 44 px tall. Approval status reads aloud as "Approval status: approved" on the meal card.
|
||
|
||
### S3.6 · Sprint 3 verification gate
|
||
- `npm run lint && npm run build` pass.
|
||
- Final playwright walkthrough. All 14 audit findings closed in screenshots.
|
||
- Update `Review/ui-nielsen-audit.md` to mark each fix with a `[x]` and commit hash reference.
|
||
|
||
---
|
||
|
||
## Future (NOT in this plan — capture as follow-up tickets)
|
||
- F1. Onboarding hints (H10) — needs `react-joyride` or hand-rolled `<Tour>` component.
|
||
- F2. Keyboard shortcuts (`/`, `g p`, `g s`, `n m`).
|
||
- F3. Bulk add on Pantry/Shopping List (H7).
|
||
- F4. Plan-the-whole-week button (H7).
|
||
- F5. Persistent week selector in URL.
|
||
- F6. `aria-label` on color-only status badges (generalized).
|
||
- F7. Global `react-query` `onError` toast handler.
|
||
|
||
---
|
||
|
||
## Sprint 6 — Bulk actions (F3 + F4)
|
||
|
||
**Status (2026-06-04):** ✅ Both items implemented. One commit: `8ad4ef6`. `npm run build` green; both new backend endpoints smoke-tested locally with curl. **Scope decision:** F3 = ShoppingList only (the checked Set is the natural substrate). F4 = `Plan the week` button with `Dinners only` / `All meals` dropdown (per design-call), partial-success with detailed report (per design-call).
|
||
|
||
### S6.1 · F3 — Bulk 'add checked to pantry' on ShoppingList
|
||
- **Files:** `backend/app/api/pantry.py`, `backend/app/schemas/__init__.py`, `frontend/src/api/index.ts`, `frontend/src/pages/ShoppingList.tsx`
|
||
- **Change (one commit `8ad4ef6`):**
|
||
1. **Backend `POST /api/pantry/bulk`:** new endpoint accepting `{items: HomePantryCreate[]}`. Each item follows the same upsert semantics as the single-item `POST /api/pantry` (insert or overwrite qty/unit/expires_at). Per-item status is reported as `added` / `updated` / `skipped` with a human-readable reason for skips. Total counts and per-item details both returned (`HomePantryBulkResult` schema).
|
||
2. **Frontend `mealPlannerApi.pantry.addBulk(items)`** is the API binding.
|
||
3. **ShoppingList:** a new primary `Add N to pantry` button appears next to the existing Reset button when `checked.size > 0`. Click → POST → toast shows `'Pantry: added X, updated Y, skipped Z'`. On success, the items that actually landed are removed from the checked Set; skipped items stay checked so the user can see what failed. Button shows `Adding…` while in flight; disabled during the request.
|
||
- **Verify:** checked items get bulk-added; partial successes surface in the toast; the Pantry list reflects the new entries after a refresh.
|
||
|
||
### S6.2 · F4 — Plan the whole week (Dashboard button)
|
||
- **Files:** `backend/app/api/meals.py`, `backend/app/schemas/__init__.py`, `frontend/src/api/index.ts`, `frontend/src/pages/Dashboard.tsx`
|
||
- **Change (one commit `8ad4ef6`):**
|
||
1. **Backend `POST /api/meals/{id}/fill-empty-slots`:** new endpoint with body `{meal_types: [str, ...]}` (subset of `["breakfast","lunch","dinner"]`). Iterates day 1..7 in order; for each day, iterates the requested meal_types; skips already-occupied slots; picks a recipe (prefer un-used, fall back to any) and inserts as `pending`. Per-slot failure model — never aborts mid-batch — returns `FillEmptySlotsResult { filled: [{day, meal_type, item}], failed: [{day, meal_type, reason}] }`. Invalid `meal_type` (e.g. `'brunch'`) returns immediately with a single FailedSlot explaining why.
|
||
2. **Frontend `mealPlannerApi.meals.fillEmptySlots(planId, mealTypes)`** is the API binding.
|
||
3. **Dashboard:** new `Plan the week` button in the header (next to the Sprint 5 week-nav control). Primary color, Sparkles icon, ChevronDown caret indicates a dropdown. Two options: `Dinners only` (sends `meal_types=['dinner']`) and `All meals` (sends `meal_types=['breakfast','lunch','dinner']`). Each option has a one-line secondary label.
|
||
4. **Toast reports partial-success precisely:** `Planned 12 of 21 meal slots — 9 failed (e.g. No recipes available)` or `Planned 15 meal slots` (full success). Query invalidated so new slots show up immediately.
|
||
- **Verify:** button fills the empty slots; partial-success toast shows the right counts; query refresh shows the new meals.
|
||
- **Out of scope (documented in `Review/handoff-ui-audit.md`):** the no-op `Generate Meal Plan` empty-state CTA at `Dashboard.tsx:415` (when the family has NO plan at all, distinct from the F4 case of "plan exists but slots are empty"). Routing that CTA needs a user-facing "create a new plan" path, which is a different feature (orchestrator/admin flow).
|
||
|
||
### S6.3 · Sprint 6 verification gate
|
||
- [x] `npm run build` green.
|
||
- [x] Backend smoke on local dev DB: `/api/pantry/bulk` (skipped count for unknown ingredient), `/api/meals/{id}/fill-empty-slots` (dinners-only partial-success).
|
||
- [ ] Deploy verified (git pull + container rebuild; backend + frontend per `Review/sprint6-verification.md`).
|
||
- [ ] No regression in Sprints 1-5.
|
||
|
||
---
|
||
|
||
**Status (2026-06-04):** ✅ Both items implemented. Two commits: `d78bd18` (F5 + 0015 cast fix) and `f740f40` (F2). `npm run build` green; backend smoke-tested locally with `alembic upgrade head` + `curl` confirming the new `?week_start=` param works. **Includes a critical bug fix to migration 0015 (Sprint 2) that was blocking Sprint 2's deploy too** — see S5.0.
|
||
|
||
### S5.0 · Critical fix — migration 0015 cast bug
|
||
- **File:** `backend/alembic/versions/0015_normalize_pantry_aisles.py`
|
||
- **Bug:** The CASE expression failed with `operator does not exist: text = boolean` on the `varchar(100) aisle` column. Root cause: CASE branches were inferred as different types (string vs NULL) so PostgreSQL could not unify the SET target type. The Sprint 2 dry-run (`dry_run_aisle_migration.sql`) used a different query path that happened to work, so the bug was not caught during Sprint 2.
|
||
- **Impact:** The deployment host's `alembic upgrade head` would have hit the same error and **Sprint 2 was effectively undeployable**. This blocks Sprints 2, 3, 4 from going live.
|
||
- **Fix:** Explicit `::varchar(100)` cast on the whole CASE expression; simplified the `WHEN '' THEN NULL` branch (was `NULLIF(...) IS NULL` with implicit boolean comparison). Tested on local dev DB: migration now succeeds; the 21,196 rows the Sprint 2 dry-run predicted actually normalize correctly. The deployment-host DB will follow the same path after this commit ships.
|
||
- **Why now:** Discovered when smoke-testing Sprint 5 F5 against the local backend. The local DB was 3 migrations behind (the pre-existing WIP), so running `alembic upgrade head` reproduced the error. Fixed and re-ran successfully.
|
||
- **Risks remaining:** The 21k-row update on the deployment host will lock the `ingredient` and `grocery_item` tables for the duration of the migration (a few seconds in dev; could be longer in prod). The `persist_aisle_backup.sql` script should still be run before `alembic upgrade head` for a recoverable record.
|
||
|
||
### S5.1 · F5 — Persistent week selector in URL
|
||
- **Files:** `backend/app/api/meals.py`, `backend/app/api/shopping_list.py`, `frontend/src/api/index.ts`, `frontend/src/lib/utils.ts`, `frontend/src/pages/Dashboard.tsx`, `frontend/src/pages/ShoppingList.tsx`
|
||
- **Change (one commit `d78bd18`):**
|
||
1. **Backend:** both `GET /api/meals` and `GET /api/shopping-list` now accept `?week_start=YYYY-MM-DD` (FastAPI `Optional[date] Query`). When set, the response is the MealPlan for that week (any status). When omitted, behaviour is unchanged.
|
||
2. **Frontend helpers:** `lib/utils.ts` gains `isoMonday()`, `parseIsoDate()`, `shiftIsoDate()`, `formatIsoDate()`. All UTC-based to match the backend date column.
|
||
3. **API layer:** `meals.getPlanned(weekStart?)` and `shoppingList.get(weekStart?)` take an optional ISO date string. Axios drops `undefined` params so callers can omit them.
|
||
4. **Dashboard:** `useSearchParams('week')` reads the URL; if absent or invalid, falls back to `isoMonday()` (so the default URL is empty). `queryKey: ['mealPlan', weekStart]`. A new segmented control in the header (chevron-left | 'This week'/'Current' jump button | chevron-right) lets the user step weeks. The jump button highlights primary-50 when the displayed week IS the current week; clicking it on the current week clears the `?week` param. All 5 mutations (move/approve/deny/delete/generate) invalidate `['mealPlan', weekStart]` so the right week refetches.
|
||
5. **ShoppingList:** same URL sync, same segmented control, same weekStart in queryKey. The 'no plan' empty state branches on `isCurrentWeek`: 'No shopping list yet' (current) vs 'No plan for that week' (any other week). The local-storage check-state key naturally isolates per week (it uses `shoppingList.week_start_date` which is the server's view of the plan's week).
|
||
- **Verify:** local backend smoke confirms `/api/shopping-list?week_start=2026-05-15` returns the 25-item plan for that week with aisles normalised to `Meat & Seafood`/`Pantry`/`Produce`/`Dairy & Eggs`. Migration 0015 cast fix verified end-to-end.
|
||
- **Deploy:** requires the backend rebuild + migration. Frontend changes are part of the same `git pull` + `docker compose up -d --build backend frontend` sequence.
|
||
|
||
### S5.2 · F2 — Global keyboard shortcuts
|
||
- **Files:** `frontend/src/hooks/useKeyboardShortcuts.ts` (new), `frontend/src/hooks/useFocusSearch.ts` (new), `frontend/src/components/ShortcutHelpBanner.tsx` (new), `frontend/src/App.tsx`, `frontend/src/pages/Pantry.tsx`, `frontend/src/pages/Recipes.tsx`
|
||
- **Change (one commit `f740f40`):**
|
||
1. **Hook** `useKeyboardShortcuts(map)`: lightweight global handler. Supports single keys (`/`, `?`, `Escape`) and vim-style 2-key sequences (`g d`, `g r`, `g p`, `g s`). 1500ms sequence timeout; pending prefix clears on any unrecognised key. Suppressed in inputs/textareas/selects/contenteditable, and on any modifier-key chord. Listener registered once via a ref.
|
||
2. **Hook** `useFocusSearchOnShortcut(ref)`: tiny CustomEvent bus. The global handler dispatches `mealplanner:focus-search` when the user presses `/`; pages that have a search input subscribe and focus + select.
|
||
3. **Component** `ShortcutHelpBanner`: dismissible help dialog (slide-down under nav) shown when `?` is pressed. Auto-dismisses after 6s; Escape dismisses; `role=dialog` + `aria-label` for screen readers.
|
||
4. **App.tsx:** new `GlobalShortcuts` child of `BrowserRouter` wires the 4 nav sequences, `/` → focus, `?` → help.
|
||
5. **Pantry + Recipes:** search inputs gain a `ref` and `useFocusSearchOnShortcut(ref)`. Pressing `/` on either page focuses + selects the search text.
|
||
- **Behaviour summary:** `g d` / `g r` / `g p` / `g s` → navigate to the 4 main pages. `/` → focus search (Pantry + Recipes only). `?` → help. Shortcuts are no-ops in text-entry controls.
|
||
- **Verify:** build green. Live test: open the app, press `?` to see the help banner, press `g p` to jump to Pantry, press `/` to focus the search box. Verify the same on Recipes. Verify `g` alone in a search input does NOT navigate.
|
||
- **Deploy:** frontend-only.
|
||
|
||
### S5.3 · Sprint 5 verification gate
|
||
- [x] `npm run build` green for Sprint 5.
|
||
- [x] Backend smoke on local dev DB: migration 0015 succeeds; `?week_start=` returns the right plan; new `?week_start=2099-01-01` returns null/empty as expected.
|
||
- [ ] Deploy verified (git pull, alembic upgrade head, docker compose up -d --build backend frontend, smoke checks per `Review/sprint5-verification.md`).
|
||
- [ ] No regression in Sprints 1-4.
|
||
|
||
---
|
||
|
||
**Status (2026-06-03):** ✅ Both items implemented and committed (`d71b67a`). `npm run build` green. Awaiting deploy.
|
||
|
||
### S4.1 · F7 — Global react-query error toast handler
|
||
- **Files:** `frontend/src/lib/toast.tsx`, `frontend/src/App.tsx`, `frontend/src/pages/Dashboard.tsx`, `Pantry.tsx`, `MealDetail.tsx`
|
||
- **Change (one commit `d71b67a`):**
|
||
1. **`lib/toast.tsx`** — added `extractErrorMessage(err, fallback)` and `showApiError(err, fallback)`. The normalizer reads `err.response.data.detail` when present (handles both `string` and Pydantic 422 `[{loc, msg, type}, ...]` array shapes), then falls back to `err.message`, then the supplied default. Never surfaces `"[object Object]"` or raw stack traces.
|
||
2. **`App.tsx`** — `QueryClient` now created with `QueryCache({ onError })` and `MutationCache({ onError })` wired to `showApiError`. Added `defaultOptions.queries: { retry: 1, refetchOnWindowFocus: false }` so background-refetch failures (H9) are no longer silent.
|
||
3. **`Dashboard.tsx`** — removed 6 local try/catch toasts (move / approve / deny / delete / generate + the outer delete handler). Kept `VoteEmailButton.handleSend` and `handleDelete`'s undo-callback with `showApiError(err, 'Failed to ...')` for action-specific fallback strings (these are user-initiated recovery paths where a contextual default is more useful than the bare FastAPI detail).
|
||
4. **`Pantry.tsx`** — removed 3 local `onError` handlers (`addMutation`, `removeMutation`, `handleAdd`'s createIngredient path) and `handleRemove`'s outer catch. Kept 3 pre-flight client-side checks that never reach the network (missing ingredient link, empty name, unresolved ingredient). `handleRemove`'s undo callback now uses `showApiError` for the restore failure.
|
||
5. **`MealDetail.tsx`** — removed `submitMutation.onError`. The local `"Failed to save feedback. Please try again."` is replaced by the actual FastAPI detail.
|
||
- **Net effect:** 10 backend-error try/catch blocks deleted; error messages are now identical to what the backend actually says; any future mutation that forgets to add a local `onError` still gets surfaced.
|
||
- **Backend audit (read-only):** Every `HTTPException(detail=...)` in the touched routes is human-friendly (e.g. `"Meal plan item not found"`, `"Slot already occupied"`, `"Family profile not found"`, `"ingredient name already exists"`). Pydantic 422s return arrays and the helper handles them. No detail message is technical/leaks internals.
|
||
- **Verify:** `npm run build` green. Live smoke: pull `100.108.208.56` and try each of the 7 Dashboard mutations + the 4 Pantry/MealDetail mutations with the backend down or returning 4xx — every failure should show a toast with the FastAPI `detail` string, not the legacy `"Failed to ..."` default.
|
||
- **Risk:** `sendVoteEmails` is fire-and-forget (`POST /orchestrate/email` returns 202 + `BackgroundTasks`; errors land in `WeeklyRun.error_message` not the HTTP response). The toast for that action will only ever show the success message or a network error. Keep the local fallback string for that one — it documents the intent.
|
||
|
||
### S4.2 · F6 — Plan-status Badge: `aria-label`
|
||
- **File:** `frontend/src/pages/Dashboard.tsx:438`
|
||
- **Change:** Added `aria-label={\`Plan status: ${mealPlan.status.replace(/_/g, ' ')}\`}` to the `<Badge>` that shows the meal-plan status (draft / awaiting_approval / approved / rejected). Matches the per-item approval-status pattern added in Sprint 3 (S3.5). A screen reader now announces `"Plan status: awaiting approval"` instead of just the colour-encoded `"awaiting approval"` text.
|
||
- **Other `<Badge>` audit:** the only other call site with colour-encoded semantics is the per-item approval status (already handled in Sprint 3) and the `"Never suggest this recipe again"` badge on MealDetail (its visible text fully describes intent, so the colour is decorative). The "Spice N/5" warning badge on RecipeDetail is also self-describing. **No further aria-label work needed.**
|
||
- **Verify:** VoiceOver/NVDA on the Dashboard header — the plan status badge announces with the category prefix.
|
||
|
||
### S4.3 · Sprint 4 verification gate
|
||
- [x] `npm run build` green for Sprint 4 (tsc 0 errors, vite 0 errors).
|
||
- [ ] Deploy verified (git pull on `100.108.224.12`, `docker compose up -d --build frontend` — no backend changes).
|
||
- [ ] Smoke pass: 11 mutation failures show FastAPI `detail` (not legacy fallback); plan-status Badge announces correctly.
|
||
- [ ] No regression in Sprint 1–3 fixes.
|
||
|
||
---
|
||
|
||
## 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.
|