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.
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.
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.
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).
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).
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
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
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.
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.
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.
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.
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.
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.
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.
- 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.
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.
- 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.
- 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.
- 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.
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.
- 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
- 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
- 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
- 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
- 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.
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.
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'
- 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
- Dashboard: stack days vertically on mobile and suppress empty meal slots
- Nav: allow wrapping on narrow screens
- MealDetail: responsive hero sizing and padding
Same root cause as meal detail: recipe JSONB stores ingredient_id but
not name. Shopping list now looks up names from the Ingredient table
before aggregating quantities, so items show "3 cups onion" instead
of "Unknown".
Recipe JSONB stores ingredient_id but not name. GET /api/meals/items/{id}
now queries the Ingredient table and injects names into the response so
the frontend displays "3 cups onion" instead of just "3 cups".
Replace two-step ORM update with single UPDATE ... CASE statement.
Eliminates IntegrityError from SQLAlchemy flush order violating the
unique constraint (meal_plan_id, day_of_week, meal_type).
- Create backend/app/api/orchestrate.py — new router for workflow steps
(scrape, generate, email, reminder, deadline, finalize) without admin auth.
- Remove orchestrate endpoints from backend/app/api/admin.py.
- Register orchestrate router in main.py under /api/orchestrate.
- Update frontend api/index.ts to call /orchestrate/{step} instead of
/admin/orchestrate/{step}.
This lets family members trigger vote emails without an admin bearer token.
- Add triggerOrchestrate() to API client calling POST /admin/orchestrate/{step}
- Replace dead <button> in Dashboard with VoteEmailButton component:
onClick calls triggerOrchestrate('email'), shows toast spinner + success/error
- /api/admin/test-email now calls get_email_backend().send() instead of only logging.
- /api/meals/vote/{id} GET now queries MealPlanVote and renders 'already voted' confirmation if found.
- api/meals.py: fix remaining uppercase MealPlanItemStatus enum ref (DENIED, APPROVED, PENDING).
- Fixes the 'all meals show as pending' status regression and the 'Error: Already voted' bug.
- backend/app/security.py: require_session() now auto-authenticates by
returning the first family_profile_id from the DB. No cookie or password
needed. Falls back to "bootstrap" sentinel if no FamilyProfile exists.
Admin routes (require_admin) still protected by bearer token.
- frontend/src/api/index.ts: removed 401→/login redirect interceptor
- frontend/src/App.tsx: removed Sign out button, removed /login route and
Login page import
- Login page kept on disk (unused) for potential future re-enablement