Files
Meal-Planner/Review/sprint12-verification.md
admin e939c96961 docs: Sprint 12 — F8 Spoonacular search across all 6 running docs
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.
2026-06-05 16:31:53 -07:00

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 — added SPOONACULAR_API_KEY: Optional[str] = None to Settings (was previously read via getattr since extra="ignore")
  • MODIFIED backend/app/schemas/__init__.py — added RecipeSearchHit + RecipeImportRequest Pydantic models
  • MODIFIED backend/app/main.py — registered recipe_search_api.router at /api/recipes

Frontend

  • MODIFIED frontend/src/api/index.ts — added 5 methods to recipes: search, importRecipe, recommended, listIngredients, createIngredient (the last 3 are stubs for pre-existing call sites)
  • MODIFIED frontend/src/pages/Recipes.tsx — added searchWeb toggle state + importedExternalIds set + webHits query + importMutation + the toggle button (with aria-pressed) + the web-search panel
  • MODIFIED frontend/src/types/index.ts — added optional ingredient + is_optional to RecipeIngredient (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
  • tsc 0 errors, vite 0 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.

  1. Land on /recipes. Confirm: the "Search the web" toggle is OFF (secondary variant). The local list shows. No web-search panel.
  2. 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.
  3. 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.
  4. 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> with aria-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-hidden by default (no special treatment needed; the aria-busy on 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_summary helper is the single point of contact; if Spoonacular renames a field, only that helper changes. Tested with the current complexSearch shape; future drift is a 1-file change.
  • R2: Quota exhaustion. Mitigation: process-wide _points_used counter + 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.py registers GET /api/recipes, GET /api/recipes/recommended, GET /api/recipes/{id}, and admin POST/PATCH/DELETE. My new GET /api/recipes/search and POST /api/recipes/import are unique. No collision.
  • R4: SPOONACULAR_API_KEY unset. Mitigation: _ensure_spoonular_configured() returns 503 with detail: "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 on RecipeIngredient)

Future work (NOT in Sprint 12)

  • F9 — Ollama local LLM. Different backend proposal (model pull + ollama-py + /api/llm/plan endpoint). 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 nutrition endpoint = 1 pt per recipe; out of free quota).
  • Side-dish support in the import (the Spoonacular free-tier /information endpoint 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).