Files
ketopath/apps/web/src/app/[locale]/plan/actions.ts
T
lucianoandClaude Opus 4.7 83bab7b6d5 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>
2026-04-30 00:01:28 +02:00

132 lines
3.8 KiB
TypeScript

'use server';
import { revalidatePath } from 'next/cache';
import { headers } from 'next/headers';
const API_URL = process.env.API_URL ?? 'http://localhost:4000';
interface SlotRecipe {
id: string;
name: string;
kcal: number;
proteinG: number;
fatG: number;
netCarbG: number;
prepMinutes: number;
}
export interface PlanSlot {
id: string;
dayOfWeek: number;
meal: 'COLAZIONE' | 'PRANZO' | 'SPUNTINO' | 'CENA';
recipeId: string | null;
status: string;
isFreeMeal: boolean;
consumed: boolean;
consumedAt: string | null;
selected: SlotRecipe | null;
alternatives: SlotRecipe[];
}
export interface CurrentPlan {
id: string;
weekStart: string;
slots: PlanSlot[];
fastingProtocol:
| 'FOURTEEN_TEN'
| 'SIXTEEN_EIGHT'
| 'EIGHTEEN_SIX'
| 'TWENTY_FOUR'
| 'ESE_24'
| 'FIVE_TWO'
| null;
currentPhase: 'INTENSIVE' | 'TRANSITION' | 'MAINTENANCE' | null;
phase2Week: number | null;
}
function cookieHeader(): string {
return headers().get('cookie') ?? '';
}
export async function fetchCurrentPlan(): Promise<CurrentPlan | null> {
const res = await fetch(`${API_URL}/me/meal-plans/current`, {
headers: { cookie: cookieHeader() },
cache: 'no-store',
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`api_error_${res.status}`);
const data = (await res.json()) as { plan: CurrentPlan };
return data.plan;
}
// Server action invocata via `<form action={regeneratePlan}>`. La firma è
// `(FormData) => Promise<void>` per allinearsi al type richiesto da React 18.
export async function regeneratePlan(_formData?: FormData): Promise<void> {
const res = await fetch(`${API_URL}/me/meal-plans`, {
method: 'POST',
headers: { cookie: cookieHeader() },
cache: 'no-store',
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error ?? `api_error_${res.status}`);
}
revalidatePath('/plan');
}
export type SwapResult = { ok: true } | { ok: false; error: string };
export async function regenerateSlot(slotId: string): Promise<SwapResult> {
const res = await fetch(`${API_URL}/me/meal-plans/slots/${slotId}/regenerate`, {
method: 'POST',
headers: { cookie: cookieHeader() },
cache: 'no-store',
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
return { ok: false, error: body.error ?? `api_error_${res.status}` };
}
revalidatePath('/plan');
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> {
const res = await fetch(`${API_URL}/me/meal-plans/slots/${slotId}/free-meal`, {
method: 'POST',
headers: { cookie: cookieHeader() },
cache: 'no-store',
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
return { ok: false, error: body.error ?? `api_error_${res.status}` };
}
revalidatePath('/plan');
return { ok: true };
}
export async function swapSlotRecipe(slotId: string, recipeId: string): Promise<SwapResult> {
const res = await fetch(`${API_URL}/me/meal-plans/slots/${slotId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', cookie: cookieHeader() },
body: JSON.stringify({ recipeId }),
cache: 'no-store',
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
return { ok: false, error: body.error ?? `api_error_${res.status}` };
}
revalidatePath('/plan');
return { ok: true };
}