Files
ketopath/packages/shared/src/nutrition/deficit.ts
T
lucianoandClaude Opus 4.7 9401497f03 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>
2026-04-29 22:44:16 +02:00

72 lines
2.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
};
}