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:
1 parent
9401497f03
commit
a583434c42
20 files changed
+460
-31
No files matched your search
@@ -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];
|
||||
|
||||
@@ -1,30 +1,49 @@
|
||||
// PRD §5.1 — preferences utente: esclusioni, cucine, tempo di cottura,
|
||||
// protocollo digiuno default. Le esclusioni filtrano il matchmaking ricette.
|
||||
// protocollo digiuno, schedule allenamento, frequenza pasti.
|
||||
|
||||
import { preferencesPatchSchema } from '@ketopath/shared';
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
|
||||
import { requireAuth } from '../../plugins/auth.js';
|
||||
|
||||
function serialize(prefs: {
|
||||
exclusions: string[];
|
||||
cuisinePreferences: string[];
|
||||
cookingTime: string;
|
||||
fastingProtocol: string | null;
|
||||
trainingDays: number[];
|
||||
trainingType: string | null;
|
||||
sessionMinutes: number | null;
|
||||
mealsPerDay: number | null;
|
||||
}) {
|
||||
return {
|
||||
exclusions: prefs.exclusions,
|
||||
cuisinePreferences: prefs.cuisinePreferences,
|
||||
cookingTime: prefs.cookingTime,
|
||||
fastingProtocol: prefs.fastingProtocol,
|
||||
trainingDays: prefs.trainingDays,
|
||||
trainingType: prefs.trainingType,
|
||||
sessionMinutes: prefs.sessionMinutes,
|
||||
mealsPerDay: prefs.mealsPerDay,
|
||||
};
|
||||
}
|
||||
|
||||
const EMPTY_PREFS = {
|
||||
exclusions: [],
|
||||
cuisinePreferences: [],
|
||||
cookingTime: 'MEDIUM' as const,
|
||||
fastingProtocol: null,
|
||||
trainingDays: [],
|
||||
trainingType: null,
|
||||
sessionMinutes: null,
|
||||
mealsPerDay: null,
|
||||
};
|
||||
|
||||
export const preferencesRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
fastify.get('/me/preferences', { preHandler: requireAuth() }, async (request) => {
|
||||
const userId = request.user!.id;
|
||||
const prefs = await fastify.prisma.preferences.findUnique({ where: { userId } });
|
||||
return {
|
||||
preferences: prefs
|
||||
? {
|
||||
exclusions: prefs.exclusions,
|
||||
cuisinePreferences: prefs.cuisinePreferences,
|
||||
cookingTime: prefs.cookingTime,
|
||||
fastingProtocol: prefs.fastingProtocol,
|
||||
}
|
||||
: {
|
||||
exclusions: [],
|
||||
cuisinePreferences: [],
|
||||
cookingTime: 'MEDIUM',
|
||||
fastingProtocol: null,
|
||||
},
|
||||
};
|
||||
return { preferences: prefs ? serialize(prefs) : EMPTY_PREFS };
|
||||
});
|
||||
|
||||
fastify.patch('/me/preferences', { preHandler: requireAuth() }, async (request, reply) => {
|
||||
@@ -35,20 +54,26 @@ export const preferencesRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const userId = request.user!.id;
|
||||
const data = parsed.data;
|
||||
|
||||
// Costruiamo il diff: con `exactOptionalPropertyTypes` Prisma rifiuta i
|
||||
// campi `undefined` espliciti.
|
||||
const create: Record<string, unknown> = {
|
||||
userId,
|
||||
exclusions: data.exclusions ?? [],
|
||||
cuisinePreferences: data.cuisinePreferences ?? [],
|
||||
cookingTime: data.cookingTime ?? 'MEDIUM',
|
||||
fastingProtocol: data.fastingProtocol ?? null,
|
||||
trainingDays: data.trainingDays ?? [],
|
||||
trainingType: data.trainingType ?? null,
|
||||
sessionMinutes: data.sessionMinutes ?? null,
|
||||
mealsPerDay: data.mealsPerDay ?? null,
|
||||
};
|
||||
const update: Record<string, unknown> = {};
|
||||
if (data.exclusions !== undefined) update.exclusions = data.exclusions;
|
||||
if (data.cuisinePreferences !== undefined) update.cuisinePreferences = data.cuisinePreferences;
|
||||
if (data.cookingTime !== undefined) update.cookingTime = data.cookingTime;
|
||||
if (data.fastingProtocol !== undefined) update.fastingProtocol = data.fastingProtocol;
|
||||
if (data.trainingDays !== undefined) update.trainingDays = data.trainingDays;
|
||||
if (data.trainingType !== undefined) update.trainingType = data.trainingType;
|
||||
if (data.sessionMinutes !== undefined) update.sessionMinutes = data.sessionMinutes;
|
||||
if (data.mealsPerDay !== undefined) update.mealsPerDay = data.mealsPerDay;
|
||||
|
||||
const prefs = await fastify.prisma.preferences.upsert({
|
||||
where: { userId },
|
||||
@@ -56,13 +81,6 @@ export const preferencesRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
update,
|
||||
});
|
||||
|
||||
return {
|
||||
preferences: {
|
||||
exclusions: prefs.exclusions,
|
||||
cuisinePreferences: prefs.cuisinePreferences,
|
||||
cookingTime: prefs.cookingTime,
|
||||
fastingProtocol: prefs.fastingProtocol,
|
||||
},
|
||||
};
|
||||
return { preferences: serialize(prefs) };
|
||||
});
|
||||
};
|
||||
@@ -28,6 +28,7 @@ function serialize(
|
||||
targetWeeklyLossKg: number | null;
|
||||
medicalConditions: string | null;
|
||||
bodyFatPct: string | null;
|
||||
dietHistory: string | null;
|
||||
currentPhase: string;
|
||||
} | null,
|
||||
) {
|
||||
@@ -55,6 +56,7 @@ function serialize(
|
||||
targetWeeklyLossKg: profile.targetWeeklyLossKg,
|
||||
medicalConditions: parseConditions(profile.medicalConditions),
|
||||
bodyFatPct,
|
||||
dietHistory: profile.dietHistory,
|
||||
currentPhase: profile.currentPhase,
|
||||
derived: {
|
||||
bmr: Math.round(bmr),
|
||||
@@ -127,6 +129,7 @@ export const profileRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
targetDate: targetDateStr,
|
||||
targetWeeklyLossKg: data.targetWeeklyLossKg ?? null,
|
||||
medicalConditions: conditionsJson,
|
||||
dietHistory: data.dietHistory ?? null,
|
||||
bodyFatPct,
|
||||
},
|
||||
update: {
|
||||
@@ -140,6 +143,7 @@ export const profileRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
targetDate: targetDateStr,
|
||||
targetWeeklyLossKg: data.targetWeeklyLossKg ?? null,
|
||||
medicalConditions: conditionsJson,
|
||||
dietHistory: data.dietHistory ?? null,
|
||||
// Se l'utente ricalcola con misure nuove, sovrascriviamo; altrimenti
|
||||
// lasciamo il valore precedente.
|
||||
...(bodyFatPct != null ? { bodyFatPct } : {}),
|
||||
|
||||
Reference in new issue
Block a user