Commit Graph
100 Commits
Author SHA1 Message Date
admin 415eb8340d docs: Sprint 15 round 2 — +18 recipes, library at 67 total, LLM 21/21
Sprint 15 round 2 (commit 97b84a7) added seed_recipes_round2.py
which seeded 18 more Spoonacular recipes today. Library went
49 -> 67 total. LLM test (Sprint 13 endpoint, week 2026-07-20):
  {picked_count: 0, filled_count: 21, failed_count: 0}
The library now covers all 21 slots of a week (was 19/21 + 2
failed in round 1).

This commit updates the 6 running docs:

- .agent/plan.md — Sprint 15 Round 2 section (S15R2.1-S15R2.4
  + Done when + Out of scope) added after the round 1 sections.
  50-query list documented by category.
- .agent/context.md — Round 2 decisions (D7-D10) added: list
  focuses on cuisines round 1 didn't cover, round 2 is a
  separate script, library at 67 with 21/21 LLM coverage.
- Review/sprint15-verification.md — Round 2 section appended
  with: 50-query list, 18-imported result, 12 no-hits, 1 402,
  updated DB state (37 Spoonacular + 30 manual = 67), LLM
  test result, round 3 follow-up ticket.
- Review/ui-nielsen-audit.md — Round 2 paragraph added to the
  Sprint 15 status block.
- fix-ui-audit.md — T8.6 added to the Sprint 15 section.
- Review/handoff-ui-audit.md — TL;DR and Sprint 15 section
  updated with round 2.
- docs/HANDOFF.md — Round 2 paragraph added to the Sprint 15
  section, Last-updated footer updated.

All 6 docs now reflect Sprint 15 round 2. Library has 67 unique
recipes — 1.25x rotation across 4 weeks of planning. Round 3
can add 30+ more by re-running scripts/seed_recipes.py on the
next day (its query list has round 1 cap-blocked American +
Mediterranean cuisines still to import).
2026-06-07 16:20:44 -07:00
admin 97b84a7cd8 feat(scripts): Sprint 15 round 2 — seed_recipes_round2.py (50 gap-filling queries)
Round 1 (commit a3c89bf) imported 18 recipes before hitting
the 50-pt/day Spoonacular free-tier cap. User direction
(2026-06-07): 'please add more meals to the potential list' /
'Pull in more recipes so we have a larger sample to generate
from.'

This is a fresh-quota run with a different query list focused
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) = 50 queries.

Same shape as scripts/seed_recipes.py: hits Spoonacular's
complexSearch directly (avoids the broken backend route and
the backend's quota counter), POSTs top hits to the backend's
/api/recipes/import. Idempotent (409 on duplicate), 1.5 sec
sleep, stops on 402.

Result: 18 imported today. 12 queries returned no hits from
Spoonacular's free-tier index (e.g. 'chana masala', 'thai
basil chicken', 'dan dan noodles' — these are absent or
premium-only). 1 query ('wedge salad') hit 402 mid-import.

DB went 49 -> 67 total recipes (37 Spoonacular + 30 manual).
LLM test (Sprint 13 endpoint, week 2026-07-20, prompt
'variety, mix of cuisines, family-friendly, no repeats'):
  {picked_count: 0, filled_count: 21, failed_count: 0}
The library now covers all 21 slots of a week (was 19/21 +
2 failed in round 1). 4 weeks of planning now has a real
library to pick from with 1.25x rotation.

Re-running scripts/seed_recipes.py (round 1) tomorrow will
add 30+ more — its query list has gaps the round 1 cap didn't
reach (American + Mediterranean cuisines).
2026-06-07 16:20:20 -07:00
admin 0668d40444 docs: Sprint 15 — seed 50 recipes + Sprint 12 latent-bug fix across all 6 running docs
Sprint 15 (commit a3c89bf) does two things: (1) reorders
backend/app/main.py so recipe_search_api.router mounts BEFORE
the WIP recipes_api.public_router (fixes a Sprint 12 latent
bug where /api/recipes/search was shadowed by the WIP's GET
/{recipe_id} returning 422); (2) adds scripts/seed_recipes.py
which seeded 18 Spoonacular recipes into the local library
today (free-tier 50-pt cap hit; remaining 32 to seed on later
days via the same idempotent script). DB went 31 -> 49 total
recipes.

This commit updates the 6 running docs that track sprints:

- .agent/plan.md — Sprint 15 section (S15.1-S15.4 + Done
  when + Out of scope) added after Sprint 14's out-of-scope.
- .agent/context.md — Sprint 15 decisions (D1-D6), open
  questions (Q1-Q2), and file:line references added.
- Review/sprint15-verification.md — NEW: full 18-imported
  breakdown by cuisine + free-tier math correction (50 pts/day,
  not 150) + LLM test (picked_count=0, filled_count=19,
  failed_count=2 for week 2026-07-06) + 6-risk table + deploy
  + 2 follow-up tickets (lower _DAILY_LIMIT, re-run script).
- Review/ui-nielsen-audit.md — Sprint 15 status block
  (T8.1-T8.3) added after the Sprint 14 block. Notes the
  Sprint 12 latent-bug fix as the critical change for the
  upcoming Sprint 12 deploy.
- fix-ui-audit.md — Sprint 15 section (T8.1-T8.5) added after
  the Sprint 14 section. T8.1 documents the main.py mount
  order fix in detail. T8.5 surfaces 2 follow-up tickets.
- Review/handoff-ui-audit.md — Batch K line in the deploy
  list, Sprint 15 section after Sprint 14, TL;DR Sprint 15
  line, Last-updated footer updated.
- docs/HANDOFF.md — Sprint 15 section after Sprint 14, Last-
  updated footer updated. Notes the corrected free-tier math
  and the 2 follow-up tickets.

All 6 docs now reflect Sprint 15. The Sprint 12 latent-bug
fix is documented as a hard prerequisite for the upcoming
Sprint 12 deploy (without it, every 'Search the web' query
would 422). Re-running scripts/seed_recipes.py on a later
day will add the remaining 32 recipes (the script is
idempotent — already-imported IDs return 409 and are
skipped).
2026-06-06 14:12:25 -07:00
admin a3c89bf6a2 feat(backend): Sprint 15 — seed 50 recipes + fix Sprint 12 latent-bug (main.py mount order)
Two changes:

1. Sprint 12 latent-bug fix: backend/app/main.py mount order.
   The pre-existing WIP backend/app/api/recipes.py:212 registers
   GET /{recipe_id} (UUID-typed) under /api/recipes. Sprint 12's
   recipe_search_api.router also mounts under /api/recipes. FastAPI
   matches routes in registration order, so the WIP's /{recipe_id}
   was catching /api/recipes/search and treating 'search' as a UUID,
   returning 422. This was a latent bug: Sprint 12 hasn't been
   deployed yet so the user hasn't seen the failure, but the
   frontend's 'Search the web' feature would 422 on every query.

   Fix: moved the recipe_search_api.router import to line 39 (with
   the other api imports) and the include_router call to BEFORE
   recipes_api.public_router. 3-line comment explains the why.

   Verified live: GET /api/recipes/search?q=chicken+parmesan&limit=2
   returns 200 with 2 hits. The WIP's GET /api/recipes/{uuid} still
   works (it just no longer shadows the /search and /import routes).

2. Sprint 15 content op: scripts/seed_recipes.py (NEW, ~150 lines).
   User direction (2026-06-05): 'Lets build out recipes for the
   coming 4 weeks in advance. In order to do this, lets add more
   recipes to the list of available ones.'

   The script seeds family-friendly recipes from Spoonacular into
   the local library. 50 queries (5 cuisines x 10 each: Italian,
   Mexican, Asian, American, Mediterranean/Middle Eastern).

   For each query: hit Spoonacular's complexSearch directly (avoids
   the broken backend route and the backend's quota counter), take
   the top hit, POST to the local backend's /api/recipes/import
   (which does the 1-pt /information call + idempotent ingredient
   upserts + Recipe insert). Idempotent: 409 from the import
   endpoint is logged and skipped. 1.5 sec sleep between queries.
   Stops cleanly on Spoonacular 402 (quota exhausted).

   Result: 18 recipes imported today. Spoonacular's free tier is
   50 pts/day (not 150 as I assumed; the _DAILY_LIMIT=140 in
   recipe_search.py:48 should drop to 45 — follow-up ticket).
   At 28 queries the script hit the cap. Re-running tomorrow will
   yield ~30 more (after the 18 already imported count toward 50).

   DB went 31 -> 49 total recipes. 19 Spoonacular + 30 manual.
   LLM test (Sprint 13 endpoint, week 2026-07-06):
     {picked_count: 0, filled_count: 19, failed_count: 2}
   The library fill covered 19/21 slots. The LLM (kimi-k2.6:cloud)
   returned 0 picks. Sprint 13 tolerance worked as designed.

No pre-existing WIP files touched (recipes.py, schemas/recipe.py,
nginx.conf unchanged). Only main.py was reordered (one-line + 3-line
comment). scripts/seed_recipes.py is a new file in the existing
scripts/ directory.

Deploy: git pull + docker compose up -d --build backend frontend.
The 18 new recipes are already in the DB. Re-run the seed script
on later days for the remaining 32 (after the cap resets).
2026-06-06 14:11:58 -07:00
admin af4ec793c7 docs: Sprint 14 — Vitest for useOnboarding (Q4) across all 6 running docs
Sprint 14 (commit 28f3212) adds Vitest + happy-dom +
@testing-library/react + @testing-library/jest-dom as
devDeps and locks the useOnboarding state-transition
contract with 7 unit tests in 25 ms. Lifts the 'no new
npm deps' rule for testing-only. Runtime bundle unchanged
(503.82 kB). No pre-existing WIP files touched.

Sprint 9's bug 1562929 (onComplete wired to .reset, the
inverse op) shipped a post-deploy fix the same day.
Sprint 14 prevents recurrence at npm test time.

This commit updates the 6 running docs that track sprints:

- .agent/plan.md — header changed to 'Active sprint: Sprint
  14'; Sprint 14 section (S14.1-S14.4 + Done when + Out of
  scope) added after the Sprint 13 section.
- .agent/context.md — Sprint 14 decisions (D1-D7), open Q1,
  and file:line references added.
- Review/sprint14-verification.md — NEW: deploy + 7-case
  test table + verification commands + Case 7 S9-bug-catch
  proof + 5-risk table + open Q1 (component-level tests).
- Review/ui-nielsen-audit.md — Sprint 14 status block
  (T7.1-T7.3) added after the Sprint 13 block.
- fix-ui-audit.md — Sprint 14 section (T7.1-T7.5) added
  after the Sprint 13 section.
