# Context — Recovery Takeover

## Why this plan exists

Prior agent marked Phases 1, 2, 3, 7 complete and consensus blockers "addressed" in docs, but verification of the repo shows:

1. Auth blocker (review §1.2) closed in docs only — no auth dependency on any router; `/api/admin/scrape` is open.
2. No tests, no CI; verification matrix from `Review/reviewconcensus.md §6` was never run.
3. Review §2.4 explicitly warned: spike scrape + email-approval BEFORE schema/UI commits. Prior agent did the opposite — schema, full API surface, and UI shell first; scrape unverified, email-approval not started.
4. `/api/admin/scrape` runs Playwright synchronously inside the request handler; will time out in production.
5. Phase 7 UI ships above engines (4/5/9) that don't exist — Dashboard renders meal plans the system can't generate.

## Decisions (locked in for this recovery branch)

- **Auth model:** bearer-token admin (single shared `ADMIN_TOKEN` env var) + signed-cookie session for family web UI. Matches what was claimed in ORIENTATION.md "Adversarial Review" section. No public-internet exposure assumed; nginx is sole entrypoint, already correct in `docker-compose.yml`.
- **Path canonicalization (R1-B+D):** dropped `/list` and `/planned` suffixes; routers use `@router.get("")` (no trailing slash) so the canonical paths are `/api/profile`, `/api/recipes`, `/api/recipes/ingredients`, `/api/meals`, `/api/pantry`, `/api/shopping-list`. Frontend `frontend/src/api/index.ts` and smoke tests updated to enforce.
- **Login bootstrap:** `/api/auth/login` signs the family-profile id; if no profile row exists yet, signs literal "bootstrap" so first-run isn't blocked. Cookie validates regardless; downstream code that needs a real id should re-issue after profile creation.
- **Recipe-ingredient:** stay JSONB-only (already chosen). Do not reopen.
- **Household model:** keep `family_member` table (already chosen). Do not reopen.
- **Day-of-week:** ISO (1=Mon). Already chosen.
- **Migrations:** Alembic only. Never `Base.metadata.create_all()` at runtime.
- **Background work:** FastAPI `BackgroundTasks` for the scrape now; APScheduler container with `--workers 1` later (R3-E).

## Open questions to surface to the user, not to assume

- Is `ADMIN_TOKEN` acceptable, or does the user want OIDC/Tailscale-style auth? Default for now: bearer token, easy to swap.
- Email backend for the spike: real SendGrid (needs key) or a console/file backend? Default for spike: console backend, swap to SendGrid in R3-C.

## Verification gate (Phase R1 must pass all)

- `cd backend && pytest` → green
- `docker compose run --rm backend alembic upgrade head` → no error, schema matches models
- `docker compose run --rm backend python -c "from app.main import app; print(app.title)"` → "MealPlanner"
- `docker compose run --rm frontend npm run build` → no error
- `curl -X POST http://localhost/api/admin/scrape` (no token) → 401
- `curl http://localhost/api/profile` (no session) → 200 (read), POST/PUT → 401
- CI workflow runs all of the above on push.

## Phase ordering rule (do not violate)

R1 and R2 are independent and run in parallel. R3 cannot start until BOTH R1 verification and R2 spikes pass. If R2 reveals schema impact, schema changes happen on this branch BEFORE R3-A.

## Swiftly API (R3-0, replaces Playwright path)

- Discovery: `GET https://luckysupermarkets.com/categories` (HTML, no auth). Selector: `<a class="swiftlyCouponCategory" href="/categories/<urlencoded slug>">`. Slug regex: `/categories/(.+)$` then `urllib.parse.unquote`. Fixture (2026-05-05) yielded 17 distinct slugs (e.g. `Product/meat_seafood`, `Product/produce`, ...).
- Products: `GET https://prod.swiftlyapi.net/search/api/v1/products/categories?cat=<slug>&store=757&limit=10000` with `Authorization: Bearer <SWIFTLY_BEARER_TOKEN>`. Response shape: `{"products": {"info": {"count": N}, "items": [...], "facets": [...]}}`. `meat_seafood` returned 256 items.
- Field mapping (item dict → grocery_item):
  - `id` (string) → new `external_id` column (migration 0005)
  - `name` → `name`
  - `description` → `description`
  - `brand` → `brand`
  - `primaryImage.url` → `image_url`
  - `price.ok.regPriceText` (e.g. `"$3.49 /lb"`) → parsed `regular_price` (Decimal) + `unit` (e.g. `"lb"`, may be NULL when no `/unit` suffix)
  - `price.ok.promoArea.promoText` (e.g. `"$2.49 /lb"`) → parsed `sale_price` (Decimal); when present `is_on_sale=True`, else `is_on_sale=False`
  - `price.ok.promoArea.validityText` (e.g. `"Valid 04/29/26 - 05/05/26"`) → ignored for v1 (no migration to add date columns; existing `sale_start_date` / `sale_end_date` left null)
  - aisle: extracted from the queried category slug (`Product/meat_seafood` → `meat_seafood`)
  - `product_url` → NULL (site has no public product page; per R2-A note kept nullable)
- Auth scoping: bearer header is attached ONLY to `prod.swiftlyapi.net` requests, NOT to the public `luckysupermarkets.com` HTML page. Two `requests.Session` objects (one with default UA, one with the bearer header).
- 401 detection: cannot use `BaseScraper._get` because it swallows HTTPError into a `None` return. The new client calls `session.get(...)` directly and checks `resp.status_code == 401` BEFORE `raise_for_status` to raise `SwiftlyAuthError`. Token in `.env.example` expires hourly per spec; on 401 the scraper aborts with a fixed error_message instructing the admin to refresh the token.
- Idempotency key: `(source, external_id)` upserts. Migration 0005 adds `grocery_item.external_id` (nullable text, indexed; not unique because legacy R2-A rows lack one).

---

# Context — Sprint 8 ("Deny" semantics, C + Z, hard-filter escalation)

## Why Sprint 8 exists

User report 2026-06-05 (follow-up to Sprint 7): "one of the meals was the meal that I rejected last week. After you fix the above, lets discuss what rejeccting means." User clarified (exact words): "Hard filter. If it is denied this week twice, it should be considered denied for good."

## Decisions (locked in for Sprint 8)

