feat: storia diete, schedule allenamento, frequenza pasti (PRD §5.1)

Tre leve aggiuntive che migliorano la personalizzazione del piano dopo
le tre del commit precedente (BF%, deficit, condizioni mediche).

1. Storia delle diete precedenti (adattamento metabolico)
   - Profile.dietHistory ∈ {NONE,SOME,EXTENSIVE,SEVERE} cifrato at-rest
   - bmrAdjustForDietHistory: 0.97 / 0.93 / 0.85 — aggiustamento BMR per
     riflettere l'adattamento metabolico osservato in studi a lungo termine
     (Fothergill 2016 Biggest Loser, Rosenbaum 2008)
   - Select in ProfileForm con copy diretto sulla "questione yo-yo"

2. Schedule allenamento (kcal extra training-day)
   - Preferences.trainingDays Int[] (0=Lun..6=Dom)
   - Preferences.trainingType ∈ {CARDIO,STRENGTH,MIXED,SPORT}
   - Preferences.sessionMinutes Int?
   - extraKcalForSession: equazione MET (Ainsworth 2011) con MET prevalenti
     8/5/6/7 a tipo. Output arrotondato a 25 kcal per evitare false-precisioni
   - Plan generation aggiunge l'extra solo nei training days
   - Chips Lun-Dom + select tipo + input durata in PreferencesForm

3. Frequenza pasti preferita
   - Preferences.mealsPerDay Int? (1..4)
   - mealShareForFrequency: 1=solo cena, 2=pranzo+cena, 3=no spuntino, 4=default
   - Quando mealsPerDay è impostato e non c'è fastingProtocol attivo,
     sostituisce la share del protocollo (il digiuno IF ha precedenza)
   - Slot a share=0 saltati alla creazione (niente colazione/spuntino "vuoti")

Schema migration 20260429220XXX_add_lifestyle_fields.

Smoke verificato via API: con mealsPerDay=3 e nessun protocollo IF, ogni
giorno del piano ha esattamente 3 pasti (Colazione+Pranzo+Cena, niente
Spuntino). 54/54 unit test verdi (11 nuovi).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-04-29 22:56:41 +02:00
1 parent 9401497f03
commit a583434c42
20 files changed
+460 -31

No files matched your search

+34 -4
View File
@@ -1,15 +1,21 @@
import {
bmrAdjustForDietHistory,
bmrAdjustmentForConditions,
calculateBmr,
calculateBmrKatchMcArdle,
calculateTdee,
computeDailyKcalTarget,
extraKcalForSession,
hasExcludingCondition,
isTrainingDay,
macrosForPhase,
matchMeals,
mealShareForFrequency,
protocolPlanForDay,
type DietHistory,
type FastingProtocolKey,
type RecipeCandidate,
type TrainingType,
} from '@ketopath/shared';
import type { FastifyPluginAsync } from 'fastify';
@@ -109,9 +115,25 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
});
// Aggiusto BMR per condizioni che lo influenzano (es. ipotiroidismo -10%).
bmr *= bmrAdjustmentForConditions(conditions);
bmr *= bmrAdjustForDietHistory(profile.dietHistory as DietHistory | null);
const tdee = calculateTdee(bmr, profile.activityLevel);
const phaseInt = phaseToInt(profile.currentPhase);
// Schedule allenamento: kcal extra sui giorni di allenamento.
const trainingDays = profile.user.preferences?.trainingDays ?? [];
const trainingType =
(profile.user.preferences?.trainingType as TrainingType | null | undefined) ?? null;
const sessionMinutes = profile.user.preferences?.sessionMinutes ?? null;
const sessionExtraKcal =
trainingType && sessionMinutes
? extraKcalForSession({
type: trainingType,
durationMinutes: sessionMinutes,
weightKg: weightCurrentKg,
})
: 0;
const mealsPerDay = profile.user.preferences?.mealsPerDay ?? null;
// Deficit dinamico da targetWeeklyLossKg (con caps di sicurezza).
const { kcalTarget: baseKcal } = computeDailyKcalTarget({
tdee,
@@ -145,7 +167,15 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
// Giorno di digiuno completo (ESE_24): salta tutto.
if (dayPlan.kcalMultiplier === 0) continue;
const dailyKcal = Math.round(baseKcal * dayPlan.kcalMultiplier);
// Se non c'è un protocollo IF attivo e l'utente ha dichiarato
// mealsPerDay, lo usiamo per ridistribuire le quote per pasto.
const effectiveShare =
!fastingProtocol && mealsPerDay ? mealShareForFrequency(mealsPerDay) : dayPlan.share;
// Sui giorni di allenamento aumentiamo le kcal per coprire l'EE
// della sessione (Ainsworth 2011 — vedi preferences/training.ts).
const trainingExtra = isTrainingDay(day, trainingDays) ? sessionExtraKcal : 0;
const dailyKcal = Math.round(baseKcal * dayPlan.kcalMultiplier + trainingExtra);
const dailyTarget = macrosForPhase({
kcalTarget: dailyKcal,
weightKg: weightCurrentKg,
@@ -158,8 +188,8 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
.map((s) => s.recipeId!);
for (const meal of MEALS) {
// Pasto disabilitato dal protocollo: non creiamo proprio lo slot.
if (dayPlan.share[meal] === 0) continue;
// Pasto disabilitato dal protocollo o dalla frequenza: salta lo slot.
if (effectiveShare[meal] === 0) continue;
const top = matchMeals({
candidates,
@@ -168,7 +198,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
excludedTags: exclusions,
recentlyConsumedIds,
dailyTarget,
mealShare: dayPlan.share,
mealShare: effectiveShare,
topN: 5,
});
const selected = top[0];