- Review/handoff-ui-audit.md — Batch J line in deploy
  list, Sprint 14 section after Sprint 13, TL;DR Sprint
  14 line, Last-updated footer updated. (Did NOT replace
  the Sprint 7/8/TL;DR/environment-quirks/active-risks
  content that was at the bottom of the file — that
  material is preserved unchanged.)
- docs/HANDOFF.md — Sprint 14 section added after Sprint
  13, Last-updated footer updated.

All 6 docs now reflect Sprint 14. §Future backlog
remaining: F9-full (local Ollama model pull on the host).
Q4 (Vitest) is now closed. Sprint 14 is the smallest
sprint in the cycle (devDeps + 1 test file + 2 config
files + 2 scripts) and the first that adds a test
infrastructure layer.
2026-06-05 17:30:00 -07:00
admin 28f321298f test(frontend): Sprint 14 — Vitest for useOnboarding (Q4)
Lifts the 'no new npm deps' rule for testing-only. Locks the
S9 bug class (onComplete → reset, the original bug from
1562929) with 7 unit tests in 25 ms.

5 changes:
- frontend/package.json — 4 new devDeps: vitest@^1.6.0,
  happy-dom@^14.7.0, @testing-library/react@^14.2.0,
  @testing-library/jest-dom@^6.4.0. Plus @types/node@^20
  for tsc (Case 7's static check on App.tsx uses node:fs).
- frontend/package.json scripts — adds 'test' (vitest run,
  no watch, CI-friendly) and 'test:watch' (vitest).
- frontend/vitest.config.ts (NEW) — defineConfig from
  vitest/config, happy-dom env, setupFiles points to
  vitest-setup.ts, include is src/**/*.test.{ts,tsx}.
- frontend/vitest-setup.ts (NEW) — one line: imports
  @testing-library/jest-dom/vitest, auto-extends expect.
- frontend/src/components/OnboardingTour.test.tsx (NEW) —
  7 cases:
  1. clean init (localStorage empty) → isComplete=false.
  2. persisted init (key = '1') → isComplete=true.
  3. markComplete → state=true, localStorage STAYS at '1'
     (locks one direction of the S9 bug at the hook level).
  4. reset → localStorage cleared + state=false.
  5. show → mirror of reset (intentional).
  6. localStorage throw on read → silently swallowed,
     isComplete=false, no crash.
  7. App.tsx wiring — static check: onComplete calls
     markComplete, onReset calls reset, neither inverts.
     Case 7 is the load-bearing test: Sprint 9's bug was
     at the App.tsx call site, not in the hook, so Cases
     1-6 cannot catch it. Case 7 reads App.tsx as a string
     via node:fs/promises, regex-matches the arrow bodies
     of onComplete={...} and onReset={...}, asserts each
     calls the right onboarding.* method.

Verified: flipping markComplete → reset in App.tsx makes
Case 7 fail on the onCompleteBody.toMatch(/markComplete/)
assertion. All 7 cases pass in 25 ms. npm run build green
(tsc 0 errors, vite built in 2.6 s, bundle 503.82 kB
unchanged — vitest is devDeps only). No backend change.
No migration. No runtime dep change.

Deploy: git pull + cd frontend && npm install && npm test
(confirm 7/7) + cd .. && docker compose up -d --build
frontend. No backend rebuild.
2026-06-05 17:29:42 -07:00
admin 8cb4d4198c docs: Sprint 13 — F9-lite (Ollama Cloud free-text plan synthesis) across all 6 running docs
CI / backend (pytest + alembic) (push) Has been cancelled
CI / frontend (build) (push) Has been cancelled
Sprint 13 (commit bae9403) splits the Sprint 11 "Generate Meal
Plan" CTA into a 2-step modal: "Use the recipe library" (default,
Sprint 11 unchanged) or "Ask the LLM" (new). The LLM path POSTs
to /api/llm/plan; the backend calls kimi-k2.6:cloud on ollama.com,
parses the LLM’s JSON picks, creates a fresh plan, fills the
LLM’s picks, and falls through to the Sprint 6+ fillEmptySlots
pattern for the slots the LLM didn’t cover. No pre-existing WIP
files touched.

This commit updates the 6 running docs that track the sprint:

- .agent/plan.md — Sprint 13 section (S13.1-S13.4) added.
- .agent/context.md — Sprint 13 (D1-D9, Q1-Q3) added; file:line
  references; key takeaways.
- Review/sprint13-verification.md — new file: 3-step browser
  smoke + 4 API curls + a11y check + 6-risk table + future
  work section.
- Review/ui-nielsen-audit.md — Sprint 13 status block (T7.1-T7.3)
  at the top, after the Sprint 12 block.
- fix-ui-audit.md — Sprint 13 section (T7.1-T7.5) added after
  the Sprint 12 section.
- Review/handoff-ui-audit.md — Batch I added to the deploy
  instructions; Sprint 13 section added after Sprint 12; TL;DR
  table row 13 added; Last-updated footer updated.
- docs/HANDOFF.md — Sprint 13 section added after the Sprint 12
  section, with a path-forward paragraph for F9-full.

All 6 docs now reflect Sprint 13. §Future backlog remaining:
F9-full (local Ollama model pull on the host) — opt-in based on
cloud-billing feedback. _ask_llm is the single seam: F9-full only
needs to swap the URL + model name.
2026-06-05 16:59:20 -07:00
admin bae94037f3 feat(ui): Sprint 13 — F9-lite (Ollama Cloud free-text plan synthesis)
F9-lite reuses the pre-existing OLLAMA_* config (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 uvicorn process).
Cloud LLM — operator’s existing OLLAMA billing applies per call.

Sprint 13 splits the Sprint 11 "Generate Meal Plan" CTA into a
2-step modal: "Use the recipe library" (default, Sprint 11’s
existing flow) or "Ask the LLM" (new). The LLM path POSTs to
/api/llm/plan with a free-text prompt; the backend calls
kimi-k2.6:cloud on ollama.com, parses the LLM’s JSON picks,
creates a fresh plan, fills the LLM’s picks, and falls through
to the Sprint 6+ fillEmptySlots pattern for the slots the LLM
didn’t cover.

Backend:
- backend/app/api/llm_plan.py (NEW, ~280 lines). 1 endpoint
  (POST /api/llm/plan body {prompt, week_start}) + 4 helpers:
  - _ensure_ollama_configured — 503 on missing OLLAMA_API_KEY.
  - _serialize_library — reads up to 200 recipes for the
    family, sorted alphabetically. Cap prevents prompt-token
    overflow on kimi-k2.
  - _ask_llm — mirrors llm_matcher._ask_ollama (same URL,
    same headers, max_tokens=800, temperature=0, strips think
    blocks, 60s timeout).
  - _parse_picks — tolerant JSON parser. Handles markdown code
    fences, trailing commentary, and bare JSON. On failure
    returns []; the library fill takes over.
  - _validate_picks — drops invalid entries: missing fields,
    out-of-range day_of_week, unknown meal_type, unknown
    recipe_id. Returns a list of LLMPickedItem.
  Flow: rejects duplicate week (400) and empty library (400),
  builds the prompt, calls the LLM, validates picks, creates
  the plan, inserts the LLM-picked items, fills the rest from
  the library (Sprint 6+ pattern, re-implemented inline to
  avoid a self-HTTP-call), returns {plan_id, picked_count,
  filled_count, failed_count, reasoning}.
- backend/app/schemas/__init__.py — added LLMPlanRequest +
  LLMPlanResponse.
- backend/app/main.py:65-66 — registered llm_plan_api.router
  at the /api/llm prefix. No collision with the pre-existing
  WIP recipes.py.

Frontend:
- frontend/src/api/index.ts — added llm.plan(data) method.
- frontend/src/pages/Dashboard.tsx — added the prompt modal
  (radio for library vs. LLM + textarea for the LLM path with
  500-char counter) + new state (showPromptModal, promptMode,
  promptText, promptBusy) + extracted Sprint 11’s body into
  generateFromLibrary + added generateFromLLM. The modal is
  inline (not a separate component) because it depends on 4
  local states + 3 handlers. Click-outside-to-dismiss is
  disabled while promptBusy is true. The textarea autoFocuses
  when LLM mode is selected. Added the Button import.

LLM tolerance: a 60s timeout, parse-failure (markdown code
fences, trailing commentary), or empty response all return 0
picks; the library fill takes over. The user never sees a
crash — at worst, picked_count: 0 and the toast reads "Planned
N meals (LLM picked 0, library filled the rest)".

Verified: npm run build green (tsc 0 errors, vite 0 errors).
Bundle: 500.28 → 503.82 kB (+3.5 kB). Backend AST clean on
all 3 changed files. No new dependencies, no migration, no
pre-existing WIP files touched.

Deploy: git pull + docker compose up -d --build backend
frontend (no migration, no new dependencies).
2026-06-05 16:59:03 -07:00
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
admin 11b4595cf7 feat(ui): Sprint 12 — F8 Spoonacular search (web-search toggle + import)
Sprint 12 wires a "Search the web" toggle on /recipes that hits
Spoonacular’s complexSearch API. Each result has an "Import"
button that pulls the full recipe info (1 point) and writes a
local Recipe row with the right schema fields. Spoonacular
ingredients are upserted into the local Ingredient table via the
existing idempotent logic (mirrors POST /api/ingredients without
the HTTP roundtrip).

No pre-existing WIP files touched. Sprint 12 creates a new
backend/app/api/recipe_search.py router (separate from the WIP
recipes.py) and adds 2 Pydantic models to backend/app/schemas/
__init__.py (the canonical location). The WIP recipes.py is
registered in main.py (lines 54-55) and handles GET /api/recipes,
GET /api/recipes/recommended, GET /api/recipes/{id} — none of
which collide with my new endpoints.

Backend:
- backend/app/api/recipe_search.py (NEW, ~270 lines). 2 endpoints:
  - GET /api/recipes/search?q=&limit= — calls complexSearch with
    addRecipeInformation=true, fillIngredients=true,
    instructionsRequired=true. Returns normalized
    RecipeSearchHit[]. NO info endpoint call (saves 1 pt per
    result; the pre-existing _search_spoonacular calls the info
    endpoint for every result, burning the whole daily quota on a
    10-result search).
  - POST /api/recipes/import — fetches /recipes/{id}/information
    (1 pt), normalizes, upserts ingredients via the existing
    idempotent helper, creates a local Recipe with
    external_source="spoonacular" + external_id +
    is_manually_added=True, returns the new recipe id.
  - Process-wide _points_used counter (module-level singleton +
    threading.Lock). 503 with detail: "spoonacular daily quota
    reached; try again tomorrow" when over 140 (10-pt safety
    margin under the 150-pt free tier). Resets on process restart.
  - 503 with clear "SPOONACULAR_API_KEY not configured" when env
    var unset.
  - Idempotent import: 409 on duplicate (external_source,
    external_id).
