diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index beb69c0..86da2f3 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -10,6 +10,7 @@ import { dbRoutes } from './modules/db/db.routes.js'; import { healthRoutes } from './modules/health/health.routes.js'; import { meRoutes } from './modules/me/me.routes.js'; import { notificationsRoutes } from './modules/notifications/notifications.routes.js'; +import { planExportRoutes } from './modules/plan/export.routes.js'; import { planRoutes } from './modules/plan/plan.routes.js'; import { preferencesRoutes } from './modules/preferences/preferences.routes.js'; import { profileRoutes } from './modules/profile/profile.routes.js'; @@ -60,6 +61,7 @@ export async function buildApp(): Promise { await app.register(checkInRoutes); await app.register(trackingExportRoutes); await app.register(planRoutes); + await app.register(planExportRoutes); await app.register(shoppingRoutes); await app.register(notificationsRoutes); diff --git a/apps/api/src/modules/plan/export.routes.ts b/apps/api/src/modules/plan/export.routes.ts new file mode 100644 index 0000000..02ed110 --- /dev/null +++ b/apps/api/src/modules/plan/export.routes.ts @@ -0,0 +1,230 @@ +// PRD §5.1 — Export PDF del piano settimanale, stampabile. +// 7 giorni × 4 pasti (o quelli effettivamente schedulati dal protocollo) +// con macros, tempi di preparazione e una lista spesa aggregata in coda. + +import type { FastifyPluginAsync } from 'fastify'; +// eslint-disable-next-line import/default +import PDFDocument from 'pdfkit'; + +import { requireAuth } from '../../plugins/auth.js'; + +const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', { + day: 'numeric', + month: 'long', + year: 'numeric', +}); + +const DAY_LABELS = ['Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato', 'Domenica']; + +const MEAL_LABELS: Record = { + COLAZIONE: 'Colazione', + PRANZO: 'Pranzo', + SPUNTINO: 'Spuntino', + CENA: 'Cena', +}; + +export const planExportRoutes: FastifyPluginAsync = async (fastify) => { + fastify.get( + '/me/meal-plans/export.pdf', + { preHandler: requireAuth() }, + async (request, reply) => { + const userId = request.user!.id; + + const plan = await fastify.prisma.mealPlan.findFirst({ + where: { userId, status: 'ACTIVE' }, + orderBy: { weekStart: 'desc' }, + include: { + slots: { + include: { + selected: { + include: { + ingredients: { include: { ingredient: true } }, + }, + }, + }, + orderBy: [{ dayOfWeek: 'asc' }, { meal: 'asc' }], + }, + }, + }); + + if (!plan) { + return reply.code(404).send({ error: 'plan_not_found' }); + } + + const doc = new PDFDocument({ size: 'A4', margin: 50, bufferPages: true }); + reply.header('Content-Type', 'application/pdf'); + reply.header('Content-Disposition', 'attachment; filename="ketopath-piano.pdf"'); + reply.send(doc); + + // ── Header ─────────────────────────────────────────────────────────── + doc + .fillColor('#221d18') + .font('Helvetica-Bold') + .fontSize(22) + .text('KetoPath', { continued: true }) + .fillColor('#9a3f29') + .text('.'); + + const weekStartDate = new Date(plan.weekStart); + const weekEndDate = new Date(weekStartDate); + weekEndDate.setDate(weekEndDate.getDate() + 6); + doc + .moveDown(0.2) + .fillColor('#3d3530') + .font('Helvetica') + .fontSize(9) + .text( + `Piano settimanale — ${ITALIAN_DATE.format(weekStartDate)} → ${ITALIAN_DATE.format(weekEndDate)}`, + ) + .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(); + + // ── Days ───────────────────────────────────────────────────────────── + const slotsByDay = new Map(); + for (const s of plan.slots) { + const arr = slotsByDay.get(s.dayOfWeek) ?? []; + arr.push(s); + slotsByDay.set(s.dayOfWeek, arr); + } + + for (let day = 0; day < 7; day++) { + const slots = slotsByDay.get(day) ?? []; + + // Forza una nuova pagina se manca spazio + if (doc.y > 720) doc.addPage(); + + doc.moveDown(0.8).fillColor('#221d18').font('Helvetica-Bold').fontSize(13); + doc.text(DAY_LABELS[day] ?? `Giorno ${day + 1}`); + + if (slots.length === 0) { + doc + .moveDown(0.2) + .fillColor('#7a7060') + .font('Helvetica-Oblique') + .fontSize(9) + .text('Giorno di digiuno completo — solo acqua, tè e tisane.'); + continue; + } + + const dayKcal = slots.reduce((acc, s) => acc + (s.selected?.kcal ?? 0), 0); + const dayP = slots.reduce((acc, s) => acc + (s.selected?.proteinG ?? 0), 0); + const dayF = slots.reduce((acc, s) => acc + (s.selected?.fatG ?? 0), 0); + const dayC = slots.reduce((acc, s) => acc + (s.selected?.netCarbG ?? 0), 0); + + doc + .fillColor('#7a7060') + .font('Helvetica') + .fontSize(8) + .text( + `${Math.round(dayKcal)} kcal · P ${Math.round(dayP)} g · G ${Math.round(dayF)} g · C ${Math.round(dayC)} g`, + ); + + for (const slot of slots) { + if (slot.isFreeMeal) { + doc + .moveDown(0.4) + .fillColor('#9a3f29') + .font('Helvetica-Oblique') + .fontSize(10) + .text(`${MEAL_LABELS[slot.meal] ?? slot.meal}: pasto libero (~750 kcal stimate)`); + continue; + } + if (!slot.selected) continue; + + doc + .moveDown(0.4) + .fillColor('#221d18') + .font('Helvetica-Bold') + .fontSize(10) + .text(MEAL_LABELS[slot.meal] ?? slot.meal, { continued: true }) + .fillColor('#3d3530') + .font('Helvetica') + .text(` · ${slot.selected.name}`); + doc + .fillColor('#7a7060') + .fontSize(8) + .text( + ` ${Math.round(slot.selected.kcal)} kcal · P ${Math.round(slot.selected.proteinG)} · G ${Math.round(slot.selected.fatG)} · C ${Math.round(slot.selected.netCarbG)} · ${slot.selected.prepMinutes} min`, + ); + } + } + + // ── Lista spesa ────────────────────────────────────────────────────── + doc.addPage(); + doc.fillColor('#221d18').font('Helvetica-Bold').fontSize(16).text('Lista della spesa'); + doc + .moveDown(0.3) + .fillColor('#7a7060') + .font('Helvetica-Oblique') + .fontSize(9) + .text('Quantità totali per la settimana, raggruppate per reparto.'); + + type AggLine = { name: string; quantity: number; unit: string }; + const byCategory = new Map>(); + for (const slot of plan.slots) { + if (!slot.selected || slot.isFreeMeal) continue; + for (const ri of slot.selected.ingredients) { + const cat = ri.ingredient.category; + if (!byCategory.has(cat)) byCategory.set(cat, new Map()); + const items = byCategory.get(cat)!; + const key = `${ri.ingredient.name}::${ri.unit}`; + const existing = items.get(key); + if (existing) { + existing.quantity += ri.quantity; + } else { + items.set(key, { + name: ri.ingredient.name, + quantity: ri.quantity, + unit: ri.unit, + }); + } + } + } + + const sortedCategories = Array.from(byCategory.keys()).sort((a, b) => + a.localeCompare(b, 'it'), + ); + for (const cat of sortedCategories) { + doc + .moveDown(0.7) + .fillColor('#221d18') + .font('Helvetica-Bold') + .fontSize(11) + .text(cat.toUpperCase()); + const items = Array.from(byCategory.get(cat)!.values()).sort((a, b) => + a.name.localeCompare(b.name, 'it'), + ); + doc.font('Helvetica').fontSize(10).fillColor('#3d3530'); + for (const it of items) { + const qty = Number.isInteger(it.quantity) ? String(it.quantity) : it.quantity.toFixed(1); + doc.text(` • ${it.name} — ${qty} ${it.unit}`); + } + } + + // ── Footer ─────────────────────────────────────────────────────────── + const pageCount = doc.bufferedPageRange().count; + for (let i = 0; i < pageCount; i++) { + doc.switchToPage(i); + doc + .fillColor('#7a7060') + .font('Helvetica') + .fontSize(7) + .text( + 'KetoPath — uno strumento di stile di vita, non sostituisce il parere medico.', + 50, + 800, + { align: 'center', width: 495 }, + ); + } + + doc.end(); + }, + ); +}; diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index 903d7a7..63d9ddd 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -483,6 +483,8 @@ "consumedMark": "Segna come consumato", "consumedUndo": "Annulla consumato", "adherence": "Aderenza", + "linkShopping": "Lista della spesa", + "exportPdf": "Esporta PDF", "freeMealMark": "Segna come pasto libero", "freeMealUndo": "Torna a una ricetta keto", "freeMealLabel": "Pasto libero pianificato", diff --git a/apps/web/src/app/[locale]/plan/page.tsx b/apps/web/src/app/[locale]/plan/page.tsx index dd403e9..ecc29d5 100644 --- a/apps/web/src/app/[locale]/plan/page.tsx +++ b/apps/web/src/app/[locale]/plan/page.tsx @@ -90,12 +90,22 @@ function PlanPageContent({ plan }: { plan: Awaited {plan ? ( - - Lista della spesa → - + <> + + {t('linkShopping')} → + + + {t('exportPdf')} ↗ + + ) : null} diff --git a/apps/web/src/app/api/plan-export/route.ts b/apps/web/src/app/api/plan-export/route.ts new file mode 100644 index 0000000..589a2f0 --- /dev/null +++ b/apps/web/src/app/api/plan-export/route.ts @@ -0,0 +1,32 @@ +// Proxy verso l'API: scarica il PDF del piano settimanale. + +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 { + const cookie = headers().get('cookie') ?? ''; + const upstream = await fetch(`${API_URL}/me/meal-plans/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-piano.pdf"', + 'Cache-Control': 'no-store', + }, + }); +}