Sprint 15 round 2 (commit 97b84a7) added seed_recipes_round2.py
which seeded 18 more Spoonacular recipes today. Library went
49 -> 67 total. LLM test (Sprint 13 endpoint, week 2026-07-20):
{picked_count: 0, filled_count: 21, failed_count: 0}
The library now covers all 21 slots of a week (was 19/21 + 2
failed in round 1).
This commit updates the 6 running docs:
- .agent/plan.md — Sprint 15 Round 2 section (S15R2.1-S15R2.4
+ Done when + Out of scope) added after the round 1 sections.
50-query list documented by category.
- .agent/context.md — Round 2 decisions (D7-D10) added: list
focuses on cuisines round 1 didn't cover, round 2 is a
separate script, library at 67 with 21/21 LLM coverage.
- Review/sprint15-verification.md — Round 2 section appended
with: 50-query list, 18-imported result, 12 no-hits, 1 402,
updated DB state (37 Spoonacular + 30 manual = 67), LLM
test result, round 3 follow-up ticket.
- Review/ui-nielsen-audit.md — Round 2 paragraph added to the
Sprint 15 status block.
- fix-ui-audit.md — T8.6 added to the Sprint 15 section.
- Review/handoff-ui-audit.md — TL;DR and Sprint 15 section
updated with round 2.
- docs/HANDOFF.md — Round 2 paragraph added to the Sprint 15
section, Last-updated footer updated.
All 6 docs now reflect Sprint 15 round 2. Library has 67 unique
recipes — 1.25x rotation across 4 weeks of planning. Round 3
can add 30+ more by re-running scripts/seed_recipes.py on the
next day (its query list has round 1 cap-blocked American +
Mediterranean cuisines still to import).
46 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 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_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.
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), setsenvironment: 'happy-dom', pointssetupFiles: ['./vitest-setup.ts'], reusestsconfig.jsonpaths.frontend/vitest-setup.ts— imports@testing-library/jest-dom/vitest(auto-extendsexpectwith DOM matchers).package.jsonscripts: 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;renderHookfrom@testing-library/react.- Case 1 — clean init: clear localStorage;
isComplete === false. - Case 2 — persisted init:
localStorage.setItem('mealplanner:onboarding-complete', '1');isComplete === trueon mount. - Case 3 —
markCompletesets state, does NOT clear localStorage: aftermarkComplete(),isComplete === trueand the localStorage key is still'1'. This is the S9 bug lock. - Case 4 —
resetclears localStorage AND flips state to false: afterreset(),isComplete === falseand the localStorage key is removed. - Case 5 —
showis a mirror ofreset: aftershow(), same assertions as Case 4. - Case 6 — localStorage throw is silently swallowed: stub
localStorage.getItemto throw; hook still returnsisComplete: false, does not crash.
S14.4 — Verify + commit
cd frontend && npm testruns all 6 cases green.npm run buildstill green (vitest's types shouldn't conflict with vite's).Review/sprint14-verification.mdwritten.- All 6 running docs updated with the Sprint 14 status block.
- Commit on host + push.
Done when (Sprint 14)
- All boxes above ticked.
npm testshows 6 passing in <5 s.npm run buildstill green.- The S9 bug class is locked: any future regression that wires
onComplete → reset(oronReset → 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
recipesAPI client oruseOnboardingcallers. Not the S9 bug class. Future sprint. - Tests for the backend. The venv on
docker-willesteris 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_llmis the single seam — F9-full only needs to swap the URL + model name.
Active sprint: Sprint 15 — Seed 50 family-friendly recipes for 4-week planning (content op)
S15.1 — Recipe target list (50 queries, family default)
Distribution: 5 cuisines × 10 recipes each = 50. Each cuisine gets a mix of cooking methods (sheet-pan, skillet, slow-cooker, one-pot, 30-min) so the LLM has variety. Each query is a free-text Spoonacular complexSearch query.
- Italian (10): "chicken parmesan", "spaghetti carbonara", "lasagna", "minestrone soup", "pesto pasta", "chicken piccata", "mushroom risotto", "caprese salad", "italian wedding soup", "eggplant parmesan"
- Mexican (10): "chicken tacos", "beef enchiladas", "black bean burritos", "shrimp fajitas", "chicken quesadilla", "taco salad", "sopa de tortilla", "carnitas", "chicken tortilla soup", "huevos rancheros"
- Asian (10): "chicken stir fry", "beef and broccoli", "pad thai", "fried rice", "teriyaki salmon", "tofu curry", "chow mein", "spring rolls", "pho", "kung pao chicken"
- American (10): "chili", "meatloaf", "mac and cheese", "BBQ chicken", "pot roast", "shepherd's pie", "chicken pot pie", "beef stew", "burgers", "pulled pork"
- Mediterranean/Middle Eastern (10): "chicken shawarma", "falafel", "hummus bowl", "greek salad", "lamb kebabs", "tabbouleh", "roasted vegetable wrap", "couscous", "stuffed peppers", "baked falafel"
Dietary tags inferred from title+ingredients: _infer_protein_simple in recipe_search.py:151-170 (Sprint 12). Vegetarian entries: caprese, minestrone, pesto pasta, mushroom risotto, black bean burritos, taco salad, sopa de tortilla, huevos rancheros, fried rice, tofu curry, spring rolls, mac and cheese, falafel, hummus bowl, greek salad, tabbouleh, roasted vegetable wrap, couscous, stuffed peppers, baked falafel. That's ~20/50 = 40% vegetarian, which is the "vegetarian-heavy" target.
S15.2 — Import script (one-shot Python)
scripts/seed_recipes.py(NEW) — reads the 50-query list, callsGET /api/recipes/search?q=...&limit=5, picks the top hit per query, callsPOST /api/recipes/importwith{external_id, external_source: "spoonacular"}. Idempotent: 409 on duplicate → skip.- Uses the existing
SESSION_PASSWORD=test-family-passwordforrequire_sessionauth. POST body is JSON. Runs in a single Python process. - 1-2 sec sleep between queries to stay well below the 1-pt + 0.01 × 5 hits = 1.05 pts/search rate. No race with the frontend's quota counter — process-local, single-threaded.
- Logs per-query result:
external_id,name,points_used_so_far,quota_status.
S15.3 — Verify
SELECT count(*) FROM recipe WHERE external_source='spoonacular';returns ~50 (give or take the 5-10 that fail to return hits).- Spot-check 5 random recipes in the UI:
/recipespage shows them with the right image, ingredients, prep time. mealPlannerApi.llm.plan({prompt: 'Italian, vegetarian', week_start})returnspicked_count > 0and uses the new recipes.- 4-week plan generation:
meals.fillEmptySlotsshould pull from a richer library, fewer "no recipe available" failures.
S15.4 — Docs (all 6 running docs updated)
Review/sprint15-verification.md(NEW) — the 50-query list + the script + the curl flow + the expected counts..agent/plan.md— Sprint 15 section (S15.1-S15.4 + Done when + Out of scope)..agent/context.md— Sprint 15 decisions (D1-D6), open Q1-Q2, file:line references.Review/ui-nielsen-audit.md— Sprint 15 status block.fix-ui-audit.md— Sprint 15 section.Review/handoff-ui-audit.md— Batch K, TL;DR, last-updated.docs/HANDOFF.md— Sprint 15 section + last-updated footer.
Done when (Sprint 15)
- All boxes above ticked.
- 50 (or close to 50) Spoonacular recipes in the local
recipetable. _points_usedcounter ends at ~105-130 pts (under the 140 cap).- All 6 doc files have a Sprint 15 status block.
Out of scope (Sprint 15)
- No new feature work, no schema changes, no UI changes. This is a content op. Any change to the import endpoint, the search endpoint, or the recipe model is out of scope.
- No tuning of the quota counter or the inference logic. Sprint 12's
_infer_protein_simpleis good enough. - No re-running of previous sprints' verification flows. Sprint 15 is additive.
- F9-full (local Ollama model pull). Still opt-in based on cloud-billing feedback.
Sprint 15 — Round 2 (2026-06-07): +18 recipes, library at 67 total
User direction (2026-06-07): "please add more meals to the potential list" / "Pull in more recipes so we have a larger sample to generate from."
Round 1 (Sprint 15) imported 18 recipes before hitting the 50-pt/day free-tier cap. The user wanted more. Round 2 uses a fresh quota (cap rolled over) and a different query list focused on cuisines and meal types the round 1 list didn't cover.
S15R2.1 — Round 2 query list (50 queries, gap-filling)
- Indian (8): chicken tikka masala, butter chicken, palak paneer, chana masala, biryani, dal, samosa, naan
- Thai (6): green curry, massaman curry, tom yum soup, mango sticky rice, papaya salad, thai basil chicken
- Chinese regional (6): mapo tofu, hot and sour soup, scallion pancakes, soup dumplings, beef noodle soup, dan dan noodles
- Soups & stews (6): french onion soup, clam chowder, chicken noodle soup, tomato soup, lentil soup, butternut squash soup
- Salads (6): caesar salad, cobb salad, nicoise salad, wedge salad, pasta salad, quinoa salad
- Sandwiches/wraps (5): banh mi, reuben sandwich, club sandwich, french dip, gyro wrap
- Breakfast (5): eggs benedict, pancakes, french toast, omelette, breakfast burrito
- German/European (4): schnitzel, spaetzle, sauerbraten, beef rouladen
- French (4): coq au vin, ratatouille, beef bourguignon, quiche lorraine
50 queries = 8+6+6+6+6+5+5+4+4.
S15R2.2 — Round 2 import script
scripts/seed_recipes_round2.py(NEW, ~120 lines) — same shape as round 1, different query list. Hits Spoonacular'scomplexSearchdirectly, POSTs top hits to backend's/api/recipes/import. Idempotent (409 on duplicate). 1.5 sec sleep. Stops on 402.
S15R2.3 — Round 2 result
- 18 recipes imported today (queries 1, 2, 3, 5, 6, 7, 8, 10, 17, 19, 21, 22, 23, 24, 25, 27, 28, 29). 12 queries returned no hits from Spoonacular's free-tier index (e.g. "chana masala", "thai basil chicken", "dan dan noodles"). 1 query hit 402 mid-import ("wedge salad", HTTP 502 from
/api/recipes/import). - DB went 49 → 67 total recipes (37 Spoonacular + 30 manual).
- 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 (was 19/21 + 2 failed in round 1).
S15R2.4 — Round 2 follow-up
- Q2 — Run a round 3? Quota resets every 24h. A round 3 could add 30 more recipes (using round 1's script, which is idempotent and will skip the 37 already imported). Trajectory: 67 → 80-100 total.
- Q3 — Lower
_DAILY_LIMIT=140to 45 to match the real 50-pt free tier. Surface in the next sprint that touches recipe_search.py.
Done when (Round 2)
- 18 recipes imported today (from 50 queries).
- DB went 49 → 67.
- LLM test: 21/21 slots filled, 0 failed.
- All 6 running docs updated with round 2 status.
Out of scope (Round 2)
- Same as round 1. No feature work, no schema changes, no UI changes.