# 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:

1. `POST /api/meals` — creates a fresh meal plan for the current week
2. `POST /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 `generatingFirstPlan` state (line ~365)
  - New `handleGenerateFirstPlan` handler (lines ~395-449)
  - `EmptyState.action` wired to `handleGenerateFirstPlan` (lines ~553-560)
- `frontend/src/components/ui/EmptyState.tsx`
  - `action.disabled?: boolean` (optional, backward-compatible)

## Build verification

```text
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
```

- `tsc` 0 errors, `vite` 0 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 = ...;`).

1. **Land on `/` with no plan.** Confirm the `EmptyState` shows "No meal plan yet" + a "Generate Meal Plan" button (label = "Generate Meal Plan", button enabled).
2. **Click the button.** Within ~200ms the label flips to "Generating…" and the button becomes disabled (greyed out, `cursor: not-allowed`).
3. **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 `fillEmptySlots` algorithm).
   - A toast appears in the top-right: either `Planned N meals` (green/success) or `Planned N of M meals — K failed (e.g. <reason>)` (red/error) or `Plan created — no recipes to add yet` (green/success, if the library is empty).
4. **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.

1. Click both buttons at the same time (or within ~50ms of each other).
2. Confirm both tabs end up with a plan on the page.
3. Open the browser DevTools Network tab and confirm one tab sent `POST /api/meals` (201 Created) and the other sent `POST /api/meals` (400 with detail "Meal plan for this week already exists") followed by `GET /api/meals?week_start=...` (200) and `POST /api/meals/{id}/fill-empty-slots` (200).
4. 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:

```bash
# 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 by `Button` from `components/ui/Button`). Keyboard-focusable, `Tab`-reachable.
- `disabled` is wired to the native `disabled` attribute (verified at `Button.tsx:37`). When the button is disabled, it's not focusable, and `cursor: not-allowed` is 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 `EmptyState` container has `text-center` + the existing `animate-fade-in` class — 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), and `fillEmptySlots` returns a per-slot failure report rather than a 500.
- **R2: Race with another tab.** Handled by the `try/catch` in `handleGenerateFirstPlan`. The second tab falls through to the existing plan. See the race test above.
- **R3: `meals.create` schema requires `week_start_date` and `status`.** Both are provided. The backend's `MealPlanCreate` schema (`backend/app/schemas/__init__.py:246-247`) defaults `items: []` and `approval_deadline: None`. No fields missing.
- **R4: `EmptyState.action.disabled` is a new optional prop.** Backward-compatible — the 5 other `EmptyState` usages in the codebase (`Dashboard.tsx`, `Recipes.tsx`, `ShoppingList.tsx`, `Pantry.tsx`, `NotFound.tsx`) don't pass `disabled`, 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` (optional `disabled` prop)

## Future work (NOT in Sprint 11)

- **F8 — Spoonacular integration.** Replace the `fillEmptySlots` call with a future `llmGenerate` call. The `EmptyState.action.onClick` is 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 `source` field on `MealPlan`** to record whether the plan was library-generated, Spoonacular-generated, or LLM-generated. Schema + migration needed.