- **D1. Two-button model:** explicit Approve / Deny this week / Never again on the webui meal card. The "Deny" button is renamed to "Deny this week" so the soft-vs-hard distinction is visible in the UI.
- **D2. Server-side 2-denial auto-escalation:** any "Deny this week" call that finds a prior `denied` row with `denial_expires_at > now()` for the same `(family, recipe)` automatically promotes the recipe to a permanent `NeverSuggest` block. The 2nd-denial toast says "Denied — won't suggest again (denied twice recently)" so the user knows what happened.
- **D3. 90-day decay window** for soft denials (`denial_expires_at = now() + 90d`). Implemented as a partial index for fast lookup; filter is at read time, no cron cleanup needed.
- **D4. Hard filter for both soft + permanent denials.** The planner's `_load_blocklists` returns 3 sets; the soft set is unioned into the `blocked_recipe_ids` filter (per user decision: "Hard filter"). A denied recipe never reappears in the next plan; the user must unblock via the `NeverSuggest` API.
- **D5. `never_again` is the explicit path** to permanent. Always writes a `NeverSuggest` row, regardless of prior denials. Idempotent: re-calling on an already-blocked recipe is a no-op.
- **D6. Email renders 3 direct-action links per recipe** (Approve / Deny this week / Never again). Each link is a one-click GET to the vote page with `?scope=...`, which consumes the token via `submit_vote` and renders a tiny confirmation page. The legacy single-link "Vote on this meal" is preserved as a secondary "Open vote page (all 3 options)" link for completeness.
- **D7. `window.confirm` on "Never again"** to prevent accidental permanent blocks. Soft denials need no confirm.
- **D8. Pre-existing 1 denied row (2026-05-15 day-2 Roasted Sweet Potato and Chickpea Bowl) is left untouched.** Its `denial_expires_at` stays NULL (the filter requires `> now()`), so the recipe is effectively eligible again ~90d from migration time. If the user wants it permanently remembered, the soft-deny cycle auto-escalates it.
- **D9. No "unblock" UI.** The `NeverSuggest` API exists (`DELETE /api/never-suggest/{id}`); no webui button to remove a row. User can use the API directly. Documented as a follow-up.

## Open questions to surface to the user, not to assume

- **Q1. Should the migration reset `denial_expires_at` for the 1 pre-existing denied row?** Default: leave it NULL. Alternative: set it to `now() + 90d` so the row is still soft-active after migration. Asked the user — they said "leave it."
- **Q2. Should "Approve" reset any prior `denial_expires_at`?** The webui approve path (Sprint 3) goes through `approve_meal_item` (POST /api/meals/items/{id}/approve) which sets `approval_status = approved` but **does not clear `denial_expires_at`**. A user who denied a recipe 30 days ago and then approves it 60 days later will see it as `approved`; the soft-deny filter still excludes it for the remaining 30 days. Acceptable as-is; the unblock path is via "Deny this week" twice → "Never again" → manual `NeverSuggest` removal. Documented as a small follow-up.
- **Q3. Pre-existing planner test failure:** `tests/test_planner_filter.py::test_filter_blocks_by_cost` fails on a clean checkout (verified via `git stash` + re-run). Pre-existing, not introduced by Sprint 8. Filed as a pre-existing repo issue.

## Sprint 8 verification gate

- `cd frontend && npm run build` → green
- `cd backend && venv/bin/python -m pytest tests/test_planner_filter.py tests/test_planner_score.py tests/test_planner_select.py --deselect tests/test_planner_filter.py::test_filter_blocks_by_cost` → 21 passed, 1 deselected
- `docker compose exec backend alembic upgrade head` → applies 0016
- `docker compose up -d --build backend frontend` → both up
- API: `POST /api/meals/items/{id}/deny?scope=never_again` returns 200 + `promoted_to_permanent: true`
- API: `GET /api/never-suggest?family_profile_id=...` shows the new row
- Webui: 3 buttons on pending meal cards; "Deny this week" toast reflects `promoted_to_permanent`
- Email: 3 direct-action links per recipe; each is a one-click vote
- `Review/sprint8-verification.md` is the source of truth for the deploy + smoke flow.

## Sprint 8 — does NOT touch

- The `extractErrorMessage` / `showApiError` flow (Sprint 4 F7) — unchanged.
- The keyboard shortcuts (Sprint 5 F2) — unchanged.
- The bulk pantry add (Sprint 6 F3) — unchanged.
- The plan-the-week (Sprint 6 F4) — unchanged.
- The undo-toast (Sprint 3 B12) — unchanged.
- The WeekRangeNav (Sprint 7) — unchanged.
- The `extractErrorMessage` flow now sees the new `denial_expires_at` field if it propagates errors that include item data, but no new error messages.

## Key file:line references

- `backend/alembic/versions/0016_denial_decay_and_scope.py` (NEW)
- `backend/app/models/__init__.py:221-242` (MealPlanItem) + `:250-269` (MealPlanVote)
- `backend/app/schemas/__init__.py:204-219, 248-269`
- `backend/app/api/meals.py:30-138` — helpers (`_apply_denial`, `_ensure_never_suggest_recipe`, `_has_prior_active_soft_denial`)
- `backend/app/api/meals.py:240-330` — `get_vote_page` HTML (3 buttons + `?scope=...` one-click)
- `backend/app/api/meals.py:380-455` — `submit_vote` (handles `never_again` + auto-escalation)
- `backend/app/api/meals.py:486-552` — `deny_meal_item` (`?scope=`)
- `backend/app/services/orchestrator/steps.py:283-300` — email template (3 direct-action links)
- `backend/app/services/planner/generate.py:59-99, 150-194` — `_load_blocklists` returns 3 sets; soft set is hard-filtered
- `frontend/src/api/index.ts:48-58` — `meals.denyItem(itemId, { scope })`
- `frontend/src/pages/Dashboard.tsx:38-50, 385-410` — `MealCard` 3-button voting row
- `Review/sprint8-verification.md` — new file (deploy + smoke)

## Sprint 7 — webui empty-meal-plan fix

## Why Sprint 7 exists

User report 2026-06-05: "Latest meal plans were emails to me this morning, but when I go to the webui, the Meal Planner page is empty." Investigation found a date-semantics mismatch.

## Decisions (locked in for Sprint 7)

