Files
ketopath/apps/web/src/app/[locale]/plan/plan-week.tsx
T
lucianoandClaude Opus 4.7 83bab7b6d5 feat: §5.2 finale — aderenza piano + export PDF
§5.2.5 Aderenza al piano alimentare
- MealSlot.consumed Boolean + consumedAt DateTime? (migration)
- POST /me/meal-plans/slots/:id/consumed (toggle, idempotente con body opzionale)
- @ketopath/shared/planner/adherence: computeAdherence() su slot dei giorni
  passati, free-meal contati come "rispettati"
- /plan: bottone ✓ in ogni SlotCard (oliva quando attivo) + 5° card nel
  summary settimanale "Aderenza: X% (n/m)"

§5.2.6 Export PDF per medico/nutrizionista
- pdfkit (Node, no headless Chrome) + @types/pdfkit
- GET /me/tracking/export.pdf: profilo, storico peso (30 entries),
  piano corrente con aderenza, footer con disclaimer medico
- Proxy Next.js /api/tracking-export per same-origin + cookie sessione
- /tracking: link "Esporta PDF per medico/nutrizionista" sotto subtitle

Schema migration: add_meal_slot_consumed.

81/81 unit test verdi. Lint, typecheck verdi.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 00:01:28 +02:00

355 lines
12 KiB
TypeScript

