Files
Meal-Planner/Review/ui-nielsen-audit.md
T
admin c54d3ffc1f docs: Sprint 16 — fix kimi-k2.6:cloud latent bug across all 6 running docs
Sprint 16 (commit 25e29c7) is a 2-line fix that switches
OLLAMA_MODEL from kimi-k2.6:cloud to gpt-oss:20b and bumps
max_tokens from 800 to 4000. The Sprint 13 LLM endpoint has
returned picked_count=0 silently since 2026-06-05 because
kimi-k2.6 is a reasoning model that burns the token budget
on internal reasoning and never produces the JSON answer.
The library fill (Sprint 6+) silently took over. Discovered
while answering the user's "is there anything else to refine?"
question.

Live verification: 5/5 test weeks return picked_count 15-21
(was 0/5 before). 11/11 vitest cases pass (4 new from S16 +
7 from S14). npm run build green. No new runtime deps. No
schema change. No UI change.

This commit updates the 6 running docs that track sprints:

- .agent/plan.md — Sprint 16 section (S16.1-S16.4 + Done
  when + Out of scope) added after the Sprint 15 sections.
  Documents the diagnosis (kimi-k2.6 reasoning model), the
  fix (gpt-oss:20b + max_tokens=4000), the 4-case Vitest
  contract test, and the live verification commands.
- .agent/context.md — Sprint 16 decisions (D1-D5), open Q1,
  and file:line references added.
- Review/sprint16-verification.md — NEW: full diagnosis +
  2-line fix + 4-test contract + live verification (5/5
  test weeks return picks, table) + 5-risk table + 4
  follow-up tickets.
- Review/ui-nielsen-audit.md — Sprint 16 status block added
  after the Sprint 15 Round 3 block.
- fix-ui-audit.md — Sprint 16 section (T9.1-T9.5) added
  after the Sprint 15 section. T9.1 documents the 2-line
  fix in detail (config.py + llm_plan.py + .env). T9.5
  surfaces 3 follow-up tickets.
- Review/handoff-ui-audit.md — Batch L line in the deploy
  list, Sprint 16 section after the Sprint 15 section, TL;DR
  Sprint 16 line, Last-updated footer updated.
- docs/HANDOFF.md — Sprint 16 section after Sprint 15, Last-
  updated footer updated. Notes the corrected model choice
  and the 4 follow-up tickets.

All 6 docs now reflect Sprint 16. The Sprint 13 LLM endpoint
now works as designed. Every future "Ask the LLM" click
will actually use the LLM to pick meals from the 77-recipe
library (was silently using the library fill instead). The
_ask_llm helper is still the single F9-full seam.
2026-06-08 07:26:22 -07:00

45 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 16 status (in progress 2026-06-08, code complete, awaiting commit + push): Fix Sprint 13 latent bug — every /api/llm/plan call has returned picked_count=0 since 2026-06-05 because kimi-k2.6:cloud is a reasoning model that burns the max_tokens budget on internal reasoning and never produces the JSON answer. The library fill (Sprint 6+) silently took over every time. Discovered by adding a temp debug log and seeing raw_response='' with finish_reason: length. Two-line fix: backend/app/config.py:38 switches OLLAMA_MODEL from kimi-k2.6:cloud to gpt-oss:20b (OpenAI's open-source 20B non-reasoning model); backend/app/api/llm_plan.py:117 bumps max_tokens from 800 to 4000 (21 picks × ~100 chars + reasoning = ~2100+ chars; 4000 gives 2x headroom); backend/.env (or docker-compose env) also updated so the container reads gpt-oss:20b. frontend/src/api/llm.test.ts (NEW, 4 cases) — Vitest contract test on the LLM response shape. Live verification: 5/5 test weeks return picked_count 15-21 (was 0 before). Tracking: Review/sprint16-verification.md. 11/11 tests pass, npm run build green. No new runtime deps. No schema change. No UI change.

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