Files
ketopath/apps/web/src/app/[locale]/plan/plan-week.tsx
T
lucianoandClaude Opus 4.7 feb54c1f9a feat: piano sensibile al protocollo digiuno (PRD §5.6 + §9.4)
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) <noreply@anthropic.com>
2026-04-29 22:27:49 +02:00

167 lines
6.2 KiB
TypeScript

'use client';
import Link from 'next/link';
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;
const DAY_NUMERAL = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII'];
export function PlanWeek({ plan }: { plan: CurrentPlan }) {
const t = useTranslations('Plan');
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-14">
{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);
// 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 (
<section key={dayKey} aria-labelledby={`day-${dayKey}`} className="space-y-5">
<header className="border-ink/15 grid items-baseline gap-2 border-b pb-3 sm:grid-cols-[auto_1fr_auto]">
<span className="font-display text-pomodoro text-xl font-medium italic leading-none">
{DAY_NUMERAL[idx]}
</span>
<h2
id={`day-${dayKey}`}
className="font-display text-ink text-2xl font-medium leading-none tracking-tight sm:text-3xl"
>
{t(`days.${dayKey}`)}
{isFastingDay ? (
<span className="font-display text-ink-dim ml-3 text-base italic">
· {t('fastingDay')}
</span>
) : isLightDay ? (
<span className="font-display text-ink-dim ml-3 text-base italic">
· {t('lightDay')}
</span>
) : null}
</h2>
<span className="text-ink-soft font-mono text-[11px] uppercase tracking-widest">
{isFastingDay ? '— kcal' : t('dayTotal', { kcal: Math.round(dayKcal) })}
</span>
</header>
{isFastingDay ? (
<p className="font-display text-ink-soft text-base italic leading-snug">
{t('fastingDayHint')}
</p>
) : (
<div className="divide-ink/10 grid grid-cols-1 divide-y sm:grid-cols-2 sm:gap-x-8 sm:divide-y-0 lg:grid-cols-4 lg:gap-x-6">
{slots.map((slot, slotIdx) => (
<SlotCard key={slot.id} slot={slot} index={slotIdx} />
))}
</div>
)}
</section>
);
})}
</div>
);
}
const MEAL_ROMAN = ['I', 'II', 'III', 'IV'];
function SlotCard({ slot, index }: { slot: PlanSlot; index: number }) {
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="group relative flex flex-col gap-3 py-5 sm:py-2">
<header className="flex items-baseline justify-between gap-3">
<p className="editorial-eyebrow flex items-baseline gap-2">
<span className="font-display not-italic-children text-pomodoro text-base italic">
{MEAL_ROMAN[index]}
</span>
<span>{t(`meals.${slot.meal}`)}</span>
</p>
</header>
{slot.selected ? (
<Link
href={`/recipes/${slot.selected.id}`}
className="font-display text-ink decoration-pomodoro hover:decoration-ink text-lg font-medium leading-tight underline decoration-[1.5px] underline-offset-[5px] transition-colors"
>
{slot.selected.name}
</Link>
) : (
<p className="font-display text-ink-soft text-lg italic leading-tight">{t('noRecipe')}</p>
)}
{slot.selected ? (
<p className="text-ink-soft font-mono text-xs tracking-tight">
{Math.round(slot.selected.kcal)}{' '}
<span className="font-display text-[10px] italic">kcal</span>
</p>
) : null}
{slot.alternatives.length > 0 ? (
<div className="mt-1">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="text-ink-soft decoration-pomodoro hover:text-ink font-mono text-[10px] uppercase tracking-widest underline decoration-[1.5px] underline-offset-[5px] transition-colors"
>
{open ? t('hideAlternatives') : t('showAlternatives', { n: slot.alternatives.length })}
</button>
{open ? (
<ul className="border-ink/15 mt-3 space-y-2 border-t pt-3">
{slot.alternatives.map((alt) => (
<li key={alt.id} className="flex items-baseline justify-between gap-2">
<Link
href={`/recipes/${alt.id}`}
className="font-display text-ink hover:text-pomodoro text-sm leading-snug transition-colors"
>
{alt.name}{' '}
<span className="text-ink-dim font-mono text-[10px]">
{Math.round(alt.kcal)} kcal
</span>
</Link>
<Button
type="button"
size="sm"
variant="ghost"
disabled={pending}
onClick={() => swap(alt.id)}
>
{t('swap')}
</Button>
</li>
))}
</ul>
) : null}
</div>
) : null}
</article>
);
}