feat: export PDF del piano settimanale

GET /me/meal-plans/export.pdf restituisce un PDF stampabile del piano
ACTIVE: 7 giorni × pasti con macros giornalieri + per-pasto + tempo
preparazione, gestione free-meal e giorni di digiuno completo, ultima
pagina con lista spesa aggregata per reparto.

- apps/api/.../plan/export.routes.ts: pdfkit, layout A4 con paginazione
  automatica e footer disclaimer su tutte le pagine
- apps/web/.../api/plan-export: proxy Next.js per same-origin + cookie
- apps/web/.../plan/page.tsx: aggiunto link "Esporta PDF ↗" accanto a
  "Lista della spesa" nell'header del piano

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:04:19 +02:00
1 parent 83bab7b6d5
commit e015abff33
5 files changed
+277 -1

No files matched your search

+2
View File
@@ -10,6 +10,7 @@ import { dbRoutes } from './modules/db/db.routes.js';
import { healthRoutes } from './modules/health/health.routes.js'; import { healthRoutes } from './modules/health/health.routes.js';
import { meRoutes } from './modules/me/me.routes.js'; import { meRoutes } from './modules/me/me.routes.js';
import { notificationsRoutes } from './modules/notifications/notifications.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 { planRoutes } from './modules/plan/plan.routes.js';
import { preferencesRoutes } from './modules/preferences/preferences.routes.js'; import { preferencesRoutes } from './modules/preferences/preferences.routes.js';
import { profileRoutes } from './modules/profile/profile.routes.js'; import { profileRoutes } from './modules/profile/profile.routes.js';
@@ -60,6 +61,7 @@ export async function buildApp(): Promise<FastifyInstance> {
await app.register(checkInRoutes); await app.register(checkInRoutes);
await app.register(trackingExportRoutes); await app.register(trackingExportRoutes);
await app.register(planRoutes); await app.register(planRoutes);
await app.register(planExportRoutes);
await app.register(shoppingRoutes); await app.register(shoppingRoutes);
await app.register(notificationsRoutes); await app.register(notificationsRoutes);
+230
View File
@@ -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<string, string> = {
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<number, typeof plan.slots>();
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<string, Map<string, AggLine>>();
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();
},
);
};
+2
View File
@@ -483,6 +483,8 @@
"consumedMark": "Segna come consumato", "consumedMark": "Segna come consumato",
"consumedUndo": "Annulla consumato", "consumedUndo": "Annulla consumato",
"adherence": "Aderenza", "adherence": "Aderenza",
"linkShopping": "Lista della spesa",
"exportPdf": "Esporta PDF",
"freeMealMark": "Segna come pasto libero", "freeMealMark": "Segna come pasto libero",
"freeMealUndo": "Torna a una ricetta keto", "freeMealUndo": "Torna a una ricetta keto",
"freeMealLabel": "Pasto libero pianificato", "freeMealLabel": "Pasto libero pianificato",
+11 -1
View File
@@ -90,12 +90,22 @@ function PlanPageContent({ plan }: { plan: Awaited<ReturnType<typeof fetchCurren
</Button> </Button>
</form> </form>
{plan ? ( {plan ? (
<>
<Link <Link
href="/shopping" href="/shopping"
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" 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"
> >
Lista della spesa → {t('linkShopping')} →
</Link> </Link>
<a
href="/api/plan-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>
</>
) : null} ) : null}
</div> </div>
</div> </div>
+32
View File
@@ -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<Response> {
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',
},
});
}