7838c49721600000c23965e0c7186f413a4d6499
155
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7838c49721 |
feat(auth): harden sessions + HA Ingress support
- backend: settings SESSION_COOKIE_SECURE + TRUSTED_NETWORK_AUTO_AUTH, require_session uses secrets.compare_digest and respects trusted-network opt-in, main.py adds require_family_session middleware gating all /api/ routes except auth/admin/email-vote-token paths - docker-compose: pass SESSION_COOKIE_SECURE + TRUSTED_NETWORK_AUTO_AUTH through to backend + scheduler (fixes env-file changes not reaching runtime) - frontend: Ingress path-prefix support (APP_BASE_PATH, BrowserRouter basename, vite base './'), Login redirect honors APP_BASE_PATH - nginx: no-cache headers on root + /assets/ - docs: Home Assistant Ingress install/troubleshooting + plan file - tests: test_auth expects 401 on no-session GET Defaults: SESSION_COOKIE_SECURE=false, TRUSTED_NETWORK_AUTO_AUTH=true (HA is the auth boundary; MealPlanner must not be port-forwarded directly). |
||
|
|
7f5757094e | feat(meals): suggest complementary sides | ||
|
|
18d7300b57 | feat(home-assistant): add ingress add-on | ||
|
|
a8debbe96a |
docs: Sprint 16.1 — _DAILY_LIMIT 140 → 45 follow-up across all 6 running docs
Sprint 16.1 (commit
|
||
|
|
11cfd46bff |
fix(recipe_search): Sprint 16.1 — lower _DAILY_LIMIT 140 → 45
One-line follow-up to Sprint 16. The _DAILY_LIMIT=140.0 in
recipe_search.py:48 was set assuming Spoonacular's free tier
was 150 pts/day. Sprint 15 round 1 (commit
|
||
|
|
c54d3ffc1f |
docs: Sprint 16 — fix kimi-k2.6:cloud latent bug across all 6 running docs
Sprint 16 (commit
|
||
|
|
25e29c714d |
fix(llm): Sprint 16 — switch OLLAMA_MODEL from kimi-k2.6:cloud to gpt-oss:20b
Sprint 13 (commit
|
||
|
|
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.
|
||
|
|
415eb8340d |
docs: Sprint 15 round 2 — +18 recipes, library at 67 total, LLM 21/21
Sprint 15 round 2 (commit
|
||
|
|
97b84a7cd8 |
feat(scripts): Sprint 15 round 2 — seed_recipes_round2.py (50 gap-filling queries)
Round 1 (commit
|
||
|
|
0668d40444 |
docs: Sprint 15 — seed 50 recipes + Sprint 12 latent-bug fix across all 6 running docs
Sprint 15 (commit
|
||
|
|
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).
|
||
|
|
af4ec793c7 |
docs: Sprint 14 — Vitest for useOnboarding (Q4) across all 6 running docs
Sprint 14 (commit |
||
|
|
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
|
||
|
|
8cb4d4198c |
docs: Sprint 13 — F9-lite (Ollama Cloud free-text plan synthesis) across all 6 running docs
Sprint 13 (commit
|
||
|
|
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). |
||
|
|
e939c96961 |
docs: Sprint 12 — F8 Spoonacular search across all 6 running docs
Sprint 12 (commit
|
||
|
|
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.
|
||
|
|
dac1364c29 |
docs: Sprint 11 — wire the dead "Generate Meal Plan" CTA across all 6 running docs
Sprint 11 (commit
|
||
|
|
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.
|
||
|
|
4c85c929d3 |
docs: record Sprint 9 post-deploy dismiss-bug fix across all 6 running docs
Sprint 9 (commit |
||
|
|
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. |
||
|
|
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).
|
||
|
|
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). |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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.
|
||
|
|
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 |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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 |
||
|
|
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.
|
||
|
|
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 ( |
||
|
|
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 ( |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
36038bb9cb |
docs(review): mark Sprint 1 P0 fixes addressed in commit f3e4a44
|
||
|
|
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.
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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) |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
3885d7d0dc | feat: feedback-driven recipe discovery (auto-ingest via Spoonacular) |