Files
Meal-Planner/fix-ui-audit.md
T
MealPlanner 09c7525a12 fix(ui): align 'this week' to upcoming Monday (Sprint 7)
User report 2026-06-05: 'webui Meal Planner page is empty' on Friday
morning after the Friday email went out. Root cause: the orchestrator
keyed plans by the most-recent-Friday while the frontend's isoMonday()
returned the most-recent-Monday — a 7-day mismatch on Fridays.

Fixes (one semantic across the stack):
- runner._current_week_start() returns the upcoming Monday (today if
  Mon, else the next Mon). The Friday email subject
  ('Meal plan for week of <date>') automatically picks up the new
  value via run.week_start_date.
- frontend isoMonday -> upcomingMonday (same logic; renamed for
  intent). isoMonday kept as a deprecated alias.
- New WeekRangeNav component (Dashboard + ShoppingList share it).
  Renders [<]  Jun 8 - Jun 14  [>] with clickable chevrons and a
  clickable range label that jumps to the upcoming week. Replaces
  the Sprint 5 inline segmented control on both pages.
- New formatWeekRange(mondayIso) helper (UTC-stable; uses
  timeZone: 'UTC' so the rendered date matches the stored ISO date
  regardless of viewer TZ; closes a latent bug in formatIsoDate too).
- New SQL fix script that retargets the user's 3-pending-items plan
  from 2026-06-05 (Friday-keyed) to 2026-06-08 (upcoming Monday).
  Idempotent + transaction-wrapped. Optional block for 2026-05-29.

No backend migration. No new dependencies. Deploy is git pull +
run the SQL fix + docker compose up -d --build backend frontend.
See Review/sprint7-verification.md for the full deploy + smoke flow.

Files:
- backend/app/services/orchestrator/runner.py:20-35
- backend/scripts/fix_2026_06_05_to_2026_06_08.sql (new)
- frontend/src/lib/utils.ts:43-130
- frontend/src/components/WeekRangeNav.tsx (new)
- frontend/src/pages/Dashboard.tsx (3 call sites + 1 segmented control)
- frontend/src/pages/ShoppingList.tsx (5 call sites + 2 segmented controls)
- Review/{sprint7-verification,ui-nielsen-audit,handoff-ui-audit}.md
- fix-ui-audit.md
- docs/HANDOFF.md
- .agent/{plan,context}.md
2026-06-05 07:46:55 -07:00

40 KiB
Raw Blame History

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
    {ing.qty != null && `${ing.qty} ${ing.unit || ''} `.trim()}
    
    with
    {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:
    {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.
  • 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:
    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).
    • UPDATEs both columns via a generated CASE LOWER(COALESCE(aisle,'')) WHEN ... END mapping. Mapped variants: canned goods/cannedPantry, freezer/frozenFrozen, dairy/eggs/cheese/milk/yogurtDairy & Eggs, meat/seafood/fish/chicken/beef/pork/meat_seafoodMeat & Seafood, bakery/breadBakery, beverage/beverages/drinksBeverages, spice/spices/seasoningSpices, pantry/dry/snack/snacksPantry, 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):
    # 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.
  • 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

  • npm run build green.
  • 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

  • npm run build green for Sprint 5.
  • 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.tsxQueryClient 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 thethat 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

  • 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 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 — isoMondayupcomingMonday + 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:
    -- 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)

  • Sprint 1: 5 P0 fixes — committed f3e4a44, deployed by user 2026-06-02.
  • Sprint 2: 6 P1 fixes + 1 bonus S3.3 — committed ccc70aa, deploy helper f5fb755. Awaiting deploy.
  • Sprint 3: 3 P2 fixes + a11y sweep — committed e90a9d6, awaiting deploy.
  • 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.
  • 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.
  • 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 (in progress): webui "empty meal plan" date-semantics mismatch. Code + SQL fix + verification doc. S7.1-S7.6 boxes in the section above.