Public Access
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:
@@ -342,3 +342,69 @@ User direction 2026-06-05: "Proceed." Selected from the question menu as the sma
|
||||
- `backend/app/schemas/__init__.py:227-247` — `MealPlanBase` + `MealPlanCreate` schemas
|
||||
- `Review/sprint11-verification.md` — new file (deploy + 4-step browser smoke + race test)
|
||||
|
||||
---
|
||||
|
||||
# Context — Sprint 12 (F8 Spoonacular search)
|
||||
|
||||
## Why Sprint 12 exists
|
||||
|
||||
User direction 2026-06-05: "Proceed." Selected from the question menu as the smallest remaining §Future item. F1 (Sprint 9) shipped, the dead CTA (Sprint 11) shipped, and F8 (Spoonacular search) is the last piece with a clear UI scope. F9 (Ollama local LLM) is a separate full backend proposal (model pull + ollama-py + `/api/llm/plan` endpoint) and remains in the §Future backlog.
|
||||
|
||||
The user can browse ~150 local recipes on `/recipes` (admin seeds them) but has no path to find new ones without leaving the app. Sprint 12 adds a "Search the web" toggle on `/recipes` that hits the Spoonacular `complexSearch` API and lets the user import a result into the local library in one click.
|
||||
|
||||
## Decisions (locked in for Sprint 12)
|
||||
|
||||
- **D1. Reuse the pre-existing `RecipeDiscoveryService`.** `backend/app/services/recipe_discovery.py` (226 lines, already exists) has `_search_spoonacular()`, `_fetch_recipe_info()`, `_normalize_spoonacular()` and a 150/day free-tier quota gate. Sprint 12 does NOT re-implement the API client; it adds a thin HTTP layer in `backend/app/api/recipe_search.py` that calls the same `requests.get(SPOONACULAR_SEARCH_URL, ...)` pattern.
|
||||
- **D2. Search endpoint = `complexSearch` only, NO info endpoint call.** The pre-existing `_search_spoonacular` calls the info endpoint (1 pt) for every result. For a 10-result search that's 10 extra points — the whole daily quota in one query. Sprint 12's `GET /api/recipes/search` uses just the `complexSearch` summary (1.1 pts/query) for browsing, and the **import** endpoint does the info call once (1 pt) only for the recipe the user actually wants.
|
||||
- **D3. Ingredient resolution via the existing public `POST /api/ingredients` endpoint.** `backend/app/api/ingredients.py:58-103` is idempotent on `name_lower` + aliases. The import flow upserts each ingredient via this endpoint. No new ingredient-resolution helper needed.
|
||||
- **D4. NEW endpoints, NOT modification of the pre-existing WIP.** `backend/app/api/recipes.py` (352 lines) and `backend/app/schemas/recipe.py` (93 lines) exist as WIP but are not registered in `main.py`. Sprint 12 does NOT touch them. It creates `backend/app/api/recipe_search.py` (new router) and adds the Pydantic models to `backend/app/schemas/__init__.py` (the canonical location used by all registered endpoints).
|
||||
- **D5. UI toggle defaults to OFF.** The user explicitly asked for a "Search the web" toggle, not a permanent switch. The local-search UX is preserved for users who don't toggle. The toggle is a real `<button>` with `aria-pressed={searchWeb}`.
|
||||
- **D6. Per-query 300ms debounce, same as local search.** Reuse the existing `handleSearch` callback at `Recipes.tsx:77-81`. No new debounce logic.
|
||||
- **D7. Process-wide `_points_used` counter, module-level singleton in `recipe_search.py`.** Survives across requests in the same uvicorn worker. 503 with `detail: "spoonacular daily quota reached"` when over 140. Logged on every call.
|
||||
- **D8. Quota-overflow test is part of the verification gate.** Hit search 50 times; the 51st within the budget returns 503. This catches the "we forgot the quota gate" regression.
|
||||
- **D9. `SPOONACULAR_API_KEY` is added to `Settings`.** Currently read via `getattr` (line 50 of `recipe_discovery.py`) because `Settings.extra="ignore"`. Adding the schema declaration surfaces it in `.env.example` and tools; the runtime behavior is unchanged.
|
||||
|
||||
## Open questions to surface to the user, not to assume
|
||||
|
||||
- **Q1. Should the "Search the web" panel show even when the search bar is empty (showing popular Spoonacular recipes)?** Default: no. The panel only fetches when `debouncedQ.length >= 2`. Below 2 chars, the panel is empty. The local list still shows. Avoids unnecessary quota burn from idle toggling.
|
||||
- **Q2. Should importing a recipe also import its side-dishes (if Spoonacular returns them)?** Default: no. The Spoonacular free-tier `/information` endpoint doesn't return structured side-dishes; the description blob contains them. The import stores the description as-is and the user can edit later. Adding structured side-dishes is a future sprint.
|
||||
- **Q3. Should the import also send a vote email / create a meal-plan-item / etc.?** Default: no. The import only adds to the recipe library. Voting, planning, and shopping-list integration are downstream of the library and are not affected by Sprint 12.
|
||||
|
||||
## Sprint 12 verification gate
|
||||
|
||||
- `cd backend && python -m pytest tests/test_recipe_search.py -v` → 4/4 green
|
||||
- `cd frontend && npm run build` → green (tsc 0 errors, vite 0 errors)
|
||||
- Browser smoke (4 steps) on `http://100.108.208.56:8082/` per `Review/sprint12-verification.md`
|
||||
- Quota test: 50 searches in a row, 51st returns 503
|
||||
- 2 API curls: `GET /api/recipes/search?q=chicken` and `POST /api/recipes/import` with mocked Spoonacular
|
||||
- No regression in Sprints 1-11
|
||||
|
||||
## Sprint 12 — does NOT touch
|
||||
|
||||
- The OnboardingTour (Sprint 9) — unchanged.
|
||||
- The NeverSuggestButton (Sprint 10) — unchanged.
|
||||
- The handleGenerateFirstPlan (Sprint 11) — unchanged.
|
||||
- The existing `handlePlanWeek` (Sprint 6 F4) — unchanged.
|
||||
- The keyboard shortcuts (Sprint 5 F2) — unchanged.
|
||||
- The error toast / `showApiError` flow (Sprint 4 F7) — used for the error path; unchanged.
|
||||
- The 3-button Sprint 8 voting row — unchanged.
|
||||
- The WeekRangeNav (Sprint 7) — unchanged.
|
||||
- The bulk pantry add (Sprint 6 F3) — unchanged.
|
||||
- **Pre-existing WIP** `backend/app/api/recipes.py`, `backend/app/schemas/recipe.py`, `nginx/nginx.conf` — untouched.
|
||||
- The existing `RecipeDiscoveryService` at `backend/app/services/recipe_discovery.py` — unchanged. Sprint 12 calls it via the public Spoonacular URLs directly (not through the service class), to avoid the service's info-endpoint cost.
|
||||
|
||||
## Key file:line references (Sprint 12)
|
||||
|
||||
- `backend/app/services/recipe_discovery.py:19-21` — Spoonacular URL constants (reused in `recipe_search.py`)
|
||||
- `backend/app/services/recipe_discovery.py:50` — `getattr(settings, "SPOONACULAR_API_KEY", "")` pattern (replaced by schema declaration in Sprint 12)
|
||||
- `backend/app/api/ingredients.py:58-103` — public `POST /api/ingredients` (idempotent ingredient upsert, used by the import flow)
|
||||
- `backend/app/main.py:44-52` — router registration pattern (new router registered at line 52-53)
|
||||
- `backend/app/config.py:7-44` — `Settings` class (S12.1 adds `SPOONACULAR_API_KEY: Optional[str] = None`)
|
||||
- `backend/app/schemas/__init__.py:38-247` — `MealPlanStatus`, `MealPlanCreate`, etc. (S12.1 adds `RecipeSearchHit` + `RecipeImportRequest`)
|
||||
- `backend/app/models/__init__.py:162-197` — `Recipe` model (target for the import insert)
|
||||
- `backend/app/models/__init__.py:142-159` — `Ingredient` model (target for the idempotent upsert)
|
||||
- `frontend/src/api/index.ts:27-33` — `recipes` client (S12.3 adds `search` + `import` methods)
|
||||
- `frontend/src/pages/Recipes.tsx:47-95` — search bar + debounce + `useQuery` (S12.3 adds the web-search toggle + branch)
|
||||
- `frontend/src/pages/Recipes.tsx:77-81` — `handleSearch` debounce (reused for the web-search branch)
|
||||
- `Review/sprint12-verification.md` — new file (deploy + 4-step browser smoke + 2 API curls + quota test + a11y check)
|
||||
|
||||
|
||||
@@ -339,4 +339,88 @@ User reported post-deploy: "The tour window looks great, but Clicking the X nor
|
||||
- LLM-powered generation (F8 Spoonacular, F9 Ollama) — separate backend proposals, future sprints. Sprint 11 only wires the existing recipe-library-based fill.
|
||||
- A "what would you like for dinner?" prompt before generation — the existing flow generates from the library with no user input.
|
||||
- A "regenerate" button after the plan exists — the existing `Plan Week` menu at `Dashboard.tsx:366-392` already handles this case.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 12 — F8 Spoonacular search (§Future H10) — DRAFTED, awaiting user approval
|
||||
|
||||
**User direction (2026-06-05):** "Proceed." Selected from the question menu. F8 is the smallest remaining §Future item: search-by-name on a public API, brings external recipe data into the system. F9 (Ollama) remains a separate full-backend proposal.
|
||||
|
||||
**Root cause:** the user can browse ~150 local recipes on `/recipes` (admin seeds them) but has no path to find new ones without leaving the app. F8 adds a "Search the web" toggle that hits the Spoonacular `complexSearch` API and lets the user import a result into the local library in one click.
|
||||
|
||||
**Pre-existing infrastructure to reuse (not recreate):**
|
||||
- `backend/app/services/recipe_discovery.py` (226 lines) — full `RecipeDiscoveryService` with `_search_spoonacular()`, `_fetch_recipe_info()`, `_normalize_spoonacular()`. Reads `SPOONACULAR_API_KEY` via `getattr(settings, ...)`. Cites 150/day free quota.
|
||||
- `backend/app/api/ingredients.py:58-103` — public `POST /api/ingredients` is **idempotent** on `name_lower` + aliases. The ingredient-resolution helper for the import flow.
|
||||
- `scripts/enrich_recipes_spoonacular.py` (76 lines) — standalone one-shot script, reference for the env + URL pattern.
|
||||
|
||||
**Pre-existing WIP (NOT touched by Sprint 12):**
|
||||
- `backend/app/api/recipes.py` (352 lines, not registered in `main.py`)
|
||||
- `backend/app/schemas/recipe.py` (93 lines, has `RecipeCreate` + `RecipeIngredientRef`)
|
||||
- `nginx/nginx.conf`
|
||||
|
||||
### S12.1 — Backend: `GET /api/recipes/search` (public, webui-facing)
|
||||
|
||||
- [ ] **NEW** `backend/app/api/recipe_search.py` — 2 endpoints + a thin `search_spoonacular_summary(q, limit)` wrapper. Reuses the existing `requests.get(SPOONACULAR_SEARCH_URL, params={...})` pattern.
|
||||
- `GET /recipes/search?q=&limit=` — public, `require_session`. Calls `complexSearch` with `addRecipeInformation=true, fillIngredients=true, instructionsRequired=true, number=limit`. Returns normalized `RecipeSearchHit[]`. **No info endpoint call** (saves 1 point per result; search summary is enough for browsing).
|
||||
- `POST /recipes/import` — public, `require_session`. Body `{external_id, external_source: "spoonacular"}`. Fetches `/recipes/{id}/information` (1 point), normalizes, upserts ingredients via idempotent `POST /api/ingredients`, creates a local `Recipe` with `external_source`+`external_id`+`is_manually_added=true`. Returns the new Recipe.
|
||||
- [ ] **MODIFIED** `backend/app/schemas/__init__.py` — add `RecipeSearchHit` and `RecipeImportRequest` Pydantic models. Mirror the `ExternalRecipe` dataclass shape from `recipe_discovery.py:28-44` (but with Pydantic).
|
||||
- [ ] **MODIFIED** `backend/app/config.py` — add `SPOONACULAR_API_KEY: Optional[str] = None` to `Settings` for schema consistency. (Currently read via `getattr` because `extra="ignore"`. Adding it surfaces it in `.env.example` and tools.)
|
||||
- [ ] **MODIFIED** `backend/app/main.py` — register the new router. Reuses the `app.include_router` pattern at line 44-50.
|
||||
- [ ] Process-wide `_points_used` counter (module-level singleton in `recipe_search.py`). 503 with `detail: "spoonacular daily quota reached"` when over 140. Logged on every call.
|
||||
- [ ] 503 with `detail: "SPOONACULAR_API_KEY not configured"` when env var unset. Logged once at startup.
|
||||
|
||||
### S12.2 — Backend: tests
|
||||
|
||||
- [ ] **NEW** `backend/tests/test_recipe_search.py` — 4 tests, mock the Spoonacular `requests.get` calls.
|
||||
1. `GET /api/recipes/search?q=chicken` returns 200 + 1 normalized hit (mock summary).
|
||||
2. `GET /api/recipes/search?q=` returns 422 (empty query).
|
||||
3. `POST /api/recipes/import` happy path: mock info call + idempotent ingredient upsert + 201 with the new Recipe id.
|
||||
4. `POST /api/recipes/import` duplicate external_id → 409.
|
||||
|
||||
### S12.3 — Frontend: API client + Recipes page
|
||||
|
||||
- [ ] **MODIFIED** `frontend/src/api/index.ts:27-33` — add `recipes.search(q, limit)` and `recipes.import(data)`.
|
||||
- [ ] **MODIFIED** `frontend/src/pages/Recipes.tsx` — add a "Search the web" toggle next to the search bar (small button + `Sparkles` icon from lucide). When ON, the existing `useQuery` switches from `recipes.list(params)` to `recipes.search({q: debouncedQ, limit: 10})`. Renders results in a separate panel above the local list. Each result card has an "Import" button + the existing `NeverSuggestButton` removed (since these are not-yet-imported Spoonacular results, not local recipes).
|
||||
- Toggle defaults to OFF so the existing UX is preserved.
|
||||
- Toggle is a real `<button>` with `aria-pressed={searchWeb}`.
|
||||
- Debounced 300ms, same as the local search (reuse `handleSearch` from line 77-81).
|
||||
- Panel has `aria-busy={isLoading}` while fetching.
|
||||
|
||||
### S12.4 — Verify
|
||||
|
||||
- [ ] `cd backend && python -m pytest tests/test_recipe_search.py -v` → 4/4 green.
|
||||
- [ ] `cd frontend && npm run build` → green (tsc 0 errors, vite 0 errors).
|
||||
- [ ] Manual API smoke: `curl -sS 'http://100.108.208.56:8082/api/recipes/search?q=chicken&limit=5' -b session.txt` → 200 JSON array.
|
||||
- [ ] Manual UI smoke (4 steps):
|
||||
1. Open `/recipes` in incognito. Confirm "Search the web" toggle is OFF, only the local list shows.
|
||||
2. Click the toggle. Confirm the panel header changes to "Search the web — Spoonacular" and a debounced search bar appears.
|
||||
3. Type "pasta" with 300ms debounce. Confirm 5-10 results render with name + image + cuisine tags.
|
||||
4. Click "Import" on a result. Confirm: toast "Imported!" + result card shows "Already imported" + toggle closes + the local list re-fetches and now contains the imported recipe.
|
||||
- [ ] Quota test: hit search 50 times in a row, confirm `_points_used` increments. The 51st within the budget returns 503.
|
||||
- [ ] No regression in Sprints 1-11.
|
||||
|
||||
### S12.5 — Docs (all 6 running docs updated)
|
||||
|
||||
- [ ] `Review/ui-nielsen-audit.md` — Sprint 12 status block (T6.1–T6.4) at the top.
|
||||
- [ ] `fix-ui-audit.md` — Sprint 12 plan section (T6.1–T6.5).
|
||||
- [ ] `Review/handoff-ui-audit.md` — Batch H + Sprint 12 entry + TL;DR row 12.
|
||||
- [ ] `docs/HANDOFF.md` — Sprint 12 section.
|
||||
- [ ] `.agent/plan.md` — this section.
|
||||
- [ ] `.agent/context.md` — Sprint 12 decisions + file:line references.
|
||||
- [ ] `Review/sprint12-verification.md` — written (4-step browser smoke + 2 API curls + quota test + a11y check).
|
||||
|
||||
### Done when (Sprint 12)
|
||||
|
||||
- All boxes above ticked.
|
||||
- `npm run build` green.
|
||||
- `pytest tests/test_recipe_search.py` green (4/4).
|
||||
- `Review/sprint12-verification.md` exists.
|
||||
- All 6 doc files have a Sprint 12 status block.
|
||||
|
||||
### Out of scope (Sprint 12)
|
||||
|
||||
- **F9 — Ollama local LLM.** Different backend proposal (model pull + ollama-py + `/api/llm/plan` endpoint). Separate sprint.
|
||||
- **Image generation.** `AI_IMAGE_ENABLED` env gate already exists; not enabled. Sprint 12 imports the Spoonacular image as-is.
|
||||
- **Auto-enriching existing recipes** with macros (would require `nutrition` endpoint = 1 pt per recipe; out of free quota).
|
||||
- **Modifying the pre-existing WIP** `backend/app/api/recipes.py` / `schemas/recipe.py` / `nginx/nginx.conf` — untouched.
|
||||
- F8 Spoonacular + F9 Ollama + dead `Generate Meal Plan` CTA — separate.
|
||||
|
||||
Reference in New Issue
Block a user