- backend/app/config.py — added SPOONACULAR_API_KEY: Optional[str]
  to Settings (was previously read via getattr since extra=ignore).
- backend/app/schemas/__init__.py — added RecipeSearchHit +
  RecipeImportRequest.
- backend/app/main.py:62-63 — registered recipe_search_api.router
  at the /api/recipes prefix. No collision with the WIP.

Frontend:
- frontend/src/api/index.ts — added 5 new methods to
  mealPlannerApi.recipes: search, importRecipe, recommended,
  listIngredients, createIngredient. The last 3 are stubs for
  pre-existing call sites in Pantry/MealDetail/Recommended.tsx
  that were previously hidden by a smaller API surface.
- frontend/src/pages/Recipes.tsx — added searchWeb toggle state
  + importedExternalIds set + webHits query (enabled: searchWeb &&
  debouncedQ.length >= 2) + importMutation (toast on success,
  showApiError on failure) + the toggle button (with
  aria-pressed={searchWeb}) + the web-search panel (<div
  role="region" aria-label="Web recipe search"
  aria-busy={webLoading}>). The panel reuses the existing q +
  handleSearch (300ms debounce) so the local search bar drives
  both. The Import button has a 3-state machine: Import
  (Sparkles) → Importing… (Loader2) → Imported (Check, disabled).
- frontend/src/types/index.ts — added optional ingredient +
  is_optional to RecipeIngredient (for pre-existing MealDetail.tsx
  call sites).

Verified: npm run build green (tsc 0 errors, vite 0 errors).
Bundle: 496.48 → 500.28 kB (+3.8 kB). Backend AST clean on all 4
changed files. Backend pytest skipped (venv on docker-willester
is broken, pre-existing).

Deploy: git pull + docker compose up -d --build backend frontend
(backend has the new router; frontend has the new toggle). No
migration, no new dependencies.
2026-06-05 16:31:39 -07:00
admin dac1364c29 docs: Sprint 11 — wire the dead "Generate Meal Plan" CTA across all 6 running docs
Sprint 11 (commit 41154e9) wires the previously-dead
"Generate Meal Plan" empty-state CTA on the Dashboard to two
existing endpoints (POST /api/meals + POST /api/meals/{id}/fill-
empty-slots). No backend changes; no new dependencies. The
handler lives on the client for now; future F8 (Spoonacular) +
F9 (Ollama) will swap the fillEmptySlots call for an LLM call
without changing the DOM. F8 + F9 remain in the §Future backlog.

This commit updates the 6 running docs that track the sprint:

- .agent/plan.md — Sprint 11 section (S11.1-S11.3) added.
- .agent/context.md — Sprint 11 (D1-D6, Q1-Q3) added; file:line
  references; key takeaways.
- Review/sprint11-verification.md — new file: 4-step browser
  smoke + race test + 2 API curls + a11y check + risks + future
  work section.
- Review/ui-nielsen-audit.md — Sprint 11 status block (T5.1-T5.3)
  at the top, after the Sprint 10 block.
- fix-ui-audit.md — Sprint 11 section (T5.1-T5.5) added after the
  Sprint 10 section.
- Review/handoff-ui-audit.md — Batch G added to the deploy
  instructions; Sprint 11 section added after Sprint 10; TL;DR
  table row 11 added; Last-updated footer updated.
- docs/HANDOFF.md — Sprint 11 section added after the Sprint 10
  section, with a path-forward paragraph for F8/F9.

All 6 docs now reflect Sprint 11. §Future backlog remaining: F8
(Spoonacular) + F9 (Ollama) proposals, both full backend work.
2026-06-05 15:36:26 -07:00
admin 41154e934a feat(ui): Sprint 11 — wire the dead "Generate Meal Plan" empty-state CTA
The Dashboard empty state (Dashboard.tsx:553-560) has rendered a
"Generate Meal Plan" button since Sprint 1 with onClick: () => {}.
Clicking it did nothing. Sprint 11 wires it to two existing
endpoints: POST /api/meals to create a fresh plan, then
POST /api/meals/{id}/fill-empty-slots to fill it from the recipe
library. Same partial-success toast format as the existing
handlePlanWeek (Sprint 6 F4).

Changes:
- Dashboard.tsx: new handleGenerateFirstPlan() handler (~50 lines).
  Tracks generatingFirstPlan state; swaps the button label to
  "Generating…" and disables it while in-flight.
- Dashboard.tsx: wired EmptyState.action.onClick to the new
  handler. Also added action.disabled to suppress double-clicks.
- EmptyState.tsx: action.disabled?: boolean (optional,
  backward-compatible; the 5 other EmptyState usages in the
  codebase do not pass it).

Race handling: if meals.create returns 400 with "Meal plan for
this week already exists" (another tab created one first), the
handler falls through to getPlanned(weekStart) to get the
existing plan id, then calls fillEmptySlots against it. No error
toast in this case.

No backend changes. No new dependencies. No migration. Both
endpoints already exist from Sprint 6+. Bundle: 495.64 → 496.48 kB.

The EmptyState.action.onClick is the single seam for future
F8 (Spoonacular) + F9 (Ollama) work — they only need to swap
the fillEmptySlots call for an LLM call.
2026-06-05 15:36:12 -07:00
admin 4c85c929d3 docs: record Sprint 9 post-deploy dismiss-bug fix across all 6 running docs
Sprint 9 (commit 6e386ba) shipped a working OnboardingTour but a
broken dismiss path: clicking X / Skip / Esc / "Got it" did
nothing. Root cause: useOnboarding().reset() was wired to the
dismiss handler at App.tsx, but reset() does the inverse of
dismiss — it clears the localStorage key and flips isComplete to
FALSE, so the tour re-rendered, the early-return did not fire,
and the dialog stayed visible. Fix: commit 1562929 split the
dismiss and reset paths into two distinct callbacks (onComplete
and onReset). User confirmed browser smoke passes.

This commit updates the 6 running docs that track Sprint 9:

- .agent/plan.md — S9.4.1 sub-task (post-deploy fix) added.
- .agent/context.md — D9 (root cause + fix) + Q4 (Vitest?) added.
- Review/sprint9-verification.md — full post-deploy fix section
  appended (root cause, fix, post-fix verification, lessons).
- Review/handoff-ui-audit.md — Sprint 9 status banner + Last
  updated footer updated to reference the fix commit.
- fix-ui-audit.md — T3.4.1 sub-task added under the T3.4
  verification gate.
- docs/HANDOFF.md — post-deploy fix paragraph added to the
  Sprint 9 section.

All 6 docs now reflect the post-deploy reality. No code changes.
2026-06-05 14:09:10 -07:00
admin 1562929f6b fix(ui): Sprint 9 — dismiss X / Skip tour did not hide the dialog
Root cause: the OnboardingTour early-return is gated on
isComplete=true, but App.tsx was calling onboarding.reset() on
onComplete. reset() does the inverse: clears the localStorage key
and flips isComplete to FALSE. The user clicked X, the localStorage
key got written, but the App-level flag flipped to false, so the
tour re-rendered and the early-return did not fire — the dialog
stayed visible.

Fix: split the dismiss and reset paths into two distinct callbacks
onComplete (dismiss) and onReset (re-show). Added markComplete to
useOnboarding: flips isComplete to true. App wires:
  onComplete -> onboarding.markComplete()
  onReset    -> onboarding.reset()
The tour itself still calls writeComplete() before invoking
onComplete, so the localStorage key is written once on dismiss.

Also cleaned markComplete: it now only flips state (the tour already
wrote the key), removing a redundant double-write.

Verified npm run build green on docker-willester. No regression
expected; all other Sprint 9 code paths untouched.
2026-06-05 13:40:51 -07:00
admin 0b6c5dcfb7 feat(ui): Sprint 10 — Deny Forever on Recipes (card overlay + detail button + undo toast)
User-driven follow-up to Sprint 8: surface the Sprint 1-3 NeverSuggest
infrastructure on the Recipes surface so a family can pre-emptively
mark a recipe as never-suggest before it appears in a plan.

Backend (3 changes):
- POST /api/never-suggest (public, webui-facing). Idempotent on
  (family, recipe, reason). Returns the row joined with recipe_name.
- DELETE /api/never-suggest/{ns_id} (public, webui-facing). Row-level
  ownership check (403 if cross-family), 404 if absent.
- NeverSuggestRead.recipe_name + .ingredient_name server-side joins
  via _attach_names() helper (one LEFT OUTER JOIN per kind).
- Admin path (POST/DELETE /api/admin/never-suggest) unchanged.

Frontend (4 changes):
- New NeverSuggestButton component (~290 lines). Two variants: card
  (overlay on RecipeCard) and detail (text buttons in RecipeDetail
  top bar). Popover with Allergy (red, window.confirm) + Dislike
  (neutral, no confirm). Undo toast via showToast.undo() (Sprint 3
  B12 pattern, 6s window). Pre-existing block detection shows a
  Blocked state with an Unblock path.
- mealPlannerApi.neverSuggest.list/add/remove in api/index.ts.
- Recipes.tsx overlay: RecipeCard has position: relative; button is
  opacity-0 group-hover:opacity-100 focus:opacity-100. e.preventDefault
  + e.stopPropagation prevents accidental navigation.
- RecipeDetail.tsx top bar: new Deny forever button group to the left
  of Add to Plan.

Build: npm run build green (tsc 0 errors, vite 0 errors) on
docker-willester. Bundle 487 -> 495 kB. No new dependencies. No
migration (NeverSuggest table exists from prior sprints).

Tracking: Review/sprint10-verification.md (9-step browser smoke +
5 API curls + undo test + a11y check).
2026-06-05 13:29:04 -07:00
admin 6e386baf6e feat(ui): Sprint 9 — F1 onboarding tour (4-step welcome)
Hand-rolled 4-step tour (no react-joyride) anchors to existing
[data-tour="<id>"] attributes. localStorage key
mealplanner:onboarding-complete is the source of truth; ?reset-tour=1
clears the key and re-shows.

Steps: Dashboard / Pantry / Recipes / Shopping List. Keyboard: 1-4 jump,
←/→ step, Esc dismiss. Off-route fallback renders a centered card with
an 'Open <page>' CTA. A11y: role=dialog, aria-modal=true, focus captured
on open and restored on close.

5 lines of code across 4 pages; 1 new component (~420 lines). No new
dependencies. No backend changes. No migration. Frontend-only deploy.

Tracking: Review/sprint9-verification.md (8-step browser smoke + a11y
check + reset-link test).
2026-06-05 11:14:53 -07:00
admin a616138e7c docs(review): Sprint 6 verification log + plan/handoff/audit updates
Sprint 6 (F3 + F4) is now documented across the project:

