Sprint 15 (commit a3c89bf) does two things: (1) reorders
backend/app/main.py so recipe_search_api.router mounts BEFORE
the WIP recipes_api.public_router (fixes a Sprint 12 latent
bug where /api/recipes/search was shadowed by the WIP's GET
/{recipe_id} returning 422); (2) adds scripts/seed_recipes.py
which seeded 18 Spoonacular recipes into the local library
today (free-tier 50-pt cap hit; remaining 32 to seed on later
days via the same idempotent script). DB went 31 -> 49 total
recipes.
This commit updates the 6 running docs that track sprints:
- .agent/plan.md — Sprint 15 section (S15.1-S15.4 + Done
when + Out of scope) added after Sprint 14's out-of-scope.
- .agent/context.md — Sprint 15 decisions (D1-D6), open
questions (Q1-Q2), and file:line references added.
- Review/sprint15-verification.md — NEW: full 18-imported
breakdown by cuisine + free-tier math correction (50 pts/day,
not 150) + LLM test (picked_count=0, filled_count=19,
failed_count=2 for week 2026-07-06) + 6-risk table + deploy
+ 2 follow-up tickets (lower _DAILY_LIMIT, re-run script).
- Review/ui-nielsen-audit.md — Sprint 15 status block
(T8.1-T8.3) added after the Sprint 14 block. Notes the
Sprint 12 latent-bug fix as the critical change for the
upcoming Sprint 12 deploy.
- fix-ui-audit.md — Sprint 15 section (T8.1-T8.5) added after
the Sprint 14 section. T8.1 documents the main.py mount
order fix in detail. T8.5 surfaces 2 follow-up tickets.
- Review/handoff-ui-audit.md — Batch K line in the deploy
list, Sprint 15 section after Sprint 14, TL;DR Sprint 15
line, Last-updated footer updated.
- docs/HANDOFF.md — Sprint 15 section after Sprint 14, Last-
updated footer updated. Notes the corrected free-tier math
and the 2 follow-up tickets.
All 6 docs now reflect Sprint 15. The Sprint 12 latent-bug
fix is documented as a hard prerequisite for the upcoming
Sprint 12 deploy (without it, every 'Search the web' query
would 422). Re-running scripts/seed_recipes.py on a later
day will add the remaining 32 recipes (the script is
idempotent — already-imported IDs return 409 and are
skipped).
43 KiB
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):
- Meal detail ingredients render without quantities (field-name bug,
MealDetail.tsx:249-250) — a core function of the page is unreadable. $N/A per servingdisplayed literally (MealDetail.tsx:191).- Recipe detail ingredients collapse unit and name (
RecipeDetail.tsx:161—2 canBlack Beans). /recommendedreturns a blank page (missing route + no 404 catch-all inApp.tsx).- 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 at100.108.208.56:8082/. Verification screenshots in/tmp/opencode/mp-review/screenshots/fix-sprint1/.Sprint 2 status (commit
ccc70aa, deploy helperf5fb755): All six P1s plus the S3.3 mobile shopping-list stat-grid fix are addressed in source.
- B6 Dashboard
MealCardtitle:truncate→line-clamp-2; image shrinks to 40×40 on<mdto give title more room.- B7
MealDetailhero: title/description no longer overlap; description stripped of spoonacular SEO copy vialib/utils.cleanDescription; raw text moved to a "Notes from source" disclosure.- B8
Pantryaisle/unit: free-text → canonicalSelectfromPANTRY_AISLESenum (types/index.ts).Ingredient namefield now marked*required. Backend migration0015_normalize_pantry_aisles.pynormalizesingredient.aisleandgrocery_item.aisleto canonical labels. Dry-run SQL helper atbackend/scripts/dry_run_aisle_migration.sql.- B9
ShoppingListaisle section headers now human-readable viaAISLE_LABELmap; 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"+ descriptivearia-label.- B11 Recipes filters: refactored to
pending/appliedstate with explicit Apply / Reset buttons.Filtersbutton shows active-count chip when filters are set. Wrapped inrole="region" aria-label="Filters".- S3.3 Shopping list stat cards: now
grid-cols-3on 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.tsfor JSX) gains a newshowToast.undo(message, onUndo, ms=5000)helper.Dashboard.handleDeletecaptures the full item, deletes, then surfaces an Undo toast that re-firesgenerateItem(planId, dayOfWeek, mealType)to refill the slot.Pantry.handleRemoveis fully reversible: re-adds viapantry.addwith the originalingredient_id/quantity/unit. Per-row loading state via newremoveIdstate.- B13
Navigationlink text getswhitespace-nowrap; padding reduced topx-2 sm:px-3so all 4 links fit on one line down to ~360 px.- S3.4 Confirmed
ErrorBoundaryis already mounted atApp.tsx:42(verifiedcomponents/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,Badgecomponent extended with optionaliconandaria-labelprops. Approval-status Badge on the meal card now passesaria-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 frontendSprint 4 status (commit
d71b67a, awaiting deploy): Two §Future items, both small, both polish.
- F7 Global react-query error handler.
lib/toast.tsxgainsextractErrorMessage(err, fallback)andshowApiError(err, fallback)that read FastAPI'sresponse.data.detail(string or Pydantic 422 array) and produce a clean user-facing string.App.tsxwiresQueryCache({ onError })andMutationCache({ onError })toshowApiError, so any future mutation that forgets a local handler still surfaces its failure. 10 local try/catch toasts deleted acrossDashboard.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.useSearchParamsreads the URL; if absent or invalid, falls back toisoMonday()(so the default URL is empty). BothDashboardandShoppingListget a segmented control (chevron-left | 'This week'/'Current' jump button | chevron-right) in the header. ThequeryKeyincludesweekStartso each week is independently cached; mutations invalidate the right key. Empty state branches onisCurrentWeek('No plan for that week' vs 'No shopping list yet'). BackendGET /api/mealsandGET /api/shopping-listboth accept the same?week_start=param; when omitted, the original "latest plan" behaviour is preserved.- F2 Keyboard shortcuts. Vim-style 2-key sequences (
g dDashboard,g rRecipes,g pPantry,g sShopping List) navigate between the 4 main pages./focuses the page's search input (Pantry + Recipes subscribe via auseFocusSearchOnShortcut(ref)hook).?shows a help banner. Suppressed inside text-entry controls and on modifier-key chords. 1.5s sequence timeout. Implementation lives infrontend/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 in0015_normalize_pantry_aisles.pyfailed withtext = booleanon thevarchar(100) aislecolumn. 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 + simplifiedWHEN '' THEN NULLbranch. Without this fix, the deployment host'salembic upgrade headwould 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.pyandshopping_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). Addsmeal_plan_item.denial_expires_at TIMESTAMPTZ NULL(partial index on non-NULL) andmeal_plan_vote.denial_scope VARCHAR(16) NULL. No data migration; existing rows keepdenial_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(defaultthis_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". Returnsdenial_scope+promoted_to_permanentso 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 viasubmit_voteand 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_blocklistsreturns 3 sets;soft_denied_recipesis hard-filtered (per user decision).rejected_summaryadds asoft_denied_recipediagnostic bucket.- T2.10 Webui:
MealCardrenders 3 buttons (Approve / Deny this week / Never again) for pending items.handleDenyis scope-aware; toast reflects the server'spromoted_to_permanentflag. "Never again" is gated by awindow.confirmto prevent accidental permanent blocks.- Verification log:
Review/sprint8-verification.md.- No new dependencies. Migration is required (
alembic upgrade headruns 0016). Deploy isgit 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 inbackend/app/services/orchestrator/runner.py:20-24. Scheduler cron stays Friday.- T1.2
isoMonday→upcomingMondayinfrontend/src/lib/utils.ts:44-50. Same logic as T1.1; rename for intent. AddformatWeekRange(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 aThis weekchip when off the upcoming week. Keyboard-accessible.- T1.4 SQL:
backend/scripts/fix_2026_06_05_to_2026_06_08.sql— guardedUPDATE meal_plan SET week_start_date='2026-06-08' WHERE week_start_date='2026-06-05';with aSELECT 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 PlanCTA 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 (noreact-joyride; keeps npm footprint flat). 4 steps: Dashboard / Pantry / Recipes / Shopping List. Anchors to[data-tour="<id>"]attributes on existing elements. localStorage keymealplanner:onboarding-complete.?reset-tour=1re-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 anchorgetBoundingClientRect; cancellable on close. Focus captured on open, restored on close. Keyboard:1–4jump,←/→step,Escdismiss.- 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 isgit 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 1–3
NeverSuggestinfrastructure on the webui Recipes page. Backend adds family-facingPOST+DELETE /api/never-suggestendpoints; the existing admin path stays unchanged.
- T4.1
POST /api/never-suggest(public, webui-facing). Idempotent on(family, recipe, reason). Returns the row joined withrecipe_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_nameserver-side joins. One LEFT OUTER JOIN per kind via_attach_names()helper.- T4.4
mealPlannerApi.neverSuggest.list/add/removeinfrontend/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 withAllergy(red,window.confirm) +Dislike(neutral, no confirm). Undo toast viashowToast.undo()(Sprint 3 B12 pattern, 6s window). Pre-existing block detection shows a "Blocked" state with an "Unblock" path.- T4.6
Recipes.tsxoverlay. Card hasposition: relative; button isopacity-0 group-hover:opacity-100 focus:opacity-100.e.preventDefault()+e.stopPropagation()— doesn't navigate.- T4.7
RecipeDetail.tsxtop bar. New "Deny forever" button group to the left of "Add to Plan".- Verification log:
Review/sprint10-verification.md. Deploy isgit pull+docker compose up -d --build backend frontend(no migration; theNeverSuggesttable 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/mealsto create a plan +POST /api/meals/{id}/fill-empty-slotsto fill it from the recipe library). The handler lives on the client for now; future F8 (Spoonacular) + F9 (Ollama) will swap thefillEmptySlotscall for an LLM call without changing the DOM. F8 + F9 are separate full backend proposals and remain in the §Future backlog.
- T5.1 New
handleGenerateFirstPlaninDashboard.tsx:400-449. TracksgeneratingFirstPlanstate; 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 togetPlanned(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 onEmptyState.tsx. Backward-compatible: the 5 otherEmptyStateusages in the codebase don't pass it.- Verification log:
Review/sprint11-verification.md(4-step browser smoke + race test + 2 API curls). Deploy isgit 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
/recipesthat hits Spoonacular'scomplexSearchAPI. Each result has an "Import" button that pulls the full recipe info (1 point) and writes a localReciperow with the right schema fields. Spoonacular ingredients are upserted via the existing idempotentPOST /api/ingredientsendpoint. 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) andPOST /api/recipes/import(1 point + ingredient upserts + Recipe insert). Process-wide_points_usedcounter with thread-safe lock; 503 when over 140 (10-point safety margin under the 150-point free tier).- T6.2
backend/app/config.py— addedSPOONACULAR_API_KEY: Optional[str] = NonetoSettings(was previously read viagetattrsinceextra="ignore"). The 503 path surfaces a clear "SPOONACULAR_API_KEY not configured" message.- T6.3
backend/app/schemas/__init__.py— addedRecipeSearchHitandRecipeImportRequestPydantic models. The router is registered inmain.py:62-63at the/api/recipesprefix.- T6.4 Frontend:
frontend/src/api/index.tsaddsrecipes.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.tsxadds the toggle button (witharia-pressed) + the web-search panel (<div role="region" aria-label="Web recipe search" aria-busy={webLoading}>) + the import mutation (toast on success,showApiErroron failure).frontend/src/types/index.tsadds optionalingredient+is_optionaltoRecipeIngredientfor 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.mdD-fix section.- Verification log:
Review/sprint12-verification.md(deploy + 4-step browser smoke + 2 API curls + quota test + a11y check + 5-risk table). Deploy isgit 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 callskimi-k2.6:cloudonollama.com, parses the LLM's JSON picks, creates a fresh plan, fills the LLM's picks, and falls through to the Sprint 6+fillEmptySlotspattern 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/planbody{prompt, week_start}) + 4 helpers (_ensure_ollama_configured,_serialize_librarywith a 200-recipe cap,_ask_llmmirroring thellm_matcher._ask_ollamapattern,_parse_pickstolerant of markdown code fences,_validate_picksthat 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— addedLLMPlanRequest+LLMPlanResponsePydantic models. The router is registered inmain.py:65-66at the/api/llmprefix.- T7.3 Frontend:
frontend/src/api/index.tsaddsllm.plan(data).frontend/src/pages/Dashboard.tsxadds the prompt modal (radio for library vs. LLM + textarea for the LLM path with 500-char counter) + extracted Sprint 11's body intogenerateFromLibrary+ addedgenerateFromLLM. New state:showPromptModal,promptMode,promptText,promptBusy. Click-outside-to-dismiss is disabled whilepromptBusyis true. The textareaautoFocuses 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 isgit 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 bug1562929shipped a post-deploy fix the same day. Sprint 14 prevents recurrence atnpm testtime.
- 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/vitestmatchers.package.jsonscripts: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),showmirror, localStorage throw silently swallowed, App.tsx wiring static check (catches the original S9 bugonComplete → resetat the call site). 7/7 pass in ~25 ms.- Verification log:
Review/sprint14-verification.md.npm run buildstill green (bundle 503.82 kB unchanged). No migration. No backend change. Deploy isgit 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.pyreorders therecipe_search_api.routermount to BEFORE the WIP'srecipes_api.public_routerso the WIP'sGET /{recipe_id}no longer shadows/searchand/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 returnedpicked_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— movedrecipe_search_api.routerimport to line 39 (with the other api imports) and theinclude_routercall to beforerecipes_api.public_routermount. Three-line comment explains the why. Verified:GET /api/recipes/search?q=...returns 200 with hits;POST /api/recipes/importstill 201.- T8.2
scripts/seed_recipes.py(NEW) — 50-query list (5 cuisines × 10 each), directcomplexSearch+ backendimport. 1.5 sec sleep. Idempotent (409 logged). Stops cleanly on 402.- T8.3 Follow-up:
_DAILY_LIMIT=140inbackend/app/api/recipe_search.py:48should 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 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/bulkaccepts{items: HomePantryCreate[]}and returns per-item status (added/updated/skipped) with totals. Per-item failure model: unknown ingredient →skippedwith reason, not a 4xx. Frontend ShoppingList gains a primaryAdd N to pantrybutton next to the existing Reset button; toast reportsadded 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-slotswith body{meal_types: [str, ...]}returnsFillEmptySlotsResult { 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 aspending. Per-slot failure model — never aborts mid-batch. Frontend Dashboard gets a primaryPlan the weekbutton (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 isdocker 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.21total) 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:99area,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/Aliterally 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.unitbut the backend returnsqty(perRecipeDetail.tsx:161working correctly). Result:lb Pork Chops, Bone-Ininstead of1 lb Pork Chops, Bone-In. - Fix: rename both fields to a single canonical name (recommend
qtyto match backend), or apply a compatibility shim:and update the type definition.const qty = ing.qty ?? ing.quantity; const unit = ing.unit ?? ing.unit;
🚨 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.descriptionshould be truncated to ~280 chars on import, with a regex strip of the "Featured In Group…" / "users who liked…" boilerplate. Alternatively, renderdescription.split('. ').slice(0,2).join('. ')+'.'on the frontend with aline-clamp-3parent.
🚨 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-3andmax-w-2xlon 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-toastwith a 5s undo that re-fires the create query). SeeDashboard.tsxmeal delete andPantry.tsxrow delete. - P2 · No keyboard shortcut to focus search on Recipes/Pantry/Shopping List. Convention is
/orCmd+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 set — lucide-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; usearia-describedbyto 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 searchg pgo to Pantryg sgo to Shopping Listn mnew meal- A small
useShortcutshook inApp.tsxplus 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/recommendedwhich works. - Fix: add a
*catch-all route inApp.tsxrendering a friendlyNotFoundcomponent with a "Back to dashboard" CTA; optionally also alias/recommended → /recipes/recommendedvia<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 inNavigation.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-reactwhenever 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.
Recommended implementation order
A pragmatic 3-sprint plan, each ending in something visible to a user testing the deployment.
Sprint 1 — Stop the bleeding (P0s, ~3 days)
- B1 (recipe ingredients space)
- B2 (meal ingredient field rename + shim)
- B3 (
$N/Afix) - B4 (404 +
/recommendedalias) - B5 (mobile empty slots visible)
Sprint 2 — Trust the data (P1s, ~4 days)
- B6 (card title line-clamp)
- B7 (meal hero overlap + description clamp)
- B8 (pantry aisle select)
- B9 (shopping list aisle map)
- B10 (mobile pantry scroll hint)
- B11 (filters: Apply, Reset, active count)
Sprint 3 — Polish (P2s + a11y, ~3 days)
- Undo-toast replaces
confirm()(B12) - Mobile nav wrap (B13)
- Stat card responsive layout (B14)
- Onboarding hints on empty dashboard (H10)
- Error boundary (H9)
- 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 ✅ |