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

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "meal_slots" ADD COLUMN "consumed" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "consumed_at" TIMESTAMP(3);
+5
View File
@@ -428,6 +428,11 @@ model MealSlot {
// è un placeholder (no ricetta KetoPath, kcal stimate ~750) e il piano
// adatta gli altri pasti della settimana.
isFreeMeal Boolean @default(false) @map("is_free_meal")
// PRD §5.2 — aderenza al piano. L'utente spunta "consumato" sui pasti
// effettivamente fatti dal menu. Solo gli slot di giorni già passati
// contano nel calcolo dell'aderenza.
consumed Boolean @default(false)
consumedAt DateTime? @map("consumed_at")
plan MealPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
selected Recipe? @relation("SelectedRecipe", fields: [recipeId], references: [id])
+1
View File
@@ -1,4 +1,5 @@
export * from './medical/conditions.js';
export * from './planner/adherence.js';
export * from './notifications/schema.js';
export * from './preferences/schema.js';
export * from './nutrition/bmr.js';
+48
View File
@@ -0,0 +1,48 @@
// PRD §5.2 — Aderenza al piano alimentare. Quota di pasti effettivamente
// consumati rispetto a quelli pianificati. Solo i giorni passati contano
// (non posso aderire a un futuro non ancora arrivato), e i free-meal sono
// auto-conteggiati come "rispettati" (l'utente li ha programmati lui).
export interface AdherenceSlot {
dayOfWeek: number; // 0 = Lunedì
consumed: boolean;
isFreeMeal: boolean;
}
export interface AdherenceResult {
pastSlots: number;
consumedSlots: number;
/** [0, 1]; 1 se non ci sono slot passati (default ottimistico). */
rate: number;
}
/**
* Calcola l'aderenza rispetto a uno specifico giorno di riferimento.
*
* @param slots tutti gli slot del piano corrente
* @param weekStart inizio settimana (Mon 00:00)
* @param now data corrente per cui calcolare il "passato"
*/
export function computeAdherence(
slots: ReadonlyArray<AdherenceSlot>,
weekStart: Date,
now: Date = new Date(),
): AdherenceResult {
const DAY_MS = 24 * 60 * 60 * 1000;
const elapsedMs = now.getTime() - weekStart.getTime();
// dayOfWeek 0 = Lun. Un giorno è "passato" appena è iniziato.
const lastPastDay = Math.min(6, Math.floor(elapsedMs / DAY_MS));
if (lastPastDay < 0) {
return { pastSlots: 0, consumedSlots: 0, rate: 1 };
}
const pastSlotsArr = slots.filter((s) => s.dayOfWeek <= lastPastDay);
const consumedSlots = pastSlotsArr.filter((s) => s.isFreeMeal || s.consumed).length;
if (pastSlotsArr.length === 0) {
return { pastSlots: 0, consumedSlots: 0, rate: 1 };
}
return {
pastSlots: pastSlotsArr.length,
consumedSlots,
rate: consumedSlots / pastSlotsArr.length,
};
}