From 9401497f0354eee55e0640ef7ea985192367afb0 Mon Sep 17 00:00:00 2001 From: luciano Date: Wed, 29 Apr 2026 22:44:16 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20composizione=20corporea,=20deficit=20di?= =?UTF-8?q?namico,=20condizioni=20mediche=20(PRD=20=C2=A75.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api/src/modules/plan/plan.routes.ts | 49 ++++- .../api/src/modules/profile/profile.routes.ts | 79 +++++++- apps/web/messages/it.json | 29 +++ .../[locale]/onboarding/onboarding-flow.tsx | 171 ++++++++++++++++-- apps/web/src/app/[locale]/profile/actions.ts | 16 ++ .../src/app/[locale]/profile/profile-form.tsx | 59 ++++++ .../migration.sql | 4 + packages/db/prisma/schema.prisma | 12 ++ packages/shared/src/index.ts | 3 + packages/shared/src/medical/conditions.ts | 56 ++++++ .../src/nutrition/body-composition.test.ts | 76 ++++++++ .../shared/src/nutrition/body-composition.ts | 64 +++++++ packages/shared/src/nutrition/deficit.test.ts | 70 +++++++ packages/shared/src/nutrition/deficit.ts | 71 ++++++++ packages/shared/src/profile/schema.ts | 11 ++ 15 files changed, 744 insertions(+), 26 deletions(-) create mode 100644 packages/db/prisma/migrations/20260429203451_add_profile_health_fields/migration.sql create mode 100644 packages/shared/src/medical/conditions.ts create mode 100644 packages/shared/src/nutrition/body-composition.test.ts create mode 100644 packages/shared/src/nutrition/body-composition.ts create mode 100644 packages/shared/src/nutrition/deficit.test.ts create mode 100644 packages/shared/src/nutrition/deficit.ts diff --git a/apps/api/src/modules/plan/plan.routes.ts b/apps/api/src/modules/plan/plan.routes.ts index df9480e..f95b81f 100644 --- a/apps/api/src/modules/plan/plan.routes.ts +++ b/apps/api/src/modules/plan/plan.routes.ts @@ -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, diff --git a/apps/api/src/modules/profile/profile.routes.ts b/apps/api/src/modules/profile/profile.routes.ts index 7acfc58..3616604 100644 --- a/apps/api/src/modules/profile/profile.routes.ts +++ b/apps/api/src/modules/profile/profile.routes.ts @@ -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 }; + }); }; diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index 108b1cd..3962263 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -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…", diff --git a/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx b/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx index 95575bb..36f0e2f 100644 --- a/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx +++ b/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx @@ -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]} setStep('preferences')} + setStep('conditions')} /> + ) : null} + + {step === 'conditions' ? ( + 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 (
-

{t('stepEyebrow', { n: 1, total: 5 })}

+

{t('stepEyebrow', { n: 1, total: 6 })}

{t('goalHeading')}

