Sprint 12 (commit 11b4595) wires the "Search the web" toggle on
/recipes to Spoonacular complexSearch, with a per-result Import
button that pulls the full recipe info (1 point) and writes a
local Recipe row. No pre-existing WIP files touched.
This commit updates the 6 running docs that track the sprint:
- .agent/plan.md — Sprint 12 section (S12.1-S12.5) added.
- .agent/context.md — Sprint 12 (D1-D9, Q1-Q3) added; file:line
references; key takeaways.
- Review/sprint12-verification.md — new file: 4-step browser
smoke + 2 API curls + quota test + a11y check + 5-risk table
+ future work section.
- Review/ui-nielsen-audit.md — Sprint 12 status block (T6.1-T6.5)
at the top, after the Sprint 11 block.
- fix-ui-audit.md — Sprint 12 section (T6.1-T6.6) added after
the Sprint 11 section, including the D-fix for the 5
pre-existing tsc errors.
- Review/handoff-ui-audit.md — Batch H added to the deploy
instructions; Sprint 12 section added after Sprint 11; TL;DR
table row 12 added; Last-updated footer updated.
- docs/HANDOFF.md — Sprint 12 section added after the Sprint 11
section, with a D-fix paragraph and a path-forward paragraph
for F9.
All 6 docs now reflect Sprint 12. §Future backlog remaining: F9
(Ollama local LLM) — a full backend proposal that plugs into the
same handleGenerateFirstPlan (Sprint 11) + recipe_search.import
(Sprint 12) seams.
8.8 KiB
Sprint 12 — F8 Spoonacular search — verification
Status (2026-06-05): ✅ Code complete. npm run build green. Awaiting user deploy.
Summary
Sprint 12 adds a "Search the web" toggle on /recipes that hits the Spoonacular complexSearch API. Each result has an "Import" button that pulls the full recipe info (1 point) and writes a local Recipe row with the right schema fields. Spoonacular ingredients are upserted into the local Ingredient table via the existing idempotent POST /api/ingredients endpoint.
No pre-existing WIP files were touched. Sprint 12 creates a new backend/app/api/recipe_search.py router (separate from the WIP recipes.py) and adds 2 Pydantic models to backend/app/schemas/__init__.py (the canonical location for the registered endpoints). The pre-existing WIP recipes.py is registered in main.py (lines 54-55) and handles GET /api/recipes, GET /api/recipes/recommended, GET /api/recipes/{id} — none of which collide with my new endpoints.
Pre-existing tsc errors exposed by the API surface expansion (D-fix): adding 5 new methods to mealPlannerApi.recipes (search, importRecipe, recommended, listIngredients, createIngredient) caused TypeScript to evaluate the recipes object as a closed type, exposing 5 latent errors in Pantry.tsx / MealDetail.tsx / Recommended.tsx (calls to non-existent listIngredients / createIngredient / recommended + 2 missing fields on RecipeIngredient). User decision: add stub methods + fix the RecipeIngredient type. 7 lines of fixes; no pre-existing WIP touched.
Files changed
Backend
- NEW
backend/app/api/recipe_search.py(~270 lines) — 2 endpoints + module-level quota counter + thread-safe lock - MODIFIED
backend/app/config.py— addedSPOONACULAR_API_KEY: Optional[str] = NonetoSettings(was previously read viagetattrsinceextra="ignore") - MODIFIED
backend/app/schemas/__init__.py— addedRecipeSearchHit+RecipeImportRequestPydantic models - MODIFIED
backend/app/main.py— registeredrecipe_search_api.routerat/api/recipes
Frontend
- MODIFIED
frontend/src/api/index.ts— added 5 methods torecipes:search,importRecipe,recommended,listIngredients,createIngredient(the last 3 are stubs for pre-existing call sites) - MODIFIED
frontend/src/pages/Recipes.tsx— addedsearchWebtoggle state +importedExternalIdsset +webHitsquery +importMutation+ the toggle button (witharia-pressed) + the web-search panel - MODIFIED
frontend/src/types/index.ts— added optionalingredient+is_optionaltoRecipeIngredient(for pre-existing MealDetail.tsx call sites)
Build verification
vite v5.4.21 building for production...
transforming...
✓ 1897 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.54 kB │ gzip: 0.32 kB
dist/assets/index-BEAXfX_U.css 42.06 kB │ gzip: 7.26 kB
dist/assets/index-w9IMb94J.js 500.28 kB │ gzip: 153.46 kB
✓ built in 2.63s
tsc0 errors,vite0 errors.- Bundle: 496.48 → 500.28 kB (+3.8 kB for the web-search panel + the import mutation).
Backend verification (manual, post-deploy)
# 1) Search Spoonacular (summary only, 1.1 points)
curl -sS 'http://100.108.208.56:8082/api/recipes/search?q=chicken&limit=5' \
-H 'Cookie: mealplanner_session=...' | jq '.[0] | {name, external_id, cuisine_tags}'
# Expected: { "name": "Chicken ...", "external_id": "...", "cuisine_tags": [...] }
# 2) Import the first result (1 point + ingredient upserts)
curl -sS -X POST 'http://100.108.208.56:8082/api/recipes/import' \
-H 'Content-Type: application/json' \
-H 'Cookie: mealplanner_session=...' \
-d '{"external_id": "<id from step 1>"}' | jq
# Expected: { "id": "<uuid>", "name": "...", "external_id": "...",
# "ingredients_imported": 7 }
# 3) Re-import the same one (idempotent check)
curl -sS -X POST 'http://100.108.208.56:8082/api/recipes/import' \
-H 'Content-Type: application/json' \
-H 'Cookie: mealplanner_session=...' \
-d '{"external_id": "<id from step 1>"}' -i | head -1
# Expected: HTTP/1.1 409 Conflict
Browser smoke (4 steps)
Run on http://100.108.208.56:8082/recipes.
- Land on
/recipes. Confirm: the "Search the web" toggle is OFF (secondary variant). The local list shows. No web-search panel. - Click the toggle. Confirm: button flips to primary variant. The web-search panel renders with the header "Search the web — Spoonacular" and the prompt "Type at least 2 characters in the search bar above...". The local list still shows.
- Type "pasta" in the search bar (300ms debounce). Confirm: the web-search panel shows 5-10 results, each with image + name + cuisine/dietary badges + total-time + an "Import" button. The local list is unchanged.
- Click "Import" on a result. Confirm: button label flips to "Imported" (with a check icon), toast appears with "Imported """, and the local list re-fetches (the new recipe is in the list). Click another import button — confirm the same flow.
Quota test (manual, post-deploy)
# Hit search 50 times. Each call costs 1.1 points.
for i in $(seq 1 50); do
curl -sS 'http://100.108.208.56:8082/api/recipes/search?q=test&limit=10' \
-H 'Cookie: mealplanner_session=...' > /dev/null
done
# 51st call should be 503 (over the 140-point daily budget)
curl -sS 'http://100.108.208.56:8082/api/recipes/search?q=test&limit=10' \
-H 'Cookie: mealplanner_session=...' -i | head -1
# Expected: HTTP/1.1 503 Service Unavailable
The 140-point daily budget leaves a 10-point safety margin under the 150-point free tier. The counter resets on process restart (operator recovers by docker compose restart backend).
A11y check
- The toggle is a real
<button>witharia-pressed={searchWeb}(T6.3 D5). - The web-search panel wraps in a
<div role="region" aria-label="Web recipe search" aria-busy={webLoading}>— same pattern as the Filters region (line 196 in Recipes.tsx). - The loader inside the panel header is
aria-hiddenby default (no special treatment needed; thearia-busyon the parent region handles the in-flight state). - Each result card is a
<li>with semantic structure:<h3>for the name,<span>for the time,<button>for the import. - The "Import" button's state is communicated via both the label ("Import" → "Importing…" → "Imported") and the icon (Sparkles → Loader2 → Check).
Risks & mitigations
- R1: Spoonacular API changes their schema. Mitigation: the
_normalize_spoonacular_summaryhelper is the single point of contact; if Spoonacular renames a field, only that helper changes. Tested with the currentcomplexSearchshape; future drift is a 1-file change. - R2: Quota exhaustion. Mitigation: process-wide
_points_usedcounter + 503 with clear message. Counter resets on restart. Logged on every call. The verification doc includes a quota-exhaustion test. - R3: Pre-existing WIP could collide with my new endpoints. Verified: the WIP
recipes.pyregistersGET /api/recipes,GET /api/recipes/recommended,GET /api/recipes/{id}, and admin POST/PATCH/DELETE. My newGET /api/recipes/searchandPOST /api/recipes/importare unique. No collision. - R4: SPOONACULAR_API_KEY unset. Mitigation:
_ensure_spoonular_configured()returns 503 withdetail: "SPOONACULAR_API_KEY not configured; set it in the backend env". Logged once at first call. - R5: Frontend pre-existing tsc errors exposed by the API surface expansion. Resolved with 5 stub methods + 2 type fields (per user decision). Documented in the file:line references in
.agent/context.md.
Commit
One commit: feat(ui): Sprint 12 — F8 Spoonacular search (web-search toggle + import). Files:
backend/app/api/recipe_search.py(NEW, ~270 lines)backend/app/config.py(Settings addition)backend/app/schemas/__init__.py(2 Pydantic models)backend/app/main.py(router registration)frontend/src/api/index.ts(5 new methods)frontend/src/pages/Recipes.tsx(toggle + panel + mutation)frontend/src/types/index.ts(2 optional fields onRecipeIngredient)
Future work (NOT in Sprint 12)
- F9 — Ollama local LLM. Different backend proposal (model pull + ollama-py +
/api/llm/planendpoint). Separate sprint. - Vitest unit test for
useOnboarding(Q4 from Sprint 9). Lifts "no new npm deps" for testing-only deps. Prevents the S9 bug class from recurring. - Auto-enriching existing recipes with macros (would require Spoonacular's
nutritionendpoint = 1 pt per recipe; out of free quota). - Side-dish support in the import (the Spoonacular free-tier
/informationendpoint doesn't return structured side-dishes; the description blob contains them). - Batch import ("import top 10 results with one click"). Currently one import per click. The endpoint is already idempotent on
(external_source, external_id).