diff --git a/apps/api/package.json b/apps/api/package.json index 7bbff9d..2598d49 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -23,6 +23,7 @@ "fastify": "^4.28.1", "fastify-plugin": "^4.5.1", "node-cron": "^3.0", + "pdfkit": "^0.15", "web-push": "^3.6", "zod": "^3.23.8" }, @@ -31,6 +32,7 @@ "@ketopath/tsconfig": "workspace:*", "@types/node": "^20.12.7", "@types/node-cron": "^3.0", + "@types/pdfkit": "^0.13", "@types/web-push": "^3.6", "pino-pretty": "^11.2.1", "tsx": "^4.16.2" diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index f779430..beb69c0 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -15,6 +15,7 @@ import { preferencesRoutes } from './modules/preferences/preferences.routes.js'; import { profileRoutes } from './modules/profile/profile.routes.js'; import { shoppingRoutes } from './modules/shopping/shopping.routes.js'; import { checkInRoutes } from './modules/tracking/check-in.routes.js'; +import { trackingExportRoutes } from './modules/tracking/export.routes.js'; import { fastRoutes } from './modules/tracking/fast.routes.js'; import { weightRoutes } from './modules/tracking/weight.routes.js'; import { authPlugin } from './plugins/auth.js'; @@ -57,6 +58,7 @@ export async function buildApp(): Promise { await app.register(weightRoutes); await app.register(fastRoutes); await app.register(checkInRoutes); + await app.register(trackingExportRoutes); await app.register(planRoutes); await app.register(shoppingRoutes); await app.register(notificationsRoutes); diff --git a/apps/api/src/modules/plan/plan.routes.ts b/apps/api/src/modules/plan/plan.routes.ts index deb0f4a..7a47c28 100644 --- a/apps/api/src/modules/plan/plan.routes.ts +++ b/apps/api/src/modules/plan/plan.routes.ts @@ -283,6 +283,32 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { }, ); + // PRD §5.2 — aderenza: l'utente segna lo slot come consumato. + fastify.post( + '/me/meal-plans/slots/:slotId/consumed', + { preHandler: requireAuth() }, + async (request, reply) => { + const slotId = (request.params as { slotId: string }).slotId; + const userId = request.user!.id; + const body = (request.body ?? {}) as { consumed?: unknown }; + const desired = typeof body.consumed === 'boolean' ? body.consumed : null; + + const slot = await fastify.prisma.mealSlot.findFirst({ + where: { id: slotId, plan: { userId } }, + select: { id: true, consumed: true }, + }); + if (!slot) return reply.code(404).send({ error: 'slot_not_found' }); + + const next = desired ?? !slot.consumed; + const updated = await fastify.prisma.mealSlot.update({ + where: { id: slotId }, + data: { consumed: next, consumedAt: next ? new Date() : null }, + select: { id: true, consumed: true, consumedAt: true }, + }); + return { slot: updated }; + }, + ); + // PRD §5.1 — toggle "pasto libero". Disponibile solo in Fase 3. fastify.post( '/me/meal-plans/slots/:slotId/free-meal', @@ -478,7 +504,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { return reply.code(409).send({ error: 'no_recipes_available' }); } - const newSelected = top[0]; + const newSelected = top[0]!; const altIds = top.slice(1).map((r) => r.id); // Re-set alternatives via Prisma `set` per pulire la M2M. diff --git a/apps/api/src/modules/tracking/export.routes.ts b/apps/api/src/modules/tracking/export.routes.ts new file mode 100644 index 0000000..6f45371 --- /dev/null +++ b/apps/api/src/modules/tracking/export.routes.ts @@ -0,0 +1,155 @@ +// PRD §5.2 — Export PDF per medico/nutrizionista. Sintesi di peso, misure +// e aderenza in un singolo documento. pdfkit lo serve come stream. + +// eslint-disable-next-line import/default +import type { FastifyPluginAsync } from 'fastify'; +import PDFDocument from 'pdfkit'; + +import { requireAuth } from '../../plugins/auth.js'; + +const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', { + day: 'numeric', + month: 'long', + year: 'numeric', +}); + +export const trackingExportRoutes: FastifyPluginAsync = async (fastify) => { + fastify.get('/me/tracking/export.pdf', { preHandler: requireAuth() }, async (request, reply) => { + const userId = request.user!.id; + const userEmail = request.user!.email ?? '—'; + + const [profile, entries, currentPlan] = await Promise.all([ + fastify.prisma.profile.findUnique({ where: { userId } }), + fastify.prisma.weightEntry.findMany({ + where: { userId }, + orderBy: { date: 'desc' }, + take: 30, + }), + fastify.prisma.mealPlan.findFirst({ + where: { userId, status: 'ACTIVE' }, + orderBy: { weekStart: 'desc' }, + include: { + slots: { + include: { + selected: { select: { kcal: true, proteinG: true, fatG: true, netCarbG: true } }, + }, + }, + }, + }), + ]); + + const doc = new PDFDocument({ size: 'A4', margin: 50, bufferPages: true }); + + reply.header('Content-Type', 'application/pdf'); + reply.header('Content-Disposition', 'attachment; filename="ketopath-export.pdf"'); + reply.send(doc); + + // ── Header ───────────────────────────────────────────────────────── + doc + .fillColor('#221d18') + .font('Helvetica-Bold') + .fontSize(22) + .text('KetoPath', { continued: true }) + .fillColor('#9a3f29') + .text('.'); + doc + .moveDown(0.2) + .fillColor('#3d3530') + .font('Helvetica') + .fontSize(9) + .text(`Sintesi tracking — ${userEmail}`) + .text(`Generato il ${ITALIAN_DATE.format(new Date())}`); + doc + .moveDown(1) + .strokeColor('#bdb6a3') + .lineWidth(0.5) + .moveTo(50, doc.y) + .lineTo(545, doc.y) + .stroke(); + + // ── Profile summary ──────────────────────────────────────────────── + doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('PROFILO'); + doc.font('Helvetica').fontSize(10).fillColor('#3d3530'); + if (profile) { + const weightStart = Number(profile.weightStartKg); + const weightCurrent = Number(profile.weightCurrentKg); + const weightGoal = Number(profile.weightGoalKg); + const lostKg = weightStart - weightCurrent; + doc + .moveDown(0.3) + .text(`Età ${profile.age} · ${profile.gender} · ${profile.heightCm} cm`) + .text( + `Peso iniziale ${weightStart.toFixed(1)} kg · attuale ${weightCurrent.toFixed(1)} kg · obiettivo ${weightGoal.toFixed(1)} kg`, + ) + .text(`Variazione: ${lostKg >= 0 ? '-' : '+'}${Math.abs(lostKg).toFixed(1)} kg dall'inizio`) + .text(`Fase corrente: ${profile.currentPhase}`); + } else { + doc.text('Profilo non ancora configurato.'); + } + + // ── Weight history ───────────────────────────────────────────────── + doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('STORICO PESO'); + if (entries.length === 0) { + doc.font('Helvetica').fontSize(10).fillColor('#3d3530').text('Nessuna pesata registrata.'); + } else { + doc.font('Helvetica').fontSize(9).fillColor('#3d3530'); + const colX = { date: 50, weight: 200, energy: 320, sleep: 400, hunger: 480 }; + doc.moveDown(0.5).font('Helvetica-Bold').text('Data', colX.date, doc.y, { continued: false }); + const headerY = doc.y - 11; + doc.text('Peso (kg)', colX.weight, headerY); + doc.text('Energia', colX.energy, headerY); + doc.text('Sonno', colX.sleep, headerY); + doc.text('Fame', colX.hunger, headerY); + doc.font('Helvetica').moveDown(0.5); + for (const e of entries) { + const y = doc.y; + doc.text(e.date.toISOString().slice(0, 10), colX.date, y); + doc.text(Number(e.weightKg).toFixed(1), colX.weight, y); + doc.text(e.energy?.toString() ?? '—', colX.energy, y); + doc.text(e.sleep?.toString() ?? '—', colX.sleep, y); + doc.text(e.hunger?.toString() ?? '—', colX.hunger, y); + doc.moveDown(0.4); + } + } + + // ── Current plan summary ─────────────────────────────────────────── + doc.moveDown(1).fillColor('#221d18').font('Helvetica-Bold').fontSize(11).text('PIANO CORRENTE'); + doc.font('Helvetica').fontSize(10).fillColor('#3d3530'); + if (!currentPlan) { + doc.moveDown(0.3).text('Nessun piano attivo.'); + } else { + const slotsWithRecipe = currentPlan.slots.filter((s) => s.selected != null); + const totalKcal = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.kcal ?? 0), 0); + const totalP = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.proteinG ?? 0), 0); + const totalF = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.fatG ?? 0), 0); + const totalC = slotsWithRecipe.reduce((acc, s) => acc + (s.selected?.netCarbG ?? 0), 0); + const days = new Set(currentPlan.slots.map((s) => s.dayOfWeek)).size || 1; + const consumedCount = currentPlan.slots.filter((s) => s.consumed || s.isFreeMeal).length; + doc + .moveDown(0.3) + .text(`Settimana del ${currentPlan.weekStart.toISOString().slice(0, 10)}`) + .text(`Aderenza: ${consumedCount}/${currentPlan.slots.length} pasti consumati`) + .text( + `Media giornaliera: ${Math.round(totalKcal / days)} kcal · P ${Math.round(totalP / days)} g · G ${Math.round(totalF / days)} g · C ${Math.round(totalC / days)} g`, + ); + } + + // ── Footer ───────────────────────────────────────────────────────── + doc + .moveDown(2) + .strokeColor('#bdb6a3') + .lineWidth(0.5) + .moveTo(50, doc.y) + .lineTo(545, doc.y) + .stroke(); + doc + .moveDown(0.5) + .fontSize(8) + .fillColor('#7a7060') + .text( + 'KetoPath è uno strumento di stile di vita: suggerisce, non prescrive. Le indicazioni nutrizionali non sostituiscono il parere del tuo medico.', + ); + + doc.end(); + }); +}; diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index 02a12bb..903d7a7 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -207,7 +207,8 @@ "checkInUpdate": "Aggiorna check-in", "checkInSaved": "Salvato.", "checkInAlreadyDone": "Già compilato", - "mood": "Umore" + "mood": "Umore", + "exportPdf": "Esporta PDF per medico/nutrizionista" }, "Onboarding": { "title": "Configura il tuo percorso", @@ -479,6 +480,9 @@ "hideAlternatives": "Nascondi", "swap": "Scegli", "regenerateSlot": "Rigenera questo pasto", + "consumedMark": "Segna come consumato", + "consumedUndo": "Annulla consumato", + "adherence": "Aderenza", "freeMealMark": "Segna come pasto libero", "freeMealUndo": "Torna a una ricetta keto", "freeMealLabel": "Pasto libero pianificato", diff --git a/apps/web/src/app/[locale]/plan/actions.ts b/apps/web/src/app/[locale]/plan/actions.ts index bb77e93..3d49ed6 100644 --- a/apps/web/src/app/[locale]/plan/actions.ts +++ b/apps/web/src/app/[locale]/plan/actions.ts @@ -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 { return { ok: true }; } +export async function toggleConsumed(slotId: string): Promise { + 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 { const res = await fetch(`${API_URL}/me/meal-plans/slots/${slotId}/free-meal`, { method: 'POST', diff --git a/apps/web/src/app/[locale]/plan/plan-week.tsx b/apps/web/src/app/[locale]/plan/plan-week.tsx index e7764d4..55faa92 100644 --- a/apps/web/src/app/[locale]/plan/plan-week.tsx +++ b/apps/web/src/app/[locale]/plan/plan-week.tsx @@ -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 (
-
+
+
+ {t('adherence')} + + {adherence.pastSlots === 0 ? '—' : `${Math.round(adherence.rate * 100)}%`} + + {adherence.pastSlots > 0 ? ( + + {adherence.consumedSlots}/{adherence.pastSlots} + + ) : null} +
{DAY_KEYS.map((dayKey, idx) => { @@ -181,6 +203,12 @@ function SlotCard({ }); } + function consume() { + startTransition(async () => { + await toggleConsumed(slot.id); + }); + } + return (
@@ -191,6 +219,19 @@ function SlotCard({ {t(`meals.${slot.meal}`)}

+ {canFreeMeal ? (