From 41154e934a15c65600128482aa13fc52b07f88d2 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Fri, 5 Jun 2026 15:36:12 -0700 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Sprint=2011=20=E2=80=94=20wire=20th?= =?UTF-8?q?e=20dead=20"Generate=20Meal=20Plan"=20empty-state=20CTA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/components/ui/EmptyState.tsx | 3 +- frontend/src/pages/Dashboard.tsx | 63 ++++++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ui/EmptyState.tsx b/frontend/src/components/ui/EmptyState.tsx index 333f726..bd109c5 100644 --- a/frontend/src/components/ui/EmptyState.tsx +++ b/frontend/src/components/ui/EmptyState.tsx @@ -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 ) : ( - + ) )} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index cf32012..bf08320 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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({ 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, + }} /> )