# 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

```text
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)

```bash
# 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 "<name>"", 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)

```bash
# 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)`.
