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
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.
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.
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.
- 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.
- 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.
- 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
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
- 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
- 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
- Add types for all API models (MealPlan, Recipe, Ingredient, etc.)
- Add API client with mealPlannerApi wrapper for all endpoints
- Implement Dashboard with weekly meal plan grid view
- Implement Pantry page with add/remove functionality
- Implement MealDetail page with recipe display
- Implement ShoppingList page with aisle grouping
- Add ShoppingList route to App.tsx
- Add vite-env.d.ts for Vite env type support
Backend (FastAPI):
- docker-compose with all 4 services
- FastAPI app with health endpoints
- SQLAlchemy models for all tables
- Placeholder API endpoints for all routes
- Config and database modules
- requirements.txt with all dependencies
Frontend (React):
- package.json with React, Tailwind, React Query, React Router
- Vite config with API proxy
- Tailwind and TypeScript configs
- Basic App with routing skeleton
- Placeholder pages (Dashboard, MealDetail, Pantry)
Infrastructure:
- nginx config for reverse proxy
- Dockerfile for backend and frontend