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:
1 parent
83bab7b6d5
commit
e015abff33
5 files changed
+282
-6
No files matched your search
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -90,12 +90,22 @@ function PlanPageContent({ plan }: { plan: Awaited<ReturnType<typeof fetchCurren
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
{plan ? (
|
{plan ? (
|
||||||
<Link
|
<>
|
||||||
href="/shopping"
|
<Link
|
||||||
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"
|
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"
|
||||||
Lista della spesa →
|
>
|
||||||
</Link>
|
{t('linkShopping')} →
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user