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>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-04-30 00:01:28 +02:00
1 parent 9327e75395
commit 83bab7b6d5
14 files changed
+347 -3

No files matched your search

+13
View File
@@ -22,6 +22,8 @@ export interface PlanSlot {
recipeId: string | null;
status: string;
isFreeMeal: boolean;
consumed: boolean;
consumedAt: string | null;
selected: SlotRecipe | null;
alternatives: SlotRecipe[];
}
@@ -88,6 +90,17 @@ export async function regenerateSlot(slotId: string): Promise<SwapResult> {
return { ok: true };
}
export async function toggleConsumed(slotId: string): Promise<SwapResult> {
const res = await fetch(`${API_URL}/me/meal-plans/slots/${slotId}/consumed`, {
method: 'POST',
headers: { cookie: cookieHeader() },
cache: 'no-store',
});
if (!res.ok) return { ok: false, error: `api_error_${res.status}` };
revalidatePath('/plan');
return { ok: true };
}
export async function toggleFreeMeal(slotId: string): Promise<SwapResult> {
const res = await fetch(`${API_URL}/me/meal-plans/slots/${slotId}/free-meal`, {
method: 'POST',
+42 -1
View File
@@ -1,5 +1,6 @@
'use client';
import { computeAdherence } from '@ketopath/shared';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useState, useTransition } from 'react';
@@ -9,6 +10,7 @@ import { Button } from '@/components/ui/button';
import {
regenerateSlot,
swapSlotRecipe,
toggleConsumed,
toggleFreeMeal,
type CurrentPlan,
type PlanSlot,
@@ -81,13 +83,33 @@ export function PlanWeek({ plan }: { plan: CurrentPlan }) {
}
: { 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-4">
<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) => {
@@ -181,6 +203,12 @@ function SlotCard({
});
}
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">
@@ -191,6 +219,19 @@ function SlotCard({
<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"
@@ -73,6 +73,17 @@ function TrackingPageContent({
{t('subtitle')}
</p>
<p className="animate-fade-up mt-4 [animation-delay:300ms]">
<a
href="/api/tracking-export"
target="_blank"
rel="noopener noreferrer"
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"
>
{t('exportPdf')} ↗
</a>
</p>
<div className="rule animate-rule-in my-10 [animation-delay:360ms]" />
<DailyCheckIn initial={todayCheckIn} />
@@ -0,0 +1,33 @@
// Proxy verso l'API: scarica il PDF dell'export tracking. Necessario per
// avere i cookie di sessione su same-origin e fornire un Content-Disposition.
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const API_URL = process.env.API_URL ?? 'http://localhost:4000';
export async function GET(): Promise<Response> {
const cookie = headers().get('cookie') ?? '';
const upstream = await fetch(`${API_URL}/me/tracking/export.pdf`, {
headers: { cookie },
cache: 'no-store',
});
if (!upstream.ok) {
return NextResponse.json(
{ error: `api_error_${upstream.status}` },
{ status: upstream.status },
);
}
const buffer = await upstream.arrayBuffer();
return new NextResponse(buffer, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition':
upstream.headers.get('content-disposition') ?? 'attachment; filename="ketopath-export.pdf"',
'Cache-Control': 'no-store',
},
});
}