Files
Meal-Planner/.agent/plan.md
T
admin af4ec793c7 docs: Sprint 14 — Vitest for useOnboarding (Q4) across all 6 running docs
Sprint 14 (commit 28f3212) adds Vitest + happy-dom +
@testing-library/react + @testing-library/jest-dom as
devDeps and locks the useOnboarding state-transition
contract with 7 unit tests in 25 ms. Lifts the 'no new
npm deps' rule for testing-only. Runtime bundle unchanged
(503.82 kB). No pre-existing WIP files touched.

Sprint 9's bug 1562929 (onComplete wired to .reset, the
inverse op) shipped a post-deploy fix the same day.
Sprint 14 prevents recurrence at npm test time.

This commit updates the 6 running docs that track sprints:

- .agent/plan.md — header changed to 'Active sprint: Sprint
  14'; Sprint 14 section (S14.1-S14.4 + Done when + Out of
  scope) added after the Sprint 13 section.
- .agent/context.md — Sprint 14 decisions (D1-D7), open Q1,
  and file:line references added.
- Review/sprint14-verification.md — NEW: deploy + 7-case
  test table + verification commands + Case 7 S9-bug-catch
  proof + 5-risk table + open Q1 (component-level tests).
- Review/ui-nielsen-audit.md — Sprint 14 status block
  (T7.1-T7.3) added after the Sprint 13 block.
- fix-ui-audit.md — Sprint 14 section (T7.1-T7.5) added
  after the Sprint 13 section.
- Review/handoff-ui-audit.md — Batch J line in deploy
  list, Sprint 14 section after Sprint 13, TL;DR Sprint
  14 line, Last-updated footer updated. (Did NOT replace
  the Sprint 7/8/TL;DR/environment-quirks/active-risks
  content that was at the bottom of the file — that
  material is preserved unchanged.)
- docs/HANDOFF.md — Sprint 14 section added after Sprint
  13, Last-updated footer updated.

All 6 docs now reflect Sprint 14. §Future backlog
remaining: F9-full (local Ollama model pull on the host).
Q4 (Vitest) is now closed. Sprint 14 is the smallest
sprint in the cycle (devDeps + 1 test file + 2 config
files + 2 scripts) and the first that adds a test
infrastructure layer.
2026-06-05 17:30:00 -07:00

38 KiB
Raw Blame History

Recovery Plan — MealPlanner

Goal: bring implementation back into alignment with Review/reviewconcensus.md. Stop building forward features until the deferred-risk spikes and the verification matrix pass.

Active sprint: Sprint 14 — Vitest for useOnboarding (Q4)

Owner: this agent. Status: starting. Tracking: Review/sprint14-verification.md (deploy + smoke), .agent/plan.md (checklist), .agent/context.md (decisions + open Qs).

Why now (Q4 rationale): Sprint 9 (F1 Onboarding Tour) shipped a hand-rolled ~420-line component, then immediately regressed in 1562929: onComplete was wired to useOnboarding().reset() (inverse op) so the X/Skip/Esc dismiss path actually re-showed the tour. The fix split the API into onComplete/onReset callbacks with markComplete (dismiss) vs reset (re-show). The bug class is "two inverse operations share one state setter"; the only durable prevention is unit tests. ~1 hr sprint, locks the seam.

Sprint 14 lifts the "no new npm deps" rule for testing-only. Runtime deps unchanged.

S8.1 — Migration: 0016_denial_decay_and_scope.py (NEW)

  • Adds meal_plan_item.denial_expires_at TIMESTAMPTZ NULL.
  • Adds meal_plan_vote.denial_scope VARCHAR(16) NULL.
  • Partial index on meal_plan_item.denial_expires_at (postgresql_where IS NOT NULL) for the planner's soft-deny lookup.
  • Downgrade reverses all three.

S8.2 — Model: app/models/__init__.py

  • MealPlanItem.denial_expires_at column added.
  • MealPlanVote.denial_scope column added.

S8.3 — Schema: app/schemas/__init__.py

  • MealPlanItemResponse.denial_expires_at: Optional[datetime].
  • VoteRequest.denial_scope: Optional[str] with pattern=^(this_week|never_again)$.
  • VoteResponse.denial_scope: Optional[str].

S8.4 — Backend helpers: app/api/meals.py

  • _apply_denial(db, item, scope) — single source of truth for the deny path. Returns {item, promoted_to_permanent, scope}. Commits.
  • _ensure_never_suggest_recipe(db, family_id, recipe_id, reason) — idempotent NeverSuggest insert. Returns True if new, False if existing.
  • _has_prior_active_soft_denial(db, family_id, recipe_id, current_item_id=None) — count query for the 2-denial check.
  • DENIAL_DECAY_DAYS = 90 constant.

S8.5 — Backend endpoints: app/api/meals.py

  • POST /api/meals/items/{id}/deny?scope=this_week|never_again (default this_week).
    • Returns {message, item, promoted_to_permanent, scope}.
    • swap_meal_item also clears denial_expires_at (defensive: a new recipe_id is a fresh start).
  • POST /api/meals/vote/{id} extended: vote: "approve" | "deny" | "never_again".
    • Returns {status, item_status, denial_scope, promoted_to_permanent}.
    • The 2-denial auto-escalation runs server-side for both deny and never_again.
  • GET /api/meals/vote/{id} HTML page renders 3 buttons. Supports one-click ?scope=... for the email's per-button links.

