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": "^4.28.1",
|
||||||
"fastify-plugin": "^4.5.1",
|
"fastify-plugin": "^4.5.1",
|
||||||
"node-cron": "^3.0",
|
"node-cron": "^3.0",
|
||||||
|
"pdfkit": "^0.15",
|
||||||
"web-push": "^3.6",
|
"web-push": "^3.6",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"@ketopath/tsconfig": "workspace:*",
|
"@ketopath/tsconfig": "workspace:*",
|
||||||
"@types/node": "^20.12.7",
|
"@types/node": "^20.12.7",
|
||||||
"@types/node-cron": "^3.0",
|
"@types/node-cron": "^3.0",
|
||||||
|
"@types/pdfkit": "^0.13",
|
||||||
"@types/web-push": "^3.6",
|
"@types/web-push": "^3.6",
|
||||||
"pino-pretty": "^11.2.1",
|
"pino-pretty": "^11.2.1",
|
||||||
"tsx": "^4.16.2"
|
"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 { profileRoutes } from './modules/profile/profile.routes.js';
|
||||||
import { shoppingRoutes } from './modules/shopping/shopping.routes.js';
|
import { shoppingRoutes } from './modules/shopping/shopping.routes.js';
|
||||||
import { checkInRoutes } from './modules/tracking/check-in.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 { fastRoutes } from './modules/tracking/fast.routes.js';
|
||||||
import { weightRoutes } from './modules/tracking/weight.routes.js';
|
import { weightRoutes } from './modules/tracking/weight.routes.js';
|
||||||
import { authPlugin } from './plugins/auth.js';
|
import { authPlugin } from './plugins/auth.js';
|
||||||
@@ -57,6 +58,7 @@ export async function buildApp(): Promise<FastifyInstance> {
|
|||||||
await app.register(weightRoutes);
|
await app.register(weightRoutes);
|
||||||
await app.register(fastRoutes);
|
await app.register(fastRoutes);
|
||||||
await app.register(checkInRoutes);
|
await app.register(checkInRoutes);
|
||||||
|
await app.register(trackingExportRoutes);
|
||||||
await app.register(planRoutes);
|
await app.register(planRoutes);
|
||||||
await app.register(shoppingRoutes);
|
await app.register(shoppingRoutes);
|
||||||
await app.register(notificationsRoutes);
|
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.
|
// PRD §5.1 — toggle "pasto libero". Disponibile solo in Fase 3.
|
||||||
fastify.post(
|
fastify.post(
|
||||||
'/me/meal-plans/slots/:slotId/free-meal',
|
'/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' });
|
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);
|
const altIds = top.slice(1).map((r) => r.id);
|
||||||
|
|
||||||
// Re-set alternatives via Prisma `set` per pulire la M2M.
|
// 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();
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -207,7 +207,8 @@
|
|||||||
"checkInUpdate": "Aggiorna check-in",
|
"checkInUpdate": "Aggiorna check-in",
|
||||||
"checkInSaved": "Salvato.",
|
"checkInSaved": "Salvato.",
|
||||||
"checkInAlreadyDone": "Già compilato",
|
"checkInAlreadyDone": "Già compilato",
|
||||||
"mood": "Umore"
|
"mood": "Umore",
|
||||||
|
"exportPdf": "Esporta PDF per medico/nutrizionista"
|
||||||
},
|
},
|
||||||
"Onboarding": {
|
"Onboarding": {
|
||||||
"title": "Configura il tuo percorso",
|
"title": "Configura il tuo percorso",
|
||||||
@@ -479,6 +480,9 @@
|
|||||||
"hideAlternatives": "Nascondi",
|
"hideAlternatives": "Nascondi",
|
||||||
"swap": "Scegli",
|
"swap": "Scegli",
|
||||||
"regenerateSlot": "Rigenera questo pasto",
|
"regenerateSlot": "Rigenera questo pasto",
|
||||||
|
"consumedMark": "Segna come consumato",
|
||||||
|
"consumedUndo": "Annulla consumato",
|
||||||
|
"adherence": "Aderenza",
|
||||||
"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",
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface PlanSlot {
|
|||||||
recipeId: string | null;
|
recipeId: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
isFreeMeal: boolean;
|
isFreeMeal: boolean;
|
||||||
|
consumed: boolean;
|
||||||
|
consumedAt: string | null;
|
||||||
selected: SlotRecipe | null;
|
selected: SlotRecipe | null;
|
||||||
alternatives: SlotRecipe[];
|
alternatives: SlotRecipe[];
|
||||||
}
|
}
|
||||||
@@ -88,6 +90,17 @@ export async function regenerateSlot(slotId: string): Promise<SwapResult> {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function toggleConsumed(slotId: string): Promise<SwapResult> {
|
||||||
|
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<SwapResult> {
|
export async function toggleFreeMeal(slotId: string): Promise<SwapResult> {
|
||||||
const res = await fetch(`${API_URL}/me/meal-plans/slots/${slotId}/free-meal`, {
|
const res = await fetch(`${API_URL}/me/meal-plans/slots/${slotId}/free-meal`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { computeAdherence } from '@ketopath/shared';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useState, useTransition } from 'react';
|
import { useState, useTransition } from 'react';
|
||||||
@@ -9,6 +10,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import {
|
import {
|
||||||
regenerateSlot,
|
regenerateSlot,
|
||||||
swapSlotRecipe,
|
swapSlotRecipe,
|
||||||
|
toggleConsumed,
|
||||||
toggleFreeMeal,
|
toggleFreeMeal,
|
||||||
type CurrentPlan,
|
type CurrentPlan,
|
||||||
type PlanSlot,
|
type PlanSlot,
|
||||||
@@ -81,13 +83,33 @@ export function PlanWeek({ plan }: { plan: CurrentPlan }) {
|
|||||||
}
|
}
|
||||||
: { kcal: 0, proteinG: 0, fatG: 0, netCarbG: 0 };
|
: { 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 (
|
return (
|
||||||
<div className="space-y-14">
|
<div className="space-y-14">
|
||||||
<section className="border-ink/15 bg-carta-light/40 grid grid-cols-2 gap-x-8 gap-y-4 border p-5 sm:grid-cols-4">
|
<section className="border-ink/15 bg-carta-light/40 grid grid-cols-2 gap-x-8 gap-y-4 border p-5 sm:grid-cols-5">
|
||||||
<MacroSummary label={t('avgKcal')} value={weekAvg.kcal} unit="kcal" big />
|
<MacroSummary label={t('avgKcal')} value={weekAvg.kcal} unit="kcal" big />
|
||||||
<MacroSummary label={t('avgProtein')} value={weekAvg.proteinG} unit="g" />
|
<MacroSummary label={t('avgProtein')} value={weekAvg.proteinG} unit="g" />
|
||||||
<MacroSummary label={t('avgFat')} value={weekAvg.fatG} unit="g" />
|
<MacroSummary label={t('avgFat')} value={weekAvg.fatG} unit="g" />
|
||||||
<MacroSummary label={t('avgNetCarb')} value={weekAvg.netCarbG} unit="g" />
|
<MacroSummary label={t('avgNetCarb')} value={weekAvg.netCarbG} unit="g" />
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="editorial-eyebrow">{t('adherence')}</span>
|
||||||
|
<span className="text-ink font-mono text-3xl font-medium tabular-nums">
|
||||||
|
{adherence.pastSlots === 0 ? '—' : `${Math.round(adherence.rate * 100)}%`}
|
||||||
|
</span>
|
||||||
|
{adherence.pastSlots > 0 ? (
|
||||||
|
<span className="text-ink-soft font-mono text-[10px] uppercase tracking-widest">
|
||||||
|
{adherence.consumedSlots}/{adherence.pastSlots}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{DAY_KEYS.map((dayKey, idx) => {
|
{DAY_KEYS.map((dayKey, idx) => {
|
||||||
@@ -181,6 +203,12 @@ function SlotCard({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function consume() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await toggleConsumed(slot.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="group relative flex flex-col gap-3 py-5 sm:py-2">
|
<article className="group relative flex flex-col gap-3 py-5 sm:py-2">
|
||||||
<header className="flex items-baseline justify-between gap-3">
|
<header className="flex items-baseline justify-between gap-3">
|
||||||
@@ -191,6 +219,19 @@ function SlotCard({
|
|||||||
<span>{t(`meals.${slot.meal}`)}</span>
|
<span>{t(`meals.${slot.meal}`)}</span>
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={consume}
|
||||||
|
disabled={pending}
|
||||||
|
aria-pressed={slot.consumed}
|
||||||
|
aria-label={slot.consumed ? t('consumedUndo') : t('consumedMark')}
|
||||||
|
title={slot.consumed ? t('consumedUndo') : t('consumedMark')}
|
||||||
|
className={`font-mono text-xs leading-none transition-colors disabled:opacity-40 ${
|
||||||
|
slot.consumed ? 'text-oliva' : 'text-ink-dim hover:text-oliva'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
{canFreeMeal ? (
|
{canFreeMeal ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -73,6 +73,17 @@ function TrackingPageContent({
|
|||||||
{t('subtitle')}
|
{t('subtitle')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p className="animate-fade-up mt-4 [animation-delay:300ms]">
|
||||||
|
<a
|
||||||
|
href="/api/tracking-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>
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="rule animate-rule-in my-10 [animation-delay:360ms]" />
|
<div className="rule animate-rule-in my-10 [animation-delay:360ms]" />
|
||||||
|
|
||||||
<DailyCheckIn initial={todayCheckIn} />
|
<DailyCheckIn initial={todayCheckIn} />
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Proxy verso l'API: scarica il PDF dell'export tracking. Necessario per
|
||||||
|
// avere i cookie di sessione su same-origin e fornire un Content-Disposition.
|
||||||
|
|
||||||
|
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/tracking/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-export.pdf"',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "meal_slots" ADD COLUMN "consumed" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "consumed_at" TIMESTAMP(3);
|
||||||
@@ -428,6 +428,11 @@ model MealSlot {
|
|||||||
// è un placeholder (no ricetta KetoPath, kcal stimate ~750) e il piano
|
// è un placeholder (no ricetta KetoPath, kcal stimate ~750) e il piano
|
||||||
// adatta gli altri pasti della settimana.
|
// adatta gli altri pasti della settimana.
|
||||||
isFreeMeal Boolean @default(false) @map("is_free_meal")
|
isFreeMeal Boolean @default(false) @map("is_free_meal")
|
||||||
|
// PRD §5.2 — aderenza al piano. L'utente spunta "consumato" sui pasti
|
||||||
|
// effettivamente fatti dal menu. Solo gli slot di giorni già passati
|
||||||
|
// contano nel calcolo dell'aderenza.
|
||||||
|
consumed Boolean @default(false)
|
||||||
|
consumedAt DateTime? @map("consumed_at")
|
||||||
|
|
||||||
plan MealPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
plan MealPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||||
selected Recipe? @relation("SelectedRecipe", fields: [recipeId], references: [id])
|
selected Recipe? @relation("SelectedRecipe", fields: [recipeId], references: [id])
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './medical/conditions.js';
|
export * from './medical/conditions.js';
|
||||||
|
export * from './planner/adherence.js';
|
||||||
export * from './notifications/schema.js';
|
export * from './notifications/schema.js';
|
||||||
export * from './preferences/schema.js';
|
export * from './preferences/schema.js';
|
||||||
export * from './nutrition/bmr.js';
|
export * from './nutrition/bmr.js';
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// PRD §5.2 — Aderenza al piano alimentare. Quota di pasti effettivamente
|
||||||
|
// consumati rispetto a quelli pianificati. Solo i giorni passati contano
|
||||||
|
// (non posso aderire a un futuro non ancora arrivato), e i free-meal sono
|
||||||
|
// auto-conteggiati come "rispettati" (l'utente li ha programmati lui).
|
||||||
|
|
||||||
|
export interface AdherenceSlot {
|
||||||
|
dayOfWeek: number; // 0 = Lunedì
|
||||||
|
consumed: boolean;
|
||||||
|
isFreeMeal: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdherenceResult {
|
||||||
|
pastSlots: number;
|
||||||
|
consumedSlots: number;
|
||||||
|
/** [0, 1]; 1 se non ci sono slot passati (default ottimistico). */
|
||||||
|
rate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcola l'aderenza rispetto a uno specifico giorno di riferimento.
|
||||||
|
*
|
||||||
|
* @param slots tutti gli slot del piano corrente
|
||||||
|
* @param weekStart inizio settimana (Mon 00:00)
|
||||||
|
* @param now data corrente per cui calcolare il "passato"
|
||||||
|
*/
|
||||||
|
export function computeAdherence(
|
||||||
|
slots: ReadonlyArray<AdherenceSlot>,
|
||||||
|
weekStart: Date,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): AdherenceResult {
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const elapsedMs = now.getTime() - weekStart.getTime();
|
||||||
|
// dayOfWeek 0 = Lun. Un giorno è "passato" appena è iniziato.
|
||||||
|
const lastPastDay = Math.min(6, Math.floor(elapsedMs / DAY_MS));
|
||||||
|
if (lastPastDay < 0) {
|
||||||
|
return { pastSlots: 0, consumedSlots: 0, rate: 1 };
|
||||||
|
}
|
||||||
|
const pastSlotsArr = slots.filter((s) => s.dayOfWeek <= lastPastDay);
|
||||||
|
const consumedSlots = pastSlotsArr.filter((s) => s.isFreeMeal || s.consumed).length;
|
||||||
|
if (pastSlotsArr.length === 0) {
|
||||||
|
return { pastSlots: 0, consumedSlots: 0, rate: 1 };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pastSlots: pastSlotsArr.length,
|
||||||
|
consumedSlots,
|
||||||
|
rate: consumedSlots / pastSlotsArr.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
Generated
BIN
Binary file not shown.
Reference in new issue
Block a user