Public Access
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user