S8.6 — Email template: app/services/orchestrator/steps.py

  • 3 direct-action links per recipe (Approve / Deny this week / Never again).
  • Legacy "Vote on this meal" preserved as a secondary "Open vote page (all 3 options)" link.

S8.7 — Planner: app/services/planner/generate.py

  • _load_blocklists returns 3 sets: (blocked_ingredients, blocked_recipes, soft_denied_recipes).
  • soft_denied_recipes is the hard filter (per user decision: same as blocked_recipes).
  • rejected_summary adds a soft_denied_recipe diagnostic bucket.

S8.8 — Frontend: Dashboard.tsx + api/index.ts

  • api/index.ts:48-58meals.denyItem(itemId, { scope }).
  • Dashboard.tsx:38-50, 385-410MealCard accepts scope-aware onDeny; 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 window.confirm to prevent accidental permanent blocks.
  • Buttons only show on pending items (approved/denied items show the badge only).

S8.9 — Verify

  • npm run build green for Sprint 8 (tsc 0 errors, vite 0 errors).
  • Backend smoke: 21/21 planner tests pass (1 pre-existing test_filter_blocks_by_cost failure is not introduced by S8 — verified via git stash + re-run on a clean tree).
  • Static checks: all 6 new modules import cleanly, helper logic verified via Python AST + import-test against backend/venv.
  • Review/sprint8-verification.md written with deploy + 11-step browser smoke + 4 API curls + email-render procedure + rollback.
  • Deploy verified on 100.108.224.12 — see verification log.
  • No regression in Sprints 1-7.

S8.10 — Docs (all 6 running docs updated)

  • Review/ui-nielsen-audit.md — Sprint 8 status block at the top (T2.1T2.10).
  • fix-ui-audit.md — Sprint 8 plan section (T2.1T2.10).
  • Review/handoff-ui-audit.md — "Active sprint" callout + bottom "Last updated" line.
  • docs/HANDOFF.md — Sprint 7 + Sprint 8 sections before the 2026-06-03 session.
  • .agent/plan.md — this section.
  • .agent/context.md — Sprint 8 decisions, file:line references, verification gate.

Done when (Sprint 8)

  • All 12 boxes above ticked.
  • npm run build green.
  • Review/sprint8-verification.md exists.
  • All 6 doc files have a Sprint 8 status block.
  • User commits + runs the deploy + runs the SQL + reports the smoke checklist.

