Sprint 13 (commit bae9403) splits the Sprint 11 "Generate Meal
Plan" CTA into a 2-step modal: "Use the recipe library" (default,
Sprint 11 unchanged) 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.
This commit updates the 6 running docs that track the sprint:
- .agent/plan.md — Sprint 13 section (S13.1-S13.4) added.
- .agent/context.md — Sprint 13 (D1-D9, Q1-Q3) added; file:line
references; key takeaways.
- Review/sprint13-verification.md — new file: 3-step browser
smoke + 4 API curls + a11y check + 6-risk table + future
work section.
- Review/ui-nielsen-audit.md — Sprint 13 status block (T7.1-T7.3)
at the top, after the Sprint 12 block.
- fix-ui-audit.md — Sprint 13 section (T7.1-T7.5) added after
the Sprint 12 section.
- Review/handoff-ui-audit.md — Batch I added to the deploy
instructions; Sprint 13 section added after Sprint 12; TL;DR
table row 13 added; Last-updated footer updated.
- docs/HANDOFF.md — Sprint 13 section added after the Sprint 12
section, with a path-forward paragraph for F9-full.
All 6 docs now reflect Sprint 13. §Future backlog remaining:
F9-full (local Ollama model pull on the host) — opt-in based on
cloud-billing feedback. _ask_llm is the single seam: F9-full only
needs to swap the URL + model name.
35 KiB
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 8 — "Deny" semantics (C + Z, hard-filter escalation)
Owner: this agent. Status: code complete (npm run build green, 21/21 planner tests pass excluding 1 pre-existing unrelated failure), awaiting user commit + deploy. Tracking: Review/sprint8-verification.md (deploy + smoke), .agent/plan.md (checklist), .agent/context.md (decisions + open Qs).
User policy decision (2026-06-05, exact): "Hard filter. If it is denied this week twice, it should be considered denied for good." — collapses the design to C + Z with a server-side 2-denial auto-escalation.
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_atcolumn added.MealPlanVote.denial_scopecolumn added.
S8.3 — Schema: app/schemas/__init__.py
MealPlanItemResponse.denial_expires_at: Optional[datetime].VoteRequest.denial_scope: Optional[str]withpattern=^(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. ReturnsTrueif new,Falseif 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 = 90constant.
S8.5 — Backend endpoints: app/api/meals.py
POST /api/meals/items/{id}/deny?scope=this_week|never_again(defaultthis_week).- Returns
{message, item, promoted_to_permanent, scope}. swap_meal_itemalso clearsdenial_expires_at(defensive: a new recipe_id is a fresh start).
- Returns
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
denyandnever_again.
- Returns
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_blocklistsreturns 3 sets:(blocked_ingredients, blocked_recipes, soft_denied_recipes).soft_denied_recipesis the hard filter (per user decision: same asblocked_recipes).rejected_summaryadds asoft_denied_recipediagnostic bucket.
S8.8 — Frontend: Dashboard.tsx + api/index.ts
api/index.ts:48-58—meals.denyItem(itemId, { scope }).Dashboard.tsx:38-50, 385-410—MealCardaccepts scope-awareonDeny; renders 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
window.confirmto prevent accidental permanent blocks. - Buttons only show on
pendingitems (approved/denied items show the badge only).
S8.9 — Verify
npm run buildgreen for Sprint 8 (tsc 0 errors, vite 0 errors).- Backend smoke: 21/21 planner tests pass (1 pre-existing
test_filter_blocks_by_costfailure is not introduced by S8 — verified viagit 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.mdwritten 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.1–T2.10).fix-ui-audit.md— Sprint 8 plan section (T2.1–T2.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 buildgreen.Review/sprint8-verification.mdexists.- 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 PlanCTA atDashboard.tsx:415). - "Unblock" UI on the webui. The
NeverSuggestAPI 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_atstays 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, aconftest.pywith a transactional DB fixture, and smoke tests covering: app import,/health,/health/db, every router's GET list endpoint, Alembicupgrade headround-trip on a throwaway DB. Add.github/workflows/ci.ymlrunning lint + pytest + frontendnpm run build. - R1-B: Auth dependencies on existing routers. Implement an
app.securitymodule with: (1)require_admindep — bearer token compared tosettings.ADMIN_TOKEN, applied to ALL/api/admin/*routes; (2)require_sessiondep — 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.examplewithADMIN_TOKEN. Document the model indocs/SECURITY.md. - R1-C: Make
/api/admin/scrapeasync. Convert the endpoint to enqueue a background job (FastAPIBackgroundTasksfor 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-scopeddbis 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
LuckyCaliforniaScraperagainsthttps://luckysupermarkets.comonce, capture the raw HTML/PNG tobackend/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), anapp/services/approval.pythat issues per-voter signed tokens (TTL, single-use), the GET confirmation page + POST submit handler (the routes already exist as stubs inmeals.py), and a CLI scriptscripts/send_test_approval.pythat creates a fake meal plan, emails one voter, and verifies the click→POST→DB write path end to end against a sandboxed inbox orMAIL_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 1for 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 §6not 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:
1–4jump,←/→step,Escdismiss,Taborder isSkip → Back → Next. useOnboarding()hook +?reset-tour=1re-trigger; localStorage keymealplanner: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,isCompletepassed to<OnboardingTour>.onCompletemapped toonboarding.reset()(flips the flag so re-renders don't re-show).- Mounted as sibling of
<ShortcutHelpBanner />inside<BrowserRouter>(souseLocation/useNavigatework).
S9.4 — Verify
npm run buildgreen (tsc 0 errors, vite 0 errors).- Browser smoke (8 steps) on
http://100.108.208.56:8082/perReview/sprint9-verification.md. - No regression in Sprints 1–8 (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 atApp.tsx:104-109.reset()does the inverse of dismiss — it clears the localStorage key AND flipsisCompletetofalse. So clicking X wrote the key, but the App-level flag flipped in the wrong direction, the tour'sif (isComplete || !currentStep) return nullearly-return never fired, and the dialog stayed visible. - Fix committed (
1562929): split the dismiss and reset paths into two distinct callbacks.useOnboardingnow exposesmarkComplete()(state flip totrue) in addition toreset()(state flip tofalse).OnboardingTourtakes two props:onComplete(dismiss) andonReset(re-show).App.tsxwiresonComplete → onboarding.markComplete()andonReset → onboarding.reset().- Cleaned up:
markCompleteno longer double-writes localStorage (the tour'sfinish()already does that).
npm run buildgreen ondocker-willesterafter 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-touranchors present atDashboard.tsx:602,Pantry.tsx:185, 208,Recipes.tsx:131,ShoppingList.tsx:231;?reset-tour=1effect callsonReset()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.1–T3.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 buildgreen.Review/sprint9-verification.mdexists.- 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 PlanCTA atDashboard.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 (usesrequire_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_iddoesn'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()inapp/api/never_suggest.py:33-58. One LEFT OUTER JOIN per kind, then merge into response dicts. - Falls back to
Noneif the recipe/ingredient was deleted (FK isON 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 onRecipeCard) anddetail(text buttons inRecipeDetailtop bar). - Popover with two reasons:
Allergy(red, requireswindow.confirm) andDislike(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
RecipeCardnow hasposition: relativeso 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 buildgreen (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/perReview/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.1–T4.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 buildgreen.Review/sprint10-verification.mdexists.- 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:- Reads
weekStart(already in scope). POST /api/mealswith{ week_start_date: weekStart, status: 'draft' }to create an empty plan.- On success,
POST /api/meals/{newId}/fill-empty-slotswith{ meal_types: ['breakfast', 'lunch', 'dinner'] }. - Invalidate
['mealPlan', weekStart]. - Toast: reuse the same partial-success pattern as
handlePlanWeek("Planned N of M meals — K failed").
- Reads
- Wire
onClickof theEmptyState.action(line 503) to callhandleGenerateFirstPlan(). - Track a
generatingFirstPlanstate for the loading spinner; swap the button label to"Generating…"while in-flight. - Handle the
"Meal plan for this week already exists"400 frommeals.create(race condition with another tab) by callingfillEmptySlotsdirectly with the existing plan's id — refetch the plan fromgetPlanned(weekStart)to get the id.
S11.2 — Verify
npm run buildgreen (tsc 0 errors, vite 0 errors).- Browser smoke (4 steps) on
http://100.108.208.56:8082/:- Log in as a family with no meal plan for the current week. Land on
/. - Confirm
EmptyStateshows "Generate Meal Plan" button. - Click the button. Confirm: button label flips to "Generating…", toast appears with "Planned N of M meals", empty state disappears, plan grid renders.
- Refresh the page. Confirm the plan persists.
- Log in as a family with no meal plan for the current week. Land on
- Race test: open two tabs, both click "Generate Meal Plan" at the same moment. Second tab should still succeed (handled by the
meals.create400 → fall-through tofillEmptySlotspath). - 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 buildgreen.Review/sprint11-verification.mdexists.- 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 Weekmenu atDashboard.tsx:366-392already 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) — fullRecipeDiscoveryServicewith_search_spoonacular(),_fetch_recipe_info(),_normalize_spoonacular(). ReadsSPOONACULAR_API_KEYviagetattr(settings, ...). Cites 150/day free quota.backend/app/api/ingredients.py:58-103— publicPOST /api/ingredientsis idempotent onname_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 inmain.py)backend/app/schemas/recipe.py(93 lines, hasRecipeCreate+RecipeIngredientRef)nginx/nginx.conf
S12.1 — Backend: GET /api/recipes/search (public, webui-facing)
- NEW
backend/app/api/recipe_search.py— 2 endpoints + a thinsearch_spoonacular_summary(q, limit)wrapper. Reuses the existingrequests.get(SPOONACULAR_SEARCH_URL, params={...})pattern.GET /recipes/search?q=&limit=— public,require_session. CallscomplexSearchwithaddRecipeInformation=true, fillIngredients=true, instructionsRequired=true, number=limit. Returns normalizedRecipeSearchHit[]. 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 idempotentPOST /api/ingredients, creates a localRecipewithexternal_source+external_id+is_manually_added=true. Returns the new Recipe.
- MODIFIED
backend/app/schemas/__init__.py— addRecipeSearchHitandRecipeImportRequestPydantic models. Mirror theExternalRecipedataclass shape fromrecipe_discovery.py:28-44(but with Pydantic). - MODIFIED
backend/app/config.py— addSPOONACULAR_API_KEY: Optional[str] = NonetoSettingsfor schema consistency. (Currently read viagetattrbecauseextra="ignore". Adding it surfaces it in.env.exampleand tools.) - MODIFIED
backend/app/main.py— register the new router. Reuses theapp.include_routerpattern at line 44-50. - Process-wide
_points_usedcounter (module-level singleton inrecipe_search.py). 503 withdetail: "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 Spoonacularrequests.getcalls.GET /api/recipes/search?q=chickenreturns 200 + 1 normalized hit (mock summary).GET /api/recipes/search?q=returns 422 (empty query).POST /api/recipes/importhappy path: mock info call + idempotent ingredient upsert + 201 with the new Recipe id.POST /api/recipes/importduplicate external_id → 409.
S12.3 — Frontend: API client + Recipes page
- MODIFIED
frontend/src/api/index.ts:27-33— addrecipes.search(q, limit)andrecipes.import(data). - MODIFIED
frontend/src/pages/Recipes.tsx— add a "Search the web" toggle next to the search bar (small button +Sparklesicon from lucide). When ON, the existinguseQueryswitches fromrecipes.list(params)torecipes.search({q: debouncedQ, limit: 10}). Renders results in a separate panel above the local list. Each result card has an "Import" button + the existingNeverSuggestButtonremoved (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>witharia-pressed={searchWeb}. - Debounced 300ms, same as the local search (reuse
handleSearchfrom 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):
- Open
/recipesin incognito. Confirm "Search the web" toggle is OFF, only the local list shows. - Click the toggle. Confirm the panel header changes to "Search the web — Spoonacular" and a debounced search bar appears.
- Type "pasta" with 300ms debounce. Confirm 5-10 results render with name + image + cuisine tags.
- 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.
- Open
- Quota test: hit search 50 times in a row, confirm
_points_usedincrements. 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.1–T6.4) at the top.fix-ui-audit.md— Sprint 12 plan section (T6.1–T6.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 buildgreen.pytest tests/test_recipe_search.pygreen (4/4).Review/sprint12-verification.mdexists.- 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/planendpoint). Separate sprint. - Image generation.
AI_IMAGE_ENABLEDenv gate already exists; not enabled. Sprint 12 imports the Spoonacular image as-is. - Auto-enriching existing recipes with macros (would require
nutritionendpoint = 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 PlanCTA — 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/completionswithAuthorization: Bearer ${OLLAMA_API_KEY},model: settings.OLLAMA_MODEL,max_tokens: 500, temperature: 0, parsechoices[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:- Validates the request body (
prompt: str1-500 chars,week_start: date). - Reads the local recipe library (
Recipetable, filtered byfamily_profile_id); serializes a compact list{id, name, cuisine_tags, dietary_tags, protein_type, total_time_minutes, dietary preferences}. - 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." - Calls
_ask_llm(prompt)(mirrors_ask_ollamafromllm_matcher.py:97-144). - Parses the JSON response (try
json.loads, fall back tore.search(r"\[.*\]", content)to handle markdown code fences). - Validates each entry:
recipe_idis a UUID,day_of_weekin 1-7,meal_typein {breakfast, lunch, dinner}. Drop invalid entries. - Creates an empty plan via
meals.create(Sprint 6+ endpoint), then bulk-inserts the LLM-picked items + callsfillEmptySlotsfor the slots the LLM didn't cover. - Returns
{plan_id, picked_count, filled_count, failed_count, reasoning: <LLM raw text if non-empty>}.
- Validates the request body (
- MODIFIED
backend/app/schemas/__init__.py— addLLMPlanRequest+LLMPlanResponsePydantic 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 11handleGenerateFirstPlaninto a 2-step:- New
MealPlanPromptModalcomponent (inline inDashboard.tsx, ~40 lines, reuseCard/Button/Inputfromcomponents/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. - Library path (default): keep Sprint 11's
meals.create+fillEmptySlotsexactly as-is. - 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)".
- New
- MODIFIED
frontend/src/api/index.ts— addllm.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-, ...}. (RequiresOLLAMA_API_KEYset on the host.)- With
OLLAMA_API_KEYunset → 503. - Empty prompt → 422.
- Manual UI smoke (3 steps):
- Land on
/with no plan. Click "Generate Meal Plan". Modal opens with the textarea + the two radio options. - 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.
- Refresh the page. Confirm the plan persists.
- Land on
- 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 buildgreen.Review/sprint13-verification.mdexists.- 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 serveprocess + 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.