From f8eaf2f493d5fc8fb951e39727de728eb9b8c810 Mon Sep 17 00:00:00 2001 From: luciano Date: Wed, 29 Apr 2026 16:23:50 +0200 Subject: [PATCH] feat(web): weekly meal plan UI with regenerate + per-slot swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api/src/modules/plan/plan.routes.ts | 33 ++++++ apps/web/messages/it.json | 30 ++++- apps/web/src/app/[locale]/page.tsx | 3 + apps/web/src/app/[locale]/plan/actions.ts | 76 ++++++++++++ apps/web/src/app/[locale]/plan/page.tsx | 53 +++++++++ apps/web/src/app/[locale]/plan/plan-week.tsx | 117 +++++++++++++++++++ 6 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/app/[locale]/plan/actions.ts create mode 100644 apps/web/src/app/[locale]/plan/page.tsx create mode 100644 apps/web/src/app/[locale]/plan/plan-week.tsx diff --git a/apps/api/src/modules/plan/plan.routes.ts b/apps/api/src/modules/plan/plan.routes.ts index 29f8340..5d08daa 100644 --- a/apps/api/src/modules/plan/plan.routes.ts +++ b/apps/api/src/modules/plan/plan.routes.ts @@ -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' }, diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index d3d6c17..48addb9 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -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.", diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index 2e7c7ef..1790a20 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -27,6 +27,9 @@ function HomeContent({ userName }: { userName: string | null }) { + ) : ( diff --git a/apps/web/src/app/[locale]/plan/actions.ts b/apps/web/src/app/[locale]/plan/actions.ts new file mode 100644 index 0000000..99d2374 --- /dev/null +++ b/apps/web/src/app/[locale]/plan/actions.ts @@ -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 { + 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 { + 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 { + 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 }; +} diff --git a/apps/web/src/app/[locale]/plan/page.tsx b/apps/web/src/app/[locale]/plan/page.tsx new file mode 100644 index 0000000..45633b0 --- /dev/null +++ b/apps/web/src/app/[locale]/plan/page.tsx @@ -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 ; +} + +function PlanPageContent({ plan }: { plan: Awaited> }) { + const t = useTranslations('Plan'); + + return ( +
+ + +
+ {t('title')} + + {plan ? t('weekOf', { date: plan.weekStart }) : t('noPlan')} + +
+
+ +
+
+ {plan ? : null} +
+
+ ); +} diff --git a/apps/web/src/app/[locale]/plan/plan-week.tsx b/apps/web/src/app/[locale]/plan/plan-week.tsx new file mode 100644 index 0000000..b576f01 --- /dev/null +++ b/apps/web/src/app/[locale]/plan/plan-week.tsx @@ -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(); + for (const slot of plan.slots) { + if (!byDay.has(slot.dayOfWeek)) byDay.set(slot.dayOfWeek, []); + byDay.get(slot.dayOfWeek)!.push(slot); + } + + return ( +
+ {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 ( +
+
+

+ {t(`days.${dayKey}`)} +

+ + {t('dayTotal', { kcal: Math.round(dayKcal) })} + +
+
+ {slots.map((slot) => ( + + ))} +
+
+ ); + })} +
+ ); +} + +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 ( +
+

+ {t(`meals.${slot.meal}`)} +

+

+ {slot.selected?.name ?? t('noRecipe')} +

+ {slot.selected ? ( +

{Math.round(slot.selected.kcal)} kcal

+ ) : null} + {slot.alternatives.length > 0 ? ( +
+ + {open ? ( +
    + {slot.alternatives.map((alt) => ( +
  • + + {alt.name}{' '} + ({Math.round(alt.kcal)} kcal) + + +
  • + ))} +
+ ) : null} +
+ ) : null} +
+ ); +}