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.
This commit is contained in:
2026-06-05 15:36:12 -07:00
parent 4c85c929d3
commit 41154e934a
2 changed files with 64 additions and 2 deletions
+2 -1
View File
@@ -10,6 +10,7 @@ interface EmptyStateProps {
label: string;
onClick?: () => void;
to?: string;
disabled?: boolean;
};
}
@@ -27,7 +28,7 @@ export function EmptyState({ icon: Icon, title, description, action }: EmptyStat
<Button>{action.label}</Button>
</Link>
) : (
<Button onClick={action.onClick}>{action.label}</Button>
<Button onClick={action.onClick} disabled={action.disabled}>{action.label}</Button>
)
)}
</div>
+62 -1
View File
@@ -362,6 +362,7 @@ export default function Dashboard() {
}
const [planningWeek, setPlanningWeek] = useState(false)
const [planMenuOpen, setPlanMenuOpen] = useState(false)
const [generatingFirstPlan, setGeneratingFirstPlan] = useState(false)
async function handlePlanWeek(mealTypes: string[]) {
if (!mealPlan || planningWeek) return
@@ -390,6 +391,62 @@ export default function Dashboard() {
setPlanningWeek(false)
}
}
// Sprint 11: wire the dead "Generate Meal Plan" empty-state CTA.
// Creates a fresh meal plan for the current week, then fills its
// empty slots from the recipe library via the same endpoint the
// existing `Plan Week` menu uses (handlePlanWeek above). Two
// requests, but they reuse existing endpoints; no backend changes.
async function handleGenerateFirstPlan() {
if (generatingFirstPlan) return
setGeneratingFirstPlan(true)
try {
// 1) Create the empty plan. The backend returns 400 with detail
// "Meal plan for this week already exists" if another tab
// created one first — we fall through to fillEmptySlots in
// that case.
let planId: string | undefined
try {
const res = await mealPlannerApi.meals.create({
week_start_date: weekStart,
status: 'draft',
items: [],
})
planId = (res.data as { id?: string } | undefined)?.id
} catch (createErr: unknown) {
// Race with another tab: re-fetch the plan to get its id.
const existing = await mealPlannerApi.meals.getPlanned(weekStart)
planId = (existing.data as { id?: string } | undefined)?.id
if (!planId) throw createErr
}
if (!planId) {
showToast.error('Failed to create meal plan')
return
}
// 2) Fill the empty slots from the library. Same partial-success
// toast pattern as handlePlanWeek.
const fillRes = await mealPlannerApi.meals.fillEmptySlots(planId, ['breakfast', 'lunch', 'dinner'])
const data = fillRes.data as { filled: unknown[]; failed: { reason: string }[] }
const filledCount = data.filled.length
const failedCount = data.failed.length
if (filledCount === 0 && failedCount === 0) {
showToast.success('Plan created — no recipes to add yet')
} else if (failedCount === 0) {
showToast.success(`Planned ${filledCount} meals`)
} else {
const reason = data.failed[0]?.reason ?? 'Unknown'
showToast.error(
`Planned ${filledCount} of ${filledCount + failedCount} meals — ${failedCount} failed (e.g. ${reason})`,
)
}
queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] })
} catch (err) {
showApiError(err, 'Failed to generate meal plan')
} finally {
setGeneratingFirstPlan(false)
}
}
const { data: mealPlan, isLoading } = useQuery<MealPlan | null>({
queryKey: ['mealPlan', weekStart],
queryFn: () => mealPlannerApi.meals.getPlanned(weekStart).then(r => r.data),
@@ -500,7 +557,11 @@ export default function Dashboard() {
icon={Sparkles}
title="No meal plan yet"
description="Generate your first weekly meal plan to get started with smart shopping lists and vote emails."
action={{ label: 'Generate Meal Plan', onClick: () => {} }}
action={{
label: generatingFirstPlan ? 'Generating' : 'Generate Meal Plan',
onClick: handleGenerateFirstPlan,
disabled: generatingFirstPlan,
}}
/>
</div>
)