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:
1 parent
feb54c1f9a
commit
9401497f03
15 files changed
+734
-16
No files matched your search
@@ -1,6 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
|
bmrAdjustmentForConditions,
|
||||||
calculateBmr,
|
calculateBmr,
|
||||||
|
calculateBmrKatchMcArdle,
|
||||||
calculateTdee,
|
calculateTdee,
|
||||||
|
computeDailyKcalTarget,
|
||||||
|
hasExcludingCondition,
|
||||||
macrosForPhase,
|
macrosForPhase,
|
||||||
matchMeals,
|
matchMeals,
|
||||||
protocolPlanForDay,
|
protocolPlanForDay,
|
||||||
@@ -28,6 +32,16 @@ function phaseToInt(p: string): 1 | 2 | 3 {
|
|||||||
return 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) => {
|
export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||||
fastify.post('/me/meal-plans', { preHandler: requireAuth() }, async (request, reply) => {
|
fastify.post('/me/meal-plans', { preHandler: requireAuth() }, async (request, reply) => {
|
||||||
const userId = request.user!.id;
|
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 weightCurrentKg = Number(profile.weightCurrentKg);
|
||||||
const bmr = calculateBmr({
|
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,
|
weightKg: weightCurrentKg,
|
||||||
heightCm: profile.heightCm,
|
heightCm: profile.heightCm,
|
||||||
ageYears: profile.age,
|
ageYears: profile.age,
|
||||||
gender: profile.gender,
|
gender: profile.gender,
|
||||||
});
|
});
|
||||||
|
// Aggiusto BMR per condizioni che lo influenzano (es. ipotiroidismo -10%).
|
||||||
|
bmr *= bmrAdjustmentForConditions(conditions);
|
||||||
const tdee = calculateTdee(bmr, profile.activityLevel);
|
const tdee = calculateTdee(bmr, profile.activityLevel);
|
||||||
const phaseInt = phaseToInt(profile.currentPhase);
|
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({
|
const baseDailyTarget = macrosForPhase({
|
||||||
kcalTarget: baseKcal,
|
kcalTarget: baseKcal,
|
||||||
weightKg: weightCurrentKg,
|
weightKg: weightCurrentKg,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
ACTIVITY_MULTIPLIERS,
|
ACTIVITY_MULTIPLIERS,
|
||||||
calculateBmr,
|
calculateBmr,
|
||||||
|
calculateBmrKatchMcArdle,
|
||||||
calculateTdee,
|
calculateTdee,
|
||||||
|
estimateBodyFatPercentageUSNavy,
|
||||||
profileInputSchema,
|
profileInputSchema,
|
||||||
type ActivityLevel,
|
type ActivityLevel,
|
||||||
type Gender,
|
type Gender,
|
||||||
@@ -23,12 +25,18 @@ function serialize(
|
|||||||
weightGoalKg: string;
|
weightGoalKg: string;
|
||||||
activityLevel: ActivityLevel;
|
activityLevel: ActivityLevel;
|
||||||
targetDate: string | null;
|
targetDate: string | null;
|
||||||
|
targetWeeklyLossKg: number | null;
|
||||||
|
medicalConditions: string | null;
|
||||||
|
bodyFatPct: string | null;
|
||||||
currentPhase: string;
|
currentPhase: string;
|
||||||
} | null,
|
} | null,
|
||||||
) {
|
) {
|
||||||
if (!profile) return null;
|
if (!profile) return null;
|
||||||
const weightCurrentKg = Number(profile.weightCurrentKg);
|
const weightCurrentKg = Number(profile.weightCurrentKg);
|
||||||
const bmr = calculateBmr({
|
const bodyFatPct = profile.bodyFatPct ? Number(profile.bodyFatPct) : null;
|
||||||
|
const bmr = bodyFatPct
|
||||||
|
? calculateBmrKatchMcArdle(weightCurrentKg, bodyFatPct)
|
||||||
|
: calculateBmr({
|
||||||
weightKg: weightCurrentKg,
|
weightKg: weightCurrentKg,
|
||||||
heightCm: profile.heightCm,
|
heightCm: profile.heightCm,
|
||||||
ageYears: profile.age,
|
ageYears: profile.age,
|
||||||
@@ -44,15 +52,29 @@ function serialize(
|
|||||||
weightGoalKg: Number(profile.weightGoalKg),
|
weightGoalKg: Number(profile.weightGoalKg),
|
||||||
activityLevel: profile.activityLevel,
|
activityLevel: profile.activityLevel,
|
||||||
targetDate: profile.targetDate,
|
targetDate: profile.targetDate,
|
||||||
|
targetWeeklyLossKg: profile.targetWeeklyLossKg,
|
||||||
|
medicalConditions: parseConditions(profile.medicalConditions),
|
||||||
|
bodyFatPct,
|
||||||
currentPhase: profile.currentPhase,
|
currentPhase: profile.currentPhase,
|
||||||
derived: {
|
derived: {
|
||||||
bmr: Math.round(bmr),
|
bmr: Math.round(bmr),
|
||||||
tdee: Math.round(tdee),
|
tdee: Math.round(tdee),
|
||||||
activityMultiplier: ACTIVITY_MULTIPLIERS[profile.activityLevel],
|
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) => {
|
export const profileRoutes: FastifyPluginAsync = async (fastify) => {
|
||||||
fastify.get('/me/profile', { preHandler: requireAuth() }, async (request, reply) => {
|
fastify.get('/me/profile', { preHandler: requireAuth() }, async (request, reply) => {
|
||||||
const profile = await fastify.prisma.profile.findUnique({
|
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;
|
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({
|
const profile = await fastify.prisma.profile.upsert({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
create: {
|
create: {
|
||||||
@@ -84,6 +125,9 @@ export const profileRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
weightGoalKg: String(data.weightGoalKg),
|
weightGoalKg: String(data.weightGoalKg),
|
||||||
activityLevel: data.activityLevel,
|
activityLevel: data.activityLevel,
|
||||||
targetDate: targetDateStr,
|
targetDate: targetDateStr,
|
||||||
|
targetWeeklyLossKg: data.targetWeeklyLossKg ?? null,
|
||||||
|
medicalConditions: conditionsJson,
|
||||||
|
bodyFatPct,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
age: data.age,
|
age: data.age,
|
||||||
@@ -94,9 +138,32 @@ export const profileRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
weightGoalKg: String(data.weightGoalKg),
|
weightGoalKg: String(data.weightGoalKg),
|
||||||
activityLevel: data.activityLevel,
|
activityLevel: data.activityLevel,
|
||||||
targetDate: targetDateStr,
|
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) };
|
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 };
|
||||||
|
});
|
||||||
};
|
};
|
||||||
@@ -177,10 +177,32 @@
|
|||||||
"steps": {
|
"steps": {
|
||||||
"goal": "L'obiettivo",
|
"goal": "L'obiettivo",
|
||||||
"profile": "Anagrafica e attività",
|
"profile": "Anagrafica e attività",
|
||||||
|
"conditions": "Condizioni mediche",
|
||||||
"preferences": "Esclusioni e cucine",
|
"preferences": "Esclusioni e cucine",
|
||||||
"notifications": "Promemoria",
|
"notifications": "Promemoria",
|
||||||
"done": "Pronti a partire"
|
"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?",
|
"goalHeading": "Perché sei qui?",
|
||||||
"goalHint": "Scegli il motivo principale: ci aiuterà a parlarti nel modo giusto. Potrai sempre cambiarlo.",
|
"goalHint": "Scegli il motivo principale: ci aiuterà a parlarti nel modo giusto. Potrai sempre cambiarlo.",
|
||||||
"goals": {
|
"goals": {
|
||||||
@@ -430,6 +452,13 @@
|
|||||||
"weightCurrentKg": "Attuale",
|
"weightCurrentKg": "Attuale",
|
||||||
"weightGoalKg": "Obiettivo",
|
"weightGoalKg": "Obiettivo",
|
||||||
"activityLevel": "Livello di attività",
|
"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…",
|
"selectPlaceholder": "Seleziona…",
|
||||||
"save": "Salva profilo",
|
"save": "Salva profilo",
|
||||||
"saving": "Salvataggio…",
|
"saving": "Salvataggio…",
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
EXCLUDING_CONDITIONS,
|
||||||
|
MEDICAL_CONDITIONS,
|
||||||
type CookingTimeLevel,
|
type CookingTimeLevel,
|
||||||
type CuisineTag,
|
type CuisineTag,
|
||||||
type ExclusionGroup,
|
type ExclusionGroup,
|
||||||
|
type MedicalCondition,
|
||||||
type PreferencesPatch,
|
type PreferencesPatch,
|
||||||
} from '@ketopath/shared';
|
} from '@ketopath/shared';
|
||||||
import Link from 'next/link';
|
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 { pushSupported, subscribe, getCurrentSubscription } from '@/lib/notifications/push-client';
|
||||||
|
|
||||||
import { regeneratePlan } from '../plan/actions';
|
import { regeneratePlan } from '../plan/actions';
|
||||||
|
import { saveConditions } from '../profile/actions';
|
||||||
import { savePreferences, type PreferencesView } from '../profile/preferences-actions';
|
import { savePreferences, type PreferencesView } from '../profile/preferences-actions';
|
||||||
import { ProfileForm as ProfileFormComponent } from '../profile/profile-form';
|
import { ProfileForm as ProfileFormComponent } from '../profile/profile-form';
|
||||||
import type { ProfileForm } 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];
|
type StepKey = (typeof STEPS)[number];
|
||||||
|
|
||||||
const GOALS = ['LOSE', 'MAINTAIN', 'ENERGY'] as const;
|
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 < 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>
|
||||||
<span
|
<span
|
||||||
className={`font-display text-lg leading-tight ${
|
className={`font-display text-lg leading-tight ${
|
||||||
@@ -79,10 +83,14 @@ export function OnboardingFlow({ initialProfile, initialPreferences }: Onboardin
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step === 'profile' ? (
|
{step === 'profile' ? (
|
||||||
<StepProfile
|
<StepProfile initial={initialProfile} goal={goal} onSaved={() => setStep('conditions')} />
|
||||||
initial={initialProfile}
|
) : null}
|
||||||
goal={goal}
|
|
||||||
onSaved={() => setStep('preferences')}
|
{step === 'conditions' ? (
|
||||||
|
<StepConditions
|
||||||
|
initial={(initialProfile?.medicalConditions as string[] | undefined) ?? []}
|
||||||
|
onContinue={() => setStep('preferences')}
|
||||||
|
onBack={() => setStep('profile')}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -92,7 +100,7 @@ export function OnboardingFlow({ initialProfile, initialPreferences }: Onboardin
|
|||||||
initialPreferences ? toFormValues(initialPreferences) : DEFAULT_PREFERENCES_VALUES
|
initialPreferences ? toFormValues(initialPreferences) : DEFAULT_PREFERENCES_VALUES
|
||||||
}
|
}
|
||||||
onSaved={() => setStep('notifications')}
|
onSaved={() => setStep('notifications')}
|
||||||
onBack={() => setStep('profile')}
|
onBack={() => setStep('conditions')}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -117,7 +125,7 @@ function StepGoal({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<header className="space-y-2">
|
<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">
|
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
|
||||||
{t('goalHeading')}
|
{t('goalHeading')}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -182,7 +190,7 @@ function StepProfile({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<header className="space-y-2">
|
<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">
|
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
|
||||||
{goal ? t(`profileHeadingGoal.${goal}`) : t('profileHeading')}
|
{goal ? t(`profileHeadingGoal.${goal}`) : t('profileHeading')}
|
||||||
</h2>
|
</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({
|
function StepPreferences({
|
||||||
initial,
|
initial,
|
||||||
onSaved,
|
onSaved,
|
||||||
@@ -215,7 +362,7 @@ function StepPreferences({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<header className="space-y-2">
|
<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">
|
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
|
||||||
{t('preferencesHeading')}
|
{t('preferencesHeading')}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -265,7 +412,7 @@ function StepNotifications({ onContinue }: { onContinue: () => void }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<header className="space-y-2">
|
<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">
|
<h2 className="font-display text-ink text-3xl font-medium leading-tight">
|
||||||
{t('notificationsHeading')}
|
{t('notificationsHeading')}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -339,7 +486,7 @@ function StepDone({ goal }: { goal: Goal | null }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
<header className="space-y-2">
|
<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">
|
<h2 className="font-display text-ink text-4xl font-medium leading-tight">
|
||||||
{goal ? t(`doneHeadingGoal.${goal}`) : t('doneHeading')}
|
{goal ? t(`doneHeadingGoal.${goal}`) : t('doneHeading')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -41,3 +41,19 @@ export async function fetchProfile(): Promise<unknown | null> {
|
|||||||
const data = (await res.json()) as { profile: unknown };
|
const data = (await res.json()) as { profile: unknown };
|
||||||
return data.profile;
|
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 ? (
|
{serverError ? (
|
||||||
<p role="alert" className="font-display text-pomodoro text-base italic">
|
<p role="alert" className="font-display text-pomodoro text-base italic">
|
||||||
{serverError}
|
{serverError}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "body_fat_pct" TEXT,
|
||||||
|
ADD COLUMN "medical_conditions" TEXT,
|
||||||
|
ADD COLUMN "target_weekly_loss_kg" DOUBLE PRECISION;
|
||||||
@@ -169,6 +169,18 @@ model Profile {
|
|||||||
activityLevel ActivityLevel @map("activity_level")
|
activityLevel ActivityLevel @map("activity_level")
|
||||||
/// @encrypted
|
/// @encrypted
|
||||||
targetDate String? @map("target_date")
|
targetDate String? @map("target_date")
|
||||||
|
// PRD §5.1 — velocità desiderata di calo peso (kg/settimana). Determina il
|
||||||
|
// deficit calorico in modo dinamico al posto del valore hard-coded.
|
||||||
|
targetWeeklyLossKg Float? @map("target_weekly_loss_kg")
|
||||||
|
// PRD §14.3 — condizioni mediche dichiarate dall'utente. Filtrano ricette,
|
||||||
|
// modulano deficit/macros, e bloccano l'app se "escludenti" (gravidanza,
|
||||||
|
// allattamento, diabete tipo 1, disturbi alimentari). Cifrate at-rest come
|
||||||
|
// dato sanitario (art. 9 GDPR — vedi ADR 0002).
|
||||||
|
/// @encrypted
|
||||||
|
medicalConditions String? @map("medical_conditions")
|
||||||
|
// PRD §5.1 — % grasso corporeo stimata (US Navy) per BMR Katch-McArdle.
|
||||||
|
/// @encrypted
|
||||||
|
bodyFatPct String? @map("body_fat_pct")
|
||||||
currentPhase Phase @default(INTENSIVE) @map("current_phase")
|
currentPhase Phase @default(INTENSIVE) @map("current_phase")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
export * from './medical/conditions.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';
|
||||||
|
export * from './nutrition/body-composition.js';
|
||||||
|
export * from './nutrition/deficit.js';
|
||||||
export * from './nutrition/tdee.js';
|
export * from './nutrition/tdee.js';
|
||||||
export * from './nutrition/types.js';
|
export * from './nutrition/types.js';
|
||||||
export * from './planner/macros.js';
|
export * from './planner/macros.js';
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// PRD §14.3 — Condizioni mediche dichiarate dall'utente.
|
||||||
|
//
|
||||||
|
// Le condizioni "escludenti" bloccano l'uso autonomo dell'app: l'utente viene
|
||||||
|
// invitato a consultare un medico prima di proseguire. Le altre influenzano
|
||||||
|
// solo i parametri di calcolo.
|
||||||
|
|
||||||
|
export const MEDICAL_CONDITIONS = [
|
||||||
|
// Adattabili
|
||||||
|
'THYROID_HYPO', // ipotiroidismo: riduce BMR ~10%
|
||||||
|
'DIABETES_T2', // diabete tipo 2: riduce velocità di chetoadattamento
|
||||||
|
'IBS', // intestino irritabile: filtra alcuni alimenti irritanti
|
||||||
|
'DYSLIPIDEMIA', // colesterolo/trigliceridi: monitorare profilo lipidico
|
||||||
|
'HYPERTENSION', // ipertensione: attenzione al sodio
|
||||||
|
'KIDNEY_ISSUES', // problemi renali: riduce target proteico
|
||||||
|
'LIVER_ISSUES', // problemi epatici: monitorare proteine + alcol
|
||||||
|
// Escludenti — bloccano l'app
|
||||||
|
'PREGNANCY',
|
||||||
|
'BREASTFEEDING',
|
||||||
|
'DIABETES_T1',
|
||||||
|
'EATING_DISORDER',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type MedicalCondition = (typeof MEDICAL_CONDITIONS)[number];
|
||||||
|
|
||||||
|
export const EXCLUDING_CONDITIONS: ReadonlySet<MedicalCondition> = new Set([
|
||||||
|
'PREGNANCY',
|
||||||
|
'BREASTFEEDING',
|
||||||
|
'DIABETES_T1',
|
||||||
|
'EATING_DISORDER',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function hasExcludingCondition(conditions: readonly string[]): boolean {
|
||||||
|
return conditions.some((c) => EXCLUDING_CONDITIONS.has(c as MedicalCondition));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggiusta il BMR per condizioni che lo influenzano.
|
||||||
|
* - Ipotiroidismo non controllato: BMR scende ~10-15%. Usiamo -10% prudente.
|
||||||
|
*/
|
||||||
|
export function bmrAdjustmentForConditions(conditions: readonly string[]): number {
|
||||||
|
let factor = 1;
|
||||||
|
if (conditions.includes('THYROID_HYPO')) factor *= 0.9;
|
||||||
|
return factor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limite di proteine raccomandato (g/kg di peso corporeo) in base a condizioni.
|
||||||
|
* - Reni compromessi → tetto 0.8 g/kg
|
||||||
|
* - Fegato compromesso → tetto 1.0 g/kg
|
||||||
|
* - Default keto → 1.5-1.8 g/kg (gestito altrove)
|
||||||
|
*/
|
||||||
|
export function proteinCeilingGPerKg(conditions: readonly string[]): number | null {
|
||||||
|
if (conditions.includes('KIDNEY_ISSUES')) return 0.8;
|
||||||
|
if (conditions.includes('LIVER_ISSUES')) return 1.0;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { calculateBmrKatchMcArdle, estimateBodyFatPercentageUSNavy } from './body-composition.js';
|
||||||
|
|
||||||
|
describe('estimateBodyFatPercentageUSNavy', () => {
|
||||||
|
it('uomo medio (180cm, vita 90, collo 38) ≈ 18-22% BF', () => {
|
||||||
|
const bf = estimateBodyFatPercentageUSNavy({
|
||||||
|
gender: 'MALE',
|
||||||
|
heightCm: 180,
|
||||||
|
neckCm: 38,
|
||||||
|
waistCm: 90,
|
||||||
|
});
|
||||||
|
expect(bf).toBeGreaterThan(15);
|
||||||
|
expect(bf).toBeLessThan(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('donna media (165cm, vita 75, fianchi 100, collo 33) ≈ 25-32% BF', () => {
|
||||||
|
const bf = estimateBodyFatPercentageUSNavy({
|
||||||
|
gender: 'FEMALE',
|
||||||
|
heightCm: 165,
|
||||||
|
neckCm: 33,
|
||||||
|
waistCm: 75,
|
||||||
|
hipsCm: 100,
|
||||||
|
});
|
||||||
|
expect(bf).toBeGreaterThan(20);
|
||||||
|
expect(bf).toBeLessThan(35);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uomo atletico (180cm, vita 80, collo 40) → BF basso (< 15%)', () => {
|
||||||
|
const bf = estimateBodyFatPercentageUSNavy({
|
||||||
|
gender: 'MALE',
|
||||||
|
heightCm: 180,
|
||||||
|
neckCm: 40,
|
||||||
|
waistCm: 80,
|
||||||
|
});
|
||||||
|
expect(bf).toBeLessThan(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FEMALE senza hipsCm lancia errore', () => {
|
||||||
|
expect(() =>
|
||||||
|
estimateBodyFatPercentageUSNavy({
|
||||||
|
gender: 'FEMALE',
|
||||||
|
heightCm: 165,
|
||||||
|
neckCm: 33,
|
||||||
|
waistCm: 75,
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamp BF% in [3, 60]', () => {
|
||||||
|
const bf = estimateBodyFatPercentageUSNavy({
|
||||||
|
gender: 'MALE',
|
||||||
|
heightCm: 180,
|
||||||
|
neckCm: 41,
|
||||||
|
waistCm: 75, // input molto magro: senza clamp uscirebbe negativo
|
||||||
|
});
|
||||||
|
expect(bf).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateBmrKatchMcArdle', () => {
|
||||||
|
it('80kg con 20% BF → BMR ~1750', () => {
|
||||||
|
const bmr = calculateBmrKatchMcArdle(80, 20);
|
||||||
|
// FFM = 64, BMR = 370 + 21.6*64 = 1752.4
|
||||||
|
expect(Math.round(bmr)).toBe(1752);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('80kg con 30% BF → BMR < lo stesso peso al 20%', () => {
|
||||||
|
expect(calculateBmrKatchMcArdle(80, 30)).toBeLessThan(calculateBmrKatchMcArdle(80, 20));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('input negativi → errore', () => {
|
||||||
|
expect(() => calculateBmrKatchMcArdle(-1, 20)).toThrow();
|
||||||
|
expect(() => calculateBmrKatchMcArdle(80, 100)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// PRD §5.1 — Composizione corporea e BMR Katch-McArdle.
|
||||||
|
//
|
||||||
|
// US Navy formula per la stima del % grasso corporeo da circonferenze.
|
||||||
|
// Più accurata di Mifflin per persone con composizione fuori-norma (atleti,
|
||||||
|
// over-fat). Una volta nota la BF%, BMR viene calcolato sulla massa magra
|
||||||
|
// (FFM) che è il driver metabolico reale.
|
||||||
|
|
||||||
|
import type { Gender } from './types.js';
|
||||||
|
|
||||||
|
export interface UsNavyInput {
|
||||||
|
gender: Gender;
|
||||||
|
heightCm: number;
|
||||||
|
neckCm: number;
|
||||||
|
waistCm: number;
|
||||||
|
hipsCm?: number; // richiesto per FEMALE / OTHER
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stima la % grasso corporeo con la formula US Navy (1981).
|
||||||
|
* Range tipico: 8-30% uomini, 18-40% donne. Errore ±3-4%.
|
||||||
|
*
|
||||||
|
* Per Gender 'OTHER' usiamo la formula femminile (più conservativa) se
|
||||||
|
* `hipsCm` è fornita, altrimenti quella maschile.
|
||||||
|
*/
|
||||||
|
export function estimateBodyFatPercentageUSNavy(input: UsNavyInput): number {
|
||||||
|
const { gender, heightCm, neckCm, waistCm, hipsCm } = input;
|
||||||
|
if (heightCm <= 0 || neckCm <= 0 || waistCm <= 0) {
|
||||||
|
throw new RangeError('heightCm, neckCm, waistCm must be positive');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formule US Navy in unità metriche (Hodgdon-Beckett 1984, conversione SI).
|
||||||
|
const useFemale = gender === 'FEMALE' || (gender === 'OTHER' && hipsCm != null);
|
||||||
|
if (useFemale) {
|
||||||
|
if (hipsCm == null || hipsCm <= 0) {
|
||||||
|
throw new RangeError('hipsCm is required for FEMALE');
|
||||||
|
}
|
||||||
|
// BF% = 495 / (1.29579 − 0.35004 × log10(waist + hips − neck) + 0.22100 × log10(height)) − 450
|
||||||
|
const denom =
|
||||||
|
1.29579 - 0.35004 * Math.log10(waistCm + hipsCm - neckCm) + 0.221 * Math.log10(heightCm);
|
||||||
|
return clampBodyFat(495 / denom - 450);
|
||||||
|
}
|
||||||
|
// Maschile: BF% = 495 / (1.0324 − 0.19077 × log10(waist − neck) + 0.15456 × log10(height)) − 450
|
||||||
|
const denom = 1.0324 - 0.19077 * Math.log10(waistCm - neckCm) + 0.15456 * Math.log10(heightCm);
|
||||||
|
return clampBodyFat(495 / denom - 450);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampBodyFat(v: number): number {
|
||||||
|
if (Number.isNaN(v)) throw new Error('invalid body fat estimate');
|
||||||
|
return Math.max(3, Math.min(60, Number(v.toFixed(1))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BMR con la formula Katch-McArdle, basata sulla FFM.
|
||||||
|
* Più accurata di Mifflin quando la BF% è nota.
|
||||||
|
*
|
||||||
|
* BMR = 370 + 21.6 × FFM_kg
|
||||||
|
*/
|
||||||
|
export function calculateBmrKatchMcArdle(weightKg: number, bodyFatPct: number): number {
|
||||||
|
if (weightKg <= 0 || bodyFatPct < 0 || bodyFatPct >= 100) {
|
||||||
|
throw new RangeError('weightKg must be positive, bodyFatPct in [0, 100)');
|
||||||
|
}
|
||||||
|
const ffm = weightKg * (1 - bodyFatPct / 100);
|
||||||
|
return 370 + 21.6 * ffm;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { computeDailyKcalTarget } from './deficit.js';
|
||||||
|
|
||||||
|
describe('computeDailyKcalTarget', () => {
|
||||||
|
it('mantenimento (fase 3) → kcal == TDEE, niente calo', () => {
|
||||||
|
const r = computeDailyKcalTarget({
|
||||||
|
tdee: 2200,
|
||||||
|
weightCurrentKg: 80,
|
||||||
|
phase: 3,
|
||||||
|
});
|
||||||
|
expect(r.kcalTarget).toBe(2200);
|
||||||
|
expect(r.effectiveWeeklyLossKg).toBe(0);
|
||||||
|
expect(r.cappedToSafe).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transizione (fase 2) → ~ -200 kcal', () => {
|
||||||
|
const r = computeDailyKcalTarget({
|
||||||
|
tdee: 2200,
|
||||||
|
weightCurrentKg: 80,
|
||||||
|
phase: 2,
|
||||||
|
});
|
||||||
|
expect(r.kcalTarget).toBe(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('intensiva 0.5 kg/sett → deficit ~550 kcal', () => {
|
||||||
|
const r = computeDailyKcalTarget({
|
||||||
|
tdee: 2200,
|
||||||
|
weightCurrentKg: 80,
|
||||||
|
targetWeeklyLossKg: 0.5,
|
||||||
|
phase: 1,
|
||||||
|
});
|
||||||
|
expect(r.kcalTarget).toBe(2200 - Math.round((0.5 * 7700) / 7));
|
||||||
|
expect(r.cappedToSafe).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cap 1% peso/settimana: chiede 1.2 kg su 80kg → effettivi 0.8 kg', () => {
|
||||||
|
const r = computeDailyKcalTarget({
|
||||||
|
tdee: 2200,
|
||||||
|
weightCurrentKg: 80,
|
||||||
|
targetWeeklyLossKg: 1.2,
|
||||||
|
phase: 1,
|
||||||
|
});
|
||||||
|
expect(r.cappedToSafe).toBe(true);
|
||||||
|
expect(r.effectiveWeeklyLossKg).toBeLessThanOrEqual(0.85);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cap 30% TDEE: persona piccola e impaziente non scende sotto soglia', () => {
|
||||||
|
// 50 kg, TDEE 1500, vuole 1 kg/sett → richiederebbe deficit 1100/giorno
|
||||||
|
// ma 30% di 1500 = 450 → cap.
|
||||||
|
const r = computeDailyKcalTarget({
|
||||||
|
tdee: 1500,
|
||||||
|
weightCurrentKg: 50,
|
||||||
|
targetWeeklyLossKg: 1,
|
||||||
|
phase: 1,
|
||||||
|
});
|
||||||
|
expect(r.cappedToSafe).toBe(true);
|
||||||
|
expect(r.kcalTarget).toBeGreaterThanOrEqual(Math.round(1500 * 0.7));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('default 0.5 kg/sett quando manca targetWeeklyLossKg', () => {
|
||||||
|
const r = computeDailyKcalTarget({
|
||||||
|
tdee: 2200,
|
||||||
|
weightCurrentKg: 80,
|
||||||
|
phase: 1,
|
||||||
|
});
|
||||||
|
expect(r.kcalTarget).toBeLessThan(2200);
|
||||||
|
expect(r.kcalTarget).toBeGreaterThan(2200 - 700);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// PRD §5.1 — Calcolo dinamico del deficit calorico.
|
||||||
|
//
|
||||||
|
// 1 kg di tessuto adiposo ≈ 7700 kcal (regola classica). Quindi un calo di
|
||||||
|
// `r` kg/settimana richiede un deficit medio di r × 7700 / 7 kcal/giorno.
|
||||||
|
//
|
||||||
|
// Limiti di sicurezza:
|
||||||
|
// - max deficit ≤ 30% del TDEE (linee guida Schoenfeld 2014)
|
||||||
|
// - max calo ≤ 1% peso corporeo / settimana (raccomandazione Helms 2014)
|
||||||
|
// Se l'utente chiede oltre, capiamo il deficit e segnaliamo.
|
||||||
|
|
||||||
|
const KCAL_PER_KG_FAT = 7700;
|
||||||
|
const SAFE_DEFICIT_PCT = 0.3; // 30% TDEE
|
||||||
|
const SAFE_LOSS_PCT_BODYWEIGHT = 0.01; // 1% peso/settimana
|
||||||
|
|
||||||
|
export interface DeficitInput {
|
||||||
|
tdee: number; // kcal/giorno
|
||||||
|
weightCurrentKg: number;
|
||||||
|
/**
|
||||||
|
* Velocità desiderata di calo peso, kg/settimana. Se omessa, usiamo
|
||||||
|
* il fallback per fase passato dal chiamante (default 0.5 kg/wk per LOSE).
|
||||||
|
*/
|
||||||
|
targetWeeklyLossKg?: number | null;
|
||||||
|
/** 1=intensiva, 2=transizione, 3=mantenimento. */
|
||||||
|
phase: 1 | 2 | 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeficitResult {
|
||||||
|
/** kcal/giorno target. */
|
||||||
|
kcalTarget: number;
|
||||||
|
/** kg/settimana effettivamente raggiungibili col target. */
|
||||||
|
effectiveWeeklyLossKg: number;
|
||||||
|
/** True se il calo richiesto era oltre i limiti di sicurezza. */
|
||||||
|
cappedToSafe: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeDailyKcalTarget(input: DeficitInput): DeficitResult {
|
||||||
|
const { tdee, weightCurrentKg, targetWeeklyLossKg, phase } = input;
|
||||||
|
|
||||||
|
// Fase 3 (mantenimento) → eat at TDEE.
|
||||||
|
if (phase === 3) {
|
||||||
|
return { kcalTarget: Math.round(tdee), effectiveWeeklyLossKg: 0, cappedToSafe: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fase 2 (transizione) → reverse dieting morbido.
|
||||||
|
if (phase === 2) {
|
||||||
|
return {
|
||||||
|
kcalTarget: Math.round(tdee - 200),
|
||||||
|
effectiveWeeklyLossKg: Math.max(0, 200 / (KCAL_PER_KG_FAT / 7)),
|
||||||
|
cappedToSafe: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fase 1 (intensiva): deficit derivato da targetWeeklyLossKg, con caps.
|
||||||
|
const requested = targetWeeklyLossKg ?? 0.5;
|
||||||
|
const safeMaxByWeight = weightCurrentKg * SAFE_LOSS_PCT_BODYWEIGHT;
|
||||||
|
const cappedRate = Math.min(requested, safeMaxByWeight);
|
||||||
|
|
||||||
|
let dailyDeficit = (cappedRate * KCAL_PER_KG_FAT) / 7;
|
||||||
|
const maxDeficitByTdee = tdee * SAFE_DEFICIT_PCT;
|
||||||
|
const finalDeficit = Math.min(dailyDeficit, maxDeficitByTdee);
|
||||||
|
dailyDeficit = finalDeficit;
|
||||||
|
|
||||||
|
const cappedToSafe =
|
||||||
|
requested > safeMaxByWeight || (cappedRate * KCAL_PER_KG_FAT) / 7 > maxDeficitByTdee;
|
||||||
|
|
||||||
|
return {
|
||||||
|
kcalTarget: Math.round(tdee - dailyDeficit),
|
||||||
|
effectiveWeeklyLossKg: Number(((dailyDeficit * 7) / KCAL_PER_KG_FAT).toFixed(2)),
|
||||||
|
cappedToSafe,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { MEDICAL_CONDITIONS } from '../medical/conditions.js';
|
||||||
|
|
||||||
export const GENDERS = ['MALE', 'FEMALE', 'OTHER'] as const;
|
export const GENDERS = ['MALE', 'FEMALE', 'OTHER'] as const;
|
||||||
export const ACTIVITY_LEVELS = ['SEDENTARY', 'LIGHT', 'MODERATE', 'INTENSE'] as const;
|
export const ACTIVITY_LEVELS = ['SEDENTARY', 'LIGHT', 'MODERATE', 'INTENSE'] as const;
|
||||||
|
|
||||||
@@ -12,6 +14,15 @@ export const profileInputSchema = z.object({
|
|||||||
weightGoalKg: z.coerce.number().min(35).max(300),
|
weightGoalKg: z.coerce.number().min(35).max(300),
|
||||||
activityLevel: z.enum(ACTIVITY_LEVELS),
|
activityLevel: z.enum(ACTIVITY_LEVELS),
|
||||||
targetDate: z.coerce.date().optional(),
|
targetDate: z.coerce.date().optional(),
|
||||||
|
// Velocità di calo desiderata (kg/sett.). Cap 0.25-1.5 per UI; il backend
|
||||||
|
// applica anche un cap di sicurezza dinamico in base al peso.
|
||||||
|
targetWeeklyLossKg: z.coerce.number().min(0.1).max(1.5).optional(),
|
||||||
|
// Condizioni mediche dichiarate. Le "escludenti" bloccano la generazione piano.
|
||||||
|
medicalConditions: z.array(z.enum(MEDICAL_CONDITIONS)).max(20).optional(),
|
||||||
|
// Circonferenza collo: serve alla formula US Navy per stimare la BF%.
|
||||||
|
neckCm: z.coerce.number().min(20).max(60).optional(),
|
||||||
|
waistCm: z.coerce.number().min(40).max(200).optional(),
|
||||||
|
hipsCm: z.coerce.number().min(40).max(200).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ProfileInput = z.input<typeof profileInputSchema>;
|
export type ProfileInput = z.input<typeof profileInputSchema>;
|
||||||
|
|||||||
Reference in new issue
Block a user