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
+744
-26
No files matched your search
@@ -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';
|
||||
|
||||
@@ -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 { 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<typeof profileInputSchema>;
|
||||
|
||||
Reference in new issue
Block a user