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:
1 parent
9327e75395
commit
83bab7b6d5
14 files changed
+347
-3
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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<FastifyInstance> {
|
||||
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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
Reference in new issue
Block a user