- **D1. "This week" = the upcoming Mon-Sun week.** The Friday email advertises the upcoming week; the plan is keyed by the upcoming Monday; the webui opens on the upcoming Monday. Past weeks accessible via the back-arrow. (User asked for a clickable `< Jun 8 — Jun 14 >` style nav, so the range is visible at a glance.)
- **D2. Plan key changes from Friday to Monday.** All future plans are Monday-keyed. Existing 2026-06-05 plan migrated to 2026-06-08 via guarded SQL.
- **D3. Email subject unchanged in form, changes in content.** `step_email` already uses `run.week_start_date` for the subject (verified `steps.py:305`). After D1, subject becomes "Meal plan for week of 2026-06-08" — natural Mon-Sun.
- **D4. Frontend `isoMonday` renamed to `upcomingMonday`.** Same surface (Dashboard + ShoppingList). No backward-compat alias needed; the only callers are within our codebase.
- **D5. New `WeekRangeNav` component is shared between Dashboard and ShoppingList.** Single source of truth for the visual + behavior.
- **D6. The no-op `Generate Meal Plan` CTA at `Dashboard.tsx:415` is still out of scope.** F4 (plan-the-week) and the dead CTA solve different problems. Documented as a follow-up.

## Open questions to surface to the user, not to assume

- **Q1. Migrate the 2026-05-29 plan too?** It's also Friday-keyed. Operator can run a separate guarded UPDATE in the same SQL script. Default for now: include the statement but commented out; user uncomments if they want.
- **Q2. Recency logic in the planner.** `_load_last_cooked` in `planner/generate.py:80-94` compares `MealPlan.week_start_date` across plans. After D2, all values are Mondays, so the comparison is symmetric and "days since last cooked" stays correct. No change needed. (Verified by reading the code.)
- **Q3. Should the email subject line shift by one day (Thu instead of Fri)?** No — the scheduler still fires Fri 02:00..18:00 PT (verified `scheduler/__main__.py`). The deadline (vote by Fri 17:00) still makes sense. The plan key shifts to Mon, the email timing stays Fri. No scheduler change.
- **Q4. Any URL bookmarked with `?week=2026-06-05`?** After the SQL fix, the plan moves to 2026-06-08. Any external link to `?week=2026-06-05` will hit "no plan for that week" (404-ish). Acceptable since the user uses the webui, not external links.

## Sprint 7 verification gate

- `cd frontend && npm run build` → green
- `curl http://100.108.208.56:8082/api/meals?week_start=2026-06-08` (after deploy + SQL) → 3 pending items
- Browser: open `/` (no `?week=` param) on deployment host → header shows `Week of Jun 8, 2026`, 3 meal cards visible
- `curl http://100.108.208.56:8082/api/meals?week_start=2026-06-01` → null (current calendar week has no plan; expected)
- `curl http://100.108.208.56:8082/api/meals?week_start=2026-05-29` → null if user opted in to migrate it, 3 items otherwise
- `Review/sprint7-verification.md` is the source of truth for the deploy + smoke flow.

## Sprint 7 — does NOT touch

- The `extractErrorMessage` / `showApiError` flow (Sprint 4 F7) — unchanged.
- The keyboard shortcuts (Sprint 5 F2) — unchanged. Note: `g d` still navigates to Dashboard at `upcomingMonday()`.
- The bulk pantry add (Sprint 6 F3) — unchanged.
- The plan-the-week (Sprint 6 F4) — unchanged. It still operates on the active plan regardless of week.
- The undo-toast (Sprint 3 B12) — unchanged.
- The aisle-migration (Sprint 2 / Sprint 5 fix) — no migration in S7.

## Key file:line references

- `backend/app/services/orchestrator/runner.py:20-24` — `_current_week_start()` (TO MODIFY)
- `backend/app/scheduler/__main__.py:31-66` — Friday cron schedule (NO CHANGE)
- `backend/app/services/orchestrator/steps.py:305` — `f"Meal plan for week of {run.week_start_date}"` (NO CHANGE; uses upstream value)
- `frontend/src/lib/utils.ts:44-50` — `isoMonday()` (TO RENAME + CHANGE)
- `frontend/src/pages/Dashboard.tsx:316-320` — default-week + navigateWeek (TO UPDATE)
- `frontend/src/pages/ShoppingList.tsx:87-90` — same (TO UPDATE)
- `frontend/src/pages/Dashboard.tsx:479-503` — inline week nav (TO REPLACE with `<WeekRangeNav>`)
- `frontend/src/pages/ShoppingList.tsx:259-283` — same (TO REPLACE)
- `frontend/src/components/` — new `WeekRangeNav.tsx` (TO ADD)
- `backend/scripts/fix_2026_06_05_to_2026_06_08.sql` — new (TO ADD)
- `Review/sprint7-verification.md` — new (TO ADD)

---

# Context — Sprint 9 (F1 Onboarding Tour, H10)

## Why Sprint 9 exists

User direction 2026-06-05: "Proceed with the next phase in the redesign." §Future backlog items: F1 (onboarding tour), F8 (Spoonacular proposal), F9 (Ollama proposal), dead `Generate Meal Plan` CTA. F1 is the only §Future item with a clear UI scope — selected.

## Decisions (locked in for Sprint 9)