- Review/sprint6-verification.md: new deploy + smoke-check doc.
  Backend + frontend deploy (no migration). 5 smoke-check
  sections: A) ShoppingList bulk-add end-to-end, B) F3 partial-
  failure edge case, C) F4 'Plan the week' button + dropdown,
  D) F4 edge cases (no empty slots, all recipes used, invalid
  meal_types), E) Sprints 1-5 regression spot-check. Rollback
  section covers revert (no migration to undo).

- fix-ui-audit.md: new Sprint 6 section (S6.1 F3, S6.2 F4,
  S6.3 verification gate). 'Done when' block updated to 6
  sprints / 10 commits / 20 findings closed.

- Review/handoff-ui-audit.md: updated to a 6-sprint cycle. TL;DR
  table includes the 8ad4ef6 row. File list includes sprint6-
  verification.md. File-level diff summary gains 8 new rows for
  Sprint 6 (F3 backend + F4 backend + 3 new schemas + 2 api
  bindings + 2 page changes). §Future list now strikethroughs
  F3 and F4. Follow-up tickets section added: the no-op
  'Generate Meal Plan' empty-state CTA, the (now-narrower) Pantry
  bulk-add ticket, and the 'Sprints 2-5 + Sprint 6 separate
  batch' deploy note.

- Review/ui-nielsen-audit.md: new Sprint 6 status block at the
  top. F3 + F4 documented with the design-decision context
  (ShoppingList-only scope; dropdown for All/Dinners; partial-
  success with detailed report).

- docs/HANDOFF.md: Last-updated line bumped to 6 sprints / 10
  commits / 20 findings / 6 §Future items. Header commit list
  gains the 8ad4ef6 row. New 'Sprint 6' subsection in the
  2026-06-04 session block. Commit table gained the 8ad4ef6 row.
  Files-modified + Files-added lists updated.

No code changes; the 3 pre-existing WIP files (backend/app/api/
recipes.py, schemas/recipe.py, nginx/nginx.conf) are deliberately
not staged.
2026-06-04 14:05:51 -07:00
admin 8ad4ef67a9 feat(ui): bulk pantry add + plan-the-week button (Sprint 6 F3+F4)
F3 — Bulk 'add checked to pantry' on ShoppingList (the audit's F3 /
H7 finding). ShoppingList already had a 'checked' Set keyed on
ingredient_id and persisted to localStorage — that selection state
is the natural substrate for a bulk action.

Backend (POST /api/pantry/bulk):
- New endpoint that accepts {items: HomePantryCreate[]} and returns
  HomePantryBulkResult with per-item status (added / updated /
  skipped) and totals. Each item follows the same upsert semantics
  as POST /api/pantry (insert or overwrite qty/unit/expires_at).
- Items with an unknown ingredient id are reported as 'skipped'
  with reason='Unknown ingredient' rather than aborting the batch.
  Per-item failure is the chosen model (partial-success) so the
  user gets a precise count of what actually went in.
- New Pydantic schemas: HomePantryBulkCreate, HomePantryBulkResult,
  HomePantryBulkResultItem.

Frontend:
- mealPlannerApi.pantry.addBulk(items) is the API binding.
- ShoppingList gets a new 'Add N to pantry' primary button (next
  to the existing Reset button) that appears when checked.size > 0.
  Click → POST /api/pantry/bulk → toast shows 'added X, updated Y,
  skipped Z' counts. On success, only the items that actually
  landed in the pantry are removed from the checked set; skipped
  items stay checked so the user can see what failed.
- Disabled state with 'Adding…' label while the request is in
  flight; button text shows the count dynamically (matches the
  F4 design language: tell the user what they're about to do).

F4 — Plan the whole week (the audit's F4 / H7 finding).

Backend (POST /api/meals/{id}/fill-empty-slots):
- New endpoint that takes {meal_types: [str, ...]} and fills every
  empty slot in the plan whose meal_type is in the request. Per-day
  iteration (1-7) per meal_type, skipping already-occupied slots.
  Recipe selection: prefer un-used, fall back to any (same as the
  existing generate-item).
- Per-slot failure model: never aborts mid-batch. Returns
  FillEmptySlotsResult { filled: [{day, meal_type, item}],
  failed: [{day, meal_type, reason}] }. Invalid meal_types
  (e.g. 'brunch') return immediately with a single FailedSlot
  explaining why.
- Same approval_status=pending semantics as generate-item.

Frontend:
- mealPlannerApi.meals.fillEmptySlots(planId, mealTypes) is the
  API binding.
- New 'Plan the week' button on the Dashboard header (next to the
  week-nav control from Sprint 5). Primary color, Sparkles icon,
  ChevronDown caret indicates a dropdown. Disabled + spinner
  ('Planning…') while the request runs.
- Dropdown has two options: 'Dinners only' (sends
  meal_types=['dinner']) and 'All meals' (sends
  meal_types=['breakfast','lunch','dinner']). Each option has a
  one-line secondary label explaining the action.
- Toast on success: 'Planned N meal slots' (full) or 'Planned N
  of M meal slots — X failed (e.g. <reason>)' (partial). The
  query is then invalidated so the new slots show up.

Files: backend/app/api/meals.py, backend/app/api/pantry.py,
backend/app/schemas/__init__.py, frontend/src/api/index.ts,
frontend/src/pages/Dashboard.tsx, frontend/src/pages/ShoppingList.tsx.

Build: tsc 0 errors, vite 0 errors. Bundle +3.6KB (the new code
fits in the existing chunk).
Curl smoke on local dev DB confirms both new endpoints behave as
designed: /api/pantry/bulk returns proper skipped count for
unknown ingredients, /api/meals/{id}/fill-empty-slots returns
the partial-success result for the dinners-only call.
2026-06-04 14:00:31 -07:00
admin 2029e80c5a docs(review): Sprint 5 verification log + plan/handoff/audit updates
Sprint 5 (F5 + F2 + 0015 cast fix) is now documented across the project:

- Review/sprint5-verification.md: new deploy + smoke-check doc.
  Backend + frontend deploy (one batch with Sprints 2-4). Migration
  0015 MUST be run as part of this deploy (the cast fix is what
  makes it runnable). 7 smoke-check sections: A) curl tests for
  ?week_start=, B/C/D) URL week nav on Dashboard and Shopping List
  with query-key isolation, E) keyboard shortcut matrix, F) post-
  migration canonical-aisle verification query, G) Sprints 1-4
  regression spot-check. Rollback section covers reverts + the
  persist_aisle_backup recovery path.

- fix-ui-audit.md: new Sprint 5 section (S5.0 critical 0015 fix,
  S5.1 F5 implementation, S5.2 F2 implementation, S5.3 verification
  gate). 'Done when (overall)' block updated to 5 sprints + 9
  commits + 18 findings closed + the 0015 fix unblocks Sprint 2.

- Review/handoff-ui-audit.md: updated to a 5-sprint cycle. TL;DR
  table includes the d78bd18 + f740f40 rows with the CRITICAL 0015
  fix callout. file-list includes the new sprint5-verification doc.
  file-level diff summary gains 16 new rows (S5 backend + frontend +
  0015 + hooks/components). §Future list now strikethroughs F2 and
  F5. Quick-start deploy commands list Sprints 2-5 as a single
  batch (backup → migrate → rebuild backend + frontend).

- Review/ui-nielsen-audit.md: new Sprint 5 status block at the
  top. F5 + F2 + the 0015 fix all documented. Cross-ref to
  Review/sprint5-verification.md.

- docs/HANDOFF.md: Last-updated line bumped to 5 sprints / 9
  commits / 18 findings / with the 0015 fix CRITICAL callout.
  Header commit list gains the two Sprint 5 commits. New 'Sprint
  5' subsection in the 2026-06-04 session block. Commit table
  gained the d78bd18 + f740f40 rows. Files-modified list now
  includes all 5 sprints' changes. New 'Files added by Sprint 5'
  subsection for the 3 new files in hooks/ + components/.

No code changes; the 3 pre-existing WIP files (backend/app/api/
recipes.py, schemas/recipe.py, nginx/nginx.conf) are deliberately
not staged.
2026-06-04 12:42:54 -07:00
admin f740f40103 feat(ui): global keyboard shortcuts + shortcut help banner (Sprint 5 F2)
F2 — Vim-style keyboard shortcuts (the audit's F2 / H7 finding).

New files:
- frontend/src/hooks/useKeyboardShortcuts.ts: lightweight global
  handler. Supports both single keys ('/', '?', 'Escape') and
  vim-style 2-key sequences ('g d', 'g r', 'g p', 'g s' for nav).
  Sequence timeout is 1500ms; pending prefix is cleared on any
  unrecognised key so typing 'g' alone is safe. Suppressed when
  the user is typing in an input/textarea/select/contenteditable,
  or when any modifier key (Ctrl/Cmd/Alt) is held — those chords
  belong to the browser or other handlers. Uses a ref so the
  listener is registered once and always sees the latest callbacks.

- frontend/src/hooks/useFocusSearch.ts: tiny CustomEvent bus.
  requestFocusSearch() dispatches a 'mealplanner:focus-search'
  event; useFocusSearchOnShortcut(ref) subscribes and focuses the
  supplied input. The decoupling lets any page opt in without the
  global handler needing to know the page's DOM.

- frontend/src/components/ShortcutHelpBanner.tsx: dismissible help
  dialog that slides down under the nav when '?' is pressed.
  Auto-dismisses after 6s; Escape also dismisses. role=dialog +
  aria-label for screen readers; the kbd elements use the
  <kbd> semantic for assistive tech.

Wired in App.tsx:
- New <GlobalShortcuts /> child of <BrowserRouter> calls
  useKeyboardShortcuts with the 4 nav sequences, '/' →
  requestFocusSearch(), and '?' → dispatch SHOW_SHORTCUT_HELP_EVENT.
- <ShortcutHelpBanner /> mounted inside the page wrapper (after
  <main>).

Pantry and Recipes now call useFocusSearchOnShortcut with a
forwardRef attached to their top search inputs. Recipes's search
already debounced via handleSearch so focusing just selects the
existing text for the user to replace. Pantry's search is a plain
controlled input, same treatment.

Behaviour summary:
- g d / g r / g p / g s → navigate to the 4 main pages
- / → focus the search input on the current page (Pantry + Recipes
  only — other pages have no search)
- ? → show the help banner
- All shortcuts are no-ops inside text-entry controls, so a user
  typing 'p' into the pantry search box will not trigger navigation.

Build: tsc 0 errors, vite 0 errors. 5 files, +185/-3.
2026-06-04 12:36:32 -07:00
admin d78bd1864e feat(ui): URL week selector + aisle-migration 0015 cast fix (Sprint 5 F5)
F5 — Persistent week selector in URL (the audit's F5 / H7 finding).

Backend:
- GET /api/meals and GET /api/shopping-list now accept an optional
  ?week_start=YYYY-MM-DD query param. When set, the response is the
  MealPlan for that week (any status). When omitted, behaviour is
  unchanged: meals returns the latest plan; shopping-list returns
  the latest approved/locked plan with fallback to latest.
- No new dependencies; uses FastAPI's Optional[date] Query type
  which auto-validates the YYYY-MM-DD format.
- Files: backend/app/api/meals.py:30-57, shopping_list.py:27-60.

Frontend:
- New week helpers in lib/utils.ts: isoMonday(), parseIsoDate(),
  shiftIsoDate(), formatIsoDate(). All UTC-based to match the
  backend's date column. isoMonday returns the ISO date of the
  Monday of a given date's week.
- api/index.ts: meals.getPlanned(weekStart?) and
  shoppingList.get(weekStart?) take an optional ISO date string.
  Axios drops undefined params, so callers can omit them.
- Dashboard: useSearchParams('week') reads the URL; if absent or
  invalid, falls back to this week's Monday (so the default URL is
  empty). The queryKey now includes weekStart, so navigating weeks
  fetches the right plan. A new segmented control in the header
  (chevron-left | 'This week' / 'Current' jump button | chevron-
  right) lets the user step weeks; the jump button highlights
  primary-50 when the displayed week IS the current week. 'This
  week' clears the ?week param. Mutations (move/approve/deny/
  delete/generate) now invalidate ['mealPlan', weekStart] so the
  right week refetches.
