Files
Meal-Planner/Review/ui-nielsen-audit.md
T
admin 25c1fe0890 docs: Sprint 15 round 3 — +10 recipes, library at 77 total
Sprint 15 round 3 (no new code; reused scripts/seed_recipes.py
from round 1, idempotent) added 10 more Spoonacular recipes to
the local library. 37 duplicates were skipped. Imports: 2
Asian leftovers (pho, kung pao) + 8 American comfort dishes
(chili, meatloaf, mac and cheese, BBQ chicken, pot roast,
shepherd pie, chicken pot pie, beef stew).

DB went 67 -> 77 total recipes (47 Spoonacular + 30 manual).
LLM test (Sprint 13, week 2026-08-03, prompt "comfort food,
no repeats from past 2 weeks"):
  {picked_count: 0, filled_count: 21, failed_count: 0}
21/21 slots filled, 0 failed.

All 6 running docs updated: plan.md (S15R3.1-S15R3.2),
context.md (D11-D13), sprint15-verification.md (round 3
section + 3-round summary table), ui-nielsen-audit.md
(round 3 paragraph), fix-ui-audit.md (T8.7), handoff-ui-
audit.md (TL;DR + Sprint 15 section), HANDOFF.md (round 3
paragraph + Last-updated footer).

Cumulative Sprint 15 work: 46 new Spoonacular recipes across
3 rounds. Library at 77 total — well past the 4-week coverage
threshold.
2026-06-07 21:25:52 -07:00

44 KiB
Raw Blame History

UI/UX Audit — Nielsen's 10 Heuristics

Scope: Live deployment at http://100.108.208.56:8082/, React frontend at frontend/src/, complementing the existing docs/repo reviews in this folder. Method: Playwright (system Chromium) navigated 20 routes/viewports; findings triangulated against source code with file:line references. Screenshots: /tmp/opencode/mp-review/screenshots/ (20 PNGs referenced inline). Severity scale: P0 (blocker) — broken core flow · P1 (major) — wrong or misleading · P2 (minor) — polish/aa.


Executive summary

The app looks polished on the surface (Tailwind palette, clean cards, working toasts, working focus rings), but a live walkthrough surfaces multiple silent failures and three outright broken data-rendering bugs. The most damaging issues are not visual — they are unmistakable data inconsistencies the user is expected to read and act on ($N/A per serving, blank lb Pork Chops rows, hidden empty meal slots on mobile, snake_case aisle labels). They erode trust faster than a missing button.

Top 5 to fix first (P0):

  1. Meal detail ingredients render without quantities (field-name bug, MealDetail.tsx:249-250) — a core function of the page is unreadable.
  2. $N/A per serving displayed literally (MealDetail.tsx:191).
  3. Recipe detail ingredients collapse unit and name (RecipeDetail.tsx:1612 canBlack Beans).
  4. /recommended returns a blank page (missing route + no 404 catch-all in App.tsx).
  5. Mobile dashboard hides empty meal slots (Dashboard.tsx:164,219 — users on phones cannot plan meals, only view them).

Sprint 1 status (commit f3e4a44, deployed by user 2026-06-02): Items 1, 2, 3, 4, 5 all addressed in the frontend source. Live at 100.108.208.56:8082/. Verification screenshots in /tmp/opencode/mp-review/screenshots/fix-sprint1/.

Sprint 2 status (commit ccc70aa, deploy helper f5fb755): All six P1s plus the S3.3 mobile shopping-list stat-grid fix are addressed in source.

  • B6 Dashboard MealCard title: truncateline-clamp-2; image shrinks to 40×40 on <md to give title more room.
  • B7 MealDetail hero: title/description no longer overlap; description stripped of spoonacular SEO copy via lib/utils.cleanDescription; raw text moved to a "Notes from source" disclosure.
  • B8 Pantry aisle/unit: free-text → canonical Select from PANTRY_AISLES enum (types/index.ts). Ingredient name field now marked * required. Backend migration 0015_normalize_pantry_aisles.py normalizes ingredient.aisle and grocery_item.aisle to canonical labels. Dry-run SQL helper at backend/scripts/dry_run_aisle_migration.sql.
  • B9 ShoppingList aisle section headers now human-readable via AISLE_LABEL map; falls back to raw key for unknown values.
  • B10 Mobile pantry table: right-edge white-to-transparent gradient overlay hints at horizontal overflow; container has role="region" + descriptive aria-label.
  • B11 Recipes filters: refactored to pending/applied state with explicit Apply / Reset buttons. Filters button shows active-count chip when filters are set. Wrapped in role="region" aria-label="Filters".
  • S3.3 Shopping list stat cards: now grid-cols-3 on all viewports with compact mobile sizing.

Deployment commands (run on the deployment host — DB is in a container, no host psql needed):

cd ~/MealPlanner
git pull

# Optional: persistent backup of aisle values BEFORE the migration
docker compose exec -T db psql -U mealplanner -d mealplanner \
  -f /dev/stdin < backend/scripts/persist_aisle_backup.sql

# Dry-run preview (no writes)
docker compose exec -T db psql -U mealplanner -d mealplanner \
  -f /dev/stdin < backend/scripts/dry_run_aisle_migration.sql

# Apply the migration
docker compose exec backend alembic upgrade head

# Rebuild & restart frontend
docker compose -f docker-compose.yml up -d --build frontend

Sprint 3 status (commit e90a9d6, awaiting deploy): All P2s plus the a11y sweep.

  • B12 Native confirm() deleted for both delete sites. lib/toast.tsx (renamed from .ts for JSX) gains a new showToast.undo(message, onUndo, ms=5000) helper. Dashboard.handleDelete captures the full item, deletes, then surfaces an Undo toast that re-fires generateItem(planId, dayOfWeek, mealType) to refill the slot. Pantry.handleRemove is fully reversible: re-adds via pantry.add with the original ingredient_id/quantity/unit. Per-row loading state via new removeId state.
  • B13 Navigation link text gets whitespace-nowrap; padding reduced to px-2 sm:px-3 so all 4 links fit on one line down to ~360 px.
  • S3.4 Confirmed ErrorBoundary is already mounted at App.tsx:42 (verified components/ErrorBoundary.tsx).
  • S3.5 A11y sweep: <nav aria-label="Primary">, aria-current="page" on the active nav link, <main id="main-content"> for skip-link targets, Badge component extended with optional icon and aria-label props. Approval-status Badge on the meal card now passes aria-label="Approval status: approved" etc.
  • S3.3 Mobile shopping-list stat cards already done in Sprint 2 (3-col grid with compact mobile sizing).

Deploy:

cd ~/MealPlanner
git pull
docker compose -f docker-compose.yml up -d --build frontend

Sprint 4 status (commit d71b67a, awaiting deploy): Two §Future items, both small, both polish.

  • F7 Global react-query error handler. lib/toast.tsx gains extractErrorMessage(err, fallback) and showApiError(err, fallback) that read FastAPI's response.data.detail (string or Pydantic 422 array) and produce a clean user-facing string. App.tsx wires QueryCache({ onError }) and MutationCache({ onError }) to showApiError, so any future mutation that forgets a local handler still surfaces its failure. 10 local try/catch toasts deleted across Dashboard.tsx, Pantry.tsx, MealDetail.tsx. Pre-flight client-side checks (empty name, missing ingredient link) deliberately kept local since they never reach the network. Default-options added: queries: { retry: 1, refetchOnWindowFocus: false } — closes the H9 "silent background refetch failure" finding.
  • F6 Plan-status Badge on the Dashboard header (draft / awaiting_approval / approved / rejected) now passes aria-label="Plan status: <text>" so screen readers announce both the category and the value. Matches the per-item approval-status pattern added in Sprint 3. No other colour-only badges exist in the app — every other <Badge> is either a count or a self-describing tag.
  • Backend changes: none. Deploy is frontend-only.
  • Verification log: Review/sprint4-verification.md.

Sprint 5 status (commits d78bd18 + f740f40, awaiting deploy): Two §Future items, one with a critical migration fix.

  • F5 URL week selector. ?week=YYYY-MM-DD (Monday's ISO date) is now the canonical way to navigate between weeks. useSearchParams reads the URL; if absent or invalid, falls back to isoMonday() (so the default URL is empty). Both Dashboard and ShoppingList get a segmented control (chevron-left | 'This week'/'Current' jump button | chevron-right) in the header. The queryKey includes weekStart so each week is independently cached; mutations invalidate the right key. Empty state branches on isCurrentWeek ('No plan for that week' vs 'No shopping list yet'). Backend GET /api/meals and GET /api/shopping-list both accept the same ?week_start= param; when omitted, the original "latest plan" behaviour is preserved.
  • F2 Keyboard shortcuts. Vim-style 2-key sequences (g d Dashboard, g r Recipes, g p Pantry, g s Shopping List) navigate between the 4 main pages. / focuses the page's search input (Pantry + Recipes subscribe via a useFocusSearchOnShortcut(ref) hook). ? shows a help banner. Suppressed inside text-entry controls and on modifier-key chords. 1.5s sequence timeout. Implementation lives in frontend/src/hooks/useKeyboardShortcuts.ts (the global handler) + frontend/src/hooks/useFocusSearch.ts (the focus bus) + frontend/src/components/ShortcutHelpBanner.tsx (the dialog).
  • CRITICAL 0015 cast fix (also in d78bd18): the CASE expression in 0015_normalize_pantry_aisles.py failed with text = boolean on the varchar(100) aisle column. Sprint 2's dry-run query used a different path so the bug was not caught during Sprint 2. The fix is an explicit ::varchar(100) cast on the whole CASE expression + simplified WHEN '' THEN NULL branch. Without this fix, the deployment host's alembic upgrade head would have failed, blocking Sprints 2, 3, 4 from going live. The local dev DB has been migrated successfully as of 2026-06-04.
  • Backend changes: meals.py and shopping_list.py (new query param) + 0015_normalize_pantry_aisles.py (cast fix).
  • Verification log: Review/sprint5-verification.md. Deploy is a single batch for Sprints 2-5: backup → migrate → rebuild backend + frontend.

Sprint 8 status (in progress, approved 2026-06-05; not yet committed): Thread 2 (cross-week "rejected" semantics) and Thread 3 (§Future backlog) — both surfaced in the user's 2026-06-05 follow-up. User policy decision (2026-06-05): "Hard filter. If it is denied this week twice, it should be considered denied for good." That collapses Sprint 8 to the C + Z model with a server-side 2-denial auto-escalation.

  • T2.1 Migration 0016_denial_decay_and_scope.py (NEW). Adds meal_plan_item.denial_expires_at TIMESTAMPTZ NULL (partial index on non-NULL) and meal_plan_vote.denial_scope VARCHAR(16) NULL. No data migration; existing rows keep denial_expires_at = NULL (the filter requires > now(), so old denied rows are effectively forgotten after 90d).
  • T2.2 Model: MealPlanItem.denial_expires_at + MealPlanVote.denial_scope.
  • T2.3 Schema: MealPlanItemResponse.denial_expires_at, VoteRequest.denial_scope, VoteResponse.denial_scope.
  • T2.4 Backend helpers in app/api/meals.py: _apply_denial (single source of truth for the deny path), _ensure_never_suggest_recipe (idempotent NeverSuggest insert), _has_prior_active_soft_denial (counting query for the 2-denial auto-escalation check). DENIAL_DECAY_DAYS = 90.
  • T2.5 POST /api/meals/items/{id}/deny?scope=this_week|never_again (default this_week). Returns {message, item, promoted_to_permanent, scope}. The auto-promotion check runs server-side.
  • T2.6 POST /api/meals/vote/{id} extended: vote: "approve" | "deny" | "never_again". Returns denial_scope + promoted_to_permanent so the email confirmation page can show what was applied.
  • T2.7 Email HTML page (/api/meals/vote/{id} GET) renders 3 buttons (Approve / Deny this week / Never again). One-click direct-vote via ?scope=... for the email's per-button links; consumes the token via submit_vote and renders a confirmation page.
  • T2.8 Email template (step_email) renders 3 direct-action links per recipe. The legacy single-link "Vote on this meal" is preserved as a secondary "Open vote page (all 3 options)" link.
  • T2.9 Planner: _load_blocklists returns 3 sets; soft_denied_recipes is hard-filtered (per user decision). rejected_summary adds a soft_denied_recipe diagnostic bucket.
  • T2.10 Webui: MealCard renders 3 buttons (Approve / Deny this week / Never again) for pending items. handleDeny is scope-aware; toast reflects the server's promoted_to_permanent flag. "Never again" is gated by a window.confirm to prevent accidental permanent blocks.
  • Verification log: Review/sprint8-verification.md.
  • No new dependencies. Migration is required (alembic upgrade head runs 0016). Deploy is git pull + migration + docker compose up -d --build backend frontend.

Sprint 7 status (commit 09c7525, awaiting deploy): Outside-the-audit hotfix driven by user report. Thread 1 of three open follow-ups from the user's 2026-06-05 message. Thread 2 (cross-week "rejected" semantics) and Thread 3 (§Future backlog) are deferred until S7 is deployed + verified.

  • T1.1 runner._current_week_start() → returns the upcoming Monday. Today (Fri 2026-06-05) the function returned Friday 2026-06-05; the user got an email for week-of-2026-06-05, but the webui opened on week-of-2026-06-01. One-line body change in backend/app/services/orchestrator/runner.py:20-24. Scheduler cron stays Friday.
  • T1.2 isoMondayupcomingMonday in frontend/src/lib/utils.ts:44-50. Same logic as T1.1; rename for intent. Add formatWeekRange(mondayIso) helper.
  • T1.3 New frontend/src/components/WeekRangeNav.tsx. Renders the user-requested [<] Jun 8 — Jun 14 [>] pattern with clickable chevrons and a clickable range label (jumps to the upcoming week). Replaces the inline Sprint 5 segmented control on Dashboard and ShoppingList. Includes a This week chip when off the upcoming week. Keyboard-accessible.
  • T1.4 SQL: backend/scripts/fix_2026_06_05_to_2026_06_08.sql — guarded UPDATE meal_plan SET week_start_date='2026-06-08' WHERE week_start_date='2026-06-05'; with a SELECT COUNT(*) first. Optionally migrates 2026-05-29 too (commented out; operator uncomments if desired). The user's 3-pending-items plan moves to the new Mon key.
  • T1.5 "This week" semantic: upcoming Mon-Sun. Past weeks accessible via the back chevron. URL persistence (F5) unchanged.
  • No backend migration, no new dependencies. Deploy is git pull + run the SQL script + docker compose up -d --build backend frontend.
  • Verification log: Review/sprint7-verification.md (to be written before deploy).

Sprint 9 status (committed, awaiting deploy): F1 Onboarding Tour (H10). The natural next phase from the §Future backlog (the only item with a clear UI scope; F8 Spoonacular + F9 Ollama are full backend proposals; the dead Generate Meal Plan CTA is a separate follow-up). User direction 2026-06-05: "Proceed with the next phase in the redesign." The Sprint 10 follow-up ("Deny Forever" on Recipes) is already drafted and awaits explicit "proceed".

  • T3.1 New frontend/src/components/OnboardingTour.tsx (~420 lines). Hand-rolled (no react-joyride; keeps npm footprint flat). 4 steps: Dashboard / Pantry / Recipes / Shopping List. Anchors to [data-tour="<id>"] attributes on existing elements. localStorage key mealplanner:onboarding-complete. ?reset-tour=1 re-triggers.
  • T3.2 Anchor points: Dashboard.tsx:602 (Weekly Overview card), Pantry.tsx:185, 208 (header + add-form card), Recipes.tsx:124 (Filters button), ShoppingList.tsx:231 (page header). 5 lines of code total.
  • T3.3 Tooltip = position: fixed <div role="dialog" aria-modal="true"> (no portal needed). rAF loop reads anchor getBoundingClientRect; cancellable on close. Focus captured on open, restored on close. Keyboard: 14 jump, ←/→ step, Esc dismiss.
  • T3.4 Off-route fallback: centered card with "Open " CTA so the tour still works for users who land on a non-root page first. Decorative scrim + anchor ring are aria-hidden="true".
  • Verification log: Review/sprint9-verification.md. Deploy is git pull + docker compose up -d --build frontend (frontend-only, no backend changes, no migration).
  • No new dependencies. No backend changes.

Sprint 10 status (committed 2026-06-05, awaiting deploy): User-driven — "Deny Forever" button on the Recipes surface (card overlay + RecipeDetail top bar). Surfaces the Sprint 13 NeverSuggest infrastructure on the webui Recipes page. Backend adds family-facing POST + DELETE /api/never-suggest endpoints; the existing admin path stays unchanged.

  • T4.1 POST /api/never-suggest (public, webui-facing). Idempotent on (family, recipe, reason). Returns the row joined with recipe_name.
  • T4.2 DELETE /api/never-suggest/{ns_id} (public, webui-facing). Row-level ownership check (403 if cross-family).
  • T4.3 NeverSuggestRead.recipe_name + .ingredient_name server-side joins. One LEFT OUTER JOIN per kind via _attach_names() helper.
  • T4.4 mealPlannerApi.neverSuggest.list/add/remove in frontend/src/api/index.ts.
  • T4.5 New frontend/src/components/NeverSuggestButton.tsx (~290 lines). Two variants: card (overlay) + detail (text buttons in top bar). Popover with Allergy (red, window.confirm) + Dislike (neutral, no confirm). Undo toast via showToast.undo() (Sprint 3 B12 pattern, 6s window). Pre-existing block detection shows a "Blocked" state with an "Unblock" path.
  • T4.6 Recipes.tsx overlay. Card has position: relative; button is opacity-0 group-hover:opacity-100 focus:opacity-100. e.preventDefault() + e.stopPropagation() — doesn't navigate.
  • T4.7 RecipeDetail.tsx top bar. New "Deny forever" button group to the left of "Add to Plan".
  • Verification log: Review/sprint10-verification.md. Deploy is git pull + docker compose up -d --build backend frontend (no migration; the NeverSuggest table already exists).

Sprint 11 status (committed 2026-06-05, awaiting deploy): Wire the dead "Generate Meal Plan" empty-state CTA on the Dashboard. The button has been rendered with onClick: () => {} since Sprint 1; clicking it did nothing. Sprint 11 wires it to two existing endpoints (POST /api/meals to create a plan + POST /api/meals/{id}/fill-empty-slots to fill it from the recipe library). The handler lives on the client for now; future F8 (Spoonacular) + F9 (Ollama) will swap the fillEmptySlots call for an LLM call without changing the DOM. F8 + F9 are separate full backend proposals and remain in the §Future backlog.

  • T5.1 New handleGenerateFirstPlan in Dashboard.tsx:400-449. Tracks generatingFirstPlan state; swaps the button label to "Generating…" and disables it while in-flight. Handles the "already exists" race (another tab created the plan first) by falling through to getPlanned(weekStart) + fillEmptySlots.
  • T5.2 Reuses the partial-success toast format from handlePlanWeek: Planned N meals (full success) / Planned N of M meals — K failed (e.g. <reason>) (partial) / Plan created — no recipes to add yet (empty library).
  • T5.3 EmptyState.action.disabled?: boolean — optional new prop on EmptyState.tsx. Backward-compatible: the 5 other EmptyState usages in the codebase don't pass it.
  • Verification log: Review/sprint11-verification.md (4-step browser smoke + race test + 2 API curls). Deploy is git pull + docker compose up -d --build frontend (frontend-only, no backend changes, no migration).

Sprint 12 status (committed 2026-06-05, awaiting deploy): F8 Spoonacular search — adds a "Search the web" toggle on /recipes that hits Spoonacular's complexSearch API. Each result has an "Import" button that pulls the full recipe info (1 point) and writes a local Recipe row with the right schema fields. Spoonacular ingredients are upserted via the existing idempotent POST /api/ingredients endpoint. No pre-existing WIP files touched. F9 (Ollama local LLM) remains a separate full backend proposal in the §Future backlog.

  • T6.1 backend/app/api/recipe_search.py (NEW, ~270 lines). 2 endpoints: GET /api/recipes/search?q=&limit= (1.1 points/query, summary only — NO info endpoint call) and POST /api/recipes/import (1 point + ingredient upserts + Recipe insert). Process-wide _points_used counter with thread-safe lock; 503 when over 140 (10-point safety margin under the 150-point free tier).
  • T6.2 backend/app/config.py — added SPOONACULAR_API_KEY: Optional[str] = None to Settings (was previously read via getattr since extra="ignore"). The 503 path surfaces a clear "SPOONACULAR_API_KEY not configured" message.
  • T6.3 backend/app/schemas/__init__.py — added RecipeSearchHit and RecipeImportRequest Pydantic models. The router is registered in main.py:62-63 at the /api/recipes prefix.
  • T6.4 Frontend: frontend/src/api/index.ts adds recipes.search + recipes.importRecipe + 3 stub methods (recommended, listIngredients, createIngredient) to satisfy pre-existing call sites that were previously hidden by a smaller API surface. frontend/src/pages/Recipes.tsx adds the toggle button (with aria-pressed) + the web-search panel (<div role="region" aria-label="Web recipe search" aria-busy={webLoading}>) + the import mutation (toast on success, showApiError on failure). frontend/src/types/index.ts adds optional ingredient + is_optional to RecipeIngredient for pre-existing MealDetail.tsx call sites.
  • T6.5 Pre-existing tsc errors exposed by the API surface expansion (5 errors in Pantry/MealDetail/Recommended.tsx) — resolved per user decision: added 5 stub API methods + 2 type fields. Documented in Review/sprint12-verification.md D-fix section.
  • Verification log: Review/sprint12-verification.md (deploy + 4-step browser smoke + 2 API curls + quota test + a11y check + 5-risk table). Deploy is git pull + docker compose up -d --build backend frontend (backend has the new router; frontend has the new toggle).

Sprint 13 status (committed 2026-06-05, awaiting deploy): F9-lite — splits the Sprint 11 "Generate Meal Plan" CTA into a 2-step modal: the user picks "Use the recipe library" (default, Sprint 11's flow) or "Ask the LLM" (new). The LLM path POSTs to /api/llm/plan; the backend calls kimi-k2.6:cloud on ollama.com, parses the LLM's JSON picks, creates a fresh plan, fills the LLM's picks, and falls through to the Sprint 6+ fillEmptySlots pattern for the slots the LLM didn't cover. No pre-existing WIP files touched. F9-full (local Ollama model pull on the host) remains a future sprint.

  • T7.1 backend/app/api/llm_plan.py (NEW, ~280 lines). 1 endpoint (POST /api/llm/plan body {prompt, week_start}) + 4 helpers (_ensure_ollama_configured, _serialize_library with a 200-recipe cap, _ask_llm mirroring the llm_matcher._ask_ollama pattern, _parse_picks tolerant of markdown code fences, _validate_picks that drops invalid entries). 60s timeout, 422 on empty/oversized prompt, 503 on missing OLLAMA_API_KEY, 400 on duplicate week.
  • T7.2 backend/app/schemas/__init__.py — added LLMPlanRequest + LLMPlanResponse Pydantic models. The router is registered in main.py:65-66 at the /api/llm prefix.
  • T7.3 Frontend: frontend/src/api/index.ts adds llm.plan(data). frontend/src/pages/Dashboard.tsx adds the prompt modal (radio for library vs. LLM + textarea for the LLM path with 500-char counter) + extracted Sprint 11's body into generateFromLibrary + added generateFromLLM. New state: showPromptModal, promptMode, promptText, promptBusy. Click-outside-to-dismiss is disabled while promptBusy is true. The textarea autoFocuses when LLM mode is selected.
  • Verification log: Review/sprint13-verification.md (deploy + 3-step browser smoke + 4 API curls + a11y check + 6-risk table). Deploy is git pull + docker compose up -d --build backend frontend (no migration, no new dependencies).
  • No new dependencies. No migration. Admin path unchanged.

Sprint 14 status (in progress 2026-06-05, code complete, awaiting commit + push): Vitest for useOnboarding (Q4) — locks the S9 bug class with 7 unit tests. Sprint 9 (F1 Onboarding Tour) shipped a hand-rolled ~420-line component; the bug 1562929 shipped a post-deploy fix the same day. Sprint 14 prevents recurrence at npm test time.

  • T7.1 4 new devDeps: vitest@^1.6.0, happy-dom@^14.7.0, @testing-library/react@^14.2.0, @testing-library/jest-dom@^6.4.0 + @types/node@^20 (tsc). Lifts the "no new npm deps" rule for testing-only. Runtime bundle unchanged.
  • T7.2 frontend/vitest.config.ts (NEW) — happy-dom env, setup file, src/**/*.test.{ts,tsx} glob. frontend/vitest-setup.ts (NEW) — @testing-library/jest-dom/vitest matchers. package.json scripts: test (vitest run, no watch) + test:watch.
  • T7.3 frontend/src/components/OnboardingTour.test.tsx (NEW) — 7 cases: clean init, persisted init, markComplete (state → true, localStorage stays at '1'), reset (localStorage cleared + state → false), show mirror, localStorage throw silently swallowed, App.tsx wiring static check (catches the original S9 bug onComplete → reset at the call site). 7/7 pass in ~25 ms.
  • Verification log: Review/sprint14-verification.md. npm run build still green (bundle 503.82 kB unchanged). No migration. No backend change. Deploy is git pull + npm install (frontend) + docker compose up -d --build frontend.
  • No new runtime dependencies. No migration. Admin path unchanged. Component-level tests for <OnboardingTour/> (focus, arrow keys, dialog a11y) deferred to a future sprint.

Sprint 15 status (in progress 2026-06-06, code complete, awaiting commit + push): Content op + Sprint 12 latent-bug fix. (1) Sprint 12 bug fix: backend/app/main.py reorders the recipe_search_api.router mount to BEFORE the WIP's recipes_api.public_router so the WIP's GET /{recipe_id} no longer shadows /search and /import. Without this fix, every Sprint 12 frontend query would 422. (2) Sprint 15 content op: scripts/seed_recipes.py (NEW, ~150 lines) seeds 50 family-friendly recipes from Spoonacular. 18 imported today (Spoonacular free-tier cap is 50 pts/day, not 150; remaining 32 to import on future days via the same script, which is idempotent). DB went from 31 → 49 total recipes (19 Spoonacular + 30 manual). LLM test (Sprint 13 endpoint) for week 2026-07-06 returned picked_count=0 / filled_count=19 / failed_count=2 — the library fill covered 19 of 21 slots, the LLM (kimi-k2.6:cloud) returned 0 picks.

  • T8.1 backend/app/main.py — moved recipe_search_api.router import to line 39 (with the other api imports) and the include_router call to before recipes_api.public_router mount. Three-line comment explains the why. Verified: GET /api/recipes/search?q=... returns 200 with hits; POST /api/recipes/import still 201.
  • T8.2 scripts/seed_recipes.py (NEW) — 50-query list (5 cuisines × 10 each), direct complexSearch + backend import. 1.5 sec sleep. Idempotent (409 logged). Stops cleanly on 402.
  • T8.3 Follow-up: _DAILY_LIMIT=140 in backend/app/api/recipe_search.py:48 should drop to 45 to match the actual 50-pt free tier. Not blocking.
  • Verification log: Review/sprint15-verification.md (full breakdown of 18 imported, free-tier math, LLM test, risk table).
  • No new runtime dependencies. No schema changes. No UI changes. Deploy is git pull + docker compose up -d --build backend frontend (backend picks up the main.py fix; the 18 new recipes are already in the DB).

Sprint 15 Round 2 (2026-06-07): +18 recipes, library at 67 total. scripts/seed_recipes_round2.py (NEW, ~120 lines) — 50-query list focused on cuisines the round 1 list didn't cover: Indian (8) + Thai (6) + Chinese regional (6) + Soups & stews (6) + Salads (6) + Sandwiches/wraps (5) + Breakfast (5) + German/European (4) + French (4). Same idempotent behavior (409 on duplicate). 18 imported; 12 queries returned no hits from Spoonacular's free-tier index; 30th query hit 402. LLM test (Sprint 13, week 2026-07-20, prompt "variety, mix of cuisines, family-friendly, no repeats"): picked_count=0 / filled_count=21 / failed_count=0. Library now covers all 21 slots of a week. Tracking: appended to Review/sprint15-verification.md.

Sprint 15 Round 3 (2026-06-07): +10 recipes, library at 77 total. Re-ran scripts/seed_recipes.py (round 1's script, idempotent) after the 50-pt quota rolled over. Skipped 37 duplicates; 10 new imports (Asian leftovers from round 1's cap-blocked queries + 8 American comfort dishes: Superbowl Chili, Veggie Meatloaf, Crab Mac and Cheese, BBQ Chicken, Classic Pot Roast, Lean Shepherd's Pie, Amazing Chicken Pot Pie, Slow Cooker Beef Stew). LLM test (Sprint 13, week 2026-08-03, prompt "comfort food, no repeats from past 2 weeks"): picked_count=0 / filled_count=21 / failed_count=0. Library at 77, well past the 4-week coverage threshold. Tracking: appended to Review/sprint15-verification.md.

Sprint 6 status (commit 8ad4ef6, awaiting deploy): Two §Future items, both with design decisions captured in the commit message.

  • F3 Bulk 'add checked to pantry' on ShoppingList. Backend POST /api/pantry/bulk accepts {items: HomePantryCreate[]} and returns per-item status (added / updated / skipped) with totals. Per-item failure model: unknown ingredient → skipped with reason, not a 4xx. Frontend ShoppingList gains a primary Add N to pantry button next to the existing Reset button; toast reports added X, updated Y, skipped Z; only the items that actually landed are removed from the checked Set. Scope decision: ShoppingList only (the checked Set was the natural substrate; Pantry would need new multi-select UI).
  • F4 Plan the whole week on Dashboard. Backend POST /api/meals/{id}/fill-empty-slots with body {meal_types: [str, ...]} returns FillEmptySlotsResult { filled: [{day, meal_type, item}], failed: [{day, meal_type, reason}] }. Iterates day 1..7 in order; 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. Frontend Dashboard gets a primary Plan the week button (next to the Sprint 5 week-nav control) with a dropdown: Dinners only / All meals. Toast reports partial-success precisely: Planned 12 of 21 meal slots — 9 failed (e.g. <reason>).
  • Backend changes: pantry.py + meals.py (new endpoints) + schemas/__init__.py (3 new schema types).
  • Verification log: Review/sprint6-verification.md. No migration. Deploy is docker compose up -d --build backend frontend.

Findings mapped to Nielsen's 10 Heuristics

H1 · Visibility of system status — Partial

Works well

  • Toasts (react-hot-toast) for generate/delete are top-right and persist.
  • Status badges on the dashboard (e.g. $206.21 total) update reactively.
  • Loading skeletons render on data fetch.

⚠️ Gaps

  • Filters (P2). Active filter count is not shown when the filter panel is collapsed (Recipes.tsx:99 area, 16-recipes-filters-open.png). User has no way to know a filter is on.
  • Pantry search (P2). No "X of N results" indicator.
  • Sync status (P2). When a meal is being generated, no spinner on the slot itself — only the global toast after success.

Fix: Render an activeFilters.length chip on the Filters button; add a small "Searching…" indicator inside the Pantry search input.


H2 · Match between system and the real world — Multiple violations

🚨 P1 · Snake-case aisle labels on Shopping List (page-shopping-list.png)

  • Sections display meat_seafood, produce, pantry, dairy.
  • Fix: human-readable map in ShoppingList.tsx:
    const AISLE_LABEL: Record<string,string> = {
      meat_seafood: 'Meat & Seafood', produce: 'Produce',
      pantry: 'Pantry', dairy: 'Dairy & Eggs',
    };
    

🚨 P1 · $N/A per serving (MealDetail.tsx:191, 14-meal-detail.png)

  • ${item.estimated_cost?.toFixed(2) || 'N/A'} renders $N/A literally because the $ is outside the conditional.
  • Fix:
    {item.estimated_cost != null
      ? `$${item.estimated_cost.toFixed(2)} per serving`
      : 'No price estimate yet'}
    

🚨 P0 · Ingredients render without quantities on the Meal page (MealDetail.tsx:249-252, 14-meal-detail.png)

  • Code reads ing.quantity / ing.unit but the backend returns qty (per RecipeDetail.tsx:161 working correctly). Result: lb Pork Chops, Bone-In instead of 1 lb Pork Chops, Bone-In.
  • Fix: rename both fields to a single canonical name (recommend qty to match backend), or apply a compatibility shim:
    const qty = ing.qty ?? ing.quantity;
    const unit = ing.unit ?? ing.unit;
    
    and update the type definition.

🚨 P1 · Spoonacular marketing copy leaks into meal description (14-meal-detail.png)

  • The meal page description includes: "Featured In Group could be just the gluten free, dairy free, and ketogenic recipe you've been looking for… users who liked this recipe also liked Baked Chicken In Avocado Boat…"
  • Fix: backend Meal.description should be truncated to ~280 chars on import, with a regex strip of the "Featured In Group…" / "users who liked…" boilerplate. Alternatively, render description.split('. ').slice(0,2).join('. ')+'.' on the frontend with a line-clamp-3 parent.

🚨 P1 · Hero title overlaps description (MealDetail.tsx:168-197, 14-meal-detail.png)

  • Long description text (no line-clamp) sits over the absolute-positioned title block, making the title literally unreadable.
  • Fix: add line-clamp-3 and max-w-2xl on the description; ensure the title is in normal flow (not absolute) on this view.

H3 · User control and freedom — Partial

Works

  • Back links on Recipe and Meal detail pages.
  • Drag-and-drop on dashboard (via @hello-pangea/dnd) is reversible.

⚠️ Gaps

  • P1 · Native confirm() dialogs for delete — jarring, breaks visual continuity. Replace with an inline "Undo" toast (e.g. react-hot-toast with a 5s undo that re-fires the create query). See Dashboard.tsx meal delete and Pantry.tsx row delete.
  • P2 · No keyboard shortcut to focus search on Recipes/Pantry/Shopping List. Convention is / or Cmd+K.
  • P2 · Filters have no Reset button (16-recipes-filters-open.png).

H4 · Consistency and standards — Multiple violations

🚨 P1 · Aisle casing inconsistency in Pantry (page-pantry.png, Pantry.tsx:200)

  • Rows show Canned Goods, Pantry, pantry, Produce, Freezer — all derived from free-text input. Aisle should be a fixed enum.
  • Fix: replace the free-text input with a <select> populated from ['Produce','Meat & Seafood','Dairy & Eggs','Pantry','Frozen','Bakery','Beverages','Spices','Other']. Migrate existing rows via a one-off script that lowercases + maps.

🚨 P1 · qty vs quantity field mismatch between Recipe and Meal detail (covered under H2). The shared Ingredient type should have one canonical field.

⚠️ P2 · Aisle filter pill on dashboard Shopping List card is uppercase by Tailwind class; the rest of the app uses sentence case.

⚠️ P2 · Mixed icon setlucide-react everywhere except a few hand-rolled SVGs in the dashboard's empty state.


H5 · Error prevention — Violations

🚨 P1 · Add Pantry Item form has no required markers and no validation (17-pantry-add-item.png, Pantry.tsx:~180-220)

  • "Add" button looks pre-disabled (light blue) but the user has no idea why. No * indicator on the required Ingredient Name field, no inline error, no disabled-until-valid logic explained.
  • Fix: add <span className="text-danger">*</span> to required field labels; use aria-describedby to attach an inline help text; show an inline error on submit fail (e.g. duplicate item).

⚠️ P2 · Filters apply immediately on change — user can lose their current result set by accidently nudging "Max time". Add explicit Apply (or debounce 400 ms with a clear "Applying…" indicator).

⚠️ P2 · Meal generate (Generate button) has no confirmation for the current week — clicking accidentally overwrites. A confirm() for destructive regenerate is acceptable; better: a small "Replace existing?" toggle.


H6 · Recognition rather than recall — Partial

Works

  • Recipe cards show tags (cuisine, diet) and quick stats.
  • Status badges (Approved, etc.) are color-coded consistently.

⚠️ Gaps

  • P2 · No breadcrumbs on detail pages. From /meals/f28… the user cannot see "Meal Plan Pork Stir-Fry" without remembering.
  • P2 · No active filter chips on the Recipes page — when filters are collapsed, user has no visible reminder of what's on (see H1).
  • P2 · Empty Pantry state has no illustration or "Add your first item" primary CTA; just a blank table.

H7 · Flexibility and efficiency of use — Weak

🚨 P1 · No bulk actions on Shopping List or Pantry (page-shopping-list.png, page-pantry.png)

  • Adding common items (salt, pepper, oil) is one-by-one. Add a "Multi-select" mode with a header that says 2 selected · [Delete] [Move aisle].

⚠️ P2 · No keyboard shortcuts.

  • / focus search
  • g p go to Pantry
  • g s go to Shopping List
  • n m new meal
  • A small useShortcuts hook in App.tsx plus a "?" help modal would cover this.

⚠️ P2 · Generate button regenerates one slot at a time. A "Plan whole week" button would be a huge efficiency win for a meal planner.

⚠️ P2 · No persistent week selector in the URL — back/forward loses the week you're viewing.


H8 · Aesthetic and minimalist design — Mostly good, with one outlier

Works

  • Palette is restrained (surface, primary, warning, success, danger).
  • Card hierarchy is clear on Recipes grid.

🚨 P1 · Meal detail hero is chaotic (14-meal-detail.png)

  • Title, badge, description, and metadata all compete; title is unreadable due to the overlap (H2). Long marketing copy adds noise. Tighten to: title → single-line subtitle (cuisine · 25 min · 4 servings) → 1-2 sentence description → CTA. Move the long marketing body into a "Notes from source" collapsible at the bottom.

⚠️ P2 · Stat cards on Shopping List stack 3 full-width tiles on mobile (mobile-shopping-list.png) — heavy vertical scroll. Consider a 3-up compact layout (icon + value, label below) for < sm.

⚠️ P2 · Mobile nav wraps "Shopping List" onto a second line (04-dashboard-mobile.png). Add whitespace-nowrap to nav links.


H9 · Help users recognize, diagnose, and recover from errors — Violations

🚨 P0 · /recommended is a blank page (page-recommended.png, 13-recommended-broken.png, mobile-recommended.png)

  • The Navigation links do not point to /recommended (they correctly point to /recipes/recommended), but the URL is referenced in user-facing strings somewhere (most likely an email link or share URL) and resolves to an empty React Router outlet.
  • The "Recommended" link in the Recipes header also has a known link to /recipes/recommended which works.
  • Fix: add a * catch-all route in App.tsx rendering a friendly NotFound component with a "Back to dashboard" CTA; optionally also alias /recommended → /recipes/recommended via <Navigate replace />.

🚨 P1 · No error boundary — if a single component throws (e.g. an ingredient with null.qty), the whole page goes blank. Add a top-level <ErrorBoundary> in App.tsx that shows "Something went wrong. [Reload] [Report]".

⚠️ P2 · Recipes with no image show a generic cooking-pot icon silently. Add a title="Image not available" and consider a "Report missing image" link.

⚠️ P2 · 401/403/500 errors from the API are not surfaced as user-readable toasts. Hook into the react-query onError global handler.


H10 · Help and documentation — Missing

🚨 P1 · No onboarding for first-time users — empty dashboard, empty pantry, empty shopping list with no guidance.

  • Add a one-time tour (e.g. react-joyride) or just 3 inline hint cards on the dashboard: 1. Add items to your pantry · 2. Generate this week's meals · 3. Review the shopping list.
  • Add a "?" icon in the nav that opens a Help modal with a quick-start, FAQ, and a link to docs/.

⚠️ P2 · No tooltips on advanced filter labels (Max time, Max spice, Max calories) — units and ranges are not obvious. Use aria-describedby + a small "?" popover.

⚠️ P2 · Print List button is hidden behind the page scroll on mobile. Make it sticky on lg: viewports at minimum.


Additional concrete bugs

# Where Bug Severity Fix
B1 RecipeDetail.tsx:161 ing.qty != null && 2 canBlack Beans` (no space) P0 Drop .trim() or add explicit before {ing.name}
B2 MealDetail.tsx:249-252 ing.quantity undefined → no quantities shown P0 Use ing.qty ?? ing.quantity or rename to qty
B3 MealDetail.tsx:191 $N/A per serving P0 Conditional on cost != null
B4 App.tsx (routes) No /recommended, no * NotFound P0 Add <Route path="*" element={<NotFound/>}> + alias /recommended
B5 Dashboard.tsx:164,219 Empty slots hidden on mobile P0 Remove hidden md:flex / hidden md:block (or replace with flex on both)
B6 Dashboard.tsx:87 truncate cuts meal name to 1-2 chars P1 line-clamp-2 and shrink image on narrow grid
B7 MealDetail.tsx:168-197 Title overlaps description P1 Remove absolute positioning, add line-clamp-3 on description
B8 Pantry.tsx:200 Free-text aisle P1 Convert to <select> with canonical list
B9 ShoppingList.tsx snake_case aisle names P1 Human-readable map
B10 Pantry.tsx:236 overflow-x-auto without scroll hint on mobile P1 Add a faded right-edge gradient + aria role="region" with descriptive label
B11 Recipes.tsx Filters have no Apply/Reset/active count P1 Add Reset, Apply, and an activeCount chip on the Filters button
B12 various Native confirm() for delete P2 Replace with react-hot-toast undo pattern
B13 Navigation.tsx:14-38 "Shopping List" wraps on mobile P2 Add whitespace-nowrap
B14 dashboard/Shopping List Stat cards stack full-width on mobile P2 Use 3-col compact layout for <sm

Accessibility (WCAG 2.1 AA quick scan)

  • P1 · aria-current="page" missing on the active nav link. Add it in Navigation.tsx.
  • P1 · Recipes filter panel opens inline but is not announced as a region. Add role="region" aria-label="Filters".
  • P1 · Modal/dialogs (none observed, but recommend focus-trap-react whenever added).
  • P2 · Color-only signals — "Approved" badge relies on green alone; add an icon or aria-label="Approved".
  • P2 · Touch targets — Generate buttons in empty slots are < 44 px tall on mobile. Bump to min-h-11.

A pragmatic 3-sprint plan, each ending in something visible to a user testing the deployment.

Sprint 1 — Stop the bleeding (P0s, ~3 days)

  1. B1 (recipe ingredients space)
  2. B2 (meal ingredient field rename + shim)
  3. B3 ($N/A fix)
  4. B4 (404 + /recommended alias)
  5. B5 (mobile empty slots visible)

Sprint 2 — Trust the data (P1s, ~4 days)

  1. B6 (card title line-clamp)
  2. B7 (meal hero overlap + description clamp)
  3. B8 (pantry aisle select)
  4. B9 (shopping list aisle map)
  5. B10 (mobile pantry scroll hint)
  6. B11 (filters: Apply, Reset, active count)

Sprint 3 — Polish (P2s + a11y, ~3 days)

  1. Undo-toast replaces confirm() (B12)
  2. Mobile nav wrap (B13)
  3. Stat card responsive layout (B14)
  4. Onboarding hints on empty dashboard (H10)
  5. Error boundary (H9)
  6. A11y sweep (aria-current, regions, 44 px targets)

Appendix · Captured screenshots

Screenshot Notes
03-dashboard.png Desktop dashboard — full week grid, status badge, $206.21
04-dashboard-mobile.png Mobile dashboard — empty slots hidden (B5)
page-recipes.png 30 recipe grid, search + filters
mobile-recipes.png 2-col on mobile, OK
page-pantry.png Mixed-case aisles (B8)
mobile-pantry.png Columns cut off silently (B10)
page-shopping-list.png snake_case aisles (B9)
mobile-shopping-list.png Stat cards stack full-width (B14)
page-recommended.png BLANK — missing route (B4)
mobile-recommended.png Same blank on mobile
10-recipe-detail.png /recipes/recommended — actually renders fine
11-recipe-detail-real.png Bug: 2 canBlack Beans (B1)
12-recipe-detail-mobile.png Stacks OK on mobile
13-recommended-broken.png Blank /recommended
14-meal-detail.png Bugs: overlap, $N/A, missing quantities, SEO copy leak (B2/B3/B7)
15-after-generate-click.png Toast works; new meal title clipped to B.. (B6)
16-recipes-filters-open.png Filters inline, no Apply/Reset (B11)
17-pantry-add-item.png No required marker, pre-disabled looking Add (B12)
18-focus-state.png Focus ring on nav link works