- **D1. Hand-rolled tour, no `react-joyride`.** Adding a new npm dep is a 1-line trade-off; the audit's prior principles ("reuse existing components/ui/*", "no new npm deps") win. The tour is 4 steps; the implementation is ~420 lines of focused React.
- **D2. localStorage key `mealplanner:onboarding-complete` (`"1"` once done).** Same shape as the other `mealplanner:` prefixed keys in the codebase (verified by grep).
- **D3. `?reset-tour=1` re-triggers the tour.** Strips the param via `navigate(..., { replace: true })` so a refresh doesn't re-clear. Operator can use this from the browser URL bar; a footer link is a 5-line follow-up if requested.
- **D4. Auto-show on `/` only.** Other routes need a manual trigger (or `?reset-tour=1`). The first-time user lands on `/` (the Dashboard is the only root route), so auto-show on first visit is the natural moment.
- **D5. Tooltip is a real `<div role="dialog" aria-modal="true">`, not a portal.** The 4 anchor elements are all in the same DOM tree as the dialog. The 20-line portal boilerplate was not worth it; a `position: fixed` dialog at the right z-index works fine.
- **D6. rAF polling for the anchor's `getBoundingClientRect`.** Runs only while the tour is open. Cancellable. One DOM read per frame; well under 1% CPU on a 60Hz display.
- **D7. Focus captured on open (primary action), restored on close.** Uses `previouslyFocused.current = document.activeElement` on mount; restores on unmount. Standard focus-trap pattern, minus the trap (the dialog is intentionally non-modal — the user can interact with the page below).
- **D8. The 4 anchor points are stable elements that already exist in the DOM.** The Dashboard's `<Card>` wrapping the Weekly Overview, the Pantry's page header, the Recipes Filters button, the Shopping List page header. Each gets `data-tour="<id>"`. The anchor also has an off-route fallback (centered card + "Open <page>" CTA) so a first-time user who lands on `/pantry` can still see the Dashboard step (with a one-click nav).
- **D9. Sprint 9 post-deploy bug fix (2026-06-05).** The dismiss path (X / Skip / Esc / "Got it") was wired to `useOnboarding().reset()` via `onComplete`, but `reset()` does the *inverse* of dismiss — 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. **Fix (`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()`. The tour's `finish()` still calls `writeComplete()` + `onComplete()`; `markComplete` is the matching App-side state setter. Cleaned up: `markComplete` no longer double-writes localStorage. The bug was missed in initial verification because `npm run build` was green and no browser smoke was run before deploy.

## Open questions to surface to the user, not to assume

- **Q1. Should the tour show on every page or only `/`?** Default: `/` only. Other pages need `?reset-tour=1`. If the user lands on a non-root page first, the tour does NOT auto-show. Documented in `Review/sprint9-verification.md` smoke step 2.
- **Q2. Should the tour re-show on logout / new device?** Default: no. The localStorage key is per-browser, not per-family-profile. If the user has multiple devices or shares a device, the tour shows once per browser. A future migration could move the key to the family profile, but that's a Sprint 11+.
- **Q3. Should the tour re-show on a recipe update / catalog change?** Default: no. The tour is a one-shot. New users see it; existing users don't.
- **Q4. Should we add a Vitest unit test for `useOnboarding` to lock the dismiss/reset/show state transitions?** Default: not now (would require adding `vitest` + `happy-dom` to frontend dev-deps; violates "no new npm deps"). Trade-off: relying on browser smoke for the dismiss path means the same class of bug can re-appear if a future change mis-wires the callbacks. Worth lifting the "no new npm deps" rule *for testing only* in a future sprint.

## Sprint 9 verification gate

- `cd frontend && npm run build` → green (tsc 0 errors, vite 0 errors)
- Browser smoke (8 steps) on `http://100.108.208.56:8082/` per `Review/sprint9-verification.md`
- No regression in Sprints 1–8

## Sprint 9 — does NOT touch

- The `extractErrorMessage` / `showApiError` flow (Sprint 4 F7) — unchanged.
- The keyboard shortcuts (Sprint 5 F2) — unchanged.
- The bulk pantry add (Sprint 6 F3) — unchanged.
- The plan-the-week (Sprint 6 F4) — unchanged.
- The undo-toast (Sprint 3 B12) — unchanged.
- The WeekRangeNav (Sprint 7) — unchanged.
- The 3-button Sprint 8 voting row — unchanged.
- Pre-existing WIP: `backend/app/api/recipes.py`, `backend/app/schemas/recipe.py`, `nginx/nginx.conf` — untouched.

## Key file:line references

- `frontend/src/components/OnboardingTour.tsx` (NEW) — ~420 lines
- `frontend/src/App.tsx:75-105` — `useOnboarding` + tour mount
- `frontend/src/pages/Dashboard.tsx:602` — `<Card data-tour="dashboard">`
- `frontend/src/pages/Pantry.tsx:185, 208` — header + add-form anchors
- `frontend/src/pages/Recipes.tsx:124` — Filters button anchor
- `frontend/src/pages/ShoppingList.tsx:231` — header anchor
- `Review/sprint9-verification.md` — new file (deploy + 8-step browser smoke + a11y check)

---

# Context — Sprint 10 ("Deny Forever" on Recipes)

## Why Sprint 10 exists

User direction 2026-06-05: "Proceed with the next phase in the redesign. Also add a phase to include a 'Deny Forever' button in the Recipes endpoint." Sprint 8's "Deny" semantics let the user block a recipe from a meal plan, but the user may want to block a recipe *before* it ever appears in a plan — for example after browsing `/recipes` and finding a recipe the family dislikes.

## Decisions (locked in for Sprint 10)

- **D1. Two-variant button component.** `NeverSuggestButton` has `card` (overlay on `RecipeCard`) and `detail` (text buttons in `RecipeDetail` top bar) variants. Single source of truth for the popover + reason + undo behavior.
- **D2. Idempotent POST.** The `add` endpoint is idempotent on `(family_profile_id, recipe_id, ingredient_id, reason)`. Re-adding the same row returns the existing one. Avoids accidental duplicates from the popover being double-clicked.
- **D3. Row-level ownership on DELETE.** The `DELETE` endpoint enforces that the row's `family_profile_id` matches the session's family id; otherwise 403. The `require_session` dep auto-resolves to the first family on the trusted network, so this is "the same family" in practice but coded defensively.
- **D4. `window.confirm` on `Allergy` only.** `Dislike` skips the confirm (undo toast is the escape hatch). `Allergy` is a more serious action; the confirm dialog prevents accidental permanent blocks.
- **D5. Undo via toast (Sprint 3 B12 pattern, 6s window).** Reuses `showToast.undo()` from `lib/toast.tsx`. The Undo handler calls `DELETE /api/never-suggest/{id}` and re-invalidates queries so the recipe reappears.
- **D6. Query invalidations cover the cross-cutting effect.** `['neverSuggest', familyId]` + `['recipes']` + `['recommendedRecipes', familyId]` + `['mealPlan']`. Blocking a recipe affects the Recipes page filter, the Recommended page, and the next planner run.
- **D7. `recipe_name` join via server-side helper.** `_attach_names()` does one LEFT OUTER JOIN per kind (recipe, ingredient), then merges into response dicts. Avoids the N+1 query pattern; for a family-scale (dozens of rows), one query per kind is sub-millisecond.
- **D8. Pre-existing block detection.** If a recipe is already blocked, the button shows a "Blocked" state (red `🚫` icon, no `opacity-0`). Clicking it offers an "Unblock" path (with `window.confirm`). This avoids the "I clicked but nothing happened" confusion of the idempotent POST.

## Open questions to surface to the user, not to assume

- **Q1. Should the public DELETE return 403 or 404 on cross-family access?** Default: 403. A 404 would leak less (don't reveal that the row exists), but 403 is the explicit "you don't own this" signal. Trade-off documented in `Review/sprint10-verification.md` R2.
- **Q2. Should the popover auto-dismiss after a reason is picked?** Default: yes (set `open = false` on success). Otherwise the user could double-click and re-fire the mutation. Documented in the component.
- **Q3. Should `notes` be required for `Allergy`?** Default: no. The webui doesn't pass `notes` at all (the API client marks it optional). A future "Manage blocked" page could surface it.

## Sprint 10 verification gate

- `cd frontend && npm run build` → green (tsc 0 errors, vite 0 errors)
- 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`
- 5 API curls (POST, GET, idempotent re-add, DELETE, 403) all return expected status codes
- No regression in Sprints 1-9

## Sprint 10 — does NOT touch

- The `extractErrorMessage` / `showApiError` flow (Sprint 4 F7) — used for the error toast, unchanged.
- The keyboard shortcuts (Sprint 5 F2) — unchanged.
- The bulk pantry add (Sprint 6 F3) — unchanged.
- The plan-the-week (Sprint 6 F4) — unchanged.
- The undo-toast (Sprint 3 B12) — reused; unchanged.
- The WeekRangeNav (Sprint 7) — unchanged.
- The 3-button Sprint 8 voting row — unchanged.
- The OnboardingTour (Sprint 9) — unchanged.
- The admin `POST /api/admin/never-suggest` path — unchanged. Admin token still required.
- Pre-existing WIP: `backend/app/api/recipes.py`, `backend/app/schemas/recipe.py`, `nginx/nginx.conf` — untouched.

## Key file:line references

- `backend/app/api/never_suggest.py:60-86` — `add_block` (POST)
- `backend/app/api/never_suggest.py:89-111` — `remove_block` (DELETE)
- `backend/app/api/never_suggest.py:33-58` — `_attach_names` (recipe_name join)
- `backend/app/schemas/never_suggest.py:31-33` — `recipe_name` + `ingredient_name` fields
- `frontend/src/components/NeverSuggestButton.tsx` (NEW, ~290 lines)
- `frontend/src/api/index.ts:75-86` — `neverSuggest` client
- `frontend/src/pages/Recipes.tsx:241-300` — `RecipeCard` (overlay button)
- `frontend/src/pages/RecipeDetail.tsx:73-78` — top bar (Deny forever button group)
- `Review/sprint10-verification.md` — new file (deploy + 9-step browser smoke + 5 API curls + a11y check)

---

# Context — Sprint 11 (Wire the dead "Generate Meal Plan" CTA)

## Why Sprint 11 exists

User direction 2026-06-05: "Proceed." Selected from the question menu as the smallest remaining §Future item. F1 (Sprint 9) shipped, F8 (Spoonacular) + F9 (Ollama) are full backend proposals, and the dead `Generate Meal Plan` CTA at `Dashboard.tsx:503` was the last remaining piece. The button renders with `onClick: () => {}` — 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), so the wiring is a 25-line client-side glue function. No backend changes. No new dependencies. F8/F9 remain future sprints that will swap the recipe-library-based fill for an LLM/Spoonacular-based generation.

## Decisions (locked in for Sprint 11)

- **D1. Wire to existing endpoints, no new backend route.** `POST /api/meals` (creates an empty plan) + `POST /api/meals/{id}/fill-empty-slots` (fills with library recipes). The `fillEmptySlots` partial-success report pattern is already in production for the existing `Plan Week` menu at `Dashboard.tsx:366-392`. Reusing the same toast messaging keeps the UX consistent.
- **D2. Client-side orchestration, not a new server endpoint.** A combined `POST /api/meals/generate` endpoint would be cleaner long-term (atomic, single source of truth for "this is how a meal plan is generated"), but it would duplicate `fillEmptySlots` logic and lock in a generation strategy before F8/F9 are decided. Keeping the orchestration on the client means F8/F9 only need to swap the `fillEmptySlots` call for a future `LLMGenerate` call.
- **D3. Handle the "already exists" race.** Two tabs clicking "Generate Meal Plan" at the same moment: the second `meals.create` returns 400 with `detail: "Meal plan for this week already exists"`. Fall through to `getPlanned(weekStart)` to get the existing plan id, then call `fillEmptySlots` against it. Same end result, no error toast.
- **D4. Reuse the partial-success toast format from `handlePlanWeek`.** `"Planned N of M meals"` on full success, `"Planned N of M — K failed (e.g. <reason>)"` on partial, `"No empty meals to fill"` on 0/0. The user already knows this toast shape.
- **D5. Track `generatingFirstPlan` state.** Swap the button label to `"Generating…"` and disable it while in-flight, matching the existing `planningWeek` state pattern at `Dashboard.tsx:363`.
- **D6. 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 function called by `onClick`. No DOM, copy, or component structure changes needed.

## Open questions to surface to the user, not to assume

- **Q1. Should the empty state show a meal-type picker ("Breakfast / Lunch / Dinner" toggles) before generating, or always generate all three?** Default: always generate all three (matching the existing `Plan Week` menu default). Surfacing a picker adds 3 checkboxes and a "Generate N meals" button; small but a separate UI decision. If you want it, it's a 5-line addition to `handleGenerateFirstPlan`.
- **Q2. Should the CTA be hidden entirely if the recipe library is empty?** Default: show it, and let it fail gracefully. The backend's `fillEmptySlots` returns `failed=[]` for every slot with reason "No recipes available" when the library is empty. The UI toast surfaces this. A library-empty case is rare in practice (admin seeds the library), and hiding the button would leave the user with no path forward.
- **Q3. Should the path forward to F8/F9 add a `source: 'library' | 'spoonacular' | 'ollama'` field to the meal plan to record which strategy was used?** Default: no. The current `MealPlan` table has no such field. Adding it is a Sprint 12+ change if F8/F9 ship.

## Sprint 11 verification gate

- `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/sprint11-verification.md`
- Race test: two tabs clicking "Generate Meal Plan" simultaneously — both succeed
- No regression in Sprints 1-10

## Sprint 11 — does NOT touch

- The OnboardingTour (Sprint 9) — unchanged. The tour's first step is the Dashboard's Weekly Overview card (`Dashboard.tsx:602`); the empty state with the CTA renders above the card and is a different element. No tour interaction needed.
- The NeverSuggestButton (Sprint 10) — unchanged.
- The 3-button Sprint 8 voting row — unchanged.
- The WeekRangeNav (Sprint 7) — unchanged.
- The bulk pantry add (Sprint 6 F3) — unchanged.
- The existing `handlePlanWeek` (Sprint 6 F4) — unchanged. That fills empty slots in an *existing* plan. Sprint 11 is the create-then-fill path.
- The keyboard shortcuts (Sprint 5 F2) — unchanged.
- The error toast / `showApiError` flow (Sprint 4 F7) — used for the error path; unchanged.
- Pre-existing WIP: `backend/app/api/recipes.py`, `backend/app/schemas/recipe.py`, `nginx/nginx.conf` — untouched.
- Backend code: no changes. The two endpoints already exist and are well-tested.

## Key file:line references (Sprint 11)

- `frontend/src/pages/Dashboard.tsx:366-392` — existing `handlePlanWeek` (model for the new handler)
- `frontend/src/pages/Dashboard.tsx:499-504` — `EmptyState` with the dead CTA (target)
- `frontend/src/pages/Dashboard.tsx:393-396` — `useQuery` for `['mealPlan', weekStart]` (invalidation target)
- `frontend/src/api/index.ts:38-65` — `meals` API client (already has `create` + `fillEmptySlots`)
- `backend/app/api/meals.py:159-195` — `POST /api/meals` (create)
- `backend/app/api/meals.py:693+` — `POST /api/meals/{id}/fill-empty-slots`
- `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)

---

# Context — Sprint 13 (F9-lite Ollama Cloud plan synthesis)

## Why Sprint 13 exists

User direction 2026-06-05: "Proceed." F9-lite reuses the pre-existing `OLLAMA_*` config (`backend/app/config.py:36-38: OLLAMA_BASE_URL=https://ollama.com/v1, OLLAMA_API_KEY, OLLAMA_MODEL=kimi-k2.6:cloud`). Avoids the local model pull (F9-full would be 4 GB on disk + a separate `ollama serve` process). Cloud LLM — operator's existing OLLAMA billing applies per call.

The Sprint 11 "Generate Meal Plan" CTA was library-only. Sprint 13 splits it into a 2-step modal: "Use the recipe library" (Sprint 11 unchanged) or "Ask the LLM" (new). The LLM path lets the user describe what they want for the week ("Italian-inspired, vegetarian", "easy weeknight dinners, no fish") and uses kimi-k2.6:cloud on ollama.com to pick meals from the local library.

## Decisions (locked in for Sprint 13)

- **D1. Reuse the pre-existing `llm_matcher._ask_ollama` call pattern.** `backend/app/services/llm_matcher.py:97-144` already implements the exact `POST ${OLLAMA_BASE_URL}/chat/completions` + `Authorization: Bearer ${OLLAMA_API_KEY}` + `model: settings.OLLAMA_MODEL` + `max_tokens` + `temperature: 0` + `re.sub` for `<think>` blocks pattern. Sprint 13's `_ask_llm` helper mirrors it (same call, same headers, same strip). The only difference: `max_tokens=800` (vs `500`) for kimi-k2's reasoning headroom — 21 picks + per-pick UUID validation can run long.
- **D2. 200-recipe cap on the library sent to the LLM.** Larger libraries would exceed the prompt token budget for kimi-k2.6. The cap is alphabetical-by-name, deterministic. The library fill on the back end iterates the full library regardless of the cap.
- **D3. Tolerant JSON parsing.** The LLM may return valid JSON, JSON in markdown code fences, JSON with trailing commentary, or pure prose. `_parse_picks` handles all four: tries the regex for ` ```json ... ``` ` first, then finds the first `[` and the matching `]`, then `json.loads`. On any failure, returns `[]` — the library fill takes over. The user never sees a crash.
- **D4. Per-pick validation.** Each entry must have a valid `day_of_week` (1-7), a known `meal_type` (breakfast/lunch/dinner), AND a `recipe_id` that's in the local library. Invalid entries are dropped silently. The library fill then covers the dropped slots.
- **D5. Library fill = Sprint 6+ pattern, re-implemented inline.** The original `meals.fillEmptySlots` is a public HTTP endpoint. Calling it from `/api/llm/plan` would be a self-HTTP-call (works but ugly). Re-implementing the logic inline (read library, skip already-used recipe_ids, pick the first remaining) keeps the endpoint self-contained. Same partial-success semantics.
- **D6. Modal is inline in `Dashboard.tsx`, not a separate component.** Depends on 4 local states + 3 handlers. Extracting to a separate component would require prop-drilling or context. ~50 lines of JSX, easy to read inline.
- **D7. 60s LLM timeout.** kimi-k2.6:cloud typical latency is 5-15s for a 21-pick request. 60s is generous. On timeout, the user sees a success toast with `picked_count: 0` — same UX as if the LLM returned 0 picks.
- **D8. `OLLLAMA_CLOUD` costs apply.** The endpoint is public + `require_session`-gated (no public abuse). A future sprint could add a per-day rate limit if the operator's OLLAMA billing becomes painful. Out of scope for the initial ship.
- **D9. Default mode = "Use the recipe library".** The LLM mode is opt-in. Users who don't toggle the radio get the Sprint 11 flow (free, fast, no API call). Users who want the LLM experience explicitly opt in by selecting the radio + typing a prompt.

## Open questions to surface to the user, not to assume

- **Q1. Should the LLM-picked items be visually distinguished from the library-filled items in the plan grid?** Default: no (out of scope). The Sprint 8 vote + Sprint 10 deny buttons work on any item, LLM-picked or not. A future sprint could add a small "✨ LLM pick" badge.
- **Q2. Should the prompt modal remember the user's last prompt + last mode across sessions?** Default: no (would require `localStorage`). A future sprint could persist `{promptMode, lastPrompt}` to `mealplanner:*` localStorage keys.
- **Q3. Should the LLM call be streaming (show a "thinking…" indicator that updates as the model generates)?** Default: no. The Ollama streaming API is a different request shape (`stream: true` + NDJSON parsing). Out of scope for the initial ship. A future sprint could add streaming with a "Asking LLM… (so far: <partial JSON>)" toast.

## Sprint 13 verification gate

- `cd frontend && npm run build` → green (tsc 0 errors, vite 0 errors)
- Backend AST clean on all 3 changed files
- Browser smoke (3 steps) on `http://100.108.208.56:8082/` per `Review/sprint13-verification.md`
- 4 API curls: happy path + OLLAMA_API_KEY unset (503) + empty prompt (422) + duplicate week (400)
- No regression in Sprints 1-12

## Sprint 13 — does NOT touch

- The OnboardingTour (Sprint 9) — unchanged. The tour's first step is the Dashboard's Weekly Overview card; the modal renders on top via `z-50` + `bg-black/40` scrim.
- The NeverSuggestButton (Sprint 10) — unchanged.
- The handleGenerateFirstPlan (Sprint 11) — extracted into `generateFromLibrary` (identical body) + `generateFromLLM` (new).
- The web-search toggle + import flow (Sprint 12) — unchanged. Sprint 12's `recipes.search` + `recipes.importRecipe` are independent of the LLM synthesis.
- The pre-existing WIP `backend/app/api/recipes.py` / `schemas/recipe.py` / `nginx/nginx.conf` — untouched.
- The existing `RecipeDiscoveryService` at `backend/app/services/recipe_discovery.py` — unchanged. Sprint 13 uses the pre-existing `OLLAMA_*` config, not the service.
- The existing `llm_matcher.py` — unchanged. Sprint 13's `_ask_llm` helper mirrors `_ask_ollama` but is a separate function (different `max_tokens`, different prompt shape).

## Key file:line references (Sprint 13)

- `backend/app/services/llm_matcher.py:97-144` — `_ask_ollama` (the call pattern Sprint 13 mirrors)
- `backend/app/services/recipe_enrichment.py:25-106` — parallel LLM helper (different prompt shape; not reused)
- `backend/app/config.py:36-38` — OLLAMA config (reused as-is)
- `backend/app/api/llm_plan.py` (NEW, ~280 lines) — Sprint 13 endpoint + helpers
- `backend/app/models/__init__.py:200-211` — `MealPlan` model (target for the create)
- `backend/app/models/__init__.py:223-224` — `MealPlanItemBase` (target for the items)
- `backend/app/schemas/__init__.py:255-265` — `LLMPlanRequest` + `LLMPlanResponse` (S13.1 schemas)
- `backend/app/main.py:65-66` — `llm_plan_api.router` registration
- `frontend/src/api/index.ts:124-128` — `llm.plan` client method
- `frontend/src/pages/Dashboard.tsx:399-487` — `handlePromptSubmit` + `generateFromLibrary` + `generateFromLLM` (the 3 Sprint 13 handlers)
- `frontend/src/pages/Dashboard.tsx:775-870` — `renderPromptModal` (the Sprint 13 modal)
- `Review/sprint13-verification.md` — new file (deploy + 3-step browser smoke + 4 API curls + a11y check + 6-risk table)

---

## Sprint 14 — Vitest for `useOnboarding` (Q4)

### Decisions

- **D1 — Lift "no new npm deps" for testing-only.** Vitest + happy-dom + @testing-library/react + @testing-library/jest-dom go under devDependencies. Runtime bundle size unchanged.
- **D2 — `happy-dom` over `jsdom`.** Lighter (7x smaller), faster startup, sufficient for hooks-only tests. No need for full DOM emulation in Sprint 14.
- **D3 — Test only the hook, not the `<OnboardingTour/>` component.** The S9 bug class is at the hook/callback wiring level. Component tests (focus, arrow keys, dialog a11y) are a different scope and a future sprint.
- **D4 — `renderHook` from `@testing-library/react`** (not a custom harness). The hook is plain React, no router or query client dependencies, so no `wrapper` option is needed.
- **D5 — `npm test` runs `vitest run` (no watch).** CI-friendly. `npm run test:watch` for local dev.
- **D6 — `markComplete` test locks the S9 bug class.** A future refactor that wires `onComplete → reset` (the original bug) would set state to false but leave localStorage at '1'; a future refactor that wires `onReset → markComplete` (the inverse) would set state to true without clearing localStorage. Case 3 (`markComplete` does NOT clear localStorage) + Case 4 (`reset` clears localStorage AND flips state) lock both directions.
- **D7 — No backend tests this sprint.** Venv on `docker-willester` is broken (Nix symlinks to `/run/current-system/sw/bin/python`). Backend pytest skipped. Frontend-only sprint.

### Open questions

- Q1 — Cover the `<OnboardingTour/>` component itself (focus, arrow keys, dialog a11y) in a future sprint? Default: yes, future sprint. Adds @testing-library/user-event for keyboard simulation.

### Sprint 14 file:line references

- `frontend/src/components/OnboardingTour.tsx:78-93` — `readComplete` / `writeComplete` / `clearComplete` helpers (the localStorage I/O seam)
- `frontend/src/components/OnboardingTour.tsx:103-133` — `useOnboarding` hook (returns `{reset, show, markComplete, isComplete}`)
- `frontend/src/components/OnboardingTour.tsx:109` — initial `useState<boolean>(readComplete)` (the localStorage → state bridge)
- `frontend/src/components/OnboardingTour.tsx:113-116` — `reset` callback: clears localStorage + flips state to false
- `frontend/src/components/OnboardingTour.tsx:118-121` — `show` callback: identical to `reset` (intentional mirror)
- `frontend/src/components/OnboardingTour.tsx:128-130` — `markComplete` callback: flips state to true only (the inverse op from `reset`)
- `frontend/src/App.tsx:104-117` — wires `onComplete → markComplete` and `onReset → reset` (must not be inverted)
- `frontend/vitest.config.ts` (NEW) — happy-dom env + setup file
- `frontend/vitest-setup.ts` (NEW) — @testing-library/jest-dom matchers
- `frontend/src/components/OnboardingTour.test.tsx` (NEW) — 6 cases (S14.3)
- `frontend/package.json` (MODIFIED) — devDeps + scripts

---

## Sprint 15 — Seed 50 family-friendly recipes for 4-week planning (content op)

### Decisions

- **D1 — 50 recipes, not 100.** 4 weeks × 21 meals = 84 picks needed minimum. 50 unique recipes with 1.7× rotation is enough variety and fits in a single Spoonacular day (105 pts under 140 cap).
- **D2 — Distribution: 5 cuisines × 10 each.** Italian + Mexican + Asian + American + Mediterranean/Middle Eastern. This gives 5/7 days of cuisine rotation per week, the family's stated preference.
- **D3 — Use Sprint 12's `POST /api/recipes/import` endpoint directly.** No new code path, no schema change, no UI change. The endpoint already does the 1-pt `/information` call + ingredient upsert + Recipe insert.
- **D4 — Pick the top hit per query, not curated.** I trust Spoonacular's ranking; if the top hit is a bad fit, the user can delete it via the existing UI. The 50-query list is curated; the per-query hit is Spoonacular's pick.
- **D5 — 1-2 sec sleep between imports.** The 1-pt rate is fine on the free tier, but throttling keeps me well under the per-second rate limit and avoids a burst that could trigger Spoonacular's abuse detector.
- **D6 — Run on the host, not locally.** The host has the live backend (`mealplanner-backend-1` up, healthy). Local backend would need its own DB connection + env. The script lives at `scripts/seed_recipes.py` and runs in the host's shell.

### Open questions

- **Q1 — Curate the queries or use my list as-is?** Default: use my list as-is. The user can re-run or pick more queries if the result set is biased.
- **Q2 — After seeding, the 4-week plan is generated client-side or server-side?** Default: client-side via the existing Dashboard's "Generate Meal Plan" CTA. Server-side plan synthesis is a future sprint.

### Sprint 15 file:line references

- `backend/app/api/recipe_search.py:48-50` — `_DAILY_LIMIT: float = 140.0`, `_SEARCH_URL`, `_INFO_URL`. The quota gate.
- `backend/app/api/recipe_search.py:55` — `_points_available()` (140 - `_points_used`).
- `backend/app/api/recipe_search.py:100-148` — `search_recipes` endpoint (`GET /api/recipes/search?q=...&limit=10`).
- `backend/app/api/recipe_search.py:194-308` — `import_recipe` endpoint (`POST /api/recipes/import`).
- `backend/app/schemas/__init__.py:398-411` — `RecipeSearchHit` Pydantic model.
- `backend/app/schemas/__init__.py:413-415` — `RecipeImportRequest` Pydantic model (`{external_id, external_source}`).
- `backend/app/security.py:54-78` — `require_session` (the SESSION_PASSWORD auth that gates both endpoints).
- `backend/app/models/__init__.py:162-197` — `Recipe` model (the destination table for the imports).
- `scripts/seed_recipes.py` (NEW) — the 50-query one-shot Python script.
- `Review/sprint15-verification.md` (NEW) — the deploy + curl flow.

### Sprint 15 — Round 2 (2026-06-07) decisions

- **D7 — Round 2 needed because user wanted "more meals" / "larger sample."** Round 1 imported 18 of 50 (cap hit). Round 2 used the fresh daily quota.
- **D8 — Round 2 list focuses on cuisines and meal types the round 1 list didn't cover:** Indian (8), Thai (6), Chinese regional (6), Soups & stews (6), Salads (6), Sandwiches/wraps (5), Breakfast (5), German/European (4), French (4). Total 50. Maximizes variety; doesn't double-cover round 1 cuisines (Italian, Mexican, Asian, American, Mediterranean).
- **D9 — Round 2 imports 18 of 50 today (cap hit again at query 30).** Library at 67 total (37 Spoonacular + 30 manual). LLM test for week 2026-07-20: 21/21 slots filled, 0 failed.
- **D10 — Round 2 is a separate script (`seed_recipes_round2.py`), not a list appended to round 1.** Keeps the round 1 script as a historical record; the new script can be re-run independently.

### Sprint 15 Round 2 file:line references

- `scripts/seed_recipes_round2.py` (NEW) — round 2 import script, ~120 lines, 50 queries.
- `Review/sprint15-verification.md` — appended "Sprint 15 — Round 2" section with full result breakdown.

### Sprint 15 — Round 3 (2026-06-07) decisions

- **D11 — Re-ran `scripts/seed_recipes.py` (round 1's script), not a new script.** Round 3 uses the same script as round 1 — it's idempotent, so it skipped 37 duplicates and picked up the cap-blocked queries from round 1 (queries 29-50 were never attempted in round 1).
- **D12 — Round 3 is the last automatic run.** Library at 77 is well past the 4-week coverage threshold (77 unique vs 84 picks needed = 1.09× rotation). The remaining 12 unrun queries would add 5-10 more (Mediterranean + American leftovers), but the family can hand-curate a round 4 with specific dishes they want rather than running a broad sweep.
- **D13 — Total Sprint 15 work: 46 new Spoonacular recipes across 3 rounds.** Round 1 = 18, round 2 = 18, round 3 = 10. ~5 minutes of agent time per round. Library trajectory 19 → 37 → 47 (Spoonacular) → 77 total (with 30 manual recipes).

### Sprint 15 Round 3 file:line references

- `scripts/seed_recipes.py` (REUSED) — round 1's script, idempotent for round 3.
- `Review/sprint15-verification.md` — appended "Sprint 15 — Round 3" section with the 10-import breakdown + 3-round summary table.

---

## Sprint 16 — Fix Sprint 13 LLM-model latent bug

### Decisions

- **D1 — Switch `OLLAMA_MODEL` from `kimi-k2.6:cloud` to `gpt-oss:20b`.** kimi-k2.6 is a reasoning model that burns 800 tokens on internal `reasoning` and returns `content=''` (empty) for complex prompts. gpt-oss is a non-reasoning model that returns the JSON answer directly. **Verified:** same prompt → kimi-k2.6 returns 0 chars content + `finish_reason: length`; gpt-oss:20b returns 21 valid picks + `finish_reason: stop`.
- **D2 — Bump `max_tokens=800` → `2000` in `_ask_llm`.** gpt-oss needs ~99 chars per pick × 21 picks = ~2074 chars. 2000 is the floor; may need 2500 for the 77-recipe library. Will adjust if any plan call returns `finish_reason: length`.
- **D3 — Add a Vitest contract test on the response shape.** Doesn't catch the kimi-k2 bug (server-side) but locks the response shape so future refactors don't break the frontend. Backend tests would be better but the venv is broken.
- **D4 — Single config-file change, no schema change, no UI change.** This is a hotfix-tier sprint (~30 min) but tracked because the bug has been silently in production for ~1 sprint.
- **D5 — Don't add `reasoning_effort: low`.** Tested it on the kimi-k2.6 model: it still uses 8000+ chars of reasoning on complex prompts and ignores the budget. Model swap is the only fix.

### Open questions

- **Q1 — If `picked_count=0` comes back with the new model, what's the next step?** Debug further: log the raw response, check if gpt-oss returns something we can't parse, try `gpt-oss:120b` if available. Default: log and report.

### Sprint 16 file:line references

- `backend/app/config.py:38` — `OLLAMA_MODEL: str = "gpt-oss:20b"` (was `kimi-k2.6:cloud`)
- `backend/app/api/llm_plan.py:117` — `max_tokens: 2000` (was 800)
- `backend/app/api/llm_plan.py:102-129` — `_ask_llm` helper (the F9-full seam; only the model name needs to swap for F9-full local)
- `backend/app/api/llm_plan.py:131-167` — `_parse_picks` (tolerant JSON parser; handles markdown fences, trailing commentary, bare JSON)
- `backend/app/api/llm_plan.py:200-300` — `synthesize_plan` endpoint (creates plan + validates picks + library fill)
- `frontend/src/api/llm.test.ts` (NEW) — Vitest contract test on the LLM response shape
- `Review/sprint16-verification.md` (NEW) — the diagnosis + fix + verification commands

