Commit Graph
148 Commits
Author SHA1 Message Date
admin 25c1fe0890 docs: Sprint 15 round 3 — +10 recipes, library at 77 total
Sprint 15 round 3 (no new code; reused scripts/seed_recipes.py
from round 1, idempotent) added 10 more Spoonacular recipes to
the local library. 37 duplicates were skipped. Imports: 2
Asian leftovers (pho, kung pao) + 8 American comfort dishes
(chili, meatloaf, mac and cheese, BBQ chicken, pot roast,
shepherd pie, chicken pot pie, beef stew).

DB went 67 -> 77 total recipes (47 Spoonacular + 30 manual).
LLM test (Sprint 13, week 2026-08-03, prompt "comfort food,
no repeats from past 2 weeks"):
  {picked_count: 0, filled_count: 21, failed_count: 0}
21/21 slots filled, 0 failed.

All 6 running docs updated: plan.md (S15R3.1-S15R3.2),
context.md (D11-D13), sprint15-verification.md (round 3
section + 3-round summary table), ui-nielsen-audit.md
(round 3 paragraph), fix-ui-audit.md (T8.7), handoff-ui-
audit.md (TL;DR + Sprint 15 section), HANDOFF.md (round 3
paragraph + Last-updated footer).

Cumulative Sprint 15 work: 46 new Spoonacular recipes across
3 rounds. Library at 77 total — well past the 4-week coverage
threshold.
2026-06-07 21:25:52 -07:00
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
MealPlanner efd1fc695f feat(ui): explicit Deny semantics with 2-denial hard-filter escalation (Sprint 8)
User policy decision (2026-06-05, exact): 'Hard filter. If it is denied
this week twice, it should be considered denied for good.'

The planner had no cross-week memory of denials: a denial on
meal_plan_item.approval_status was never consulted by the planner,
and NeverSuggest (the per-family permanent blocklist) was empty for
the user. The 'Roasted Sweet Potato and Chickpea Bowl' the user
denied on 2026-05-15 was still in the planner's pool 3 weeks
later.

Implements C + Z (explicit two-button model + soft-decay +
hard-filter escalation):
- Approve: untouched.
- Deny this week (1st in 90d): denial_expires_at = now() + 90d.
- Deny this week (2nd in 90d, server-side auto-escalation):
  denial_expires_at = NULL + a NeverSuggest row written.
- Never again (explicit): same as the 2nd-time auto-escalation.

Both soft and permanent denials are hard filters in the planner
(per user). A denied recipe never reappears until either the 90d
window expires or the user un-blocks via the NeverSuggest API.

Changes:
- Migration 0016: meal_plan_item.denial_expires_at (partial index)
  and meal_plan_vote.denial_scope.
- 3 backend helpers (_apply_denial, _ensure_never_suggest_recipe,
  _has_prior_active_soft_denial) — single source of truth for the
  deny path.
- POST /api/meals/items/{id}/deny?scope=this_week|never_again
  (default this_week). Returns promoted_to_permanent.
- POST /api/meals/vote/{id} extended: vote=approve|deny|never_again.
  Returns denial_scope + promoted_to_permanent.
- GET /api/meals/vote/{id} HTML page renders 3 buttons; supports
  one-click ?scope=... for email direct-action links.
- Email template (step_email): 3 direct-action links per recipe
  plus a secondary 'open vote page' link.
- Planner: _load_blocklists returns 3 sets; soft_denied_recipes
  is hard-filtered (union with blocked_recipes at the call site).
- Frontend: MealCard renders 3 buttons (Approve / Deny this week
  / Never again) for pending items. handleDeny is scope-aware;
  toast reflects promoted_to_permanent. window.confirm on
  'Never again' prevents accidental permanent blocks.

Verification:
- npm run build green.
- 21/21 planner tests pass (1 pre-existing test_filter_blocks_by_cost
  failure is NOT introduced by Sprint 8 — verified via git stash).
- Review/sprint8-verification.md: 11-step browser smoke + 4 API
  curls + email-render procedure + rollback.

Files:
- backend/alembic/versions/0016_denial_decay_and_scope.py (new)
- backend/app/models/__init__.py:221-242, 250-269
- backend/app/schemas/__init__.py:204-219, 248-269
- backend/app/api/meals.py:30-138 (helpers), 240-330 (HTML page),
  380-455 (submit_vote), 486-552 (deny_meal_item)
- backend/app/services/orchestrator/steps.py:283-300
- backend/app/services/planner/generate.py:59-99, 150-194
- frontend/src/api/index.ts:48-58
- frontend/src/pages/Dashboard.tsx:38-50, 385-410
- Review/{sprint8-verification,ui-nielsen-audit,handoff-ui-audit}.md
- fix-ui-audit.md
- docs/HANDOFF.md
- .agent/{plan,context}.md

Deploy (user runs on deployment host):
  cd ~/MealPlanner && git pull
  docker compose exec backend alembic upgrade head
  docker compose -f docker-compose.yml up -d --build backend frontend
2026-06-05 10:24:35 -07:00
MealPlanner 09c7525a12 fix(ui): align 'this week' to upcoming Monday (Sprint 7)
User report 2026-06-05: 'webui Meal Planner page is empty' on Friday
morning after the Friday email went out. Root cause: the orchestrator
keyed plans by the most-recent-Friday while the frontend's isoMonday()
returned the most-recent-Monday — a 7-day mismatch on Fridays.

Fixes (one semantic across the stack):
- runner._current_week_start() returns the upcoming Monday (today if
  Mon, else the next Mon). The Friday email subject
  ('Meal plan for week of <date>') automatically picks up the new
  value via run.week_start_date.
- frontend isoMonday -> upcomingMonday (same logic; renamed for
  intent). isoMonday kept as a deprecated alias.
- New WeekRangeNav component (Dashboard + ShoppingList share it).
  Renders [<]  Jun 8 - Jun 14  [>] with clickable chevrons and a
  clickable range label that jumps to the upcoming week. Replaces
  the Sprint 5 inline segmented control on both pages.
- New formatWeekRange(mondayIso) helper (UTC-stable; uses
  timeZone: 'UTC' so the rendered date matches the stored ISO date
  regardless of viewer TZ; closes a latent bug in formatIsoDate too).
- New SQL fix script that retargets the user's 3-pending-items plan
  from 2026-06-05 (Friday-keyed) to 2026-06-08 (upcoming Monday).
  Idempotent + transaction-wrapped. Optional block for 2026-05-29.

No backend migration. No new dependencies. Deploy is git pull +
run the SQL fix + docker compose up -d --build backend frontend.
See Review/sprint7-verification.md for the full deploy + smoke flow.

Files:
- backend/app/services/orchestrator/runner.py:20-35
- backend/scripts/fix_2026_06_05_to_2026_06_08.sql (new)
- frontend/src/lib/utils.ts:43-130
- frontend/src/components/WeekRangeNav.tsx (new)
- frontend/src/pages/Dashboard.tsx (3 call sites + 1 segmented control)
- frontend/src/pages/ShoppingList.tsx (5 call sites + 2 segmented controls)
- Review/{sprint7-verification,ui-nielsen-audit,handoff-ui-audit}.md
- fix-ui-audit.md
- docs/HANDOFF.md
- .agent/{plan,context}.md
2026-06-05 07:46:55 -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