feat(web): weekly meal plan UI with regenerate + per-slot swap

apps/api:
- New PATCH /me/meal-plans/slots/:slotId — accepts { recipeId } and validates
  ownership + that the new recipe is one of the slot's existing alternatives
  (or already selected). Returns the updated slot with the new selected recipe.

apps/web — /plan route:
- Server component runs the same auth+disclaimer+profile guard chain as
  /profile, then redirects to /profile if no profile exists yet (you can't
  generate a plan without macros)
- actions.ts exposes three server actions: fetchCurrentPlan, regeneratePlan
  and swapSlotRecipe; all proxy to api with the user's session cookie and
  revalidatePath('/plan') after writes
- plan-week.tsx renders day-by-day sections (Lun→Dom) with a 4-card meal
  row (1/2/4 columns at sm/lg breakpoints), recipe name + kcal, daily kcal
  totals, expandable list of alternatives with one-click "Scegli"
- "Genera piano" CTA when there's no plan yet, "Rigenera" when there is one
- Italian copy under Plan.* with day, meal and kcal-total interpolations
- Home gains a "Vedi il piano settimanale" CTA next to "Completa profilo"

Verified end-to-end in Chrome:
- generate plan → 7 sections (Lun-Dom), each with 4 meal cards and per-day
  kcal sum (e.g. Lunedì 1510 kcal = 360+540+150+460)
- recently-consumed penalty visible: Mon/Tue/Wed have different breakfasts
- swap: clicked "Vedi 3 alternative" on Mon Colazione → "Scegli" on
  "Uova strapazzate" → card refreshed to that recipe and 410 kcal

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-04-29 16:23:50 +02:00
1 parent e7158abbad
commit f8eaf2f493
6 files changed
+311 -1

No files matched your search

@@ -0,0 +1,117 @@
'use client';
import { useTranslations } from 'next-intl';
import { useState, useTransition } from 'react';
import { Button } from '@/components/ui/button';
import { swapSlotRecipe, type CurrentPlan, type PlanSlot } from './actions';
const MEAL_ORDER: PlanSlot['meal'][] = ['COLAZIONE', 'PRANZO', 'SPUNTINO', 'CENA'];
const DAY_KEYS = [
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
] as const;
export function PlanWeek({ plan }: { plan: CurrentPlan }) {
const t = useTranslations('Plan');
// Group slots by day for quick lookup.
const byDay = new Map<number, PlanSlot[]>();
for (const slot of plan.slots) {
if (!byDay.has(slot.dayOfWeek)) byDay.set(slot.dayOfWeek, []);
byDay.get(slot.dayOfWeek)!.push(slot);
}
return (
<div className="space-y-6">
{DAY_KEYS.map((dayKey, idx) => {
const slots = (byDay.get(idx) ?? []).sort(
(a, b) => MEAL_ORDER.indexOf(a.meal) - MEAL_ORDER.indexOf(b.meal),
);
const dayKcal = slots.reduce((sum, s) => sum + (s.selected?.kcal ?? 0), 0);
return (
<section key={dayKey} aria-labelledby={`day-${dayKey}`}>
<header className="mb-2 flex items-baseline justify-between">
<h2 id={`day-${dayKey}`} className="text-lg font-semibold">
{t(`days.${dayKey}`)}
</h2>
<span className="text-muted-foreground text-xs">
{t('dayTotal', { kcal: Math.round(dayKcal) })}
</span>
</header>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{slots.map((slot) => (
<SlotCard key={slot.id} slot={slot} />
))}
</div>
</section>
);
})}
</div>
);
}
function SlotCard({ slot }: { slot: PlanSlot }) {
const t = useTranslations('Plan');
const [open, setOpen] = useState(false);
const [pending, startTransition] = useTransition();
function swap(recipeId: string) {
startTransition(async () => {
await swapSlotRecipe(slot.id, recipeId);
setOpen(false);
});
}
return (
<article className="border-border bg-card flex flex-col rounded-md border p-3">
<p className="text-muted-foreground text-xs uppercase tracking-wider">
{t(`meals.${slot.meal}`)}
</p>
<p className="mt-1 text-sm font-medium leading-tight">
{slot.selected?.name ?? t('noRecipe')}
</p>
{slot.selected ? (
<p className="text-muted-foreground mt-1 text-xs">{Math.round(slot.selected.kcal)} kcal</p>
) : null}
{slot.alternatives.length > 0 ? (
<div className="mt-2">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="text-primary text-xs hover:underline"
>
{open ? t('hideAlternatives') : t('showAlternatives', { n: slot.alternatives.length })}
</button>
{open ? (
<ul className="mt-2 space-y-1">
{slot.alternatives.map((alt) => (
<li key={alt.id} className="flex items-center justify-between gap-2">
<span className="text-xs">
{alt.name}{' '}
<span className="text-muted-foreground">({Math.round(alt.kcal)} kcal)</span>
</span>
<Button
type="button"
size="sm"
variant="ghost"
disabled={pending}
onClick={() => swap(alt.id)}
>
{t('swap')}
</Button>
</li>
))}
</ul>
) : null}
</div>
) : null}
</article>
);
}