'use client';
import { computeAdherence } from '@ketopath/shared';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useState, useTransition } from 'react';
import { Button } from '@/components/ui/button';
import {
regenerateSlot,
swapSlotRecipe,
toggleConsumed,
toggleFreeMeal,
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'];
function aggregateMacros(slots: PlanSlot[]): {
kcal: number;
proteinG: number;
fatG: number;
netCarbG: number;
} {
return slots.reduce(
(acc, s) => {
// Per i free meal usiamo una stima fissa kcal e azzeriamo i macros
// (non li conosciamo davvero) — il PRD parla di "compensazione" non di
// tracking puntuale del pasto libero.
if (s.isFreeMeal) {
return {
kcal: acc.kcal + FREE_MEAL_DEFAULT_KCAL,
proteinG: acc.proteinG,
fatG: acc.fatG,
netCarbG: acc.netCarbG,
};
}
if (!s.selected) return acc;
return {
kcal: acc.kcal + s.selected.kcal,
proteinG: acc.proteinG + s.selected.proteinG,
fatG: acc.fatG + s.selected.fatG,
netCarbG: acc.netCarbG + s.selected.netCarbG,
};
},
{ kcal: 0, proteinG: 0, fatG: 0, netCarbG: 0 },
);
}
const FREE_MEAL_DEFAULT_KCAL = 750;
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);
}
const isPhase3 = plan.currentPhase === 'MAINTENANCE';
const weekTotals = aggregateMacros(plan.slots);
const activeDays = Array.from(byDay.values()).filter((s) => s.length > 0).length;
const weekAvg =
activeDays > 0
? {
kcal: Math.round(weekTotals.kcal / activeDays),
proteinG: Math.round(weekTotals.proteinG / activeDays),
fatG: Math.round(weekTotals.fatG / activeDays),
netCarbG: Math.round(weekTotals.netCarbG / activeDays),
}
: { kcal: 0, proteinG: 0, fatG: 0, netCarbG: 0 };
const adherence = computeAdherence(
plan.slots.map((s) => ({
dayOfWeek: s.dayOfWeek,
consumed: s.consumed,
isFreeMeal: s.isFreeMeal,
})),
new Date(plan.weekStart + 'T00:00:00'),
);
return (
<div className="space-y-14">
<section className="border-ink/15 bg-carta-light/40 grid grid-cols-2 gap-x-8 gap-y-4 border p-5 sm:grid-cols-5">
<MacroSummary label={t('avgKcal')} value={weekAvg.kcal} unit="kcal" big />
<MacroSummary label={t('avgProtein')} value={weekAvg.proteinG} unit="g" />
<MacroSummary label={t('avgFat')} value={weekAvg.fatG} unit="g" />
<MacroSummary label={t('avgNetCarb')} value={weekAvg.netCarbG} unit="g" />
<div className="flex flex-col gap-1">
<span className="editorial-eyebrow">{t('adherence')}</span>
<span className="text-ink font-mono text-3xl font-medium tabular-nums">
{adherence.pastSlots === 0 ? '—' : `${Math.round(adherence.rate * 100)}%`}
</span>
{adherence.pastSlots > 0 ? (
<span className="text-ink-soft font-mono text-[10px] uppercase tracking-widest">
{adherence.consumedSlots}/{adherence.pastSlots}
</span>
) : null}
</div>
</section>
{DAY_KEYS.map((dayKey, idx) => {
const slots = (byDay.get(idx) ?? []).sort(
(a, b) => MEAL_ORDER.indexOf(a.meal) - MEAL_ORDER.indexOf(b.meal),
);
const dayMacros = aggregateMacros(slots);
// 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'
: `${Math.round(dayMacros.kcal)} kcal · P ${Math.round(dayMacros.proteinG)} · G ${Math.round(dayMacros.fatG)} · C ${Math.round(dayMacros.netCarbG)}`}
</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} canFreeMeal={isPhase3} />
))}
</div>
)}
</section>
);
})}
</div>
);
}
const MEAL_ROMAN = ['I', 'II', 'III', 'IV'];
function SlotCard({
slot,
index,
canFreeMeal,
}: {
slot: PlanSlot;
index: number;
canFreeMeal: boolean;
}) {
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);
});
}
function regenerate() {
startTransition(async () => {
await regenerateSlot(slot.id);
setOpen(false);
});
}
function freeMeal() {
startTransition(async () => {
await toggleFreeMeal(slot.id);
setOpen(false);
});
}
function consume() {
startTransition(async () => {
await toggleConsumed(slot.id);
});
}
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>
<div className="flex items-center gap-2">
<button
type="button"
onClick={consume}
disabled={pending}
aria-pressed={slot.consumed}
aria-label={slot.consumed ? t('consumedUndo') : t('consumedMark')}
title={slot.consumed ? t('consumedUndo') : t('consumedMark')}
className={`font-mono text-xs leading-none transition-colors disabled:opacity-40 ${
slot.consumed ? 'text-oliva' : 'text-ink-dim hover:text-oliva'
}`}
>
✓
</button>
{canFreeMeal ? (
<button
type="button"
onClick={freeMeal}
disabled={pending}
aria-label={slot.isFreeMeal ? t('freeMealUndo') : t('freeMealMark')}
title={slot.isFreeMeal ? t('freeMealUndo') : t('freeMealMark')}
className={`font-mono text-xs leading-none transition-colors disabled:opacity-40 ${
slot.isFreeMeal ? 'text-pomodoro' : 'text-ink-dim hover:text-pomodoro'
}`}
>
☆
</button>
) : null}
{!slot.isFreeMeal ? (
<button
type="button"
onClick={regenerate}
disabled={pending}
aria-label={t('regenerateSlot')}
title={t('regenerateSlot')}
className="text-ink-dim hover:text-pomodoro font-mono text-xs leading-none transition-colors disabled:opacity-40"
>
↻
</button>
) : null}
</div>
</header>
{slot.isFreeMeal ? (
<>
<p className="font-display text-pomodoro text-lg font-medium italic leading-tight">
{t('freeMealLabel')}
</p>
<p className="text-ink-soft font-mono text-xs tracking-tight">
~{FREE_MEAL_DEFAULT_KCAL}{' '}
<span className="font-display text-[10px] italic">kcal stimate</span>
</p>
</>
) : 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.isFreeMeal && 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>
);
}
function MacroSummary({
label,
value,
unit,
big = false,
}: {
label: string;
value: number;
unit: string;
big?: boolean;
}) {
return (
<div className="flex flex-col gap-1">
<span className="editorial-eyebrow">{label}</span>
<span
className={`font-mono tabular-nums ${
big ? 'text-ink text-3xl font-medium' : 'text-ink text-2xl'
}`}
>
{value}
<span className="font-display text-ink-soft ml-1 text-xs italic">{unit}</span>
</span>
</div>
);
}