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>
102 lines
3.8 KiB
TypeScript
102 lines
3.8 KiB
TypeScript
import { prisma } from '@ketopath/db';
|
||
import Link from 'next/link';
|
||
import { redirect } from 'next/navigation';
|
||
import { useTranslations } from 'next-intl';
|
||
import { setRequestLocale } from 'next-intl/server';
|
||
|
||
import { Masthead } from '@/components/masthead';
|
||
import { Button } from '@/components/ui/button';
|
||
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('/onboarding');
|
||
|
||
const plan = await fetchCurrentPlan();
|
||
|
||
return <PlanPageContent plan={plan} />;
|
||
}
|
||
|
||
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
|
||
day: 'numeric',
|
||
month: 'long',
|
||
year: 'numeric',
|
||
});
|
||
|
||
function formatWeek(weekStart: string): string {
|
||
const start = new Date(`${weekStart}T00:00:00`);
|
||
const end = new Date(start);
|
||
end.setDate(end.getDate() + 6);
|
||
const startStr = new Intl.DateTimeFormat('it-IT', { day: 'numeric', month: 'long' }).format(
|
||
start,
|
||
);
|
||
const endStr = ITALIAN_DATE.format(end);
|
||
return `${startStr} – ${endStr}`;
|
||
}
|
||
|
||
function PlanPageContent({ plan }: { plan: Awaited<ReturnType<typeof fetchCurrentPlan>> }) {
|
||
const t = useTranslations('Plan');
|
||
|
||
return (
|
||
<div className="mx-auto min-h-screen max-w-6xl px-6 sm:px-10">
|
||
<Masthead issueLabel="N. 05 — Cucina" />
|
||
<main className="py-12 sm:py-16">
|
||
<div className="grid items-end gap-6 md:grid-cols-12">
|
||
<div className="md:col-span-8">
|
||
<p className="editorial-eyebrow animate-fade-up">Capitolo V</p>
|
||
<h1 className="font-display text-display text-ink animate-fade-up mt-3 font-medium leading-[0.95] tracking-tight [animation-delay:120ms]">
|
||
{t('title')}
|
||
<span className="text-pomodoro">.</span>
|
||
</h1>
|
||
<p className="font-display text-ink-soft animate-fade-up mt-5 max-w-2xl text-xl italic leading-snug [animation-delay:240ms]">
|
||
{plan ? formatWeek(plan.weekStart) : t('noPlan')}
|
||
</p>
|
||
{plan?.fastingProtocol ? (
|
||
<p className="animate-fade-up mt-4 [animation-delay:300ms]">
|
||
<span className="editorial-eyebrow">{t('window')}</span>{' '}
|
||
<span className="font-display text-pomodoro ml-2 text-base italic">
|
||
{t(`protocolLabel.${plan.fastingProtocol}`)}
|
||
</span>
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
<div className="animate-fade-up flex flex-col items-stretch gap-3 [animation-delay:360ms] md:col-span-4 md:items-end">
|
||
<form action={regeneratePlan}>
|
||
<Button type="submit" variant={plan ? 'outline' : 'default'} size="lg">
|
||
{plan ? t('regenerate') : t('generate')}
|
||
</Button>
|
||
</form>
|
||
{plan ? (
|
||
<Link
|
||
href="/shopping"
|
||
className="text-ink-soft decoration-pomodoro hover:text-ink font-mono text-[11px] uppercase tracking-widest underline decoration-[1.5px] underline-offset-[5px] transition-colors"
|
||
>
|
||
Lista della spesa →
|
||
</Link>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rule animate-rule-in my-10 [animation-delay:420ms]" />
|
||
|
||
{plan ? (
|
||
<div className="animate-fade-up [animation-delay:480ms]">
|
||
<PlanWeek plan={plan} />
|
||
</div>
|
||
) : null}
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|