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:
1 parent
e7158abbad
commit
f8eaf2f493
6 files changed
+311
-1
No files matched your search
@@ -131,6 +131,39 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
});
|
||||
});
|
||||
|
||||
fastify.patch(
|
||||
'/me/meal-plans/slots/:slotId',
|
||||
{ preHandler: requireAuth() },
|
||||
async (request, reply) => {
|
||||
const slotId = (request.params as { slotId: string }).slotId;
|
||||
const body = request.body as { recipeId?: unknown };
|
||||
if (typeof body.recipeId !== 'string') {
|
||||
return reply.code(400).send({ error: 'invalid_body' });
|
||||
}
|
||||
const userId = request.user!.id;
|
||||
|
||||
const slot = await fastify.prisma.mealSlot.findFirst({
|
||||
where: { id: slotId, plan: { userId } },
|
||||
include: { alternatives: { select: { id: true } } },
|
||||
});
|
||||
if (!slot) return reply.code(404).send({ error: 'slot_not_found' });
|
||||
|
||||
const allowedIds = new Set([slot.recipeId, ...slot.alternatives.map((a) => a.id)]);
|
||||
if (!allowedIds.has(body.recipeId)) {
|
||||
return reply.code(400).send({ error: 'recipe_not_in_alternatives' });
|
||||
}
|
||||
|
||||
const updated = await fastify.prisma.mealSlot.update({
|
||||
where: { id: slotId },
|
||||
data: { recipeId: body.recipeId },
|
||||
include: {
|
||||
selected: { select: { id: true, name: true, kcal: true } },
|
||||
},
|
||||
});
|
||||
return { slot: updated };
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get('/me/meal-plans/current', { preHandler: requireAuth() }, async (request, reply) => {
|
||||
const plan = await fastify.prisma.mealPlan.findFirst({
|
||||
where: { userId: request.user!.id, status: 'ACTIVE' },
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"disclaimer": "Le informazioni fornite da questa app non sostituiscono il parere di un medico.",
|
||||
"signedInAs": "Accesso effettuato come {name}",
|
||||
"signOut": "Esci",
|
||||
"completeProfile": "Completa il tuo profilo"
|
||||
"completeProfile": "Completa il tuo profilo",
|
||||
"viewPlan": "Vedi il piano settimanale"
|
||||
},
|
||||
"Welcome": {
|
||||
"title": "Prima di iniziare",
|
||||
@@ -61,6 +62,33 @@
|
||||
"disclaimer": "Procedendo accetti che i tuoi dati siano trattati come descritto nell'informativa sulla privacy. KetoPath non sostituisce il parere medico."
|
||||
}
|
||||
},
|
||||
"Plan": {
|
||||
"title": "Il tuo piano settimanale",
|
||||
"weekOf": "Settimana del {date}",
|
||||
"noPlan": "Non hai ancora un piano. Generalo per ricevere ricette adatte alla tua fase.",
|
||||
"generate": "Genera piano",
|
||||
"regenerate": "Rigenera",
|
||||
"noRecipe": "Nessuna ricetta selezionata",
|
||||
"showAlternatives": "Vedi {n} alternative",
|
||||
"hideAlternatives": "Nascondi alternative",
|
||||
"swap": "Scegli",
|
||||
"dayTotal": "{kcal} kcal totali",
|
||||
"meals": {
|
||||
"COLAZIONE": "Colazione",
|
||||
"PRANZO": "Pranzo",
|
||||
"SPUNTINO": "Spuntino",
|
||||
"CENA": "Cena"
|
||||
},
|
||||
"days": {
|
||||
"monday": "Lunedì",
|
||||
"tuesday": "Martedì",
|
||||
"wednesday": "Mercoledì",
|
||||
"thursday": "Giovedì",
|
||||
"friday": "Venerdì",
|
||||
"saturday": "Sabato",
|
||||
"sunday": "Domenica"
|
||||
}
|
||||
},
|
||||
"Profile": {
|
||||
"title": "Il tuo profilo",
|
||||
"subtitle": "Inserisci i tuoi dati per calcolare BMR e TDEE personalizzati.",
|
||||
|
||||
@@ -27,6 +27,9 @@ function HomeContent({ userName }: { userName: string | null }) {
|
||||
<Button asChild>
|
||||
<Link href="/profile">{t('completeProfile')}</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/plan">{t('viewPlan')}</Link>
|
||||
</Button>
|
||||
<SignOutButton label={t('signOut')} />
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
const API_URL = process.env.API_URL ?? 'http://localhost:4000';
|
||||
|
||||
interface SlotRecipe {
|
||||
id: string;
|
||||
name: string;
|
||||
kcal: number;
|
||||
}
|
||||
|
||||
export interface PlanSlot {
|
||||
id: string;
|
||||
dayOfWeek: number;
|
||||
meal: 'COLAZIONE' | 'PRANZO' | 'SPUNTINO' | 'CENA';
|
||||
recipeId: string | null;
|
||||
status: string;
|
||||
selected: SlotRecipe | null;
|
||||
alternatives: SlotRecipe[];
|
||||
}
|
||||
|
||||
export interface CurrentPlan {
|
||||
id: string;
|
||||
weekStart: string;
|
||||
slots: PlanSlot[];
|
||||
}
|
||||
|
||||
function cookieHeader(): string {
|
||||
return headers().get('cookie') ?? '';
|
||||
}
|
||||
|
||||
export async function fetchCurrentPlan(): Promise<CurrentPlan | null> {
|
||||
const res = await fetch(`${API_URL}/me/meal-plans/current`, {
|
||||
headers: { cookie: cookieHeader() },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`api_error_${res.status}`);
|
||||
const data = (await res.json()) as { plan: CurrentPlan };
|
||||
return data.plan;
|
||||
}
|
||||
|
||||
export type RegenerateResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export async function regeneratePlan(): Promise<RegenerateResult> {
|
||||
const res = await fetch(`${API_URL}/me/meal-plans`, {
|
||||
method: 'POST',
|
||||
headers: { cookie: cookieHeader() },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
return { ok: false, error: body.error ?? `api_error_${res.status}` };
|
||||
}
|
||||
revalidatePath('/plan');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export type SwapResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export async function swapSlotRecipe(slotId: string, recipeId: string): Promise<SwapResult> {
|
||||
const res = await fetch(`${API_URL}/me/meal-plans/slots/${slotId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', cookie: cookieHeader() },
|
||||
body: JSON.stringify({ recipeId }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
return { ok: false, error: body.error ?? `api_error_${res.status}` };
|
||||
}
|
||||
revalidatePath('/plan');
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { prisma } from '@ketopath/db';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { getServerSession } from '@/lib/auth';
|
||||
|
||||
import { fetchCurrentPlan, regeneratePlan } from './actions';
|
||||
import { PlanWeek } from './plan-week';
|
||||
|
||||
export default async function PlanPage({ params: { locale } }: { params: { locale: string } }) {
|
||||
setRequestLocale(locale);
|
||||
const session = await getServerSession();
|
||||
if (!session?.user) redirect('/sign-in');
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { disclaimerAcceptedAt: true, profile: { select: { id: true } } },
|
||||
});
|
||||
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
|
||||
if (!user.profile) redirect('/profile');
|
||||
|
||||
const plan = await fetchCurrentPlan();
|
||||
|
||||
return <PlanPageContent plan={plan} />;
|
||||
}
|
||||
|
||||
function PlanPageContent({ plan }: { plan: Awaited<ReturnType<typeof fetchCurrentPlan>> }) {
|
||||
const t = useTranslations('Plan');
|
||||
|
||||
return (
|
||||
<main className="mx-auto min-h-screen max-w-5xl px-4 py-10 sm:px-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
<CardDescription>
|
||||
{plan ? t('weekOf', { date: plan.weekStart }) : t('noPlan')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<form action={regeneratePlan}>
|
||||
<Button type="submit" variant={plan ? 'outline' : 'default'} size="sm">
|
||||
{plan ? t('regenerate') : t('generate')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardHeader>
|
||||
<CardContent>{plan ? <PlanWeek plan={plan} /> : null}</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user