From feb54c1f9a9cd3c24cc4926cb0cae7129cd1d3f9 Mon Sep 17 00:00:00 2001 From: luciano Date: Wed, 29 Apr 2026 22:27:49 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20piano=20sensibile=20al=20protocollo=20d?= =?UTF-8?q?igiuno=20(PRD=20=C2=A75.6=20+=20=C2=A79.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finora il fastingProtocol era una preferenza salvata ma ignorata dal planner — ogni giorno generava 4 pasti. Ora cambia struttura per protocollo e per giorno della settimana. @ketopath/shared/planner - protocolPlanForDay(protocol, dayOfWeek) → { share, kcalMultiplier } - 14:10 → tutti e 4 i pasti, share default - 16:8 → niente colazione: pranzo 50%, spuntino 10%, cena 40% - 18:6 → niente colazione: pranzo 55%, spuntino 5%, cena 40% - 20:4 → solo spuntino+cena (10/90) - ESE 24h → mercoledì kcalMultiplier=0 (giorno saltato), altri default - 5:2 → lunedì + giovedì kcalMultiplier=0.25, share 100% cena - 7 unit test in protocol.test.ts apps/api/plan - POST /me/meal-plans: legge preferences.fastingProtocol, deriva il piano per ogni giorno, salta MealSlot con share=0 e giorni a kcalMultiplier=0 - GET /me/meal-plans/current: include fastingProtocol nella risposta - baseDailyTarget restituito invariato per il client apps/web - /plan mostra "Finestra alimentare 16:8" sotto il titolo della settimana - plan-week distingue i giorni "fasting" (digiuno completo) e "leggeri" (5:2 fasting day) con copy dedicato; rimuove totali calorici inappropriati - i18n: Plan.protocolLabel + Plan.fastingDay/lightDay Smoke tested: settato 16:8 da /profile, rigenerato il piano, verificato che l'etichetta appaia e che ogni giorno abbia esattamente 3 pasti (Pranzo / Spuntino / Cena, niente Colazione). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/src/modules/plan/plan.routes.ts | 64 +++++++++++---- apps/web/messages/it.json | 13 +++ apps/web/src/app/[locale]/plan/actions.ts | 8 ++ apps/web/src/app/[locale]/plan/page.tsx | 8 ++ apps/web/src/app/[locale]/plan/plan-week.tsx | 32 ++++++-- packages/shared/src/planner/matchmaking.ts | 85 +++++++++++++++++++- packages/shared/src/planner/protocol.test.ts | 73 +++++++++++++++++ 7 files changed, 258 insertions(+), 25 deletions(-) create mode 100644 packages/shared/src/planner/protocol.test.ts diff --git a/apps/api/src/modules/plan/plan.routes.ts b/apps/api/src/modules/plan/plan.routes.ts index 9eec1ef..df9480e 100644 --- a/apps/api/src/modules/plan/plan.routes.ts +++ b/apps/api/src/modules/plan/plan.routes.ts @@ -1,9 +1,10 @@ import { - DEFAULT_MEAL_SHARE, calculateBmr, calculateTdee, macrosForPhase, matchMeals, + protocolPlanForDay, + type FastingProtocolKey, type RecipeCandidate, } from '@ketopath/shared'; import type { FastifyPluginAsync } from 'fastify'; @@ -84,9 +85,15 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { const tdee = calculateTdee(bmr, profile.activityLevel); const phaseInt = phaseToInt(profile.currentPhase); // Deficit base solo in fase 1; in fase 2/3 si avvicina a TDEE. - const kcalTarget = Math.round(phaseInt === 1 ? tdee - 500 : phaseInt === 2 ? tdee - 200 : tdee); - const dailyTarget = macrosForPhase({ kcalTarget, weightKg: weightCurrentKg, phase: phaseInt }); + const baseKcal = Math.round(phaseInt === 1 ? tdee - 500 : phaseInt === 2 ? tdee - 200 : tdee); + const baseDailyTarget = macrosForPhase({ + kcalTarget: baseKcal, + weightKg: weightCurrentKg, + phase: phaseInt, + }); const exclusions = profile.user.preferences?.exclusions ?? []; + const fastingProtocol = + (profile.user.preferences?.fastingProtocol as FastingProtocolKey | null | undefined) ?? null; const weekStart = startOfWeek(new Date()); @@ -101,12 +108,26 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { const plannedSlots: Array<{ day: number; meal: string; recipeId: string | null }> = []; for (let day = 0; day < 7; day++) { - // Per ogni pasto, le ricette già scelte negli ultimi 2 giorni sono "recenti". + const dayPlan = protocolPlanForDay(fastingProtocol, day); + // Giorno di digiuno completo (ESE_24): salta tutto. + if (dayPlan.kcalMultiplier === 0) continue; + + const dailyKcal = Math.round(baseKcal * dayPlan.kcalMultiplier); + const dailyTarget = macrosForPhase({ + kcalTarget: dailyKcal, + weightKg: weightCurrentKg, + phase: phaseInt, + }); + + // Ricette scelte negli ultimi 2 giorni sono "recenti". const recentlyConsumedIds: string[] = plannedSlots .filter((s) => day - s.day <= 2 && s.recipeId) .map((s) => s.recipeId!); for (const meal of MEALS) { + // Pasto disabilitato dal protocollo: non creiamo proprio lo slot. + if (dayPlan.share[meal] === 0) continue; + const top = matchMeals({ candidates, meal, @@ -114,7 +135,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { excludedTags: exclusions, recentlyConsumedIds, dailyTarget, - mealShare: DEFAULT_MEAL_SHARE, + mealShare: dayPlan.share, topN: 5, }); const selected = top[0]; @@ -135,7 +156,8 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { plan: { id: plan.id, weekStart: plan.weekStart.toISOString().slice(0, 10), - dailyTarget, + dailyTarget: baseDailyTarget, + fastingProtocol, }, }); }); @@ -174,25 +196,33 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { ); 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' }, - orderBy: { weekStart: 'desc' }, - include: { - slots: { - include: { - selected: { select: { id: true, name: true, kcal: true } }, - alternatives: { select: { id: true, name: true, kcal: true } }, + const userId = request.user!.id; + const [plan, prefs] = await Promise.all([ + fastify.prisma.mealPlan.findFirst({ + where: { userId, status: 'ACTIVE' }, + orderBy: { weekStart: 'desc' }, + include: { + slots: { + include: { + selected: { select: { id: true, name: true, kcal: true } }, + alternatives: { select: { id: true, name: true, kcal: true } }, + }, + orderBy: [{ dayOfWeek: 'asc' }, { meal: 'asc' }], }, - orderBy: [{ dayOfWeek: 'asc' }, { meal: 'asc' }], }, - }, - }); + }), + fastify.prisma.preferences.findUnique({ + where: { userId }, + select: { fastingProtocol: true }, + }), + ]); if (!plan) return reply.code(404).send({ error: 'plan_not_found' }); return { plan: { id: plan.id, weekStart: plan.weekStart.toISOString().slice(0, 10), slots: plan.slots, + fastingProtocol: prefs?.fastingProtocol ?? null, }, }; }); diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index 572cc79..108b1cd 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -391,6 +391,19 @@ "hideAlternatives": "Nascondi", "swap": "Scegli", "dayTotal": "{kcal} kcal totali", + "window": "Finestra alimentare", + "fastingDay": "Giorno di digiuno", + "fastingDayHint": "Acqua, tè e tisane. Niente cibo solido.", + "lightDay": "Giorno leggero", + "lightDayHint": "Solo cena, kcal ridotte (~25% del giornaliero abituale).", + "protocolLabel": { + "FOURTEEN_TEN": "14:10 — finestra di 10h", + "SIXTEEN_EIGHT": "16:8 — finestra di 8h", + "EIGHTEEN_SIX": "18:6 — finestra di 6h", + "TWENTY_FOUR": "20:4 — finestra di 4h", + "ESE_24": "ESE 24h — un giorno di digiuno completo a settimana", + "FIVE_TWO": "5:2 — due giorni leggeri a settimana" + }, "meals": { "COLAZIONE": "Colazione", "PRANZO": "Pranzo", diff --git a/apps/web/src/app/[locale]/plan/actions.ts b/apps/web/src/app/[locale]/plan/actions.ts index 9d41821..7233490 100644 --- a/apps/web/src/app/[locale]/plan/actions.ts +++ b/apps/web/src/app/[locale]/plan/actions.ts @@ -25,6 +25,14 @@ export interface CurrentPlan { id: string; weekStart: string; slots: PlanSlot[]; + fastingProtocol: + | 'FOURTEEN_TEN' + | 'SIXTEEN_EIGHT' + | 'EIGHTEEN_SIX' + | 'TWENTY_FOUR' + | 'ESE_24' + | 'FIVE_TWO' + | null; } function cookieHeader(): string { diff --git a/apps/web/src/app/[locale]/plan/page.tsx b/apps/web/src/app/[locale]/plan/page.tsx index bc1bfd7..14ce471 100644 --- a/apps/web/src/app/[locale]/plan/page.tsx +++ b/apps/web/src/app/[locale]/plan/page.tsx @@ -62,6 +62,14 @@ function PlanPageContent({ plan }: { plan: Awaited {plan ? formatWeek(plan.weekStart) : t('noPlan')}

+ {plan?.fastingProtocol ? ( +

+ {t('window')}{' '} + + {t(`protocolLabel.${plan.fastingProtocol}`)} + +

+ ) : null}
diff --git a/apps/web/src/app/[locale]/plan/plan-week.tsx b/apps/web/src/app/[locale]/plan/plan-week.tsx index a55c0fd..0910d03 100644 --- a/apps/web/src/app/[locale]/plan/plan-week.tsx +++ b/apps/web/src/app/[locale]/plan/plan-week.tsx @@ -36,6 +36,11 @@ export function PlanWeek({ plan }: { plan: CurrentPlan }) { (a, b) => MEAL_ORDER.indexOf(a.meal) - MEAL_ORDER.indexOf(b.meal), ); const dayKcal = slots.reduce((sum, s) => sum + (s.selected?.kcal ?? 0), 0); + // Nessuno slot → giorno di digiuno completo (ESE_24). + const isFastingDay = slots.length === 0; + // Solo CENA con dailyKcal ridotto → giorno "leggero" del 5:2. + const isLightDay = !isFastingDay && slots.length === 1 && slots[0].meal === 'CENA'; + return (
@@ -47,16 +52,31 @@ export function PlanWeek({ plan }: { plan: CurrentPlan }) { className="font-display text-ink text-2xl font-medium leading-none tracking-tight sm:text-3xl" > {t(`days.${dayKey}`)} + {isFastingDay ? ( + + · {t('fastingDay')} + + ) : isLightDay ? ( + + · {t('lightDay')} + + ) : null} - {t('dayTotal', { kcal: Math.round(dayKcal) })} + {isFastingDay ? '— kcal' : t('dayTotal', { kcal: Math.round(dayKcal) })}
-
- {slots.map((slot, slotIdx) => ( - - ))} -
+ {isFastingDay ? ( +

+ {t('fastingDayHint')} +

+ ) : ( +
+ {slots.map((slot, slotIdx) => ( + + ))} +
+ )}
); })} diff --git a/packages/shared/src/planner/matchmaking.ts b/packages/shared/src/planner/matchmaking.ts index f9c9b43..ce968a1 100644 --- a/packages/shared/src/planner/matchmaking.ts +++ b/packages/shared/src/planner/matchmaking.ts @@ -91,10 +91,91 @@ export function matchMeals(opts: MatchOptions): MatchResult[] { return results.slice(0, opts.topN ?? 5); } +export type MealShare = MatchOptions['mealShare']; + // Default share per pasto (somma 1.0). Può essere sovrascritta dall'utente. -export const DEFAULT_MEAL_SHARE = { +export const DEFAULT_MEAL_SHARE: MealShare = { COLAZIONE: 0.25, PRANZO: 0.35, SPUNTINO: 0.1, CENA: 0.3, -} as const; +}; + +export type FastingProtocolKey = + | 'FOURTEEN_TEN' + | 'SIXTEEN_EIGHT' + | 'EIGHTEEN_SIX' + | 'TWENTY_FOUR' + | 'ESE_24' + | 'FIVE_TWO'; + +/** + * Piano del singolo giorno per un certo protocollo IF. + * - `share`: quote per pasto (sommano a `kcalMultiplier`, non a 1, perché un + * giorno di digiuno completo ha tutto a 0). + * - `kcalMultiplier`: moltiplicatore sul TDEE/kcalTarget del giorno. + * - 1.0 → giorno normale + * - 0.0 → giorno di digiuno completo (il piano salta il giorno intero) + * - 0.25 → giorno "fasting day" del 5:2 (~500 kcal su 2000) + */ +export interface ProtocolDayPlan { + share: MealShare; + kcalMultiplier: number; +} + +const ALL_ZERO: MealShare = { COLAZIONE: 0, PRANZO: 0, SPUNTINO: 0, CENA: 0 }; + +// PRD §5.6 + §9.4 — finestra alimentare per protocollo, declinata per giorno +// della settimana. `dayOfWeek` è Mon-anchored: 0=Lunedì … 6=Domenica. +// +// 14:10 → 10h, tutti i pasti, ogni giorno. +// 16:8 → 8h, niente colazione (brunch + cena + spuntino), ogni giorno. +// 18:6 → 6h, niente colazione, pranzo grande + cena + spuntino piccolo. +// 20:4 → 4h, un solo pasto sostanzioso a cena + spuntino opzionale. +// ESE 24h → mercoledì digiuno completo (kcalMultiplier=0), altri giorni +// default. Variante "una volta a settimana" del classico Eat-Stop-Eat. +// 5:2 → lunedì + giovedì giorni "magri" (~500 kcal, un solo pasto a cena), +// gli altri 5 giorni default. +export function protocolPlanForDay( + protocol: FastingProtocolKey | null | undefined, + dayOfWeek: number, +): ProtocolDayPlan { + switch (protocol) { + case 'SIXTEEN_EIGHT': + return { + share: { COLAZIONE: 0, PRANZO: 0.5, SPUNTINO: 0.1, CENA: 0.4 }, + kcalMultiplier: 1, + }; + case 'EIGHTEEN_SIX': + return { + share: { COLAZIONE: 0, PRANZO: 0.55, SPUNTINO: 0.05, CENA: 0.4 }, + kcalMultiplier: 1, + }; + case 'TWENTY_FOUR': + return { + share: { COLAZIONE: 0, PRANZO: 0, SPUNTINO: 0.1, CENA: 0.9 }, + kcalMultiplier: 1, + }; + case 'ESE_24': + // Mercoledì (dayOfWeek === 2) digiuno completo, altrimenti normale. + return dayOfWeek === 2 + ? { share: ALL_ZERO, kcalMultiplier: 0 } + : { share: DEFAULT_MEAL_SHARE, kcalMultiplier: 1 }; + case 'FIVE_TWO': + // Lunedì (0) e giovedì (3) "fasting days": un solo pasto a cena con + // kcal complessive ridotte (~25% di TDEE, target classico ~500 kcal). + // Nota: la share somma comunque a 1 (è la quota del *giorno ridotto*), + // mentre la riduzione totale viene applicata via kcalMultiplier. + return dayOfWeek === 0 || dayOfWeek === 3 + ? { + share: { COLAZIONE: 0, PRANZO: 0, SPUNTINO: 0, CENA: 1 }, + kcalMultiplier: 0.25, + } + : { share: DEFAULT_MEAL_SHARE, kcalMultiplier: 1 }; + case 'FOURTEEN_TEN': + case null: + case undefined: + default: + return { share: DEFAULT_MEAL_SHARE, kcalMultiplier: 1 }; + } +} diff --git a/packages/shared/src/planner/protocol.test.ts b/packages/shared/src/planner/protocol.test.ts new file mode 100644 index 0000000..e1c0413 --- /dev/null +++ b/packages/shared/src/planner/protocol.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_MEAL_SHARE, protocolPlanForDay } from './matchmaking.js'; + +describe('protocolPlanForDay', () => { + it('ritorna il default quando il protocollo non è impostato', () => { + const r = protocolPlanForDay(null, 0); + expect(r.share).toEqual(DEFAULT_MEAL_SHARE); + expect(r.kcalMultiplier).toBe(1); + }); + + it('14:10 lascia tutti i quattro pasti uguali al default', () => { + for (let day = 0; day < 7; day++) { + const r = protocolPlanForDay('FOURTEEN_TEN', day); + expect(r.share).toEqual(DEFAULT_MEAL_SHARE); + expect(r.kcalMultiplier).toBe(1); + } + }); + + it('16:8 azzera la colazione e mantiene 8h di alimentazione', () => { + const r = protocolPlanForDay('SIXTEEN_EIGHT', 0); + expect(r.share.COLAZIONE).toBe(0); + expect(r.share.PRANZO + r.share.SPUNTINO + r.share.CENA).toBeCloseTo(1, 5); + expect(r.kcalMultiplier).toBe(1); + }); + + it('18:6 azzera la colazione e tiene lo spuntino piccolo', () => { + const r = protocolPlanForDay('EIGHTEEN_SIX', 0); + expect(r.share.COLAZIONE).toBe(0); + expect(r.share.SPUNTINO).toBeLessThan(0.1); + expect(r.share.PRANZO + r.share.SPUNTINO + r.share.CENA).toBeCloseTo(1, 5); + }); + + it('20:4 lascia praticamente solo la cena', () => { + const r = protocolPlanForDay('TWENTY_FOUR', 0); + expect(r.share.COLAZIONE).toBe(0); + expect(r.share.PRANZO).toBe(0); + expect(r.share.CENA).toBeGreaterThan(0.8); + }); + + it('ESE_24 mette il mercoledì a digiuno completo, gli altri normali', () => { + const wed = protocolPlanForDay('ESE_24', 2); + expect(wed.kcalMultiplier).toBe(0); + expect(wed.share).toEqual({ COLAZIONE: 0, PRANZO: 0, SPUNTINO: 0, CENA: 0 }); + + for (const day of [0, 1, 3, 4, 5, 6]) { + const r = protocolPlanForDay('ESE_24', day); + expect(r.kcalMultiplier).toBe(1); + expect(r.share).toEqual(DEFAULT_MEAL_SHARE); + } + }); + + it("5:2 segna lunedì e giovedì come 'fasting day' a kcal ridotte", () => { + for (const day of [0, 3]) { + const r = protocolPlanForDay('FIVE_TWO', day); + expect(r.kcalMultiplier).toBeCloseTo(0.25, 5); + // Share somma a 1 sul giorno-ridotto: la riduzione kcal è già nel multiplier. + expect(r.share.COLAZIONE + r.share.PRANZO + r.share.SPUNTINO + r.share.CENA).toBeCloseTo( + 1, + 5, + ); + expect(r.share.CENA).toBe(1); + expect(r.share.COLAZIONE).toBe(0); + expect(r.share.PRANZO).toBe(0); + expect(r.share.SPUNTINO).toBe(0); + } + for (const day of [1, 2, 4, 5, 6]) { + const r = protocolPlanForDay('FIVE_TWO', day); + expect(r.kcalMultiplier).toBe(1); + expect(r.share).toEqual(DEFAULT_MEAL_SHARE); + } + }); +});