Out of scope (Sprint 8)

  • Thread 3: §Future backlog (F1 onboarding, F8/F9 proposals, dead Generate Meal Plan CTA at Dashboard.tsx:415).
  • "Unblock" UI on the webui. The NeverSuggest API exists; no UI to remove a row. User can use the API directly.
  • Decay-sweep cron. The 90-day filter is at read time; expired rows just become invisible. No cleanup needed.
  • Pre-existing denied row (2026-05-15 day-2 Roasted Sweet Potato and Chickpea Bowl) — left untouched. denial_expires_at stays NULL; the recipe is effectively forgotten after 90d from now (today is 2026-06-05, so it'll be eligible again ~2026-09-03). If the user wants it remembered permanently, they can re-trigger the soft-deny cycle by clicking "Deny this week" on the next plan that includes it.

Phase R1 — Stabilize (parallel-safe)

  • R1-A: Verification harness. Add backend/tests/ with pytest config, a conftest.py with a transactional DB fixture, and smoke tests covering: app import, /health, /health/db, every router's GET list endpoint, Alembic upgrade head round-trip on a throwaway DB. Add .github/workflows/ci.yml running lint + pytest + frontend npm run build.
  • R1-B: Auth dependencies on existing routers. Implement an app.security module with: (1) require_admin dep — bearer token compared to settings.ADMIN_TOKEN, applied to ALL /api/admin/* routes; (2) require_session dep — signed-cookie session (itsdangerous, key = SECRET_KEY) for profile/pantry/recipes/meals/shopping-list mutations; reads stay open inside the trusted network. Per-voter approval token flow stays as-is. Update .env.example with ADMIN_TOKEN. Document the model in docs/SECURITY.md.
  • R1-C: Make /api/admin/scrape async. Convert the endpoint to enqueue a background job (FastAPI BackgroundTasks for now; APScheduler later). Endpoint returns 202 + scrape_log_id; status polled via /api/admin/logs/{id}. ScraperService must open its own DB session inside the task (the request-scoped db is gone by then).

Phase R2 — De-risk deferred work (parallel-safe, must run BEFORE further feature work per review §2.4)

  • R2-A: Live-scrape spike. Run LuckyCaliforniaScraper against https://luckysupermarkets.com once, capture the raw HTML/PNG to backend/tests/fixtures/lucky_ca/, write a unit test that parses the captured fixture (no live network in CI). Document selector decisions in .agent/context.md. If the page can't be parsed, file the schema impact before going further.
  • R2-B: Email + approval round-trip spike. Implement minimal SendGrid sender (app/services/email.py), an app/services/approval.py that issues per-voter signed tokens (TTL, single-use), the GET confirmation page + POST submit handler (the routes already exist as stubs in meals.py), and a CLI script scripts/send_test_approval.py that creates a fake meal plan, emails one voter, and verifies the click→POST→DB write path end to end against a sandboxed inbox or MAIL_BACKEND=console. Goal: prove the schema (family_member, approval_token tables) survives one full round trip BEFORE building Phase 4/5/9.

Phase R3 — Resume feature work (sequential, only after R1+R2 green)

  • R3-A: Phase 4 Recipe Engine — search, tagging, never-suggest filter.
  • R3-B: Phase 9 Meal Planner generation algorithm.
  • R3-C: Phase 6 SendGrid templated emails (proposal, reminder, confirmation).
  • R3-D: Phase 8 Feedback UI.
  • R3-E: APScheduler with --workers 1 for weekly scrape + plan generation + email send.
  • R3-F: Phase 10 image strategy.

Halt conditions

  • R2 spikes fail → stop, propose schema/spec change, await approval.
  • Verification matrix in Review/reviewconcensus.md §6 not green → no R3 work begins.

Sprint 9 — F1 Onboarding Tour (H10)

Owner: this agent. Status: code complete, npm run build green, awaiting user commit + deploy. Tracking: Review/sprint9-verification.md.

User policy decision (2026-06-05, exact): "Proceed with the next phase in the redesign." Selected Sprint 9 = F1 (the only §Future item with a clear UI scope). F8 (Spoonacular) and F9 (Ollama) are full backend proposals; the dead Generate Meal Plan CTA is a separate follow-up.

S9.1 — New OnboardingTour.tsx component (NEW)

  • 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.
  • Tooltip card pinned to anchor (top/bottom/center fallback for off-route steps).
  • Anchor highlight = primary-400 ring + soft scrim; tooltip is a real <div role="dialog" aria-modal="true">.
  • Step progress = 4 progress bars.
  • Keyboard: 14 jump, ←/→ step, Esc dismiss, Tab order is Skip → Back → Next.
  • useOnboarding() hook + ?reset-tour=1 re-trigger; localStorage key mealplanner:onboarding-complete.
  • Focus captured on open (primary action), restored on close.
  • All reads/writes to localStorage wrapped in try/catch (private mode safe).

S9.2 — Anchor points (5 lines of code total)

  • pages/Dashboard.tsx:602<Card data-tour="dashboard"> on the Weekly Overview grid.
  • pages/Pantry.tsx:185<div data-tour="pantry"> on the page header (always present).
  • pages/Pantry.tsx:208 — second anchor on the add-form <Card> (when the form is open).
  • pages/Recipes.tsx:124<Button data-tour="recipes"> on the Filters button.
  • pages/ShoppingList.tsx:231<div data-tour="shopping-list"> on the page header.

S9.3 — App.tsx mount

  • useOnboarding() at App root, isComplete passed to <OnboardingTour>.
  • onComplete mapped to onboarding.reset() (flips the flag so re-renders don't re-show).
  • Mounted as sibling of <ShortcutHelpBanner /> inside <BrowserRouter> (so useLocation / useNavigate work).

S9.4 — Verify

  • npm run build green (tsc 0 errors, vite 0 errors).
  • Browser smoke (8 steps) on http://100.108.208.56:8082/ per Review/sprint9-verification.md.
  • No regression in Sprints 18 (keyboard shortcuts, error toast, 3-button vote row, WeekRangeNav, bulk pantry add).

S9.4.1 — Post-deploy fix (2026-06-05)

User reported post-deploy: "The tour window looks great, but Clicking the X nor skip tour do anything. I cannot exit the tour." Build was green but the dismiss path was broken.

  • Root cause identified (systematic-debugging Phase 4): useOnboarding().reset() was wired to the dismiss handler at App.tsx:104-109. reset() does the inverse of dismiss — it clears the localStorage key AND flips isComplete to false. So clicking X wrote the key, but the App-level flag flipped in the wrong direction, the tour's if (isComplete || !currentStep) return null early-return never fired, and the dialog stayed visible.
  • Fix committed (1562929): split the dismiss and reset paths into two distinct callbacks.
    • useOnboarding now exposes markComplete() (state flip to true) in addition to reset() (state flip to false).
    • OnboardingTour takes two props: onComplete (dismiss) and onReset (re-show).
    • App.tsx wires onComplete → onboarding.markComplete() and onReset → onboarding.reset().
    • Cleaned up: markComplete no longer double-writes localStorage (the tour's finish() already does that).
  • npm run build green on docker-willester after the fix (495.64 kB, no size change).
  • User confirmed post-deploy smoke test passes (2026-06-05).
  • Anchors + URL effect re-verified: 5/5 data-tour anchors present at Dashboard.tsx:602, Pantry.tsx:185, 208, Recipes.tsx:131, ShoppingList.tsx:231; ?reset-tour=1 effect calls onReset() correctly.

S9.5 — Docs (all 6 running docs updated)

  • Review/ui-nielsen-audit.md — Sprint 9 status block at the top.
  • fix-ui-audit.md — Sprint 9 plan section (T3.1T3.4).
  • Review/handoff-ui-audit.md — Sprint 9 entry in the "How to take over" section + TL;DR row.
  • docs/HANDOFF.md — Sprint 9 section.
  • .agent/plan.md — this section.
  • .agent/context.md — Sprint 9 decisions + file:line references.
  • Review/sprint9-verification.md — written (8-step browser smoke + a11y check + reset-link test).

Done when (Sprint 9)

  • All boxes above ticked.
  • npm run build green.
  • Review/sprint9-verification.md exists.
  • All 6 doc files have a Sprint 9 status block.

Out of scope (Sprint 9)

  • Thread 3 follow-ups: F8 (Spoonacular), F9 (Ollama), dead Generate Meal Plan CTA at Dashboard.tsx:415.
  • Per-page deep tutorials, video demos, hover tooltips.
  • A user-facing "Show tour" link in the footer (operator uses ?reset-tour=1; a footer link is a 5-line follow-up if requested).
  • Sprint 10 — "Deny Forever" on Recipes — committed 2026-06-05, awaiting user deploy.

Sprint 10 — "Deny Forever" on Recipes (user-driven)

Owner: this agent. Status: code complete, npm run build green, 21/21 planner tests pass, awaiting user commit + deploy. Tracking: Review/sprint10-verification.md.

User direction (2026-06-05, exact): "Proceed with the next phase in the redesign. Also add a phase to include a 'Deny Forever' button in the Recipes endpoint." Sprint 10 ships the Deny Forever button on both the Recipes page (card overlay) and the RecipeDetail page (top bar).

S10.1 — Backend: POST /api/never-suggest (public)

  • New endpoint in app/api/never_suggest.py:60-86. Family-facing (uses require_session).
  • Body: {family_profile_id, recipe_id, reason: "allergy"|"dislike", notes?}.
  • Idempotent on (family_profile_id, recipe_id, ingredient_id, reason).
  • Returns the row joined with recipe_name.

S10.2 — Backend: DELETE /api/never-suggest/{ns_id} (public)

  • New endpoint in app/api/never_suggest.py:89-111. Family-facing.
  • Row-level ownership check: 403 if family_profile_id doesn't match the session.
  • 404 if the row doesn't exist.

S10.3 — Backend: NeverSuggestRead.recipe_name + .ingredient_name

  • New fields in app/schemas/never_suggest.py:31-33.
  • Server-side JOIN helper _attach_names() in app/api/never_suggest.py:33-58. One LEFT OUTER JOIN per kind, then merge into response dicts.
  • Falls back to None if the recipe/ingredient was deleted (FK is ON DELETE CASCADE).

S10.4 — Frontend: API client

  • mealPlannerApi.neverSuggest.list(familyProfileId)frontend/src/api/index.ts:75-86.
  • mealPlannerApi.neverSuggest.add({...}) — POST.
  • mealPlannerApi.neverSuggest.remove(nsId) — DELETE.

S10.5 — Frontend: NeverSuggestButton component (NEW)

  • frontend/src/components/NeverSuggestButton.tsx (~290 lines).
  • Two variants: card (overlay on RecipeCard) and detail (text buttons in RecipeDetail top bar).
  • Popover with two reasons: Allergy (red, requires window.confirm) and 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.
  • Query invalidations: ['neverSuggest', familyId], ['recipes'], ['recommendedRecipes', familyId], ['mealPlan'].
  • A11y: aria-label, aria-expanded, aria-haspopup="menu", role="menu", Esc dismisses, outside click dismisses.

S10.6 — Frontend: Recipes.tsx overlay

  • RecipeCard now has position: relative so the overlay anchors correctly.
  • Button is opacity-0 group-hover:opacity-100 (visible on hover or focus).
  • e.preventDefault() + e.stopPropagation() on the click — doesn't navigate to the detail page.

S10.7 — Frontend: RecipeDetail.tsx top bar

  • New "Deny forever" button group to the left of "Add to Plan".
  • Same popover + confirm/undo semantics as the card overlay.

S10.8 — Verify

  • npm run build green (tsc 0 errors, vite 0 errors). Bundle: 487 → 495 kB.
  • Backend imports clean; routes registered.
  • 21/21 planner tests pass (1 pre-existing failure deselected).
  • Browser smoke (9 steps) on http://100.108.208.56:8082/ per Review/sprint10-verification.md.
  • No regression in Sprints 1-9.

S10.9 — Docs (all 6 running docs updated)

  • Review/ui-nielsen-audit.md — Sprint 10 status block at the top.
  • fix-ui-audit.md — Sprint 10 plan section (T4.1T4.9).
  • Review/handoff-ui-audit.md — Sprint 10 entry in the "How to take over" section + TL;DR row.
  • docs/HANDOFF.md — Sprint 10 section.
  • .agent/plan.md — this section.
  • .agent/context.md — Sprint 10 decisions, file:line references, verification gate.
  • Review/sprint10-verification.md — written (deploy + 9-step browser smoke + 5 API curls + undo test + a11y check).

Done when (Sprint 10)

  • All boxes above ticked.
  • npm run build green.
  • Review/sprint10-verification.md exists.
  • All 6 doc files have a Sprint 10 status block.

Out of scope (Sprint 10)

  • A "Manage blocked recipes" page.
  • Bulk unblock.
  • Touch-device gesture for the card overlay (the focus state already surfaces the button on tap).

Sprint 11 — Wire the dead "Generate Meal Plan" CTA (Dashboard.tsx:499-504)

User direction (2026-06-05): "Proceed." Selected from the question menu as the smallest §Future item. F1 (Sprint 9) is shipped, F8 (Spoonacular) + F9 (Ollama) are full backend proposals, and the dead Generate Meal Plan CTA at Dashboard.tsx:503 is the final remaining item. The button is rendered with onClick: () => {} — clicking it does nothing. Wired to existing endpoints, no backend changes, no new dependencies.

Root cause: the user lands on the Dashboard with no meal plan and sees a "Generate Meal Plan" button. Clicking it does nothing. The backend already has the two endpoints needed (POST /api/meals to create a plan + POST /api/meals/{id}/fill-empty-slots to fill it from the recipe library), and the fillEmptySlots partial-success report pattern is already in production for the existing Plan Week menu (handlePlanWeek at Dashboard.tsx:366-392). The wiring is a 25-line client-side glue function that calls both in sequence.

S11.1 — handleGenerateFirstPlan() in Dashboard.tsx

  • Add a new handler next to handlePlanWeek (line 366) that:
    1. Reads weekStart (already in scope).
    2. POST /api/meals with { week_start_date: weekStart, status: 'draft' } to create an empty plan.
    3. On success, POST /api/meals/{newId}/fill-empty-slots with { meal_types: ['breakfast', 'lunch', 'dinner'] }.
    4. Invalidate ['mealPlan', weekStart].
    5. Toast: reuse the same partial-success pattern as handlePlanWeek ("Planned N of M meals — K failed").
  • Wire onClick of the EmptyState.action (line 503) to call handleGenerateFirstPlan().
  • Track a generatingFirstPlan state for the loading spinner; swap the button label to "Generating…" while in-flight.
  • Handle the "Meal plan for this week already exists" 400 from meals.create (race condition with another tab) by calling fillEmptySlots directly with the existing plan's id — refetch the plan from getPlanned(weekStart) to get the id.

S11.2 — Verify

  • npm run build green (tsc 0 errors, vite 0 errors).
  • Browser smoke (4 steps) on http://100.108.208.56:8082/:
    1. Log in as a family with no meal plan for the current week. Land on /.
    2. Confirm EmptyState shows "Generate Meal Plan" button.
    3. Click the button. Confirm: button label flips to "Generating…", toast appears with "Planned N of M meals", empty state disappears, plan grid renders.
    4. Refresh the page. Confirm the plan persists.
  • Race test: open two tabs, both click "Generate Meal Plan" at the same moment. Second tab should still succeed (handled by the meals.create 400 → fall-through to fillEmptySlots path).
  • No regression in Sprints 1-10.

S11.3 — Docs (all 6 running docs updated)

  • Review/ui-nielsen-audit.md — Sprint 11 status block at the top.
  • fix-ui-audit.md — Sprint 11 plan section (T5.1-T5.3).
  • Review/handoff-ui-audit.md — Sprint 11 entry in the "How to take over" section + TL;DR row.
  • docs/HANDOFF.md — Sprint 11 section.
  • .agent/plan.md — this section.
  • .agent/context.md — Sprint 11 decisions + file:line references.
  • Review/sprint11-verification.md — written (4-step browser smoke + race test).

Done when (Sprint 11)

  • All boxes above ticked.
  • npm run build green.
  • Review/sprint11-verification.md exists.
  • All 6 doc files have a Sprint 11 status block.

Out of scope (Sprint 11)

  • LLM-powered generation (F8 Spoonacular, F9 Ollama) — separate backend proposals, future sprints. Sprint 11 only wires the existing recipe-library-based fill.
  • A "what would you like for dinner?" prompt before generation — the existing flow generates from the library with no user input.
  • A "regenerate" button after the plan exists — the existing Plan Week menu at Dashboard.tsx:366-392 already handles this case.

Sprint 12 — F8 Spoonacular search (§Future H10) — DRAFTED, awaiting user approval

User direction (2026-06-05): "Proceed." Selected from the question menu. F8 is the smallest remaining §Future item: search-by-name on a public API, brings external recipe data into the system. F9 (Ollama) remains a separate full-backend proposal.

Root cause: the user can browse ~150 local recipes on /recipes (admin seeds them) but has no path to find new ones without leaving the app. F8 adds a "Search the web" toggle that hits the Spoonacular complexSearch API and lets the user import a result into the local library in one click.

Pre-existing infrastructure to reuse (not recreate):

  • backend/app/services/recipe_discovery.py (226 lines) — full RecipeDiscoveryService with _search_spoonacular(), _fetch_recipe_info(), _normalize_spoonacular(). Reads SPOONACULAR_API_KEY via getattr(settings, ...). Cites 150/day free quota.
  • backend/app/api/ingredients.py:58-103 — public POST /api/ingredients is idempotent on name_lower + aliases. The ingredient-resolution helper for the import flow.
  • scripts/enrich_recipes_spoonacular.py (76 lines) — standalone one-shot script, reference for the env + URL pattern.

Pre-existing WIP (NOT touched by Sprint 12):

  • backend/app/api/recipes.py (352 lines, not registered in main.py)
  • backend/app/schemas/recipe.py (93 lines, has RecipeCreate + RecipeIngredientRef)
  • nginx/nginx.conf

S12.1 — Backend: GET /api/recipes/search (public, webui-facing)

  • NEW backend/app/api/recipe_search.py — 2 endpoints + a thin search_spoonacular_summary(q, limit) wrapper. Reuses the existing requests.get(SPOONACULAR_SEARCH_URL, params={...}) pattern.
    • GET /recipes/search?q=&limit= — public, require_session. Calls complexSearch with addRecipeInformation=true, fillIngredients=true, instructionsRequired=true, number=limit. Returns normalized RecipeSearchHit[]. No info endpoint call (saves 1 point per result; search summary is enough for browsing).
    • POST /recipes/import — public, require_session. Body {external_id, external_source: "spoonacular"}. Fetches /recipes/{id}/information (1 point), normalizes, upserts ingredients via idempotent POST /api/ingredients, creates a local Recipe with external_source+external_id+is_manually_added=true. Returns the new Recipe.
  • MODIFIED backend/app/schemas/__init__.py — add RecipeSearchHit and RecipeImportRequest Pydantic models. Mirror the ExternalRecipe dataclass shape from recipe_discovery.py:28-44 (but with Pydantic).
  • MODIFIED backend/app/config.py — add SPOONACULAR_API_KEY: Optional[str] = None to Settings for schema consistency. (Currently read via getattr because extra="ignore". Adding it surfaces it in .env.example and tools.)
  • MODIFIED backend/app/main.py — register the new router. Reuses the app.include_router pattern at line 44-50.
  • Process-wide _points_used counter (module-level singleton in recipe_search.py). 503 with detail: "spoonacular daily quota reached" when over 140. Logged on every call.
  • 503 with detail: "SPOONACULAR_API_KEY not configured" when env var unset. Logged once at startup.

S12.2 — Backend: tests

  • NEW backend/tests/test_recipe_search.py — 4 tests, mock the Spoonacular requests.get calls.
    1. GET /api/recipes/search?q=chicken returns 200 + 1 normalized hit (mock summary).
    2. GET /api/recipes/search?q= returns 422 (empty query).
    3. POST /api/recipes/import happy path: mock info call + idempotent ingredient upsert + 201 with the new Recipe id.
    4. POST /api/recipes/import duplicate external_id → 409.

S12.3 — Frontend: API client + Recipes page

  • MODIFIED frontend/src/api/index.ts:27-33 — add recipes.search(q, limit) and recipes.import(data).
  • MODIFIED frontend/src/pages/Recipes.tsx — add a "Search the web" toggle next to the search bar (small button + Sparkles icon from lucide). When ON, the existing useQuery switches from recipes.list(params) to recipes.search({q: debouncedQ, limit: 10}). Renders results in a separate panel above the local list. Each result card has an "Import" button + the existing NeverSuggestButton removed (since these are not-yet-imported Spoonacular results, not local recipes).
    • Toggle defaults to OFF so the existing UX is preserved.
    • Toggle is a real <button> with aria-pressed={searchWeb}.
    • Debounced 300ms, same as the local search (reuse handleSearch from line 77-81).
    • Panel has aria-busy={isLoading} while fetching.

S12.4 — Verify

  • cd backend && python -m pytest tests/test_recipe_search.py -v → 4/4 green.
  • cd frontend && npm run build → green (tsc 0 errors, vite 0 errors).
  • Manual API smoke: curl -sS 'http://100.108.208.56:8082/api/recipes/search?q=chicken&limit=5' -b session.txt → 200 JSON array.
  • Manual UI smoke (4 steps):
    1. Open /recipes in incognito. Confirm "Search the web" toggle is OFF, only the local list shows.
    2. Click the toggle. Confirm the panel header changes to "Search the web — Spoonacular" and a debounced search bar appears.
    3. Type "pasta" with 300ms debounce. Confirm 5-10 results render with name + image + cuisine tags.
    4. Click "Import" on a result. Confirm: toast "Imported!" + result card shows "Already imported" + toggle closes + the local list re-fetches and now contains the imported recipe.
  • Quota test: hit search 50 times in a row, confirm _points_used increments. The 51st within the budget returns 503.
  • No regression in Sprints 1-11.

S12.5 — Docs (all 6 running docs updated)

  • Review/ui-nielsen-audit.md — Sprint 12 status block (T6.1T6.4) at the top.
  • fix-ui-audit.md — Sprint 12 plan section (T6.1T6.5).
  • Review/handoff-ui-audit.md — Batch H + Sprint 12 entry + TL;DR row 12.
  • docs/HANDOFF.md — Sprint 12 section.
  • .agent/plan.md — this section.
  • .agent/context.md — Sprint 12 decisions + file:line references.
  • Review/sprint12-verification.md — written (4-step browser smoke + 2 API curls + quota test + a11y check).

Done when (Sprint 12)

  • All boxes above ticked.
  • npm run build green.
  • pytest tests/test_recipe_search.py green (4/4).
  • Review/sprint12-verification.md exists.
  • All 6 doc files have a Sprint 12 status block.

Out of scope (Sprint 12)

  • F9 — Ollama local LLM. Different backend proposal (model pull + ollama-py + /api/llm/plan endpoint). Separate sprint.
  • Image generation. AI_IMAGE_ENABLED env gate already exists; not enabled. Sprint 12 imports the Spoonacular image as-is.
  • Auto-enriching existing recipes with macros (would require nutrition endpoint = 1 pt per recipe; out of free quota).
  • Modifying the pre-existing WIP backend/app/api/recipes.py / schemas/recipe.py / nginx/nginx.conf — untouched.
  • F8 Spoonacular + F9 Ollama + dead Generate Meal Plan CTA — separate.

Sprint 13 — F9-lite (Ollama Cloud free-text plan synthesis) — DRAFTED, awaiting user approval

User direction (2026-06-05): "Proceed." F9-lite reuses the pre-existing OLLAMA_* config (config.py:36-38: OLLAMA_BASE_URL=https://ollama.com/v1, OLLAMA_API_KEY, OLLAMA_MODEL=kimi-k2.6:cloud). Avoids the local model pull (F9-full would be 4 GB on disk + a separate uvicorn process). Cloud LLM — costs apply per call (operator's existing OLLAMA billing).

Pre-existing infrastructure to reuse (not recreate):

  • backend/app/services/llm_matcher.py:97-144_ask_ollama(ingredient_name, candidates) helper. The exact call pattern Sprint 13 mirrors: POST ${OLLAMA_BASE_URL}/chat/completions with Authorization: Bearer ${OLLAMA_API_KEY}, model: settings.OLLAMA_MODEL, max_tokens: 500, temperature: 0, parse choices[0].message.content, strip `` blocks.
  • backend/app/services/recipe_enrichment.py — parallel LLM helper for recipes (different prompt shape; not reused).
  • backend/app/config.py:36-38 — OLLAMA config.

Pre-existing WIP (NOT touched): same as Sprint 12.

S13.1 — Backend: POST /api/llm/plan (public, webui-facing)

  • NEW backend/app/api/llm_plan.py — single endpoint + a thin _ask_llm(prompt) helper. The endpoint:
    1. Validates the request body (prompt: str 1-500 chars, week_start: date).
    2. Reads the local recipe library (Recipe table, filtered by family_profile_id); serializes a compact list {id, name, cuisine_tags, dietary_tags, protein_type, total_time_minutes, dietary preferences}.
    3. Builds a prompt: "You are planning a 7-day meal plan (Mon-Sun). The user wants: ''. Pick up to 21 meals (7 breakfasts + 7 lunches + 7 dinners) from the recipe library. Return JSON: [{"day_of_week": 1-7, "meal_type": "breakfast|lunch|dinner", "recipe_id": "<uuid>"}]. If a slot has no good match, omit it. Use only recipe_ids from the list. Reply with JSON only — no commentary."
    4. Calls _ask_llm(prompt) (mirrors _ask_ollama from llm_matcher.py:97-144).
    5. Parses the JSON response (try json.loads, fall back to re.search(r"\[.*\]", content) to handle markdown code fences).
    6. Validates each entry: recipe_id is a UUID, day_of_week in 1-7, meal_type in {breakfast, lunch, dinner}. Drop invalid entries.
    7. Creates an empty plan via meals.create (Sprint 6+ endpoint), then bulk-inserts the LLM-picked items + calls fillEmptySlots for the slots the LLM didn't cover.
    8. Returns {plan_id, picked_count, filled_count, failed_count, reasoning: <LLM raw text if non-empty>}.
  • MODIFIED backend/app/schemas/__init__.py — add LLMPlanRequest + LLMPlanResponse Pydantic models.
  • MODIFIED backend/app/main.py — register the new router at /api/llm.
  • 503 with clear detail: "OLLAMA_API_KEY not configured" when env var unset.
  • 422 on empty / oversized prompt.
  • Cap on library size sent to the LLM: 200 recipes max (alphabetical by name). Larger libraries would exceed prompt tokens.

S13.2 — Frontend: free-text prompt in the Sprint 11 flow

  • MODIFIED frontend/src/pages/Dashboard.tsx — turn the Sprint 11 handleGenerateFirstPlan into a 2-step:
    1. New MealPlanPromptModal component (inline in Dashboard.tsx, ~40 lines, reuse Card/Button/Input from components/ui/*): a small modal with a textarea (max 500 chars, char counter) + two radio options: "Use the recipe library" (default) and "Ask the LLM". On submit, calls one of two API methods.
    2. Library path (default): keep Sprint 11's meals.create + fillEmptySlots exactly as-is.
    3. LLM path (new): mealPlannerApi.llm.plan({prompt, week_start}). On success, invalidate ['mealPlan', weekStart] and toast "Planned N meals (LLM picked N, library filled the rest)".
  • MODIFIED frontend/src/api/index.ts — add llm.plan(data).
  • A11y: modal has role="dialog", aria-modal="true", focus trapped on the textarea on open, restored to the CTA button on close. Esc dismisses. Tab cycles within the modal.

S13.3 — Verify

  • cd frontend && npm run build → green (tsc 0 errors, vite 0 errors).
  • Backend AST clean.
  • Manual API smoke:
    • curl -X POST /api/llm/plan -d '{"prompt": "easy weeknight dinners, no fish", "week_start": "2026-06-08"}' → 200 with {plan_id, picked_count: 7+, filled_count: 14-, ...}. (Requires OLLAMA_API_KEY set on the host.)
    • With OLLAMA_API_KEY unset → 503.
    • Empty prompt → 422.
  • Manual UI smoke (3 steps):
    1. Land on / with no plan. Click "Generate Meal Plan". Modal opens with the textarea + the two radio options.
    2. Type "Italian-inspired, vegetarian" + select "Ask the LLM" + click submit. Confirm: button label flips to "Asking LLM…", modal shows a small spinner, ~5-15s later the modal closes, plan grid renders, toast shows the picked/filled split.
    3. Refresh the page. Confirm the plan persists.
  • No regression in Sprints 1-12.

S13.4 — Docs (all 6 running docs updated)

  • Review/ui-nielsen-audit.md — Sprint 13 status block.
  • fix-ui-audit.md — Sprint 13 plan section (T7.1-T7.4).
  • Review/handoff-ui-audit.md — Batch I + Sprint 13 entry + TL;DR row 13.
  • docs/HANDOFF.md — Sprint 13 section.
  • .agent/plan.md — this section.
  • .agent/context.md — Sprint 13 decisions + file:line references.
  • Review/sprint13-verification.md — written.

Done when (Sprint 13)

  • All boxes above ticked.
  • npm run build green.
  • Review/sprint13-verification.md exists.
  • All 6 doc files have a Sprint 13 status block.

Out of scope (Sprint 13)

  • F9-full — local Ollama model pull on the host. Would require pulling Mistral 7B or Llama 3 8B (~4 GB) + a separate ollama serve process + a different config (OLLAMA_BASE_URL=http://localhost:11434). F9-lite uses the cloud tier. Future sprint if the cloud costs become painful.
  • Prompt engineering / quality iteration. The prompt is a first cut. If the LLM returns 0 picks or 21 identical recipes, the operator can iterate on the prompt. Out of scope for the initial ship.
  • Multi-week plans. One week at a time.
  • Save the prompt as a template for reuse. Future sprint.

Active sprint: Sprint 14 — Vitest for useOnboarding (Q4)

S14.1 — Dependencies (devDeps only)

  • Add vitest@^1.6.0 — the runner.
  • Add happy-dom@^14.7.0 — DOM env (lighter than jsdom, faster startup; 7x smaller).
  • Add @testing-library/react@^14.2.0 — render + assert helper.
  • Add @testing-library/jest-dom@^6.4.0.toBeInTheDocument() etc.
  • All four go under devDependencies. Lifts the "no new npm deps" rule for testing-only.

S14.2 — Config

  • frontend/vitest.config.ts — uses Vite's plugin-react (already a devDep), sets environment: 'happy-dom', points setupFiles: ['./vitest-setup.ts'], reuses tsconfig.json paths.
  • frontend/vitest-setup.ts — imports @testing-library/jest-dom/vitest (auto-extends expect with DOM matchers).
  • package.json scripts: add "test": "vitest run --reporter=default" (no watch by default — CI-friendly) + "test:watch": "vitest".

S14.3 — Tests for useOnboarding (the S9 bug class)

  • frontend/src/components/OnboardingTour.test.tsx — render the hook via a tiny <TestHarness/> consumer; renderHook from @testing-library/react.
  • Case 1 — clean init: clear localStorage; isComplete === false.
  • Case 2 — persisted init: localStorage.setItem('mealplanner:onboarding-complete', '1'); isComplete === true on mount.
  • Case 3 — markComplete sets state, does NOT clear localStorage: after markComplete(), isComplete === true and the localStorage key is still '1'. This is the S9 bug lock.
  • Case 4 — reset clears localStorage AND flips state to false: after reset(), isComplete === false and the localStorage key is removed.
  • Case 5 — show is a mirror of reset: after show(), same assertions as Case 4.
  • Case 6 — localStorage throw is silently swallowed: stub localStorage.getItem to throw; hook still returns isComplete: false, does not crash.

S14.4 — Verify + commit

  • cd frontend && npm test runs all 6 cases green.
  • npm run build still green (vitest's types shouldn't conflict with vite's).
  • Review/sprint14-verification.md written.
  • All 6 running docs updated with the Sprint 14 status block.
  • Commit on host + push.

Done when (Sprint 14)

  • All boxes above ticked.
  • npm test shows 6 passing in <5 s.
  • npm run build still green.
  • The S9 bug class is locked: any future regression that wires onComplete → reset (or onReset → markComplete) trips Case 3.

Out of scope (Sprint 14)

  • Component-level tests for <OnboardingTour/> itself (the dialog, focus management, arrow-key navigation). The hook covers the S9 bug class; component tests are a different scope. Future sprint.
  • Tests for recipes API client or useOnboarding callers. Not the S9 bug class. Future sprint.
  • Tests for the backend. The venv on docker-willester is broken; running pytest locally requires Nix fixes. Out of scope.
  • F9-full (local Ollama model pull). Opt-in based on cloud-billing feedback only. _ask_llm is the single seam — F9-full only needs to swap the URL + model name.