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,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,
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
};
|
||||
Reference in new issue
Block a user