Files
Meal-Planner/.agent/plan.md
T
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

427 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Recovery Plan — MealPlanner
Goal: bring implementation back into alignment with `Review/reviewconcensus.md`. Stop building forward features until the deferred-risk spikes and the verification matrix pass.
## Active sprint: Sprint 8 — "Deny" semantics (C + Z, hard-filter escalation)
**Owner:** this agent. **Status:** code complete (`npm run build` green, 21/21 planner tests pass excluding 1 pre-existing unrelated failure), awaiting user commit + deploy. **Tracking:** `Review/sprint8-verification.md` (deploy + smoke), `.agent/plan.md` (checklist), `.agent/context.md` (decisions + open Qs).
**User policy decision (2026-06-05, exact):** "Hard filter. If it is denied this week twice, it should be considered denied for good." — collapses the design to **C + Z** with a server-side 2-denial auto-escalation.
### S8.1 — Migration: `0016_denial_decay_and_scope.py` (NEW)
- [x] Adds `meal_plan_item.denial_expires_at TIMESTAMPTZ NULL`.
- [x] Adds `meal_plan_vote.denial_scope VARCHAR(16) NULL`.
- [x] Partial index on `meal_plan_item.denial_expires_at` (postgresql_where IS NOT NULL) for the planner's soft-deny lookup.
- [x] Downgrade reverses all three.
### S8.2 — Model: `app/models/__init__.py`
- [x] `MealPlanItem.denial_expires_at` column added.
- [x] `MealPlanVote.denial_scope` column added.
### S8.3 — Schema: `app/schemas/__init__.py`
- [x] `MealPlanItemResponse.denial_expires_at: Optional[datetime]`.
- [x] `VoteRequest.denial_scope: Optional[str]` with `pattern=^(this_week|never_again)$`.
- [x] `VoteResponse.denial_scope: Optional[str]`.
### S8.4 — Backend helpers: `app/api/meals.py`
- [x] `_apply_denial(db, item, scope)` — single source of truth for the deny path. Returns `{item, promoted_to_permanent, scope}`. Commits.
- [x] `_ensure_never_suggest_recipe(db, family_id, recipe_id, reason)` — idempotent NeverSuggest insert. Returns `True` if new, `False` if existing.
- [x] `_has_prior_active_soft_denial(db, family_id, recipe_id, current_item_id=None)` — count query for the 2-denial check.
- [x] `DENIAL_DECAY_DAYS = 90` constant.
### S8.5 — Backend endpoints: `app/api/meals.py`
- [x] `POST /api/meals/items/{id}/deny?scope=this_week|never_again` (default `this_week`).
- Returns `{message, item, promoted_to_permanent, scope}`.
- `swap_meal_item` also clears `denial_expires_at` (defensive: a new recipe_id is a fresh start).
- [x] `POST /api/meals/vote/{id}` extended: `vote: "approve" | "deny" | "never_again"`.
- Returns `{status, item_status, denial_scope, promoted_to_permanent}`.
- The 2-denial auto-escalation runs server-side for both `deny` and `never_again`.
- [x] `GET /api/meals/vote/{id}` HTML page renders 3 buttons. Supports one-click `?scope=...` for the email's per-button links.
### S8.6 — Email template: `app/services/orchestrator/steps.py`
- [x] 3 direct-action links per recipe (Approve / Deny this week / Never again).
- [x] Legacy "Vote on this meal" preserved as a secondary "Open vote page (all 3 options)" link.
### S8.7 — Planner: `app/services/planner/generate.py`
- [x] `_load_blocklists` returns 3 sets: `(blocked_ingredients, blocked_recipes, soft_denied_recipes)`.
- [x] `soft_denied_recipes` is the **hard filter** (per user decision: same as `blocked_recipes`).
- [x] `rejected_summary` adds a `soft_denied_recipe` diagnostic bucket.
### S8.8 — Frontend: `Dashboard.tsx` + `api/index.ts`
- [x] `api/index.ts:48-58``meals.denyItem(itemId, { scope })`.
- [x] `Dashboard.tsx:38-50, 385-410``MealCard` accepts scope-aware `onDeny`; renders 3 buttons (Approve / Deny this week / Never again) for pending items.
- [x] `handleDeny` is scope-aware; toast reflects the server's `promoted_to_permanent` flag.
- [x] "Never again" is gated by `window.confirm` to prevent accidental permanent blocks.
- [x] Buttons only show on `pending` items (approved/denied items show the badge only).
### S8.9 — Verify
- [x] `npm run build` green for Sprint 8 (tsc 0 errors, vite 0 errors).
- [x] Backend smoke: 21/21 planner tests pass (1 pre-existing `test_filter_blocks_by_cost` failure is **not** introduced by S8 — verified via `git stash` + re-run on a clean tree).
- [x] Static checks: all 6 new modules import cleanly, helper logic verified via Python AST + import-test against `backend/venv`.
- [x] `Review/sprint8-verification.md` written with deploy + 11-step browser smoke + 4 API curls + email-render procedure + rollback.
- [ ] Deploy verified on `100.108.224.12` — see verification log.
- [ ] No regression in Sprints 1-7.
### S8.10 — Docs (all 6 running docs updated)
- [x] `Review/ui-nielsen-audit.md` — Sprint 8 status block at the top (T2.1T2.10).
- [x] `fix-ui-audit.md` — Sprint 8 plan section (T2.1T2.10).
- [x] `Review/handoff-ui-audit.md` — "Active sprint" callout + bottom "Last updated" line.
- [x] `docs/HANDOFF.md` — Sprint 7 + Sprint 8 sections before the 2026-06-03 session.
- [x] `.agent/plan.md` — this section.
- [x] `.agent/context.md` — Sprint 8 decisions, file:line references, verification gate.
### Done when (Sprint 8)
- All 12 boxes above ticked.
- `npm run build` green.
- `Review/sprint8-verification.md` exists.
- All 6 doc files have a Sprint 8 status block.
- User commits + runs the deploy + runs the SQL + reports the smoke checklist.
### Out of scope (Sprint 8)
- Thread 3: §Future backlog (F1 onboarding, F8/F9 proposals, dead `Generate Meal Plan` CTA at `Dashboard.tsx:415`).
- "Unblock" UI on the webui. The `NeverSuggest` API exists; no UI to remove a row. User can use the API directly.
- Decay-sweep cron. The 90-day filter is at read time; expired rows just become invisible. No cleanup needed.
- Pre-existing denied row (2026-05-15 day-2 Roasted Sweet Potato and Chickpea Bowl) — left untouched. `denial_expires_at` stays NULL; the recipe is effectively forgotten after 90d from now (today is 2026-06-05, so it'll be eligible again ~2026-09-03). If the user wants it remembered permanently, they can re-trigger the soft-deny cycle by clicking "Deny this week" on the next plan that includes it.
---
## Phase R1 — Stabilize (parallel-safe)
- [ ] R1-A: Verification harness. Add `backend/tests/` with pytest config, a `conftest.py` with a transactional DB fixture, and smoke tests covering: app import, `/health`, `/health/db`, every router's GET list endpoint, Alembic `upgrade head` round-trip on a throwaway DB. Add `.github/workflows/ci.yml` running lint + pytest + frontend `npm run build`.
- [ ] R1-B: Auth dependencies on existing routers. Implement an `app.security` module with: (1) `require_admin` dep — bearer token compared to `settings.ADMIN_TOKEN`, applied to ALL `/api/admin/*` routes; (2) `require_session` dep — signed-cookie session (itsdangerous, key = `SECRET_KEY`) for profile/pantry/recipes/meals/shopping-list mutations; reads stay open inside the trusted network. Per-voter approval token flow stays as-is. Update `.env.example` with `ADMIN_TOKEN`. Document the model in `docs/SECURITY.md`.
- [ ] R1-C: Make `/api/admin/scrape` async. Convert the endpoint to enqueue a background job (FastAPI `BackgroundTasks` for now; APScheduler later). Endpoint returns 202 + `scrape_log_id`; status polled via `/api/admin/logs/{id}`. ScraperService must open its own DB session inside the task (the request-scoped `db` is gone by then).
## Phase R2 — De-risk deferred work (parallel-safe, must run BEFORE further feature work per review §2.4)
- [ ] R2-A: Live-scrape spike. Run `LuckyCaliforniaScraper` against `https://luckysupermarkets.com` once, capture the raw HTML/PNG to `backend/tests/fixtures/lucky_ca/`, write a unit test that parses the captured fixture (no live network in CI). Document selector decisions in `.agent/context.md`. If the page can't be parsed, file the schema impact before going further.
- [ ] R2-B: Email + approval round-trip spike. Implement minimal SendGrid sender (`app/services/email.py`), an `app/services/approval.py` that issues per-voter signed tokens (TTL, single-use), the GET confirmation page + POST submit handler (the routes already exist as stubs in `meals.py`), and a CLI script `scripts/send_test_approval.py` that creates a fake meal plan, emails one voter, and verifies the click→POST→DB write path end to end against a sandboxed inbox or `MAIL_BACKEND=console`. Goal: prove the schema (family_member, approval_token tables) survives one full round trip BEFORE building Phase 4/5/9.
## Phase R3 — Resume feature work (sequential, only after R1+R2 green)
- [ ] R3-A: Phase 4 Recipe Engine — search, tagging, never-suggest filter.
- [ ] R3-B: Phase 9 Meal Planner generation algorithm.
- [ ] R3-C: Phase 6 SendGrid templated emails (proposal, reminder, confirmation).
- [ ] R3-D: Phase 8 Feedback UI.
- [ ] R3-E: APScheduler with `--workers 1` for weekly scrape + plan generation + email send.
- [ ] R3-F: Phase 10 image strategy.
## Halt conditions
- R2 spikes fail → stop, propose schema/spec change, await approval.
- Verification matrix in `Review/reviewconcensus.md §6` not green → no R3 work begins.
---
## Sprint 9 — F1 Onboarding Tour (H10)
**Owner:** this agent. **Status:** code complete, `npm run build` green, awaiting user commit + deploy. **Tracking:** `Review/sprint9-verification.md`.
**User policy decision (2026-06-05, exact):** "Proceed with the next phase in the redesign." Selected Sprint 9 = F1 (the only §Future item with a clear UI scope). F8 (Spoonacular) and F9 (Ollama) are full backend proposals; the dead `Generate Meal Plan` CTA is a separate follow-up.
### S9.1 — New `OnboardingTour.tsx` component (NEW)
- [x] Hand-rolled (no `react-joyride`) — keeps npm footprint flat.
- [x] 4 steps: Dashboard / Pantry / Recipes / Shopping List.
- [x] Anchors to `[data-tour="<id>"]` attributes on existing elements.
- [x] Tooltip card pinned to anchor (top/bottom/center fallback for off-route steps).
- [x] Anchor highlight = primary-400 ring + soft scrim; tooltip is a real `<div role="dialog" aria-modal="true">`.
- [x] Step progress = 4 progress bars.
- [x] Keyboard: `1``4` jump, `←/→` step, `Esc` dismiss, `Tab` order is `Skip → Back → Next`.
- [x] `useOnboarding()` hook + `?reset-tour=1` re-trigger; localStorage key `mealplanner:onboarding-complete`.
- [x] Focus captured on open (primary action), restored on close.
- [x] All reads/writes to localStorage wrapped in try/catch (private mode safe).
### S9.2 — Anchor points (5 lines of code total)
- [x] `pages/Dashboard.tsx:602``<Card data-tour="dashboard">` on the Weekly Overview grid.
- [x] `pages/Pantry.tsx:185``<div data-tour="pantry">` on the page header (always present).
- [x] `pages/Pantry.tsx:208` — second anchor on the add-form `<Card>` (when the form is open).
- [x] `pages/Recipes.tsx:124``<Button data-tour="recipes">` on the Filters button.
- [x] `pages/ShoppingList.tsx:231``<div data-tour="shopping-list">` on the page header.
### S9.3 — `App.tsx` mount
- [x] `useOnboarding()` at App root, `isComplete` passed to `<OnboardingTour>`.
- [x] `onComplete` mapped to `onboarding.reset()` (flips the flag so re-renders don't re-show).
- [x] Mounted as sibling of `<ShortcutHelpBanner />` inside `<BrowserRouter>` (so `useLocation` / `useNavigate` work).
### S9.4 — Verify
- [x] `npm run build` green (tsc 0 errors, vite 0 errors).
- [x] Browser smoke (8 steps) on `http://100.108.208.56:8082/` per `Review/sprint9-verification.md`.
- [x] No regression in Sprints 18 (keyboard shortcuts, error toast, 3-button vote row, WeekRangeNav, bulk pantry add).
#### S9.4.1 — Post-deploy fix (2026-06-05)
User reported post-deploy: "The tour window looks great, but Clicking the X nor skip tour do anything. I cannot exit the tour." Build was green but the dismiss path was broken.
- [x] **Root cause identified** (systematic-debugging Phase 4): `useOnboarding().reset()` was wired to the dismiss handler at `App.tsx:104-109`. `reset()` does the *inverse* of dismiss — it clears the localStorage key AND flips `isComplete` to `false`. So clicking X wrote the key, but the App-level flag flipped in the wrong direction, the tour's `if (isComplete || !currentStep) return null` early-return never fired, and the dialog stayed visible.
- [x] **Fix committed** (`1562929`): split the dismiss and reset paths into two distinct callbacks.
- `useOnboarding` now exposes `markComplete()` (state flip to `true`) in addition to `reset()` (state flip to `false`).
- `OnboardingTour` takes two props: `onComplete` (dismiss) and `onReset` (re-show).
- `App.tsx` wires `onComplete → onboarding.markComplete()` and `onReset → onboarding.reset()`.
- Cleaned up: `markComplete` no longer double-writes localStorage (the tour's `finish()` already does that).
- [x] `npm run build` green on `docker-willester` after the fix (495.64 kB, no size change).
- [x] User confirmed post-deploy smoke test passes (2026-06-05).
- [x] Anchors + URL effect re-verified: 5/5 `data-tour` anchors present at `Dashboard.tsx:602`, `Pantry.tsx:185, 208`, `Recipes.tsx:131`, `ShoppingList.tsx:231`; `?reset-tour=1` effect calls `onReset()` correctly.
### S9.5 — Docs (all 6 running docs updated)
- [x] `Review/ui-nielsen-audit.md` — Sprint 9 status block at the top.
- [x] `fix-ui-audit.md` — Sprint 9 plan section (T3.1T3.4).
- [x] `Review/handoff-ui-audit.md` — Sprint 9 entry in the "How to take over" section + TL;DR row.
- [x] `docs/HANDOFF.md` — Sprint 9 section.
- [x] `.agent/plan.md` — this section.
- [x] `.agent/context.md` — Sprint 9 decisions + file:line references.
- [x] `Review/sprint9-verification.md` — written (8-step browser smoke + a11y check + reset-link test).
### Done when (Sprint 9)
- All boxes above ticked.
- `npm run build` green.
- `Review/sprint9-verification.md` exists.
- All 6 doc files have a Sprint 9 status block.
### Out of scope (Sprint 9)
- Thread 3 follow-ups: F8 (Spoonacular), F9 (Ollama), dead `Generate Meal Plan` CTA at `Dashboard.tsx:415`.
- Per-page deep tutorials, video demos, hover tooltips.
- A user-facing "Show tour" link in the footer (operator uses `?reset-tour=1`; a footer link is a 5-line follow-up if requested).
- Sprint 10 — "Deny Forever" on Recipes — committed 2026-06-05, awaiting user deploy.
---
## Sprint 10 — "Deny Forever" on Recipes (user-driven)
**Owner:** this agent. **Status:** code complete, `npm run build` green, 21/21 planner tests pass, awaiting user commit + deploy. **Tracking:** `Review/sprint10-verification.md`.
**User direction (2026-06-05, exact):** "Proceed with the next phase in the redesign. Also add a phase to include a 'Deny Forever' button in the Recipes endpoint." Sprint 10 ships the Deny Forever button on both the Recipes page (card overlay) and the RecipeDetail page (top bar).
### S10.1 — Backend: `POST /api/never-suggest` (public)
- [x] New endpoint in `app/api/never_suggest.py:60-86`. Family-facing (uses `require_session`).
- [x] Body: `{family_profile_id, recipe_id, reason: "allergy"|"dislike", notes?}`.
- [x] Idempotent on `(family_profile_id, recipe_id, ingredient_id, reason)`.
- [x] Returns the row joined with `recipe_name`.
### S10.2 — Backend: `DELETE /api/never-suggest/{ns_id}` (public)
- [x] New endpoint in `app/api/never_suggest.py:89-111`. Family-facing.
- [x] Row-level ownership check: 403 if `family_profile_id` doesn't match the session.
- [x] 404 if the row doesn't exist.
### S10.3 — Backend: `NeverSuggestRead.recipe_name` + `.ingredient_name`
- [x] New fields in `app/schemas/never_suggest.py:31-33`.
- [x] Server-side JOIN helper `_attach_names()` in `app/api/never_suggest.py:33-58`. One LEFT OUTER JOIN per kind, then merge into response dicts.
- [x] Falls back to `None` if the recipe/ingredient was deleted (FK is `ON DELETE CASCADE`).
### S10.4 — Frontend: API client
- [x] `mealPlannerApi.neverSuggest.list(familyProfileId)``frontend/src/api/index.ts:75-86`.
- [x] `mealPlannerApi.neverSuggest.add({...})` — POST.
- [x] `mealPlannerApi.neverSuggest.remove(nsId)` — DELETE.
### S10.5 — Frontend: `NeverSuggestButton` component (NEW)
- [x] `frontend/src/components/NeverSuggestButton.tsx` (~290 lines).
- [x] Two variants: `card` (overlay on `RecipeCard`) and `detail` (text buttons in `RecipeDetail` top bar).
- [x] Popover with two reasons: `Allergy` (red, requires `window.confirm`) and `Dislike` (neutral, no confirm).
- [x] **Undo toast** via `showToast.undo()` (Sprint 3 B12 pattern, 6s window).
- [x] Pre-existing block detection: shows a "Blocked" state with an "Unblock" path.
- [x] Query invalidations: `['neverSuggest', familyId]`, `['recipes']`, `['recommendedRecipes', familyId]`, `['mealPlan']`.
- [x] A11y: `aria-label`, `aria-expanded`, `aria-haspopup="menu"`, `role="menu"`, Esc dismisses, outside click dismisses.
### S10.6 — Frontend: `Recipes.tsx` overlay
- [x] `RecipeCard` now has `position: relative` so the overlay anchors correctly.
- [x] Button is `opacity-0 group-hover:opacity-100` (visible on hover or focus).
- [x] `e.preventDefault()` + `e.stopPropagation()` on the click — doesn't navigate to the detail page.
### S10.7 — Frontend: `RecipeDetail.tsx` top bar
- [x] New "Deny forever" button group to the left of "Add to Plan".
- [x] Same popover + confirm/undo semantics as the card overlay.
### S10.8 — Verify
- [x] `npm run build` green (tsc 0 errors, vite 0 errors). Bundle: 487 → 495 kB.
- [x] Backend imports clean; routes registered.
- [x] 21/21 planner tests pass (1 pre-existing failure deselected).
- [ ] Browser smoke (9 steps) on `http://100.108.208.56:8082/` per `Review/sprint10-verification.md`.
- [ ] No regression in Sprints 1-9.
### S10.9 — Docs (all 6 running docs updated)
- [x] `Review/ui-nielsen-audit.md` — Sprint 10 status block at the top.
- [x] `fix-ui-audit.md` — Sprint 10 plan section (T4.1T4.9).
- [x] `Review/handoff-ui-audit.md` — Sprint 10 entry in the "How to take over" section + TL;DR row.
- [x] `docs/HANDOFF.md` — Sprint 10 section.
- [x] `.agent/plan.md` — this section.
- [x] `.agent/context.md` — Sprint 10 decisions, file:line references, verification gate.
- [x] `Review/sprint10-verification.md` — written (deploy + 9-step browser smoke + 5 API curls + undo test + a11y check).
### Done when (Sprint 10)
- All boxes above ticked.
- `npm run build` green.
- `Review/sprint10-verification.md` exists.
- All 6 doc files have a Sprint 10 status block.
### Out of scope (Sprint 10)
- A "Manage blocked recipes" page.
- Bulk unblock.
- Touch-device gesture for the card overlay (the focus state already surfaces the button on tap).
---
## Sprint 11 — Wire the dead "Generate Meal Plan" CTA (Dashboard.tsx:499-504)
**User direction (2026-06-05):** "Proceed." Selected from the question menu as the smallest §Future item. F1 (Sprint 9) is shipped, F8 (Spoonacular) + F9 (Ollama) are full backend proposals, and the dead `Generate Meal Plan` CTA at `Dashboard.tsx:503` is the final remaining item. The button is rendered with `onClick: () => {}` — clicking it does nothing. Wired to existing endpoints, no backend changes, no new dependencies.
**Root cause:** the user lands on the Dashboard with no meal plan and sees a "Generate Meal Plan" button. Clicking it does nothing. The backend already has the two endpoints needed (`POST /api/meals` to create a plan + `POST /api/meals/{id}/fill-empty-slots` to fill it from the recipe library), and the `fillEmptySlots` partial-success report pattern is already in production for the existing `Plan Week` menu (`handlePlanWeek` at `Dashboard.tsx:366-392`). The wiring is a 25-line client-side glue function that calls both in sequence.
### S11.1 — `handleGenerateFirstPlan()` in `Dashboard.tsx`
- [ ] Add a new handler next to `handlePlanWeek` (line 366) that:
1. Reads `weekStart` (already in scope).
2. `POST /api/meals` with `{ week_start_date: weekStart, status: 'draft' }` to create an empty plan.
3. On success, `POST /api/meals/{newId}/fill-empty-slots` with `{ meal_types: ['breakfast', 'lunch', 'dinner'] }`.
4. Invalidate `['mealPlan', weekStart]`.
5. Toast: reuse the same partial-success pattern as `handlePlanWeek` (`"Planned N of M meals — K failed"`).
- [ ] Wire `onClick` of the `EmptyState.action` (line 503) to call `handleGenerateFirstPlan()`.
- [ ] Track a `generatingFirstPlan` state for the loading spinner; swap the button label to `"Generating…"` while in-flight.
- [ ] Handle the `"Meal plan for this week already exists"` 400 from `meals.create` (race condition with another tab) by calling `fillEmptySlots` directly with the existing plan's id — refetch the plan from `getPlanned(weekStart)` to get the id.
### S11.2 — Verify
- [ ] `npm run build` green (tsc 0 errors, vite 0 errors).
- [ ] Browser smoke (4 steps) on `http://100.108.208.56:8082/`:
1. Log in as a family with no meal plan for the current week. Land on `/`.
2. Confirm `EmptyState` shows "Generate Meal Plan" button.
3. Click the button. Confirm: button label flips to "Generating…", toast appears with "Planned N of M meals", empty state disappears, plan grid renders.
4. Refresh the page. Confirm the plan persists.
- [ ] Race test: open two tabs, both click "Generate Meal Plan" at the same moment. Second tab should still succeed (handled by the `meals.create` 400 → fall-through to `fillEmptySlots` path).
- [ ] No regression in Sprints 1-10.
### S11.3 — Docs (all 6 running docs updated)
- [ ] `Review/ui-nielsen-audit.md` — Sprint 11 status block at the top.
- [ ] `fix-ui-audit.md` — Sprint 11 plan section (T5.1-T5.3).
- [ ] `Review/handoff-ui-audit.md` — Sprint 11 entry in the "How to take over" section + TL;DR row.
- [ ] `docs/HANDOFF.md` — Sprint 11 section.
- [ ] `.agent/plan.md` — this section.
- [ ] `.agent/context.md` — Sprint 11 decisions + file:line references.
- [ ] `Review/sprint11-verification.md` — written (4-step browser smoke + race test).
### Done when (Sprint 11)
- All boxes above ticked.
- `npm run build` green.
- `Review/sprint11-verification.md` exists.
- All 6 doc files have a Sprint 11 status block.
### Out of scope (Sprint 11)
- 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.1T6.4) at the top.
- [ ] `fix-ui-audit.md` — Sprint 12 plan section (T6.1T6.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.