Public Access
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.
129 lines
8.8 KiB
Markdown
129 lines
8.8 KiB
Markdown
# 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)`.
|