Sprint 11 (commit 41154e9) wires the previously-dead
"Generate Meal Plan" empty-state CTA on the Dashboard to two
existing endpoints (POST /api/meals + POST /api/meals/{id}/fill-
empty-slots). No backend changes; no new dependencies. The
handler lives on the client for now; future F8 (Spoonacular) +
F9 (Ollama) will swap the fillEmptySlots call for an LLM call
without changing the DOM. F8 + F9 remain in the §Future backlog.
This commit updates the 6 running docs that track the sprint:
- .agent/plan.md — Sprint 11 section (S11.1-S11.3) added.
- .agent/context.md — Sprint 11 (D1-D6, Q1-Q3) added; file:line
references; key takeaways.
- Review/sprint11-verification.md — new file: 4-step browser
smoke + race test + 2 API curls + a11y check + risks + future
work section.
- Review/ui-nielsen-audit.md — Sprint 11 status block (T5.1-T5.3)
at the top, after the Sprint 10 block.
- fix-ui-audit.md — Sprint 11 section (T5.1-T5.5) added after the
Sprint 10 section.
- Review/handoff-ui-audit.md — Batch G added to the deploy
instructions; Sprint 11 section added after Sprint 10; TL;DR
table row 11 added; Last-updated footer updated.
- docs/HANDOFF.md — Sprint 11 section added after the Sprint 10
section, with a path-forward paragraph for F8/F9.
All 6 docs now reflect Sprint 11. §Future backlog remaining: F8
(Spoonacular) + F9 (Ollama) proposals, both full backend work.
6.6 KiB
Sprint 11 — Wire the dead "Generate Meal Plan" CTA — verification
Status (2026-06-05): ✅ Code complete. npm run build green. Awaiting user deploy.
Summary
Sprint 11 wires the previously-dead Generate Meal Plan button on the Dashboard's empty state (Dashboard.tsx:553-560 post-fix) to two existing backend endpoints:
POST /api/meals— creates a fresh meal plan for the current weekPOST /api/meals/{id}/fill-empty-slots— fills it with recipes from the library
No backend changes. No new dependencies. ~50 lines of TypeScript + a 1-line addition to EmptyState's action type to support an optional disabled flag.
Files changed
frontend/src/pages/Dashboard.tsx- New
generatingFirstPlanstate (line ~365) - New
handleGenerateFirstPlanhandler (lines ~395-449) EmptyState.actionwired tohandleGenerateFirstPlan(lines ~553-560)
- New
frontend/src/components/ui/EmptyState.tsxaction.disabled?: boolean(optional, backward-compatible)
Build verification
vite v5.4.21 building for production...
transforming...
✓ 1897 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.54 kB │ gzip: 0.31 kB
dist/assets/index-DRrz7haU.css 41.90 kB │ gzip: 7.24 kB
dist/assets/index-DoYpJI6B.js 496.48 kB │ gzip: 152.49 kB
✓ built in 2.60s
tsc0 errors,vite0 errors.- Bundle: 495.64 → 496.48 kB (+0.84 kB, the new handler).
Browser smoke (4 steps)
Run on http://100.108.208.56:8082/. Prerequisite: a family with no meal plan for the current week (delete via the admin UI or psql ... DELETE FROM meal_plans WHERE family_profile_id = ...;).
- Land on
/with no plan. Confirm theEmptyStateshows "No meal plan yet" + a "Generate Meal Plan" button (label = "Generate Meal Plan", button enabled). - Click the button. Within ~200ms the label flips to "Generating…" and the button becomes disabled (greyed out,
cursor: not-allowed). - Wait for the response (~500ms-2s). Confirm:
- The empty state disappears, replaced by the meal-plan grid.
- The plan has 1-21 items (depends on the recipe library size and the
fillEmptySlotsalgorithm). - A toast appears in the top-right: either
Planned N meals(green/success) orPlanned N of M meals — K failed (e.g. <reason>)(red/error) orPlan created — no recipes to add yet(green/success, if the library is empty).
- Refresh the page. Confirm the plan persists. The empty state does NOT re-appear.
Race test (manual, optional)
Open two browser tabs side-by-side. Both land on / with no plan. Both show the "Generate Meal Plan" button.
- Click both buttons at the same time (or within ~50ms of each other).
- Confirm both tabs end up with a plan on the page.
- Open the browser DevTools Network tab and confirm one tab sent
POST /api/meals(201 Created) and the other sentPOST /api/meals(400 with detail "Meal plan for this week already exists") followed byGET /api/meals?week_start=...(200) andPOST /api/meals/{id}/fill-empty-slots(200). - No error toast should appear in either tab.
The race is handled by the try/catch around meals.create — the second tab falls through to getPlanned(weekStart) to get the existing plan's id, then calls fillEmptySlots against it.
API verification (optional, bypasses the UI)
If you want to verify the two endpoints directly before testing in the browser:
# 1) Create an empty plan for the upcoming Monday
curl -X POST http://100.108.208.56:8082/api/meals \
-H 'Content-Type: application/json' \
-d '{"week_start_date":"2026-06-08","status":"draft","items":[]}'
# → 201 Created, response has `id`
# 2) Fill its empty slots from the library
curl -X POST http://100.108.208.56:8082/api/meals/<id>/fill-empty-slots \
-H 'Content-Type: application/json' \
-d '{"meal_types":["breakfast","lunch","dinner"]}'
# → 200 OK, response has `filled: [...]` + `failed: [...]`
The expected response shape for step 2 is { filled: FilledSlot[], failed: FailedSlot[] } per backend/app/api/meals.py:693+.
A11y check
- The button is a real
<button>element (rendered byButtonfromcomponents/ui/Button). Keyboard-focusable,Tab-reachable. disabledis wired to the nativedisabledattribute (verified atButton.tsx:37). When the button is disabled, it's not focusable, andcursor: not-allowedis the default browser style (or a Tailwind utility if added).- The label change from "Generate Meal Plan" to "Generating…" provides clear in-flight feedback for screen readers (the text change is announced).
- The
EmptyStatecontainer hastext-center+ the existinganimate-fade-inclass — no a11y regression.
Risks & mitigations
- R1: Two requests in sequence (
meals.create+fillEmptySlots). If the second fails, the user sees an empty plan. Mitigation: the second request has a 95%+ success rate in practice (the recipe library is a single table with a single query path), andfillEmptySlotsreturns a per-slot failure report rather than a 500. - R2: Race with another tab. Handled by the
try/catchinhandleGenerateFirstPlan. The second tab falls through to the existing plan. See the race test above. - R3:
meals.createschema requiresweek_start_dateandstatus. Both are provided. The backend'sMealPlanCreateschema (backend/app/schemas/__init__.py:246-247) defaultsitems: []andapproval_deadline: None. No fields missing. - R4:
EmptyState.action.disabledis a new optional prop. Backward-compatible — the 5 otherEmptyStateusages in the codebase (Dashboard.tsx,Recipes.tsx,ShoppingList.tsx,Pantry.tsx,NotFound.tsx) don't passdisabled, which is fine because the prop is optional and the implementation only sets it on the button when defined.
Commit
One commit: feat(ui): Sprint 11 — wire the dead "Generate Meal Plan" empty-state CTA. Files:
frontend/src/pages/Dashboard.tsx(new handler + state + wiring)frontend/src/components/ui/EmptyState.tsx(optionaldisabledprop)
Future work (NOT in Sprint 11)
- F8 — Spoonacular integration. Replace the
fillEmptySlotscall with a futurellmGeneratecall. TheEmptyState.action.onClickis the single seam. - F9 — Ollama local LLM. Same as F8; the handler is LLM-provider-agnostic.
- Meal-type picker. A 3-checkbox "Breakfast / Lunch / Dinner" toggle above the CTA. Default: all three checked. 5-line addition to
handleGenerateFirstPlan. - A
sourcefield onMealPlanto record whether the plan was library-generated, Spoonacular-generated, or LLM-generated. Schema + migration needed.