feat: composizione corporea, deficit dinamico, condizioni mediche (PRD §5.1)

Tre leve per migliorare l'accuratezza del piano oltre Mifflin-St Jeor.

1. Composizione corporea (US Navy + Katch-McArdle)
   - Profile: neckCm/waistCm/hipsCm opzionali in input
   - bodyFatPct calcolato lato server con la formula US Navy metrica
     (Hodgdon-Beckett 1984) e cifrato at-rest come dato sanitario
   - BMR usa Katch-McArdle (FFM-based) quando bodyFatPct è noto, Mifflin
     altrimenti — più accurato del 5-10% sui casi fuori-norma
   - Test: estimateBodyFatPercentageUSNavy + calculateBmrKatchMcArdle

2. Deficit dinamico
   - Profile.targetWeeklyLossKg (kg/settimana, opzionale)
   - computeDailyKcalTarget calcola il deficit da kg/sett ×7700/7 con due cap
     di sicurezza: 30% TDEE (Schoenfeld 2014) e 1% peso/sett (Helms 2014)
   - plan.routes lo usa al posto del -500/-200 hard-coded
   - Test: 6 casi (mantenimento, transizione, intensiva, cap peso, cap TDEE,
     default mancante)

3. Condizioni mediche strutturate
   - Profile.medicalConditions (array JSON cifrato)
   - 11 condizioni: 7 adattabili (tiroide → BMR -10%, diabete II, IBS,
     dislipidemia, ipertensione, reni → cap proteine 0.8 g/kg, fegato →
     1.0 g/kg) + 4 escludenti (gravidanza, allattamento, diabete I, disturbi
     alimentari)
   - PATCH /me/profile/conditions per salvataggio mirato dall'onboarding
   - hasExcludingCondition: plan.routes restituisce 409 medical_block se
     attiva una condizione incompatibile

Onboarding allargato a 6 step: aggiunto "Condizioni mediche" tra Profile
e Preferences. Step labels e numeri rinumerati.

Schema migration 20260429203451_add_profile_health_fields.

44/44 unit test verdi.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-04-29 22:44:16 +02:00
1 parent feb54c1f9a
commit 9401497f03
15 files changed
+744 -26

No files matched your search