@@ -182,7 +190,7 @@ function StepProfile({ return (
-

{t('stepEyebrow', { n: 2, total: 5 })}

+

{t('stepEyebrow', { n: 2, total: 6 })}

{goal ? t(`profileHeadingGoal.${goal}`) : t('profileHeading')}

@@ -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(initial); + const [pending, setPending] = useState(false); + const [error, setError] = useState(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 { + 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 ( +
+
+

{t('stepEyebrow', { n: 3, total: 6 })}

+

+ {t('conditionsHeading')} +

+

+ {t('conditionsHint')} +

+
+ +
+ {t('conditionsAdaptive')} +
+ {adaptive.map((c) => ( + toggle(c as MedicalCondition)} + /> + ))} +
+
+ +
+ {t('conditionsExcluding')} +

+ {t('conditionsExcludingHint')} +

+
+ {Array.from(EXCLUDING_CONDITIONS).map((c) => ( + toggle(c)} + danger + /> + ))} +
+
+ + {blocked ? ( +
+

{t('blockedEyebrow')}

+

{t('blockedMessage')}

+
+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + +
+ + +
+
+ ); +} + +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 ( + + ); +} + function StepPreferences({ initial, onSaved, @@ -215,7 +362,7 @@ function StepPreferences({ return (
-

{t('stepEyebrow', { n: 3, total: 5 })}

+

{t('stepEyebrow', { n: 4, total: 6 })}

{t('preferencesHeading')}

@@ -265,7 +412,7 @@ function StepNotifications({ onContinue }: { onContinue: () => void }) { return (
-

{t('stepEyebrow', { n: 4, total: 5 })}

+

{t('stepEyebrow', { n: 5, total: 6 })}

{t('notificationsHeading')}

@@ -339,7 +486,7 @@ function StepDone({ goal }: { goal: Goal | null }) { return (
-

{t('stepEyebrow', { n: 5, total: 5 })}

+

{t('stepEyebrow', { n: 6, total: 6 })}

{goal ? t(`doneHeadingGoal.${goal}`) : t('doneHeading')}

diff --git a/apps/web/src/app/[locale]/profile/actions.ts b/apps/web/src/app/[locale]/profile/actions.ts index 933d77d..8715ec6 100644 --- a/apps/web/src/app/[locale]/profile/actions.ts +++ b/apps/web/src/app/[locale]/profile/actions.ts @@ -41,3 +41,19 @@ export async function fetchProfile(): Promise { 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 { + 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 }; +} diff --git a/apps/web/src/app/[locale]/profile/profile-form.tsx b/apps/web/src/app/[locale]/profile/profile-form.tsx index 3f3a28c..43539c3 100644 --- a/apps/web/src/app/[locale]/profile/profile-form.tsx +++ b/apps/web/src/app/[locale]/profile/profile-form.tsx @@ -190,6 +190,65 @@ export function ProfileForm({ )} /> +
+
+

{t('measurementsTitle')}

+

+ {t('measurementsHint')} +

+
+
+ {(['neckCm', 'waistCm', 'hipsCm'] as const).map((name) => ( + ( + + {t(name)} + + + + + + )} + /> + ))} +
+
+ + ( + + {t('targetWeeklyLossKg')} + + + +

+ {t('targetWeeklyLossKgHint')} +

+ +
+ )} + /> + {serverError ? (

{serverError} diff --git a/packages/db/prisma/migrations/20260429203451_add_profile_health_fields/migration.sql b/packages/db/prisma/migrations/20260429203451_add_profile_health_fields/migration.sql new file mode 100644 index 0000000..0bfa945 --- /dev/null +++ b/packages/db/prisma/migrations/20260429203451_add_profile_health_fields/migration.sql @@ -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; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 7dec59f..da22fc5 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -169,6 +169,18 @@ model Profile { activityLevel ActivityLevel @map("activity_level") /// @encrypted 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") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index da7f327..0b936db 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,6 +1,9 @@ +export * from './medical/conditions.js'; export * from './notifications/schema.js'; export * from './preferences/schema.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/types.js'; export * from './planner/macros.js'; diff --git a/packages/shared/src/medical/conditions.ts b/packages/shared/src/medical/conditions.ts new file mode 100644 index 0000000..c0b289f --- /dev/null +++ b/packages/shared/src/medical/conditions.ts @@ -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 = 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; +} diff --git a/packages/shared/src/nutrition/body-composition.test.ts b/packages/shared/src/nutrition/body-composition.test.ts new file mode 100644 index 0000000..43a78ff --- /dev/null +++ b/packages/shared/src/nutrition/body-composition.test.ts @@ -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(); + }); +}); diff --git a/packages/shared/src/nutrition/body-composition.ts b/packages/shared/src/nutrition/body-composition.ts new file mode 100644 index 0000000..57c9fcb --- /dev/null +++ b/packages/shared/src/nutrition/body-composition.ts @@ -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; +} diff --git a/packages/shared/src/nutrition/deficit.test.ts b/packages/shared/src/nutrition/deficit.test.ts new file mode 100644 index 0000000..c3706da --- /dev/null +++ b/packages/shared/src/nutrition/deficit.test.ts @@ -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); + }); +}); diff --git a/packages/shared/src/nutrition/deficit.ts b/packages/shared/src/nutrition/deficit.ts new file mode 100644 index 0000000..811bbdf --- /dev/null +++ b/packages/shared/src/nutrition/deficit.ts @@ -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, + }; +} diff --git a/packages/shared/src/profile/schema.ts b/packages/shared/src/profile/schema.ts index f4b50e6..375a2fd 100644 --- a/packages/shared/src/profile/schema.ts +++ b/packages/shared/src/profile/schema.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { MEDICAL_CONDITIONS } from '../medical/conditions.js'; + export const GENDERS = ['MALE', 'FEMALE', 'OTHER'] 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), activityLevel: z.enum(ACTIVITY_LEVELS), 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;