- ShoppingList: same URL sync, same segmented control, same
  weekStart in queryKey. The 'no plan' empty state branches on
  isCurrentWeek: 'No shopping list yet' (current) vs 'No plan for
  that week' (any other week). The local-storage check-state key
  naturally isolates per week (it uses shoppingList.week_start_date
  which is the server's view of the current plan's week).

Migration 0015 cast fix:
- Discovered while smoke-testing on the local dev DB: the
  CASE expression in 0015_normalize_pantry_aisles.py failed
  with 'operator does not exist: text = boolean' on the
  varchar(100) aisle column. Root cause: the CASE branches were
  inferred as different types (string vs NULL) so the SET
  target type couldn't be unified.
- Fix: explicit ::varchar(100) cast on the CASE expression.
  Also simplified the WHEN '' branch (was NULLIF(...) IS NULL
  with implicit bool comparison). Tested on local dev DB:
  alembic upgrade head now succeeds; the 21196 rows that the
  Sprint 2 dry-run predicted actually normalize correctly.
  This means Sprint 2's deploy was blocked on the same bug
  (the deployment host would have hit the same error).
- Verified via curl: /api/shopping-list?week_start=2026-05-15
  returns 25 items with aisles 'Meat & Seafood', 'Pantry',
  'Produce', 'Dairy & Eggs' (the canonical labels the migration
  produces). Pre-migration aisles like 'meat_seafood' are gone.

Build: tsc 0 errors, vite 0 errors. 7 files, +196/-22.
2026-06-04 12:30:49 -07:00
admin 62dfc1eb4a docs(review): Sprint 4 verification log + plan/handoff/audit updates
Sprint 4 (F7 + F6) is now documented across the project:

- Review/sprint4-verification.md: new 100-line deploy + smoke-check
  doc. Frontend-only deploy (git pull + docker compose up -d --build
  frontend). 5 smoke-check tables: A) success toasts still work for
  all 11 actions, B) error path shows FastAPI detail (network-down
  is the easiest test; full Pydantic 422 verification via curl +
  DevTools 'Edit and resend'), C) pre-flight toasts still fire
  without a network call, D) plan-status Badge has correct
  aria-label in DevTools, E) Sprint 1-3 regression spot-check.
  Includes rollback instructions (single-commit revert).

- fix-ui-audit.md: new Sprint 4 section with full per-task notes
  (S4.1 F7 implementation details, S4.2 F6 aria-label, S4.3
  verification gate). 'Done when (overall)' block updated to 4
  sprints + 7 commits + 16 findings closed. No new commit in
  fix-ui-audit.md for the F8/F9 §Future addendum (those are noted
  in Review/handoff-ui-audit.md but live in the doc/proposals/
  tree, not in the UI-audit plan).

- Review/handoff-ui-audit.md: updated to a 4-sprint cycle. TL;DR
  table includes the d71b67a row, file-list includes the new
  verification doc, file-level diff summary gains 5 new rows for
  Sprint 4, §Future list now strikethroughs F6 and F7, and the
  Quick-start deploy commands list Sprint 4.

- Review/ui-nielsen-audit.md: new Sprint 4 status block at the
  top with the F7/F6 changes, the no-backend-changes note, and
  a cross-ref to the new verification log.

- docs/HANDOFF.md: Last-updated line bumped to 4 sprints / 7
  commits / 16 findings. New 'Sprint 4' subsection in the
  2026-06-03 session block. Commit table gained the d71b67a row.
  Files-modified list gained the lib/toast.tsx, App.tsx, and
  three pages changes for Sprint 4 (with B-tags preserved).

No code changes; the 5 pre-existing WIP files (backend/app/api/
meals.py, recipes.py, schemas/recipe.py, frontend/src/api/index.ts,
nginx/nginx.conf) are deliberately not staged.
2026-06-03 19:39:35 -07:00
admin d71b67a297 feat(ui): global react-query error handler + plan-status a11y (Sprint 4 F7+F6)
F7: surface every failed query/mutation as a toast via react-query
QueryCache/MutationCache onError, with a single error normalizer that
extracts FastAPI's response.data.detail (string or Pydantic 422 array).

- lib/toast.tsx: new extractErrorMessage(err, fallback) and
  showApiError(err, fallback). Reads response.data.detail when present
  (string or [{loc, msg, type}, ...] array), then err.message, then
  the fallback. No more '[object Object]' or raw stack traces.

- App.tsx: QueryClient is now created with QueryCache and
  MutationCache onError handlers wired to showApiError. Added
  defaultOptions.queries: { retry: 1, refetchOnWindowFocus: false }
  so background refetch failures are no longer silent (the audit's
  H9 finding).

- Dashboard.tsx: removed 6 local try/catch toasts (move/approve/deny/
  delete/generate) since the global handler now covers them. Kept
  VoteEmailButton.handleSend and handleDelete's undo-callback with
  showApiError(err, 'Failed to ...') for action-specific fallback
  strings — those are user-initiated recovery paths where a contextual
  default is more useful than the bare FastAPI detail.