+41 -8
View File
@@ -1,6 +1,10 @@
import {
bmrAdjustmentForConditions,
calculateBmr,
calculateBmrKatchMcArdle,
calculateTdee,
computeDailyKcalTarget,
hasExcludingCondition,
macrosForPhase,
matchMeals,
protocolPlanForDay,
@@ -28,6 +32,16 @@ function phaseToInt(p: string): 1 | 2 | 3 {
return 3;
}
function parseConditions(raw: string | null): string[] {
if (!raw) return [];
try {
const v = JSON.parse(raw) as unknown;
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
} catch {
return [];
}
}
export const planRoutes: FastifyPluginAsync = async (fastify) => {
fastify.post('/me/meal-plans', { preHandler: requireAuth() }, async (request, reply) => {
const userId = request.user!.id;
@@ -75,17 +89,36 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
};
});
// Condizioni escludenti: PRD §14.3 — blocchiamo la generazione e
// rimandiamo l'utente al medico (lato UI mostriamo un disclaimer).
const conditions = parseConditions(profile.medicalConditions);
if (hasExcludingCondition(conditions)) {
return reply.code(409).send({ error: 'medical_block', conditions });
}
const weightCurrentKg = Number(profile.weightCurrentKg);
const bmr = calculateBmr({
weightKg: weightCurrentKg,
heightCm: profile.heightCm,
ageYears: profile.age,
gender: profile.gender,
});
const bodyFatPct = profile.bodyFatPct ? Number(profile.bodyFatPct) : null;
// BMR: Katch-McArdle (FFM-based) se BF% noto, altrimenti Mifflin.
let bmr = bodyFatPct
? calculateBmrKatchMcArdle(weightCurrentKg, bodyFatPct)
: calculateBmr({
weightKg: weightCurrentKg,
heightCm: profile.heightCm,
ageYears: profile.age,
gender: profile.gender,
});
// Aggiusto BMR per condizioni che lo influenzano (es. ipotiroidismo -10%).
bmr *= bmrAdjustmentForConditions(conditions);
const tdee = calculateTdee(bmr, profile.activityLevel);
const phaseInt = phaseToInt(profile.currentPhase);
// Deficit base solo in fase 1; in fase 2/3 si avvicina a TDEE.
const baseKcal = Math.round(phaseInt === 1 ? tdee - 500 : phaseInt === 2 ? tdee - 200 : tdee);
// Deficit dinamico da targetWeeklyLossKg (con caps di sicurezza).
const { kcalTarget: baseKcal } = computeDailyKcalTarget({
tdee,
weightCurrentKg,
targetWeeklyLossKg: profile.targetWeeklyLossKg,
phase: phaseInt,
});
const baseDailyTarget = macrosForPhase({
kcalTarget: baseKcal,
weightKg: weightCurrentKg,
+73 -6
View File
@@ -1,7 +1,9 @@
import {
ACTIVITY_MULTIPLIERS,
calculateBmr,
calculateBmrKatchMcArdle,
calculateTdee,
estimateBodyFatPercentageUSNavy,
profileInputSchema,
type ActivityLevel,
type Gender,
@@ -23,17 +25,23 @@ function serialize(
weightGoalKg: string;
activityLevel: ActivityLevel;
targetDate: string | null;
targetWeeklyLossKg: number | null;
medicalConditions: string | null;
bodyFatPct: string | null;
currentPhase: string;
} | null,
) {
if (!profile) return null;
const weightCurrentKg = Number(profile.weightCurrentKg);
const bmr = calculateBmr({
weightKg: weightCurrentKg,
heightCm: profile.heightCm,
ageYears: profile.age,
gender: profile.gender,
});
const bodyFatPct = profile.bodyFatPct ? Number(profile.bodyFatPct) : null;
const bmr = bodyFatPct
? calculateBmrKatchMcArdle(weightCurrentKg, bodyFatPct)
: calculateBmr({
weightKg: weightCurrentKg,
heightCm: profile.heightCm,
ageYears: profile.age,
gender: profile.gender,
});
const tdee = calculateTdee(bmr, profile.activityLevel);
return {
age: profile.age,
@@ -44,15 +52,29 @@ function serialize(
weightGoalKg: Number(profile.weightGoalKg),
activityLevel: profile.activityLevel,
targetDate: profile.targetDate,
targetWeeklyLossKg: profile.targetWeeklyLossKg,
medicalConditions: parseConditions(profile.medicalConditions),
bodyFatPct,
currentPhase: profile.currentPhase,
derived: {
bmr: Math.round(bmr),
tdee: Math.round(tdee),
activityMultiplier: ACTIVITY_MULTIPLIERS[profile.activityLevel],
bmrFormula: bodyFatPct ? 'katch_mcardle' : 'mifflin_st_jeor',
},
};
}
function parseConditions(raw: string | null): string[] {
if (!raw) return [];
try {
const v = JSON.parse(raw) as unknown;
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
} catch {
return [];
}
}
export const profileRoutes: FastifyPluginAsync = async (fastify) => {
fastify.get('/me/profile', { preHandler: requireAuth() }, async (request, reply) => {
const profile = await fastify.prisma.profile.findUnique({
@@ -72,6 +94,25 @@ export const profileRoutes: FastifyPluginAsync = async (fastify) => {
const targetDateStr = data.targetDate ? data.targetDate.toISOString().slice(0, 10) : null;
// Stima %BF se le misure essenziali sono tutte presenti.
let bodyFatPct: string | null = null;
if (data.neckCm && data.waistCm) {
try {
const pct = estimateBodyFatPercentageUSNavy({
gender: data.gender,
heightCm: data.heightCm,
neckCm: data.neckCm,
waistCm: data.waistCm,
...(data.hipsCm != null ? { hipsCm: data.hipsCm } : {}),
});
bodyFatPct = String(pct);
} catch {
bodyFatPct = null; // input fuori range → nessuna stima
}
}
const conditionsJson = data.medicalConditions ? JSON.stringify(data.medicalConditions) : null;
const profile = await fastify.prisma.profile.upsert({
where: { userId },
create: {
@@ -84,6 +125,9 @@ export const profileRoutes: FastifyPluginAsync = async (fastify) => {
weightGoalKg: String(data.weightGoalKg),
activityLevel: data.activityLevel,
targetDate: targetDateStr,
targetWeeklyLossKg: data.targetWeeklyLossKg ?? null,
medicalConditions: conditionsJson,
bodyFatPct,
},
update: {
age: data.age,
@@ -94,9 +138,32 @@ export const profileRoutes: FastifyPluginAsync = async (fastify) => {
weightGoalKg: String(data.weightGoalKg),
activityLevel: data.activityLevel,
targetDate: targetDateStr,
targetWeeklyLossKg: data.targetWeeklyLossKg ?? null,
medicalConditions: conditionsJson,
// Se l'utente ricalcola con misure nuove, sovrascriviamo; altrimenti
// lasciamo il valore precedente.
...(bodyFatPct != null ? { bodyFatPct } : {}),
},
});
return { profile: serialize(profile) };
});
// PATCH dedicato per le condizioni mediche: l'onboarding lo usa per salvare
// solo questo delta senza richiedere tutti i campi obbligatori del PUT.
fastify.patch('/me/profile/conditions', { preHandler: requireAuth() }, async (request, reply) => {
const body = request.body as { conditions?: unknown };
if (!Array.isArray(body.conditions)) {
return reply.code(400).send({ error: 'invalid_body' });
}
const conditions = body.conditions.filter((c): c is string => typeof c === 'string');
const userId = request.user!.id;
const existing = await fastify.prisma.profile.findUnique({ where: { userId } });
if (!existing) return reply.code(404).send({ error: 'profile_not_found' });
await fastify.prisma.profile.update({
where: { userId },
data: { medicalConditions: JSON.stringify(conditions) },
});
return { conditions };
});
};
+29
View File
@@ -177,10 +177,32 @@
"steps": {
"goal": "L'obiettivo",
"profile": "Anagrafica e attività",
"conditions": "Condizioni mediche",
"preferences": "Esclusioni e cucine",
"notifications": "Promemoria",
"done": "Pronti a partire"
},
"conditionsHeading": "Qualcosa di te che dovremmo sapere?",
"conditionsHint": "Indica le condizioni che ti riguardano. Servono ad adattare i calcoli e a segnalarti i casi in cui meglio consultare un medico prima di proseguire.",
"conditionsAdaptive": "Condizioni adattabili",
"conditionsExcluding": "Condizioni che richiedono il medico",
"conditionsExcludingHint": "Se selezioni una di queste l'app non genera piani autonomamente: vai dal tuo medico.",
"conditionsSaveError": "Non è stato possibile salvare. Riprova fra poco.",
"blockedEyebrow": "Importante",
"blockedMessage": "KetoPath non è uno strumento adatto a questa condizione. Ti consigliamo di rivolgerti al tuo medico curante prima di continuare. Puoi comunque proseguire l'esplorazione, ma il piano non verrà generato automaticamente.",
"conditionLabel": {
"THYROID_HYPO": "Ipotiroidismo",
"DIABETES_T2": "Diabete tipo 2",
"IBS": "Intestino irritabile",
"DYSLIPIDEMIA": "Colesterolo / trigliceridi alti",
"HYPERTENSION": "Ipertensione",
"KIDNEY_ISSUES": "Problemi renali",
"LIVER_ISSUES": "Problemi epatici",
"PREGNANCY": "Gravidanza",
"BREASTFEEDING": "Allattamento",
"DIABETES_T1": "Diabete tipo 1",
"EATING_DISORDER": "Disturbi alimentari"
},
"goalHeading": "Perché sei qui?",
"goalHint": "Scegli il motivo principale: ci aiuterà a parlarti nel modo giusto. Potrai sempre cambiarlo.",
"goals": {
@@ -430,6 +452,13 @@
"weightCurrentKg": "Attuale",
"weightGoalKg": "Obiettivo",
"activityLevel": "Livello di attività",
"measurementsTitle": "Circonferenze (cm) — opzionali",
"measurementsHint": "Se inserisci collo, vita e (per le donne) fianchi, calcoliamo la tua composizione corporea con la formula US Navy e usiamo Katch-McArdle invece di Mifflin: BMR più accurato.",
"neckCm": "Collo",
"waistCm": "Vita",
"hipsCm": "Fianchi",
"targetWeeklyLossKg": "Velocità di calo (kg/settimana)",
"targetWeeklyLossKgHint": "Suggerito: 0.4–0.7 kg/settimana. Oltre 1% del peso corporeo è considerato troppo aggressivo.",
"selectPlaceholder": "Seleziona…",
"save": "Salva profilo",
"saving": "Salvataggio…",
@@ -1,9 +1,12 @@
'use client';
import {
EXCLUDING_CONDITIONS,
MEDICAL_CONDITIONS,
type CookingTimeLevel,
type CuisineTag,
type ExclusionGroup,
type MedicalCondition,
type PreferencesPatch,
} from '@ketopath/shared';
import Link from 'next/link';
@@ -20,11 +23,12 @@ import { Button } from '@/components/ui/button';
import { pushSupported, subscribe, getCurrentSubscription } from '@/lib/notifications/push-client';
import { regeneratePlan } from '../plan/actions';
import { saveConditions } from '../profile/actions';
import { savePreferences, type PreferencesView } from '../profile/preferences-actions';
import { ProfileForm as ProfileFormComponent } from '../profile/profile-form';
import type { ProfileForm } from '../profile/profile-form';
const STEPS = ['goal', 'profile', 'preferences', 'notifications', 'done'] as const;
const STEPS = ['goal', 'profile', 'conditions', 'preferences', 'notifications', 'done'] as const;
type StepKey = (typeof STEPS)[number];
const GOALS = ['LOSE', 'MAINTAIN', 'ENERGY'] as const;
@@ -59,7 +63,7 @@ export function OnboardingFlow({ initialProfile, initialPreferences }: Onboardin
i < stepIndex ? 'text-oliva' : i === stepIndex ? 'text-pomodoro' : 'text-ink-dim'
}`}
>
{['I', 'II', 'III', 'IV', 'V'][i]}
{['I', 'II', 'III', 'IV', 'V', 'VI'][i]}
</span>
<span
className={`font-display text-lg leading-tight ${
@@ -79,10 +83,14 @@ export function OnboardingFlow({ initialProfile, initialPreferences }: Onboardin
) : null}
{step === 'profile' ? (
<StepProfile
initial={initialProfile}
goal={goal}
onSaved={() => setStep('preferences')}
<StepProfile initial={initialProfile} goal={goal} onSaved={() => setStep('conditions')} />
) : null}
{step === 'conditions' ? (
<StepConditions
initial={(initialProfile?.medicalConditions as string[] | undefined) ?? []}
onContinue={() => setStep('preferences')}
onBack={() => setStep('profile')}
/>
) : null}
@@ -92,7 +100,7 @@ export function OnboardingFlow({ initialProfile, initialPreferences }: Onboardin
initialPreferences ? toFormValues(initialPreferences) : DEFAULT_PREFERENCES_VALUES
}
onSaved={() => setStep('notifications')}
onBack={() => setStep('profile')}
onBack={() => setStep('conditions')}
/>
) : null}
@@ -117,7 +125,7 @@ function StepGoal({
return (
<div className="space-y-8">
<header className="space-y-2">
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 1, total: 5 })}</p>
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 1, total: 6 })}</p>
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
{t('goalHeading')}
</h2>
@@ -182,7 +190,7 @@ function StepProfile({
return (
<div className="space-y-8">
<header className="space-y-2">
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 2, total: 5 })}</p>
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 2, total: 6 })}</p>
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
{goal ? t(`profileHeadingGoal.${goal}`) : t('profileHeading')}
</h2>
@@ -195,6 +203,145 @@ function StepProfile({
);
}
function StepConditions({
initial,
onContinue,
onBack,
}: {
initial: string[];
onContinue: () => void;
onBack: () => void;
}) {
const t = useTranslations('Onboarding');
const [selected, setSelected] = useState<string[]>(initial);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
function toggle(c: MedicalCondition): void {
setSelected((prev) => (prev.includes(c) ? prev.filter((x) => x !== c) : [...prev, c]));
}
const blocked = selected.some((c) => EXCLUDING_CONDITIONS.has(c as MedicalCondition));
async function handleNext(): Promise<void> {
setError(null);
setPending(true);
const result = await saveConditions(selected);
setPending(false);
if (!result.ok) {
setError(t('conditionsSaveError'));
return;
}
onContinue();
}
const adaptive = MEDICAL_CONDITIONS.filter(
(c) => !EXCLUDING_CONDITIONS.has(c as MedicalCondition),
);
return (
<div className="space-y-8">
<header className="space-y-2">
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 3, total: 6 })}</p>
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
{t('conditionsHeading')}
</h2>
<p className="font-display text-ink-soft max-w-xl text-base italic leading-snug">
{t('conditionsHint')}
</p>
</header>
<fieldset className="space-y-3">
<legend className="editorial-eyebrow">{t('conditionsAdaptive')}</legend>
<div className="flex flex-wrap gap-2">
{adaptive.map((c) => (
<ConditionChip
key={c}
label={t(`conditionLabel.${c}`)}
active={selected.includes(c)}
onToggle={() => toggle(c as MedicalCondition)}
/>
))}
</div>
</fieldset>
<fieldset className="space-y-3">
<legend className="editorial-eyebrow">{t('conditionsExcluding')}</legend>
<p className="font-display text-ink-soft text-sm italic leading-snug">
{t('conditionsExcludingHint')}
</p>
<div className="flex flex-wrap gap-2">
{Array.from(EXCLUDING_CONDITIONS).map((c) => (
<ConditionChip
key={c}
label={t(`conditionLabel.${c}`)}
active={selected.includes(c)}
onToggle={() => toggle(c)}
danger
/>
))}
</div>
</fieldset>
{blocked ? (
<div className="border-pomodoro/40 bg-pomodoro/5 border-2 border-dashed p-5">
<p className="editorial-eyebrow">{t('blockedEyebrow')}</p>
<p className="font-display text-ink mt-3 text-base leading-snug">{t('blockedMessage')}</p>
</div>
) : null}
{error ? (
<p className="font-display text-pomodoro text-base italic" role="alert">
{error}
</p>
) : null}
<div className="flex flex-wrap items-center gap-4">
<Button type="button" size="lg" onClick={handleNext} disabled={pending}>
{pending ? t('working') : t('next')}
</Button>
<button
type="button"
onClick={onBack}
className="text-ink-soft hover:text-ink font-mono text-[11px] uppercase tracking-widest"
>
{t('back')}
</button>
</div>
</div>
);
}
function ConditionChip({
label,
active,
onToggle,
danger,
}: {
label: string;
active: boolean;
onToggle: () => void;
danger?: boolean;
}) {
const palette = active
? danger
? 'bg-pomodoro text-carta-light border-pomodoro'
: 'bg-ink text-carta-light border-ink'
: danger
? 'border-pomodoro/40 hover:border-pomodoro text-pomodoro hover:bg-pomodoro/5 bg-transparent'
: 'border-ink/30 hover:border-ink text-ink hover:bg-ink/5 bg-transparent';
return (
<button
type="button"
aria-pressed={active}
onClick={onToggle}
className={`font-display rounded-full border px-4 py-2 text-sm leading-tight transition-colors ${palette}`}
>
{label}
</button>
);
}
function StepPreferences({
initial,
onSaved,
@@ -215,7 +362,7 @@ function StepPreferences({
return (
<div className="space-y-8">
<header className="space-y-2">
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 3, total: 5 })}</p>
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 4, total: 6 })}</p>
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
{t('preferencesHeading')}
</h2>
@@ -265,7 +412,7 @@ function StepNotifications({ onContinue }: { onContinue: () => void }) {
return (
<div className="space-y-8">
<header className="space-y-2">
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 4, total: 5 })}</p>
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 5, total: 6 })}</p>
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
{t('notificationsHeading')}
</h2>
@@ -339,7 +486,7 @@ function StepDone({ goal }: { goal: Goal | null }) {
return (
<div className="space-y-10">
<header className="space-y-2">
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 5, total: 5 })}</p>
<p className="editorial-eyebrow">{t('stepEyebrow', { n: 6, total: 6 })}</p>
<h2 className="font-display text-ink text-4xl font-medium leading-tight">
{goal ? t(`doneHeadingGoal.${goal}`) : t('doneHeading')}
</h2>
@@ -41,3 +41,19 @@ export async function fetchProfile(): Promise<unknown | null> {
const data = (await res.json()) as { profile: unknown };
return data.profile;
}
export type SaveConditionsResult = { ok: true } | { ok: false; error: string };
export async function saveConditions(conditions: readonly string[]): Promise<SaveConditionsResult> {
const cookie = headers().get('cookie') ?? '';
const res = await fetch(`${API_URL}/me/profile/conditions`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', cookie },
body: JSON.stringify({ conditions }),
cache: 'no-store',
});
if (!res.ok) {
return { ok: false, error: `api_error_${res.status}` };
}
return { ok: true };
}
@@ -190,6 +190,65 @@ export function ProfileForm({
)}
/>
<div className="space-y-6">
<div className="space-y-2">
<p className="editorial-eyebrow">{t('measurementsTitle')}</p>
<p className="font-display text-ink-soft text-sm italic leading-snug">
{t('measurementsHint')}
</p>
</div>
<div className="grid grid-cols-3 gap-x-6 gap-y-8">
{(['neckCm', 'waistCm', 'hipsCm'] as const).map((name) => (
<FormField
key={name}
control={form.control}
name={name}
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>{t(name)}</FormLabel>
<FormControl>
<Input
type="number"
step="0.5"
inputMode="decimal"
{...field}
value={(field.value as number | undefined) ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
</div>
<FormField
control={form.control}
name="targetWeeklyLossKg"
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>{t('targetWeeklyLossKg')}</FormLabel>
<FormControl>
<Input
type="number"
step="0.1"
min={0.1}
max={1.5}
inputMode="decimal"
placeholder="0.5"
{...field}
value={(field.value as number | undefined) ?? ''}
/>
</FormControl>
<p className="font-display text-ink-soft text-sm italic leading-snug">
{t('targetWeeklyLossKgHint')}
</p>
<FormMessage />
</FormItem>
)}
/>
{serverError ? (
<p role="alert" className="font-display text-pomodoro text-base italic">
{serverError}