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>
167 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
}
|