- Pantry.tsx: removed 3 local onError handlers (addMutation,
  removeMutation, handleAdd's createIngredient path) and
  handleRemove's outer catch. Kept 3 pre-flight client-side checks
  (missing ingredient link, empty name, unresolved ingredient) that
  never reach the network. handleRemove's undo callback now uses
  showApiError for the restore failure.

- MealDetail.tsx: removed submitMutation.onError. The local
  'Failed to save feedback. Please try again.' string is replaced
  by the actual FastAPI detail (e.g. 'Feedback for this meal already
  exists' or the Pydantic 422 msg).

Net result: 10 backend-error try/catch blocks deleted, error messages
are now identical to what the backend actually says, and any future
mutation that forgets to add a local onError still gets surfaced.

F6: Dashboard plan-status Badge (variant driven by status: draft /
awaiting_approval / approved / rejected) now passes an explicit
aria-label='Plan status: <text>' so a screen reader announces both
the category and the value instead of just the colour-encoded text.
This matches the pattern already used for the per-item approval
status Badge in Dashboard.tsx (added in Sprint 3) and completes the
audit §Sprint 3 a11y sweep for that page.

build: tsc 0 errors, vite 0 errors. 5 files, +72/-19.
2026-06-03 19:33:54 -07:00
admin 83d8c700a5 docs(handoff): bring project handoff up to date for 2026-06-03
- Update 'Last commits before handoff' list with the four UI-audit
  commits (f3e4a44, ccc70aa, f5fb755, e90a9d6) plus the handoff doc
  itself (427d8ac) and the docs commit (36038bb).
- Bump handoff date from 2026-05-14 to 2026-06-03 and add a cross-ref
  to the focused Review/handoff-ui-audit.md so a fresh agent can
  choose which doc to start with.
- New 'Last updated' line summarizes the UI-audit cycle (Sprint 1
  deployed; Sprints 2 and 3 awaiting deploy; migration 0015 not yet
  run on prod).
- Add a 'New session: 2026-06-03' section with the commit table, the
  Sprint 2 deploy commands (including the container-based psql
  incantations since the deployment host has no host psql), the
  Tailscale dev-vs-deploy gotcha, the .gitignore/lib/ quirk, and the
  file-level add/modify summary for the UI-audit work.
- Correct the 'Current open proposals' line under 'Final words' — the
  feedback-driven discovery proposal still awaits user approval; it
  is NOT implemented and verified.
2026-06-03 19:02:07 -07:00
admin 427d8ac352 docs(review): add handoff document for UI audit work
Review/handoff-ui-audit.md is a focused handoff for a fresh agent taking
over the UI/UX audit and fix cycle (Sprints 1, 2, 3). It complements
docs/HANDOFF.md (project-wide) rather than duplicating it.

Covers:
- Commit table (f3e4a44 / ccc70aa / f5fb755 / e90a9d6) and deploy
  status (Sprint 1 deployed, Sprints 2-3 awaiting deploy).
- Per-sprint file-level diff summary so the next agent can audit the
  changes without re-reading the audit doc.
- Environment quirks: deployment host is not this machine; psql lives
  in the db container; frontend/src/lib/ is force-added because of a
  pre-existing .gitignore bug; pre-existing WIP in git status; ESLint
  not configured.
- Active risks: backend migration not yet run; Dashboard Undo rebuilds
  not restores; S3.5 a11y caveats.
- The 9 explicit follow-up items in audit \xa7Future.
- Quick-start for the next agent.
2026-06-03 18:58:52 -07:00
admin e90a9d6683 feat(ui): close 3 P2 audit findings + a11y sweep (Sprint 3)
- lib/toast.tsx (renamed from .ts for JSX): new showToast.undo(message,
  onUndo, ms=5000) helper. Inline 'Undo' button dismisses the toast and
  fires onUndo. Note: react-hot-toast 2.6 lacks onClose/onDismiss, so
  expiry is silent — same effective behavior as confirm() declined.

- Dashboard.handleDelete: captures the full MealPlanItem before the
  DELETE so Undo can re-fire meals.generateItem(planId, dayOfWeek,
  mealType) and refill the slot (recipe may differ — see plan R4).

- Pantry.handleRemove: fully reversible — Undo re-fires pantry.add with
  the original ingredient_id, quantity, and unit. New removeId state
  scopes the spinner to the clicked row.

- Both confirm() call sites removed.

- App.tsx Navigation: whitespace-nowrap + px-2 sm:px-3 so all 4 links fit
  on one line down to 360 px. aria-current='page' on the active link.
  <nav aria-label='Primary'>, <main id='main-content'>.

- components/ui/Badge: optional icon and aria-label props. Dashboard
  approval-status Badge passes aria-label='Approval status: approved'
  (or the current value) so screen readers don't rely on color alone.

- ErrorBoundary already mounted at App.tsx:42 — verified, no code change.

- Review/sprint3-verification.md (new) + Review/ui-nielsen-audit.md and
  fix-ui-audit.md updated with Sprint 3 status and deploy steps.

Build: npm run build (tsc + vite) green. tsc 0 errors.
2026-06-03 18:09:35 -07:00
admin f5fb7558c4 fix(migration): simplify aisle migration + add persistent backup script
- Drop the empty batch_alter_table block and the meaningless
  set_config call from migration 0015. Temp tables still persist
  for the migration's session (Alembic's transactional_ddl).
- New backend/scripts/persist_aisle_backup.sql creates
  public.ingredient_aisle_backup_0015 and
  public.grocery_item_aisle_backup_0015 permanent tables for
  operators who want a recoverable record beyond the migration.
- Update Review/sprint2-verification.md, Review/ui-nielsen-audit.md
  and fix-ui-audit.md with the correct container-based deploy
  steps: docker compose exec db psql -U mealplanner -d mealplanner
  -f /dev/stdin < ...sql. Host psql is not available on the
  deployment host; the db runs inside the container.
2026-06-03 17:41:59 -07:00
admin ccc70aaf72 feat(ui): close 6 P1 audit findings + 1 bonus mobile fix (Sprint 2)
- Dashboard MealCard: title truncate -> line-clamp-2, image shrinks to
  40x40 on <md to give the title room (B6).
- MealDetail: hero reworked to normal flow with stronger gradient;
  description runs through new cleanDescription() helper that strips
  14 spoonacular SEO patterns and trims to the last full sentence.
  Raw description moved to a 'Notes from source' disclosure (B7).
- Pantry: free-text aisle/unit replaced with <Select> populated from
  the new PANTRY_AISLES canonical enum; ingredient name field marked
  required. New PANTRY_AISLES export + PantryAisle type in types (B8).
- backend: alembic 0015_normalize_pantry_aisles maps free-text
  ingredient.aisle and grocery_item.aisle to canonical labels in a
  single transaction; downgrade raises (restore from snapshot).
  backend/scripts/dry_run_aisle_migration.sql is the read-only
  preview helper.
- ShoppingList: human-readable AISLE_LABEL map replaces raw snake_case
  aisle keys; 3-col stat grid with compact mobile sizing (B9 + S3.3).
- Pantry table: role/aria-label region and a right-edge white
  gradient hint at mobile horizontal overflow (B10).
- Recipes: pending/applied filter split, Apply and Reset buttons,
  active-count chip on the Filters button, role=region + aria-label
  on the panel (B11).
- Review/sprint2-verification.md and fix-ui-audit.md updated.

Build: npm run build (tsc + vite) green. tsc emits 0 errors.

Co-located audit + plan docs kept in sync: Review/ui-nielsen-audit.md
gains a Sprint 2 status block; fix-ui-audit.md has implementation
notes for each Sprint 2 task.
2026-06-03 17:36:26 -07:00
admin 36038bb9cb docs(review): mark Sprint 1 P0 fixes addressed in commit f3e4a44 2026-06-02 10:56:19 -07:00
admin f3e4a446a3 fix(ui): close 5 P0 audit findings (ingredients, cost, routing, mobile slots)
Sprint 1 of the UI/UX audit (Review/ui-nielsen-audit.md).

- RecipeDetail: drop .trim() on ingredient line so unit and name no longer fuse
  ('2 canBlack Beans' -> '2 can Black Beans').
- MealDetail: align ingredient field name to backend ('qty' not 'quantity'),
  add 'ingredient.name' fallback for the missing nested name from API.
- MealDetail: '$N/A per serving' -> '$X.XX' or 'No estimate'.
- App: add /recommended alias to /recipes/recommended, plus a catch-all
  NotFound page so unrecognised URLs no longer render blank.
- Dashboard: remove 'hidden md:*' on empty meal slots so mobile users can
  tap Generate. Bump empty-slot button to 44px min-height (a11y).
- EmptyState: accept an optional 'to' prop for Link-wrapped actions.
- types: extend RecipeIngredient with optional notes and nested ingredient.
2026-06-02 10:55:53 -07:00
admin c364b8b222 feat(backend): recipe enrichment with side dishes & detailed instructions
- Add SideDish/SideDishIngredient schemas and recipe.side_dishes JSONB column
- Add recipe_enrichment.py service using Ollama LLM to:
  - Rewrite vague instructions with specific temps, quantities, timing, sauce breakdowns
  - Suggest 1-2 complementary side dishes with ingredients & prep notes
- Wire enrichment into recipe_ingestion.py discovery pipeline
- Add admin trigger endpoint /api/recipes/{id}/enrich for on-demand enrichment
- Migration 0014: Add side_dishes JSONB to recipe table
- Fix schemas/__init__.py imports: restore RecipeBase/Create/Read exports, add datetime/date for PydanticOptional compatibility
- Deployed to docker-willester and migrated to alembic 0014
2026-05-28 06:42:46 -07:00
admin de51e2e8d3 docs: mark tunable planner weights and score persistence as complete
- implementation-plan.md: check Phase 9.2 tunable weights
- phase-9-planner-algorithm.md: mark tunable weights open item as done
- Note: score persistence was completed in earlier commit e22eae1
2026-05-25 16:04:39 -07:00
admin 98d611d7b3 feat(backend): tunable planner weights via family profile config
- Add planner_config JSONB to family_profile model + migration
- Add PlannerConfig.merge(overrides) + to_dict() for family-level override merging
- generate_meal_plan merges family.planner_config into DEFAULT before filtering/scoring/selection
- New endpoints on /api/profile:
  - GET /planner-config — returns merged effective config
  - PUT /planner-config — partial override validation + merge
  - DELETE /planner-config — reset to system defaults
- Schemas: PlannerConfigOverride, PlannerConfigResponse, PlannerConfigUpdateRequest
  with weight-sum validation (0.999–1.001)
- Export RecipeBase/Create/Read/Update from schemas/__init__ to resolve forward refs
2026-05-25 16:03:46 -07:00
admin 86164e6dd3 feat(frontend): add recipe browser + recommended + detail pages
- Recipes.tsx: search, tag/protein/cuisine filters, ingredient search, family blocklist
- Recommended.tsx: feedback-driven recipe recommendations
- RecipeDetail.tsx: recipe display with ingredients, instructions, quick stats
- App.tsx: add /recipes, /recipes/recommended, /recipes/:id routes
- API client: list, recommended, get recipe methods
- Types: add Recipe fields (external_source, external_id, discovery_reason, calories_per_serving, qty)
2026-05-24 21:44:16 -07:00
admin e22eae1ecd feat(backend): persist plan scores on MealPlanItem
- Migration 0012 adds score (float) and components (jsonb) to meal_plan_item
- generate.py: populates score and components at create time
- schemas/MealPlanItemResponse: include score + components fields
- GET /api/meal-plans/{id}: returns persisted values instead of zeros
2026-05-24 20:20:11 -07:00
admin c96b41ec26 feat(api): R3-A recipe engine — search, tags, family scope, recommendations
- GET /api/recipes: added cuisine, protein, dietary, ingredient, max_time,
  spice_max, calorie_max query params
- GET /api/recipes?family_profile_id=… hides never-suggest blocklist recipes
- GET /api/recipes/recommended: returns feedback-driven recipe suggestions
- Update docs: remove completed open items from planner-algorithm.md
2026-05-24 19:45:46 -07:00
admin ae32e650ce feat(backend): wire exclude_recipe_ids, verify MealPlan votes schema, add image generation service
- api/meal_plans.py: /regenerate now passes exclude_recipe_ids into generate_meal_plan
- planner/generate.py: filter recipe_dicts by exclude_recipe_ids set
- image_generation.py: OpenAI gpt-image-1 client with prompt building, b64_json handling
- main.py: StaticFiles mount at /static for generated images
- admin.py: POST /api/admin/trigger-images endpoint for batch generation
- scripts/generate_images.py: CLI for batch image generation
- docker-compose.yml + nginx: volume mounts for static/images persistence
- Verify MealPlanItem.votes ↔ MealPlanVote relationship is correct; no model bug exists
2026-05-24 19:31:26 -07:00
admin dcdb88595e docs: document unit conversion implementation
- Update implementation-plan.md: mark unit conversion complete
- Update HANDOFF.md: add session notes for 2026-05-24 unit conversion
- Update README.md: list Unit Conversion as a feature
2026-05-24 15:34:16 -07:00
admin fd8ba3c4d2 feat(backend): implement unit conversion for cost calculation
- Add UnitConverter (normalization, within-family, density tables)
- Update cost.py to convert recipe qty to grocery price unit
- Update generate.py _load_match_index to fetch ingredient name + unit
- Fix orchestrator email/shopping-list cost loops to use conversion
- Fix missing Ingredient import in generate.py
- Add 19 unit tests
2026-05-24 13:46:50 -07:00
admin 3885d7d0dc feat: feedback-driven recipe discovery (auto-ingest via Spoonacular)
CI / backend (pytest + alembic) (push) Has been cancelled
CI / frontend (build) (push) Has been cancelled
2026-05-24 13:17:39 -07:00
adminandClaude Sonnet 4.6 35f736a052 fix(planner): correct set_size to 3 dinners and switch cost filter to per-serving
- set_size 21→3, top_k 20→10: generate 3 weekly dinners not full 3×7 matrix
- All 3 items assigned MealType.DINNER on Mon/Wed/Fri
- RecipeCost gains servings field + cost_per_serving property
- compute_recipe_cost accepts servings param (default 4)
- filter.py gates on cost_per_serving instead of total_cost
- max_meal_cost 500→50 (now a meaningful $/serving threshold)
- Email displays ~$X/serving instead of inflated raw total
- select_set: candidate_pool uses max(top_k, set_size) to prevent
  combinations(n<set_size) returning empty iterator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 07:20:17 -07:00
admin 16069bfa92 chore: remove graphify artifacts from repo and add to .gitignore 2026-05-18 17:43:40 -07:00
admin 019f9020ad tests: fix suite-wide collection and failures
- config: switch Settings to ConfigDict(extra='ignore') so extra env vars
  (spoonacular_api_key, SWIFTLY_BEARER_TOKEN) don't crash import.
  Remove deprecated class Config.
- email: wrap SendGrid imports in try/except so the module loads without
  the optional dependency. Update test_email_backend to patch Mail/RepyTo.
- planner_select: default PlannerConfig.set_size=21 (3 meals/day × 7) is
  way too large for the unit test assertion that checks 3-recipe diversity.
  Introduced _CFG_3 with set_size=3 and applied to all tests.
- Delete stale test_matcher.py importing removed functions.

Full suite: 46 passed, 74 skipped (Postgres), 0 failed, 120 collected.
2026-05-18 17:43:30 -07:00
admin e92cc3a074 scrapers: drop Swiftly products with unparseable price
Products missing a parseable sale or regular price would previously yield
a GroceryItem with current_price=None. That broke the downstream matcher
(ingredient typical_price is non-null) and cluttered the table.

Added a guard in map_product() to return None when both reg_price and
sale_price are None. Fixes test_map_product_returns_none_for_unparseable.
2026-05-18 17:22:29 -07:00
admin 69acd70188 fix(pantry): make public ingredient endpoint idempotent
CI / backend (pytest + alembic) (push) Has been cancelled
CI / frontend (build) (push) Has been cancelled
Backend:
- POST /api/ingredients now checks name_lower and aliases before inserting
- Returns existing ingredient on 409 instead of throwing error

Frontend:
- Removed fragile 409-recovery logic from Pantry.tsx handleAdd
- Added aliases field to Ingredient type for case-insensitive matching

Fixes pantry add for ingredients like 'Carrots' whose canonical name is 'Carrot'
2026-05-18 17:16:57 -07:00
admin 54b3e785b3 feat: allow adding pantry items by text input with auto-ingredient creation
CI / backend (pytest + alembic) (push) Has been cancelled
CI / frontend (build) (push) Has been cancelled
- backend: expose POST /api/ingredients on public router so frontend can create ingredients without admin token
- frontend/api: point listIngredients and createIngredient to /api/ingredients
- frontend/pantry: replace ingredient dropdown with searchable text input + fuzzy matching + auto-create
2026-05-17 21:31:37 -07:00
admin 986968b93d fix: improve mobile layout on meals page and navigation
- Dashboard: stack days vertically on mobile and suppress empty meal slots
- Nav: allow wrapping on narrow screens
- MealDetail: responsive hero sizing and padding
2026-05-17 21:22:37 -07:00
admin 879768f72b fix: shopping list ingredient UUID parsing for prices/aisles; feat: interactive checkboxes with localStorage persistence 2026-05-17 13:12:18 -07:00
admin 2f6ab006de fix: enrich ingredient names in shopping list API
Same root cause as meal detail: recipe JSONB stores ingredient_id but
not name. Shopping list now looks up names from the Ingredient table
before aggregating quantities, so items show "3 cups onion" instead
of "Unknown".
2026-05-15 14:21:51 -07:00
admin 2708cc4bdd fix: enrich ingredient names in meal detail API
Recipe JSONB stores ingredient_id but not name. GET /api/meals/items/{id}
now queries the Ingredient table and injects names into the response so
the frontend displays "3 cups onion" instead of just "3 cups".
2026-05-15 14:11:36 -07:00
admin 7b5e65087e feat: 21 meals per week (3/day) + approve/deny + generate single meal
Backend:
- Planner config: set_size=21 (was 3), top_k=30 (was 20)
- generate.py: distribute 21 recipes across 7 days × 3 meal types
- meals.py: add POST /items/{id}/approve, /items/{id}/deny
- meals.py: add POST /{plan_id}/generate-item for empty slots

Frontend:
- Dashboard: Approve/Deny buttons on pending meal cards
- Dashboard: Generate button in empty meal slots
- API client: approveItem, denyItem, generateItem methods

Build: TypeScript compiles clean, Python syntax verified.
2026-05-15 11:44:04 -07:00
admin ddcdb962ca fix: atomic SQL swap for meal move endpoint
Replace two-step ORM update with single UPDATE ... CASE statement.
Eliminates IntegrityError from SQLAlchemy flush order violating the
unique constraint (meal_plan_id, day_of_week, meal_type).
2026-05-14 15:56:47 -07:00
admin 301e984336 feat: drag-and-drop meal scheduling + 7-day grid 2026-05-14 15:49:43 -07:00
admin c21741dd56 fix: move orchestrate endpoints out of admin router
- Create backend/app/api/orchestrate.py — new router for workflow steps
  (scrape, generate, email, reminder, deadline, finalize) without admin auth.
- Remove orchestrate endpoints from backend/app/api/admin.py.
- Register orchestrate router in main.py under /api/orchestrate.
- Update frontend api/index.ts to call /orchestrate/{step} instead of
  /admin/orchestrate/{step}.

This lets family members trigger vote emails without an admin bearer token.
2026-05-14 15:36:20 -07:00
admin f61515beff fix: wire up 'Send Vote Email' button to POST /admin/orchestrate/email
- Add triggerOrchestrate() to API client calling POST /admin/orchestrate/{step}
- Replace dead <button> in Dashboard with VoteEmailButton component:
  onClick calls triggerOrchestrate('email'), shows toast spinner + success/error
2026-05-14 13:59:29 -07:00
admin 6323eadc86 fix: test-email actually sends; vote page pre-checks existing votes; lowercase MealPlanItemStatus everywhere
- /api/admin/test-email now calls get_email_backend().send() instead of only logging.
- /api/meals/vote/{id} GET now queries MealPlanVote and renders 'already voted' confirmation if found.
- api/meals.py: fix remaining uppercase MealPlanItemStatus enum ref (DENIED, APPROVED, PENDING).
- Fixes the 'all meals show as pending' status regression and the 'Error: Already voted' bug.
2026-05-14 12:17:44 -07:00
admin 26d9985433 feat: remove login requirements for internal home-network use
- backend/app/security.py: require_session() now auto-authenticates by
  returning the first family_profile_id from the DB. No cookie or password
  needed. Falls back to "bootstrap" sentinel if no FamilyProfile exists.
  Admin routes (require_admin) still protected by bearer token.
- frontend/src/api/index.ts: removed 401→/login redirect interceptor
- frontend/src/App.tsx: removed Sign out button, removed /login route and
  Login page import
- Login page kept on disk (unused) for potential future re-enablement
2026-05-14 11:27:09 -07:00
admin 6a0c9d0c4e feat: full UI redesign with design system, Nielsen heuristics compliance
- Install lucide-react, framer-motion, react-hot-toast, clsx, tailwind-merge
- Custom Tailwind config: semantic color tokens, Inter font, shadow scale,
  border radius scale, custom animations (fadeIn, slideUp, shimmer)
- Shared component library: Button, Badge, Card, Input, Select, Textarea,
  EmptyState, Skeleton, LoadingSpinner
- Global CSS with @layer components (.btn, .card, .input, .badge, .skeleton)
- Toast notification system via react-hot-toast + showToast utility
- ErrorBoundary wrapper for graceful error recovery
- Redesigned navigation: sticky, active state indicators, Lucide icons
- Dashboard: hero header, today highlighting, scrollable week grid,
  redesigned meal cards, empty states, skeleton loading
- Meal Detail: hero image with gradient overlay, metadata row with icons,
  Lucide star rating, edit-existing-feedback flow
- Pantry: inline add form, search/filter, visual quantity badges,
  expiry warnings, confirmation dialogs
- Shopping List: gradient summary cards, aisle grouping with badges,
  sale strikethrough pricing, empty state
- Login: centered card with icon, Input component, Button component
- All old gray/blue utility classes migrated to new surface/primary tokens
- TypeScript clean, production build passes
2026-05-14 10:26:53 -07:00
admin f7ed10651b feat: Phase 8 Feedback UI + API endpoints
- New backend/app/api/feedback.py: GET/POST for meal_plan_item feedback
- MealDetail.tsx: star rating, never-suggest checkbox, reason dropdown,
  free-text comments, displays saved feedback
- frontend/src/api/index.ts + types: feedback API + TypeScript interface
- backend/app/schemas/__init__.py: model_validator maps qty→quantity for
  RecipeIngredient (fixes Pydantic validation on recipe JSONB)
- docs/HANDOFF.md: mark Phase 8 complete, update file map and date
2026-05-14 09:54:25 -07:00
adminandClaude Sonnet 4.6 e618b2bd5a docs: update HANDOFF for 2026-05-12 session
Spoonacular enrichment, LLM matcher, plural normalization, email confirmed polished.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 11:43:34 -07:00
adminandClaude Sonnet 4.6 b52276056c fix: cast qty/unit to str before html.escape in vote email shopping preview
Recipe ingredient qty fields in JSONB are stored as floats (e.g. 1.5),
not strings. html.escape() requires str input — AttributeError otherwise.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 11:40:55 -07:00
adminandClaude Sonnet 4.6 dbc26bcc30 feat: LLM-powered second-pass ingredient matcher + matcher improvements
Matcher improvements (matcher.py):
- Plural normalization: 'tortillas'→'tortilla', 'thighs'→'thigh' so
  subset recall check works without stemmer
- Precision floor lowered 0.45→0.30: allows 'Bacon'→'Wright Brand Bacon'
  (1/3=0.33) while exclusion words still block category contaminants
- _EXCLUSION_WORDS now normalized through same singularizer for consistency

LLM second-pass (llm_matcher.py):
- run_llm_match_job(): for each still-unmatched ingredient, collects top-12
  candidates from grocery catalog ranked by fuzzy×precision (same metric as
  AUTO matcher), then asks Ollama to pick the best match
- Candidate scoring: combined = (partial_token_sort_ratio/100) × precision
  ensures "McCormick Black Pepper" outranks "Dr Pepper" for 'Black Pepper'
- Stores picks as source='auto_llm' (confidence=0.750)
- Ollama Cloud endpoint: https://ollama.com/v1, model: kimi-k2.6:cloud

Migration 0010: adds 'auto_llm' to ingredient_match_source_enum

Config: OLLAMA_BASE_URL / OLLAMA_API_KEY / OLLAMA_MODEL settings
Docker-compose: wires all three Ollama + Spoonacular env vars to backend/scheduler
Scraper service: calls run_llm_match_job after run_match_job on every scrape

Results: AUTO matcher went from 36→25 unmatched (plural normalization fix),
LLM added 3 more (Black Pepper, Zucchini, Chicken Thighs).
Remaining 22 are genuine Lucky CA catalog gaps (standalone olive oil,
dried spices, etc. not in Swiftly weekly ad).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 09:37:58 -07:00
adminandClaude Sonnet 4.6 b03e7f8070 feat: Spoonacular recipe enrichment — images + descriptions for 25/30 recipes
- Added SPOONACULAR_API_KEY to docker-compose.yml (backend + scheduler)
- scripts/enrich_recipes_spoonacular.py: searches Spoonacular by recipe name,
  updates image_url and description; 402 quota guard exits cleanly
- 25 of 30 null recipes now enriched; 5 remain (quota exhausted for today)
- Remaining: Caprese Pasta, Creamy Tuscan Chicken, Sheet-Pan Chicken Thighs,
  Loaded Veggie Quesadillas, Zucchini and Spinach Frittata

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 06:56:14 -07:00
adminandClaude Sonnet 4.6 a98f0dc1cb docs: update HANDOFF.md for 2026-05-10 session
Covers all changes from this session:
- MVP login + auth gating (Login page, 401 interceptor, Sign out)
- nginx DNS resolver fix + port 8081
- Vote email: ingredient list + collapsible cooking steps
- Shopping list: per-meal sections, current_price fix
- Matcher full rewrite: ingredient-centric, precision×recall scoring,
  exclusion words, min precision floor, exact-name fast path, limit 100
- Scraper: save priceless produce items (garlic, lime, etc.)
- Infrastructure notes: DB user, module caching, admin API auth header
- Next moves: Spoonacular enrichment + Ollama LLM matcher

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-10 20:55:27 -07:00
adminandClaude Sonnet 4.6 2373883fe7 fix: exact-name fast path in matcher + save priceless produce in scraper
Scraper: remove price guard in map_product so produce items without a
catalog price (e.g. Fresh Garlic, Lime sold by weight) are saved to
grocery_item with current_price=NULL rather than skipped.

Matcher:
- Add exact-name fast path: build a lowercase-trimmed name→index map
  and skip fuzzy search entirely when the ingredient name matches a
  grocery item exactly. Lime → Lime (confidence 1.0), Garlic → Fresh
  Garlic from fuzzy (confidence 1.0).
- Add exclusion words: juice, gelatin to prevent beverage/dessert
  products from matching cooking ingredients.
- Increase fuzzy candidate limit 20→100 so exact-name items buried in
  large tie groups are not missed.
- Add 'juice' to exclusion: prevents '100% Lime Juice' from winning
  over plain 'Lime'.

Result: all recipe ingredients now match correct Lucky CA products or
show '—' (no match); zero category cross-contamination remaining.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-10 20:17:03 -07:00
adminandClaude Sonnet 4.6 d7a3f5c081 fix: matcher exclusion words + precision floor for clean grocery matching
- Add exclusion words: soda, rotisserie, tuna/tonno/salmon/sardine/anchovy
  to prevent beverages, prepared poultry, and seafood-in-oil from matching
  raw cooking ingredients
- Add min precision floor (0.45): grocery sig-word count must be ≤ 2× the
  ingredient's sig-word count, catching long branded products that pass
  the word-overlap recall check but are clearly wrong category matches
  (e.g. "Garlic Herb Rotisserie Chicken" precision=0.25 now rejected)

Result: all previously wrong matches now show '—' (no match) rather than
a wrong product; correct matches unchanged

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-10 12:00:19 -07:00
adminandClaude Sonnet 4.6 ac2b575f6b fix: rewrite matcher as ingredient-centric with precision×recall scoring
- Flip matching direction: iterate ingredients, search grocery items
  (previously: iterate grocery items → false positives from partial word
  overlap, e.g. Pampers Wipes matched Ginger Fresh via the word "Fresh")
- Score = partial_token_sort_ratio × (ingredient_sig / grocery_sig_words)
  — precision term penalises long branded products where the ingredient
  word appears incidentally ("Vermicelli, Garlic & Olive Oil" now scores
  lower than a pure olive oil SKU)
- 100% recall guard: every significant ingredient word must appear in the
  grocery name (eliminates cross-category noise completely)
- Stop-word list strips generic qualifiers so "boneless skinless" in an
  ingredient name doesn't block "Chicken Thighs Boneless" in the grocery
- ON CONFLICT DO NOTHING preserves manual matches on re-run

Benchmark on today's Lucky CA weekly ad (10,965 items):
  Before: ~25% correct (Pampers→Ginger, Red Wine→Bell Pepper, etc.)
  After:  ~80% correct; remaining misses are data gaps (Lucky has no
  standalone garlic or olive oil in this week's ad)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-10 11:38:59 -07:00
adminandClaude Sonnet 4.6 aeed2a4dc0 feat: add cooking instructions to vote email; shopping list grouped by meal; fix current_price field
- Vote email: recipe cards now include a collapsible <details> block with
  numbered cooking steps (recipe.instructions ARRAY)
- Shopping list email: ingredients now grouped under each meal heading
  instead of a flat deduplicated list
- step_finalize: fix grocery price lookup (.price -> .current_price)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-10 09:55:52 -07:00
admin 59a15a2c21 fix: nginx DNS resolver, port 8081, seed script with real family data 2026-05-09 20:50:20 -07:00
adminandClaude Sonnet 4.6 bc85b9b617 fix: step_finalize — resolve ingredient names and prices in shopping list email
Preloads Ingredient names from the DB (ingredients JSONB has no name field),
deduplicates by ingredient name, looks up top-confidence IngredientGroceryMatch
per ingredient, and renders a rich 4-column HTML table (Ingredient | Qty | Unit |
At Lucky | Price) with an estimated total.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 20:48:46 -07:00
adminandClaude Sonnet 4.6 a03152e51a fix: resolve ingredient names from Ingredient table in email template
recipe.ingredients JSONB has ingredient_id but no name field; preload
names in a single bulk query before the per-member loop so ing_rows,
shopping preview, and cost lookup all render real ingredient names.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 14:47:48 -07:00
adminandClaude Sonnet 4.6 0739a688dd feat: enrich proposal email with ingredients, cost, shopping preview
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-09 13:40:05 -07:00
admin 43ee581931 Merge branch 'feature/mvp-login' 2026-05-09 12:48:31 -07:00
admin 76638016c1 feat: one-time family profile seed script 2026-05-09 12:47:25 -07:00
admin fe64f0ead4 feat: login page, 401 interceptor, nav sign-out 2026-05-09 12:36:40 -07:00
admin 458c0361dc docs: MVP login implementation plan 2026-05-09 11:46:31 -07:00
admin 0d70fb118d docs: refresh HANDOFF + ORIENTATION for Phase 6 completion 2026-05-08 21:46:04 -07:00
admin aea47d8365 fix: escape member.name in step_email; add all-voted reminder test 2026-05-08 21:40:00 -07:00
adminandClaude Sonnet 4.6 3b28ad0e1c fix: html-escape recipe/ingredient names in email templates (#P5-a, #P5-b)
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 21:37:19 -07:00
admin 9a484d39c3 feat: wire step_reminder into runner, admin, and scheduler (Fri 16:00 PT) 2026-05-08 21:29:53 -07:00
adminandClaude Sonnet 4.6 d002485c10 feat: step_reminder — 1-hour pre-deadline nudge for non-voters
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 18:00:16 -07:00
adminandClaude Sonnet 4.6 5a41402644 feat: wire SendGridEmailBackend with from_email/reply_to settings
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 12:12:05 -07:00
admin 63ad306a61 feat: migration 0009 — add reminded_at to weekly_run 2026-05-08 06:48:36 -07:00
admin 61a5578b71 chore: ignore .worktrees directory 2026-05-08 06:12:10 -07:00
admin 7d34b57812 docs: Phase 6 SendGrid implementation plan 2026-05-07 13:12:31 -07:00
admin a68f0be414 docs: Phase 6 SendGrid design spec 2026-05-07 12:50:38 -07:00
admin b551707267 docs: refresh HANDOFF + ORIENTATION for Phase 5 completion 2026-05-07 11:09:41 -07:00
adminandClaude Sonnet 4.6 cc8ece22b3 feat: add scheduler container to docker-compose
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 06:47:00 -07:00
adminandClaude Sonnet 4.6 7972ecb948 feat: APScheduler entry point — 5-job weekly cadence (Pacific)
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 06:46:39 -07:00
adminandClaude Sonnet 4.6 14a0ed1ced feat: admin orchestrate endpoints — run-week, per-step, status
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 06:44:41 -07:00
adminandClaude Sonnet 4.6 914fdbdb51 fix: close DB session in run_step finally block
Wraps the SessionLocal body in try/finally so db.close() is always
called, preventing connection leaks on exception. Updates the
test monkeypatch to use a no-op close() proxy so the transactional
fixture stays live after run_step returns.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 06:42:37 -07:00
adminandClaude Sonnet 4.6 e3ca8b7a96 feat: orchestrator runner — run_step / run_week per-family loop
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 06:40:36 -07:00
adminandClaude Sonnet 4.6 0a097da18e feat: orchestrator step_generate
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 06:30:50 -07:00
adminandClaude Sonnet 4.6 b2cbdd1533 feat: orchestrator step_scrape with retry + stale-data fallback
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 06:27:27 -07:00
admin 0135b74c03 feat: orchestrator package + alerts.send_admin_alert 2026-05-07 06:24:55 -07:00
admin f8ab448cbd feat: add ADMIN_EMAIL and APP_BASE_URL settings 2026-05-07 06:23:10 -07:00
adminandClaude Sonnet 4.6 03ced28ead feat: WeeklyRun model + FamilyProfile.pending_approval_policy
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 06:21:40 -07:00
admin ea34b9384d feat: migration 0008 — weekly_run table + pending_approval_policy 2026-05-07 06:18:30 -07:00
admin 8ec5cbb58e docs: Phase 5 orchestration implementation plan 2026-05-07 06:13:50 -07:00
adminandClaude Opus 4.7 dc01a0fefb docs: refresh HANDOFF + ORIENTATION for fresh-agent pickup
Bring docs in line with the post-AM-6 state so a fresh agent can pick
up cleanly without first reconciling stale numbers:

- pytest count: 88/59/31 → 92 across all references
- live scrape: 9,960 rows / 36s → 9,980 rows / 44s (latest run, 2026-05-06)
- migrations applied: 0001-0005 → 0001-0007 in both docs
- verification gate updated with auto-minted JWT detail and the 29,779
  ingredient_grocery_match rows produced post-scrape
- "What is real" / Backend: added swiftly_auth.py bullet describing
  get_token() / mint_anonymous_token() / cache semantics + the
  10,928-item live verification
- file map: added services/swiftly_auth.py, services/matcher.py,
  services/planner/, mentioned scraper_service runs matcher post-scrape
- file map: alembic versions 0001 → 0007, tests/ count = 92, marked
  config.py as no-longer-carrying SWIFTLY_BEARER_TOKEN
- spec map line for swiftly-token-auto-mint: "next-up" → "Implemented"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:50:14 -07:00