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.
This commit is contained in:
2026-06-05 16:31:53 -07:00
parent 11b4595cf7
commit e939c96961
7 changed files with 378 additions and 7 deletions
+20 -6
View File
@@ -20,14 +20,15 @@ If you are a new agent continuing this work, do this **in order**:
- **Batch E:** Sprint 9 (one `git pull`, `docker compose up -d --build frontend` — frontend-only, no migration, no backend rebuild).
- **Batch F:** Sprint 10 (one `git pull`, `docker compose up -d --build backend frontend` — no migration; the `NeverSuggest` table already exists from prior sprints).
- **Batch G:** Sprint 11 (one `git pull`, `docker compose up -d --build frontend` — frontend-only, no migration, no backend rebuild).
4. **Open issues** in `.agent/plan.md` (the "Phase R1-R3" section is a prior plan; the **Sprint 11 active-sprint** section is the current state) and in `.agent/context.md` (decisions + open Qs for the current sprint).
- **Batch H:** Sprint 12 (one `git pull`, `docker compose up -d --build backend frontend` — backend has the new `recipe_search.py` router, frontend has the new toggle).
4. **Open issues** in `.agent/plan.md` (the "Phase R1-R3" section is a prior plan; the **Sprint 12 active-sprint** section is the current state) and in `.agent/context.md` (decisions + open Qs for the current sprint).
5. **Do not** touch the pre-existing WIP files: `backend/app/api/recipes.py`, `backend/app/schemas/recipe.py`, `nginx/nginx.conf` (untouched since before this work; user's to manage).
6. **When you commit,** use the `fix(ui):`, `feat(ui):`, `refactor(frontend):`, `docs(review):` Conventional Commit style. Force-add new files in `frontend/src/lib/` (the `.gitignore` line 17 `lib/` is a pre-existing bug that catches it).
**TL;DR of where things stand:**
- Sprints 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8: code committed and build green. Sprint 1 deployed. Sprints 2-8 awaiting user deploy.
- The only remaining §Future items are F8 Spoonacular enrichment (proposal) and F9 Ollama LLM matcher (proposal). F1 (onboarding) shipped as Sprint 9; the dead `Generate Meal Plan` CTA shipped as Sprint 11. Both are deployment-pending.
- The only remaining §Future item is F9 Ollama LLM matcher (proposal). F1 (onboarding) shipped as Sprint 9; the dead "Generate Meal Plan" CTA shipped as Sprint 11; F8 (Spoonacular) shipped as Sprint 12. All three are deployment-pending.
- Pre-existing repo issues: 1 failing test (`test_filter_blocks_by_cost` — verified pre-Sprint 8), `.gitignore` `lib/` bug, no CI. Documented.
---
@@ -53,7 +54,7 @@ If you are a new agent continuing this work, do this **in order**:
**Tracking docs:** `Review/sprint8-verification.md` (deploy + smoke), `Review/ui-nielsen-audit.md` Sprint 8 status block, `fix-ui-audit.md` T2.1T2.10, this file, `docs/HANDOFF.md` Sprint 8 section.
**Thread 3 (§Future backlog) is deferred** until S8 is deployed + verified. F8/F9 proposals remain. **Sprint 9 (F1 onboarding) + post-deploy fix (`1562929`), Sprint 10 (Deny Forever on Recipes), and Sprint 11 (wire the dead "Generate Meal Plan" CTA) are all committed 2026-06-05, awaiting user deploy.**
**Thread 3 (§Future backlog) is deferred** until S8 is deployed + verified. F9 (Ollama) proposal remains. **Sprint 9 (F1 onboarding) + post-deploy fix (`1562929`), Sprint 10 (Deny Forever on Recipes), Sprint 11 (wire the dead "Generate Meal Plan" CTA), and Sprint 12 (F8 Spoonacular search) are all committed 2026-06-05, awaiting user deploy.**
### Sprint 9 — F1 Onboarding Tour (H10)
@@ -103,7 +104,19 @@ If you are a new agent continuing this work, do this **in order**:
**Tracking docs:** `Review/sprint11-verification.md` (deploy + 4-step browser smoke + race test + 2 API curls), `Review/ui-nielsen-audit.md` Sprint 11 status block, `fix-ui-audit.md` T5.1T5.5, this file, `docs/HANDOFF.md` Sprint 11 section.
**Path forward to F8/F9:** the `EmptyState.action.onClick` is the single seam. Future F8 (Spoonacular) or F9 (Ollama) work only needs to swap the `fillEmptySlots` call in `handleGenerateFirstPlan` for an LLM call. No DOM, copy, or component structure changes needed.
### Sprint 12 — F8 Spoonacular search (§Future H10) (user-driven)
**Status: COMMITTED on 2026-06-05. Build green. Backend + frontend.** Awaiting user to `git pull` + `docker compose up -d --build backend frontend` (no migration).
**Root cause (one-liner):** the user can browse ~150 local recipes on `/recipes` but has no path to find new ones without leaving the app.
**Scope (6 boxes):** NEW `backend/app/api/recipe_search.py` (~270 lines, 2 endpoints + module-level quota counter), 1 schema addition (`RecipeSearchHit` + `RecipeImportRequest`), 1 `Settings` field (`SPOONACULAR_API_KEY`), 1 router registration, 5 new `mealPlannerApi.recipes` methods, 1 toggle button + 1 web-search panel + 1 import mutation in `Recipes.tsx`, 2 optional `RecipeIngredient` fields. **No new dependencies. No migration. No pre-existing WIP files touched.**
**Free-tier quota:** Spoonacular = 150 points/day. `complexSearch` = 1 pt + 0.01/result. The new search uses summary-only (1.1 pts/query). The import uses 1 pt for the info call + ingredient upserts (no extra points). 140-pt daily budget leaves a 10-pt safety margin. Counter resets on process restart.
**Pre-existing tsc errors exposed:** adding 5 methods to the `recipes` object literal exposed 5 latent errors in Pantry/MealDetail/Recommended.tsx (calls to non-existent methods + 2 missing `RecipeIngredient` fields). Resolved per user decision: 3 stub methods + 2 optional type fields. ~7 lines of fixes; no WIP touched. Documented in `Review/sprint12-verification.md` D-fix section + `fix-ui-audit.md` T6.4.
**Tracking docs:** `Review/sprint12-verification.md` (deploy + 4-step browser smoke + 2 API curls + quota test + a11y check + 5-risk table), `Review/ui-nielsen-audit.md` Sprint 12 status block, `fix-ui-audit.md` T6.1T6.6, this file, `docs/HANDOFF.md` Sprint 12 section.
### Sprint 7 — Fix webui "empty meal plan" (date-semantics mismatch)
@@ -135,8 +148,9 @@ Twelve commits land all 14 audit findings + 6 §Future items + 2 user-driven spr
| 9 | (committed 2026-06-05) | F1 Onboarding Tour (H10) — hand-rolled, no new deps, 4-step welcome tour with `?reset-tour=1` reset | ✅ green | ⚠️ committed; awaiting user deploy (frontend-only) |
| 10 | (committed 2026-06-05) | "Deny Forever" on Recipes — card overlay + RecipeDetail top bar + reason dropdown (allergy/dislike) + undo toast. New `POST`/`DELETE /api/never-suggest` (public) + `recipe_name` join. | ✅ green | ⚠️ committed; awaiting user deploy (backend + frontend, no migration) |
| 11 | (committed 2026-06-05) | Wire the dead "Generate Meal Plan" empty-state CTA — `meals.create` + `meals.fillEmptySlots`; race-safe; reusable for F8/F9. | ✅ green | ⚠️ committed; awaiting user deploy (frontend-only) |
| 12 | (committed 2026-06-05) | F8 Spoonacular search — "Search the web" toggle on `/recipes` + Import button. New `GET /api/recipes/search` + `POST /api/recipes/import`. Quota-gated (140pt/day). | ✅ green | ⚠️ committed; awaiting user deploy (backend + frontend) |
All work is on `main` ahead of `origin/main` (pre-existing WIP also present). All 11 sprints compile. **Sprint 1 is live. Sprints 2-11 are not yet live on `100.108.208.56:8082/`.**
All work is on `main` ahead of `origin/main` (pre-existing WIP also present). All 12 sprints compile. **Sprint 1 is live. Sprints 2-12 are not yet live on `100.108.208.56:8082/`.**
**CRITICAL — Sprint 2 was effectively undeployable** because the CASE expression in `0015_normalize_pantry_aisles.py` failed with `text = boolean` on the `varchar(100) aisle` column. The bug is fixed in `d78bd18` (Sprint 5). Without that commit, `alembic upgrade head` would have failed on the deployment host, blocking Sprints 2, 3, 4 from going live. **The deployment host's DB still has the pre-0015 schema** — the migration must be run as part of the Sprints 2-5 batch deploy.
@@ -380,4 +394,4 @@ cd frontend && npm run build
Trust the build output. Trust the smoke checklist. Don't trust the deployment host's UI until the user confirms. The verification model is "I shipped, you verified, you reported, I fixed" — the agent in this role never sees the live UI directly.
**Last updated: 2026-06-05** — Sprint 1 deployed; Sprints 2-6 awaiting user deploy; **Sprint 7 (`09c7525`), Sprint 8 (`efd1fc6`), Sprint 9 (F1 Onboarding Tour) + post-deploy fix (`1562929`), Sprint 10 (Deny Forever on Recipes), and Sprint 11 (Wire the dead "Generate Meal Plan" CTA) committed on 2026-06-05, awaiting user deploy**. See the "How to take over" and "Pending user deploy" sections at the top of this file.
**Last updated: 2026-06-05** — Sprint 1 deployed; Sprints 2-6 awaiting user deploy; **Sprint 7 (`09c7525`), Sprint 8 (`efd1fc6`), Sprint 9 (F1 Onboarding Tour) + post-deploy fix (`1562929`), Sprint 10 (Deny Forever on Recipes), Sprint 11 (Wire the dead "Generate Meal Plan" CTA), and Sprint 12 (F8 Spoonacular search) committed on 2026-06-05, awaiting user deploy**. See the "How to take over" and "Pending user deploy" sections at the top of this file.
+128
View File
@@ -0,0 +1,128 @@
# 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)`.
+8
View File
@@ -123,6 +123,14 @@ The app looks polished on the surface (Tailwind palette, clean cards, working to
> - **T5.2** Reuses the partial-success toast format from `handlePlanWeek`: `Planned N meals` (full success) / `Planned N of M meals — K failed (e.g. <reason>)` (partial) / `Plan created — no recipes to add yet` (empty library).
> - **T5.3** `EmptyState.action.disabled?: boolean` — optional new prop on `EmptyState.tsx`. Backward-compatible: the 5 other `EmptyState` usages in the codebase don't pass it.
> - **Verification log:** `Review/sprint11-verification.md` (4-step browser smoke + race test + 2 API curls). Deploy is `git pull` + `docker compose up -d --build frontend` (frontend-only, no backend changes, no migration).
>
> **Sprint 12 status (committed 2026-06-05, awaiting deploy):** F8 Spoonacular search — adds a "Search the web" toggle on `/recipes` that hits Spoonacular's `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 via the existing idempotent `POST /api/ingredients` endpoint. **No pre-existing WIP files touched.** F9 (Ollama local LLM) remains a separate full backend proposal in the §Future backlog.
> - **T6.1** `backend/app/api/recipe_search.py` (NEW, ~270 lines). 2 endpoints: `GET /api/recipes/search?q=&limit=` (1.1 points/query, summary only — NO info endpoint call) and `POST /api/recipes/import` (1 point + ingredient upserts + Recipe insert). Process-wide `_points_used` counter with thread-safe lock; 503 when over 140 (10-point safety margin under the 150-point free tier).
> - **T6.2** `backend/app/config.py` — added `SPOONACULAR_API_KEY: Optional[str] = None` to `Settings` (was previously read via `getattr` since `extra="ignore"`). The 503 path surfaces a clear "SPOONACULAR_API_KEY not configured" message.
> - **T6.3** `backend/app/schemas/__init__.py` — added `RecipeSearchHit` and `RecipeImportRequest` Pydantic models. The router is registered in `main.py:62-63` at the `/api/recipes` prefix.
> - **T6.4** Frontend: `frontend/src/api/index.ts` adds `recipes.search` + `recipes.importRecipe` + 3 stub methods (`recommended`, `listIngredients`, `createIngredient`) to satisfy pre-existing call sites that were previously hidden by a smaller API surface. `frontend/src/pages/Recipes.tsx` adds the toggle button (with `aria-pressed`) + the web-search panel (`<div role="region" aria-label="Web recipe search" aria-busy={webLoading}>`) + the import mutation (toast on success, `showApiError` on failure). `frontend/src/types/index.ts` adds optional `ingredient` + `is_optional` to `RecipeIngredient` for pre-existing MealDetail.tsx call sites.
> - **T6.5** Pre-existing tsc errors exposed by the API surface expansion (5 errors in Pantry/MealDetail/Recommended.tsx) — resolved per user decision: added 5 stub API methods + 2 type fields. Documented in `Review/sprint12-verification.md` D-fix section.
> - **Verification log:** `Review/sprint12-verification.md` (deploy + 4-step browser smoke + 2 API curls + quota test + a11y check + 5-risk table). Deploy is `git pull` + `docker compose up -d --build backend frontend` (backend has the new router; frontend has the new toggle).
> - **No new dependencies. No migration. Admin path unchanged.**
>
> **Sprint 6 status (commit `8ad4ef6`, awaiting deploy):** Two §Future items, both with design decisions captured in the commit message.