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
+454
-25
No files matched your search
@@ -1,15 +1,21 @@
|
|||||||
import {
|
import {
|
||||||
|
bmrAdjustForDietHistory,
|
||||||
bmrAdjustmentForConditions,
|
bmrAdjustmentForConditions,
|
||||||
calculateBmr,
|
calculateBmr,
|
||||||
calculateBmrKatchMcArdle,
|
calculateBmrKatchMcArdle,
|
||||||
calculateTdee,
|
calculateTdee,
|
||||||
computeDailyKcalTarget,
|
computeDailyKcalTarget,
|
||||||
|
extraKcalForSession,
|
||||||
hasExcludingCondition,
|
hasExcludingCondition,
|
||||||
|
isTrainingDay,
|
||||||
macrosForPhase,
|
macrosForPhase,
|
||||||
matchMeals,
|
matchMeals,
|
||||||
|
mealShareForFrequency,
|
||||||
protocolPlanForDay,
|
protocolPlanForDay,
|
||||||
|
type DietHistory,
|
||||||
type FastingProtocolKey,
|
type FastingProtocolKey,
|
||||||
type RecipeCandidate,
|
type RecipeCandidate,
|
||||||
|
type TrainingType,
|
||||||
} from '@ketopath/shared';
|
} from '@ketopath/shared';
|
||||||
import type { FastifyPluginAsync } from 'fastify';
|
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%).
|
// Aggiusto BMR per condizioni che lo influenzano (es. ipotiroidismo -10%).
|
||||||
bmr *= bmrAdjustmentForConditions(conditions);
|
bmr *= bmrAdjustmentForConditions(conditions);
|
||||||
|
bmr *= bmrAdjustForDietHistory(profile.dietHistory as DietHistory | null);
|
||||||
const tdee = calculateTdee(bmr, profile.activityLevel);
|
const tdee = calculateTdee(bmr, profile.activityLevel);
|
||||||
const phaseInt = phaseToInt(profile.currentPhase);
|
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).
|
// Deficit dinamico da targetWeeklyLossKg (con caps di sicurezza).
|
||||||
const { kcalTarget: baseKcal } = computeDailyKcalTarget({
|
const { kcalTarget: baseKcal } = computeDailyKcalTarget({
|
||||||
tdee,
|
tdee,
|
||||||
@@ -145,7 +167,15 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
// Giorno di digiuno completo (ESE_24): salta tutto.
|
// Giorno di digiuno completo (ESE_24): salta tutto.
|
||||||
if (dayPlan.kcalMultiplier === 0) continue;
|
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({
|
const dailyTarget = macrosForPhase({
|
||||||
kcalTarget: dailyKcal,
|
kcalTarget: dailyKcal,
|
||||||
weightKg: weightCurrentKg,
|
weightKg: weightCurrentKg,
|
||||||
@@ -158,8 +188,8 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
.map((s) => s.recipeId!);
|
.map((s) => s.recipeId!);
|
||||||
|
|
||||||
for (const meal of MEALS) {
|
for (const meal of MEALS) {
|
||||||
// Pasto disabilitato dal protocollo: non creiamo proprio lo slot.
|
// Pasto disabilitato dal protocollo o dalla frequenza: salta lo slot.
|
||||||
if (dayPlan.share[meal] === 0) continue;
|
if (effectiveShare[meal] === 0) continue;
|
||||||
|
|
||||||
const top = matchMeals({
|
const top = matchMeals({
|
||||||
candidates,
|
candidates,
|
||||||
@@ -168,7 +198,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
excludedTags: exclusions,
|
excludedTags: exclusions,
|
||||||
recentlyConsumedIds,
|
recentlyConsumedIds,
|
||||||
dailyTarget,
|
dailyTarget,
|
||||||
mealShare: dayPlan.share,
|
mealShare: effectiveShare,
|
||||||
topN: 5,
|
topN: 5,
|
||||||
});
|
});
|
||||||
const selected = top[0];
|
const selected = top[0];
|
||||||
|
|||||||
@@ -1,30 +1,49 @@
|
|||||||
// PRD §5.1 — preferences utente: esclusioni, cucine, tempo di cottura,
|
// 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 { preferencesPatchSchema } from '@ketopath/shared';
|
||||||
import type { FastifyPluginAsync } from 'fastify';
|
import type { FastifyPluginAsync } from 'fastify';
|
||||||
|
|
||||||
import { requireAuth } from '../../plugins/auth.js';
|
import { requireAuth } from '../../plugins/auth.js';
|
||||||
|
|
||||||
export const preferencesRoutes: FastifyPluginAsync = async (fastify) => {
|
function serialize(prefs: {
|
||||||
fastify.get('/me/preferences', { preHandler: requireAuth() }, async (request) => {
|
exclusions: string[];
|
||||||
const userId = request.user!.id;
|
cuisinePreferences: string[];
|
||||||
const prefs = await fastify.prisma.preferences.findUnique({ where: { userId } });
|
cookingTime: string;
|
||||||
|
fastingProtocol: string | null;
|
||||||
|
trainingDays: number[];
|
||||||
|
trainingType: string | null;
|
||||||
|
sessionMinutes: number | null;
|
||||||
|
mealsPerDay: number | null;
|
||||||
|
}) {
|
||||||
return {
|
return {
|
||||||
preferences: prefs
|
|
||||||
? {
|
|
||||||
exclusions: prefs.exclusions,
|
exclusions: prefs.exclusions,
|
||||||
cuisinePreferences: prefs.cuisinePreferences,
|
cuisinePreferences: prefs.cuisinePreferences,
|
||||||
cookingTime: prefs.cookingTime,
|
cookingTime: prefs.cookingTime,
|
||||||
fastingProtocol: prefs.fastingProtocol,
|
fastingProtocol: prefs.fastingProtocol,
|
||||||
|
trainingDays: prefs.trainingDays,
|
||||||
|
trainingType: prefs.trainingType,
|
||||||
|
sessionMinutes: prefs.sessionMinutes,
|
||||||
|
mealsPerDay: prefs.mealsPerDay,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
: {
|
|
||||||
|
const EMPTY_PREFS = {
|
||||||
exclusions: [],
|
exclusions: [],
|
||||||
cuisinePreferences: [],
|
cuisinePreferences: [],
|
||||||
cookingTime: 'MEDIUM',
|
cookingTime: 'MEDIUM' as const,
|
||||||
fastingProtocol: null,
|
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 ? serialize(prefs) : EMPTY_PREFS };
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.patch('/me/preferences', { preHandler: requireAuth() }, async (request, reply) => {
|
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 userId = request.user!.id;
|
||||||
const data = parsed.data;
|
const data = parsed.data;
|
||||||
|
|
||||||
// Costruiamo il diff: con `exactOptionalPropertyTypes` Prisma rifiuta i
|
|
||||||
// campi `undefined` espliciti.
|
|
||||||
const create: Record<string, unknown> = {
|
const create: Record<string, unknown> = {
|
||||||
userId,
|
userId,
|
||||||
exclusions: data.exclusions ?? [],
|
exclusions: data.exclusions ?? [],
|
||||||
cuisinePreferences: data.cuisinePreferences ?? [],
|
cuisinePreferences: data.cuisinePreferences ?? [],
|
||||||
cookingTime: data.cookingTime ?? 'MEDIUM',
|
cookingTime: data.cookingTime ?? 'MEDIUM',
|
||||||
fastingProtocol: data.fastingProtocol ?? null,
|
fastingProtocol: data.fastingProtocol ?? null,
|
||||||
|
trainingDays: data.trainingDays ?? [],
|
||||||
|
trainingType: data.trainingType ?? null,
|
||||||
|
sessionMinutes: data.sessionMinutes ?? null,
|
||||||
|
mealsPerDay: data.mealsPerDay ?? null,
|
||||||
};
|
};
|
||||||
const update: Record<string, unknown> = {};
|
const update: Record<string, unknown> = {};
|
||||||
if (data.exclusions !== undefined) update.exclusions = data.exclusions;
|
if (data.exclusions !== undefined) update.exclusions = data.exclusions;
|
||||||
if (data.cuisinePreferences !== undefined) update.cuisinePreferences = data.cuisinePreferences;
|
if (data.cuisinePreferences !== undefined) update.cuisinePreferences = data.cuisinePreferences;
|
||||||
if (data.cookingTime !== undefined) update.cookingTime = data.cookingTime;
|
if (data.cookingTime !== undefined) update.cookingTime = data.cookingTime;
|
||||||
if (data.fastingProtocol !== undefined) update.fastingProtocol = data.fastingProtocol;
|
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({
|
const prefs = await fastify.prisma.preferences.upsert({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@@ -56,13 +81,6 @@ export const preferencesRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
update,
|
update,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return { preferences: serialize(prefs) };
|
||||||
preferences: {
|
|
||||||
exclusions: prefs.exclusions,
|
|
||||||
cuisinePreferences: prefs.cuisinePreferences,
|
|
||||||
cookingTime: prefs.cookingTime,
|
|
||||||
fastingProtocol: prefs.fastingProtocol,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -28,6 +28,7 @@ function serialize(
|
|||||||
targetWeeklyLossKg: number | null;
|
targetWeeklyLossKg: number | null;
|
||||||
medicalConditions: string | null;
|
medicalConditions: string | null;
|
||||||
bodyFatPct: string | null;
|
bodyFatPct: string | null;
|
||||||
|
dietHistory: string | null;
|
||||||
currentPhase: string;
|
currentPhase: string;
|
||||||
} | null,
|
} | null,
|
||||||
) {
|
) {
|
||||||
@@ -55,6 +56,7 @@ function serialize(
|
|||||||
targetWeeklyLossKg: profile.targetWeeklyLossKg,
|
targetWeeklyLossKg: profile.targetWeeklyLossKg,
|
||||||
medicalConditions: parseConditions(profile.medicalConditions),
|
medicalConditions: parseConditions(profile.medicalConditions),
|
||||||
bodyFatPct,
|
bodyFatPct,
|
||||||
|
dietHistory: profile.dietHistory,
|
||||||
currentPhase: profile.currentPhase,
|
currentPhase: profile.currentPhase,
|
||||||
derived: {
|
derived: {
|
||||||
bmr: Math.round(bmr),
|
bmr: Math.round(bmr),
|
||||||
@@ -127,6 +129,7 @@ export const profileRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
targetDate: targetDateStr,
|
targetDate: targetDateStr,
|
||||||
targetWeeklyLossKg: data.targetWeeklyLossKg ?? null,
|
targetWeeklyLossKg: data.targetWeeklyLossKg ?? null,
|
||||||
medicalConditions: conditionsJson,
|
medicalConditions: conditionsJson,
|
||||||
|
dietHistory: data.dietHistory ?? null,
|
||||||
bodyFatPct,
|
bodyFatPct,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
@@ -140,6 +143,7 @@ export const profileRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
targetDate: targetDateStr,
|
targetDate: targetDateStr,
|
||||||
targetWeeklyLossKg: data.targetWeeklyLossKg ?? null,
|
targetWeeklyLossKg: data.targetWeeklyLossKg ?? null,
|
||||||
medicalConditions: conditionsJson,
|
medicalConditions: conditionsJson,
|
||||||
|
dietHistory: data.dietHistory ?? null,
|
||||||
// Se l'utente ricalcola con misure nuove, sovrascriviamo; altrimenti
|
// Se l'utente ricalcola con misure nuove, sovrascriviamo; altrimenti
|
||||||
// lasciamo il valore precedente.
|
// lasciamo il valore precedente.
|
||||||
...(bodyFatPct != null ? { bodyFatPct } : {}),
|
...(bodyFatPct != null ? { bodyFatPct } : {}),
|
||||||
|
|||||||
@@ -308,6 +308,26 @@
|
|||||||
"TWENTY_FOUR": "20:4 — avanzato",
|
"TWENTY_FOUR": "20:4 — avanzato",
|
||||||
"ESE_24": "ESE 24h",
|
"ESE_24": "ESE 24h",
|
||||||
"FIVE_TWO": "5:2"
|
"FIVE_TWO": "5:2"
|
||||||
|
},
|
||||||
|
"trainingTitle": "Schedule allenamento",
|
||||||
|
"trainingHint": "Indica i giorni in cui ti alleni di solito: nei training day il piano alza il fabbisogno calorico.",
|
||||||
|
"trainingType": "Tipo di allenamento",
|
||||||
|
"trainingNone": "Nessuno per ora",
|
||||||
|
"trainingTypeLabel": {
|
||||||
|
"CARDIO": "Cardio (corsa, bici)",
|
||||||
|
"STRENGTH": "Forza (pesi)",
|
||||||
|
"MIXED": "Misto / HIIT",
|
||||||
|
"SPORT": "Sport di squadra"
|
||||||
|
},
|
||||||
|
"sessionMinutes": "Durata media (min)",
|
||||||
|
"mealsPerDayTitle": "Frequenza pasti",
|
||||||
|
"mealsPerDayHint": "Quanti pasti preferisci fare al giorno? Se hai un protocollo IF attivo, ha la precedenza lui.",
|
||||||
|
"mealsAuto": "Automatico (4 pasti)",
|
||||||
|
"mealsLabel": {
|
||||||
|
"1": "1 pasto al giorno",
|
||||||
|
"2": "2 pasti (pranzo + cena)",
|
||||||
|
"3": "3 pasti (no spuntino)",
|
||||||
|
"4": "4 pasti (con spuntino)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Notifications": {
|
"Notifications": {
|
||||||
@@ -459,6 +479,14 @@
|
|||||||
"hipsCm": "Fianchi",
|
"hipsCm": "Fianchi",
|
||||||
"targetWeeklyLossKg": "Velocità di calo (kg/settimana)",
|
"targetWeeklyLossKg": "Velocità di calo (kg/settimana)",
|
||||||
"targetWeeklyLossKgHint": "Suggerito: 0.4–0.7 kg/settimana. Oltre 1% del peso corporeo è considerato troppo aggressivo.",
|
"targetWeeklyLossKgHint": "Suggerito: 0.4–0.7 kg/settimana. Oltre 1% del peso corporeo è considerato troppo aggressivo.",
|
||||||
|
"dietHistory": "Storia delle diete precedenti",
|
||||||
|
"dietHistoryHint": "Diete restrittive ripetute (yo-yo) abbassano il metabolismo basale del 5-15%. Indicarcelo ci serve a calibrare il deficit con prudenza.",
|
||||||
|
"dietHistoryLabel": {
|
||||||
|
"NONE": "Mai fatto diete restrittive",
|
||||||
|
"SOME": "1-2 diete negli ultimi 3 anni",
|
||||||
|
"EXTENSIVE": "Più diete o effetto yo-yo",
|
||||||
|
"SEVERE": "Periodi di semi-fame o disturbi alim. in remissione"
|
||||||
|
},
|
||||||
"selectPlaceholder": "Seleziona…",
|
"selectPlaceholder": "Seleziona…",
|
||||||
"save": "Salva profilo",
|
"save": "Salva profilo",
|
||||||
"saving": "Salvataggio…",
|
"saving": "Salvataggio…",
|
||||||
|
|||||||
@@ -579,5 +579,9 @@ function toFormValues(view: PreferencesView): PreferencesValues {
|
|||||||
cuisinePreferences: view.cuisinePreferences as CuisineTag[],
|
cuisinePreferences: view.cuisinePreferences as CuisineTag[],
|
||||||
cookingTime: view.cookingTime as CookingTimeLevel,
|
cookingTime: view.cookingTime as CookingTimeLevel,
|
||||||
fastingProtocol: view.fastingProtocol as PreferencesValues['fastingProtocol'],
|
fastingProtocol: view.fastingProtocol as PreferencesValues['fastingProtocol'],
|
||||||
|
trainingDays: view.trainingDays,
|
||||||
|
trainingType: view.trainingType as PreferencesValues['trainingType'],
|
||||||
|
sessionMinutes: view.sessionMinutes,
|
||||||
|
mealsPerDay: view.mealsPerDay,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,10 @@ export interface PreferencesView {
|
|||||||
cuisinePreferences: string[];
|
cuisinePreferences: string[];
|
||||||
cookingTime: 'LOW' | 'MEDIUM' | 'HIGH';
|
cookingTime: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||||
fastingProtocol: string | null;
|
fastingProtocol: string | null;
|
||||||
|
trainingDays: number[];
|
||||||
|
trainingType: string | null;
|
||||||
|
sessionMinutes: number | null;
|
||||||
|
mealsPerDay: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchPreferences(): Promise<PreferencesView | null> {
|
export async function fetchPreferences(): Promise<PreferencesView | null> {
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ export function PreferencesSection({ initial }: { initial: PreferencesView | nul
|
|||||||
|
|
||||||
function toFormValues(view: PreferencesView): PreferencesValues {
|
function toFormValues(view: PreferencesView): PreferencesValues {
|
||||||
return {
|
return {
|
||||||
|
trainingDays: view.trainingDays,
|
||||||
|
trainingType: view.trainingType as PreferencesValues['trainingType'],
|
||||||
|
sessionMinutes: view.sessionMinutes,
|
||||||
|
mealsPerDay: view.mealsPerDay,
|
||||||
exclusions: view.exclusions as ExclusionGroup[],
|
exclusions: view.exclusions as ExclusionGroup[],
|
||||||
cuisinePreferences: view.cuisinePreferences as CuisineTag[],
|
cuisinePreferences: view.cuisinePreferences as CuisineTag[],
|
||||||
cookingTime: view.cookingTime as CookingTimeLevel,
|
cookingTime: view.cookingTime as CookingTimeLevel,
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { ACTIVITY_LEVELS, GENDERS, profileInputSchema, type ProfileInput } from '@ketopath/shared';
|
import {
|
||||||
|
ACTIVITY_LEVELS,
|
||||||
|
DIET_HISTORIES,
|
||||||
|
GENDERS,
|
||||||
|
profileInputSchema,
|
||||||
|
type ProfileInput,
|
||||||
|
} from '@ketopath/shared';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -56,6 +62,10 @@ export function ProfileForm({
|
|||||||
weightCurrentKg: initial.weightCurrentKg,
|
weightCurrentKg: initial.weightCurrentKg,
|
||||||
weightGoalKg: initial.weightGoalKg,
|
weightGoalKg: initial.weightGoalKg,
|
||||||
activityLevel: initial.activityLevel,
|
activityLevel: initial.activityLevel,
|
||||||
|
...(initial.targetWeeklyLossKg != null
|
||||||
|
? { targetWeeklyLossKg: initial.targetWeeklyLossKg }
|
||||||
|
: {}),
|
||||||
|
...(initial.dietHistory ? { dietHistory: initial.dietHistory } : {}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
@@ -249,6 +259,34 @@ export function ProfileForm({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="dietHistory"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="space-y-3">
|
||||||
|
<FormLabel>{t('dietHistory')}</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value ?? ''}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t('selectPlaceholder')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{DIET_HISTORIES.map((d) => (
|
||||||
|
<SelectItem key={d} value={d}>
|
||||||
|
{t(`dietHistoryLabel.${d}`)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="font-display text-ink-soft text-sm italic leading-snug">
|
||||||
|
{t('dietHistoryHint')}
|
||||||
|
</p>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{serverError ? (
|
{serverError ? (
|
||||||
<p role="alert" className="font-display text-pomodoro text-base italic">
|
<p role="alert" className="font-display text-pomodoro text-base italic">
|
||||||
{serverError}
|
{serverError}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import {
|
|||||||
CUISINE_TAGS,
|
CUISINE_TAGS,
|
||||||
EXCLUSION_GROUPS,
|
EXCLUSION_GROUPS,
|
||||||
FASTING_PROTOCOL_VALUES,
|
FASTING_PROTOCOL_VALUES,
|
||||||
|
TRAINING_TYPES,
|
||||||
type CookingTimeLevel,
|
type CookingTimeLevel,
|
||||||
type CuisineTag,
|
type CuisineTag,
|
||||||
type ExclusionGroup,
|
type ExclusionGroup,
|
||||||
type PreferencesPatch,
|
type PreferencesPatch,
|
||||||
|
type TrainingType,
|
||||||
} from '@ketopath/shared';
|
} from '@ketopath/shared';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -27,6 +29,10 @@ export interface PreferencesValues {
|
|||||||
cuisinePreferences: CuisineTag[];
|
cuisinePreferences: CuisineTag[];
|
||||||
cookingTime: CookingTimeLevel;
|
cookingTime: CookingTimeLevel;
|
||||||
fastingProtocol: (typeof FASTING_PROTOCOL_VALUES)[number] | null;
|
fastingProtocol: (typeof FASTING_PROTOCOL_VALUES)[number] | null;
|
||||||
|
trainingDays: number[];
|
||||||
|
trainingType: TrainingType | null;
|
||||||
|
sessionMinutes: number | null;
|
||||||
|
mealsPerDay: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_PREFERENCES_VALUES: PreferencesValues = {
|
export const DEFAULT_PREFERENCES_VALUES: PreferencesValues = {
|
||||||
@@ -34,8 +40,14 @@ export const DEFAULT_PREFERENCES_VALUES: PreferencesValues = {
|
|||||||
cuisinePreferences: [],
|
cuisinePreferences: [],
|
||||||
cookingTime: 'MEDIUM',
|
cookingTime: 'MEDIUM',
|
||||||
fastingProtocol: null,
|
fastingProtocol: null,
|
||||||
|
trainingDays: [],
|
||||||
|
trainingType: null,
|
||||||
|
sessionMinutes: null,
|
||||||
|
mealsPerDay: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DAY_LABELS = ['Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab', 'Dom'];
|
||||||
|
|
||||||
interface PreferencesFormProps {
|
interface PreferencesFormProps {
|
||||||
initial: PreferencesValues;
|
initial: PreferencesValues;
|
||||||
onSubmit: (patch: PreferencesPatch) => Promise<{ ok: boolean; error?: string }>;
|
onSubmit: (patch: PreferencesPatch) => Promise<{ ok: boolean; error?: string }>;
|
||||||
@@ -86,6 +98,10 @@ export function PreferencesForm({
|
|||||||
cuisinePreferences: values.cuisinePreferences,
|
cuisinePreferences: values.cuisinePreferences,
|
||||||
cookingTime: values.cookingTime,
|
cookingTime: values.cookingTime,
|
||||||
fastingProtocol: values.fastingProtocol,
|
fastingProtocol: values.fastingProtocol,
|
||||||
|
trainingDays: values.trainingDays,
|
||||||
|
trainingType: values.trainingType,
|
||||||
|
sessionMinutes: values.sessionMinutes,
|
||||||
|
mealsPerDay: values.mealsPerDay,
|
||||||
});
|
});
|
||||||
setPending(false);
|
setPending(false);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
@@ -198,6 +214,108 @@ export function PreferencesForm({
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<fieldset className="space-y-5">
|
||||||
|
<legend className="font-display text-ink text-2xl font-medium leading-tight tracking-tight">
|
||||||
|
{t('trainingTitle')}
|
||||||
|
</legend>
|
||||||
|
<p className="font-display text-ink-soft text-base italic leading-snug">
|
||||||
|
{t('trainingHint')}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{DAY_LABELS.map((label, idx) => (
|
||||||
|
<Chip
|
||||||
|
key={idx}
|
||||||
|
label={label}
|
||||||
|
active={values.trainingDays.includes(idx)}
|
||||||
|
onToggle={() =>
|
||||||
|
setValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
trainingDays: prev.trainingDays.includes(idx)
|
||||||
|
? prev.trainingDays.filter((d) => d !== idx)
|
||||||
|
: [...prev.trainingDays, idx].sort((a, b) => a - b),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="editorial-eyebrow mb-3">{t('trainingType')}</p>
|
||||||
|
<Select
|
||||||
|
value={values.trainingType ?? 'NONE'}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
trainingType: v === 'NONE' ? null : (v as TrainingType),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="NONE">{t('trainingNone')}</SelectItem>
|
||||||
|
{TRAINING_TYPES.map((tt) => (
|
||||||
|
<SelectItem key={tt} value={tt}>
|
||||||
|
{t(`trainingTypeLabel.${tt}`)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="editorial-eyebrow mb-3">{t('sessionMinutes')}</p>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={10}
|
||||||
|
max={240}
|
||||||
|
step={5}
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="60"
|
||||||
|
value={values.sessionMinutes ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
sessionMinutes: e.target.value === '' ? null : Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="border-ink/30 focus:border-ink w-full border-b bg-transparent py-2 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="space-y-5">
|
||||||
|
<legend className="font-display text-ink text-2xl font-medium leading-tight tracking-tight">
|
||||||
|
{t('mealsPerDayTitle')}
|
||||||
|
</legend>
|
||||||
|
<p className="font-display text-ink-soft text-base italic leading-snug">
|
||||||
|
{t('mealsPerDayHint')}
|
||||||
|
</p>
|
||||||
|
<div className="max-w-xs">
|
||||||
|
<Select
|
||||||
|
value={values.mealsPerDay != null ? String(values.mealsPerDay) : 'AUTO'}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
mealsPerDay: v === 'AUTO' ? null : Number(v),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="AUTO">{t('mealsAuto')}</SelectItem>
|
||||||
|
<SelectItem value="1">{t('mealsLabel.1')}</SelectItem>
|
||||||
|
<SelectItem value="2">{t('mealsLabel.2')}</SelectItem>
|
||||||
|
<SelectItem value="3">{t('mealsLabel.3')}</SelectItem>
|
||||||
|
<SelectItem value="4">{t('mealsLabel.4')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="font-display text-pomodoro text-base italic" role="alert">
|
<p className="font-display text-pomodoro text-base italic" role="alert">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "preferences" ADD COLUMN "meals_per_day" INTEGER,
|
||||||
|
ADD COLUMN "session_minutes" INTEGER,
|
||||||
|
ADD COLUMN "training_days" INTEGER[] DEFAULT ARRAY[]::INTEGER[],
|
||||||
|
ADD COLUMN "training_type" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "diet_history" TEXT;
|
||||||
@@ -181,6 +181,12 @@ model Profile {
|
|||||||
// PRD §5.1 — % grasso corporeo stimata (US Navy) per BMR Katch-McArdle.
|
// PRD §5.1 — % grasso corporeo stimata (US Navy) per BMR Katch-McArdle.
|
||||||
/// @encrypted
|
/// @encrypted
|
||||||
bodyFatPct String? @map("body_fat_pct")
|
bodyFatPct String? @map("body_fat_pct")
|
||||||
|
// PRD §5.1 — storia delle diete restrittive precedenti, per stimare
|
||||||
|
// l'adattamento metabolico (Fothergill 2016, Rosenbaum 2008). Categoriale:
|
||||||
|
// NONE / SOME / EXTENSIVE / SEVERE. Cifrato perché può alludere a
|
||||||
|
// disturbi alimentari (art. 9 GDPR).
|
||||||
|
/// @encrypted
|
||||||
|
dietHistory String? @map("diet_history")
|
||||||
currentPhase Phase @default(INTENSIVE) @map("current_phase")
|
currentPhase Phase @default(INTENSIVE) @map("current_phase")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
@@ -198,6 +204,14 @@ model Preferences {
|
|||||||
cookingTime CookingTime @default(MEDIUM) @map("cooking_time")
|
cookingTime CookingTime @default(MEDIUM) @map("cooking_time")
|
||||||
fastingProtocol FastingProtocol? @map("fasting_protocol")
|
fastingProtocol FastingProtocol? @map("fasting_protocol")
|
||||||
notificationSettings Json @default("{}") @map("notification_settings")
|
notificationSettings Json @default("{}") @map("notification_settings")
|
||||||
|
// PRD §5.1 — schedule allenamento. trainingDays è Mon-anchored (0=Lun..6=Dom).
|
||||||
|
// Per i giorni in lista, il planner alza le kcal in base a tipo+durata.
|
||||||
|
trainingDays Int[] @default([]) @map("training_days")
|
||||||
|
trainingType String? @map("training_type") // CARDIO|STRENGTH|MIXED|SPORT
|
||||||
|
sessionMinutes Int? @map("session_minutes")
|
||||||
|
// PRD §5.1 — pasti/giorno preferiti (1..4). Se null, fallback al default.
|
||||||
|
// Il fastingProtocol ha precedenza: se attivo definisce lui la struttura.
|
||||||
|
mealsPerDay Int? @map("meals_per_day")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
|||||||
@@ -9,5 +9,7 @@ export * from './nutrition/types.js';
|
|||||||
export * from './planner/macros.js';
|
export * from './planner/macros.js';
|
||||||
export * from './planner/matchmaking.js';
|
export * from './planner/matchmaking.js';
|
||||||
export * from './planner/types.js';
|
export * from './planner/types.js';
|
||||||
|
export * from './preferences/training.js';
|
||||||
|
export * from './profile/diet-history.js';
|
||||||
export * from './profile/schema.js';
|
export * from './profile/schema.js';
|
||||||
export * from './tracking/schema.js';
|
export * from './tracking/schema.js';
|
||||||
@@ -179,3 +179,24 @@ export function protocolPlanForDay(
|
|||||||
return { share: DEFAULT_MEAL_SHARE, kcalMultiplier: 1 };
|
return { share: DEFAULT_MEAL_SHARE, kcalMultiplier: 1 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PRD §5.1 — Quote per pasto in base alla frequenza dichiarata dall'utente.
|
||||||
|
// Usato solo in assenza di un fastingProtocol attivo (che ha precedenza).
|
||||||
|
//
|
||||||
|
// 1 pasto → solo cena (OMAD light)
|
||||||
|
// 2 pasti → pranzo + cena
|
||||||
|
// 3 pasti → colazione + pranzo + cena, niente spuntino
|
||||||
|
// 4 pasti → default (con spuntino)
|
||||||
|
export function mealShareForFrequency(mealsPerDay: number | null | undefined): MealShare {
|
||||||
|
switch (mealsPerDay) {
|
||||||
|
case 1:
|
||||||
|
return { COLAZIONE: 0, PRANZO: 0, SPUNTINO: 0, CENA: 1 };
|
||||||
|
case 2:
|
||||||
|
return { COLAZIONE: 0, PRANZO: 0.5, SPUNTINO: 0, CENA: 0.5 };
|
||||||
|
case 3:
|
||||||
|
return { COLAZIONE: 0.3, PRANZO: 0.4, SPUNTINO: 0, CENA: 0.3 };
|
||||||
|
case 4:
|
||||||
|
default:
|
||||||
|
return DEFAULT_MEAL_SHARE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { TRAINING_TYPES } from './training.js';
|
||||||
|
|
||||||
// PRD §5.1 — gruppi di esclusione legati agli ingredienti.
|
// PRD §5.1 — gruppi di esclusione legati agli ingredienti.
|
||||||
// Devono coincidere con i tag su `Ingredient.exclusionGroups` nel seed
|
// Devono coincidere con i tag su `Ingredient.exclusionGroups` nel seed
|
||||||
// (vedi packages/db/prisma/seed-ingredients.ts).
|
// (vedi packages/db/prisma/seed-ingredients.ts).
|
||||||
@@ -45,6 +47,12 @@ export const preferencesPatchSchema = z
|
|||||||
cuisinePreferences: z.array(z.enum(CUISINE_TAGS)).max(CUISINE_TAGS.length).optional(),
|
cuisinePreferences: z.array(z.enum(CUISINE_TAGS)).max(CUISINE_TAGS.length).optional(),
|
||||||
cookingTime: z.enum(COOKING_TIMES).optional(),
|
cookingTime: z.enum(COOKING_TIMES).optional(),
|
||||||
fastingProtocol: z.enum(FASTING_PROTOCOL_VALUES).nullable().optional(),
|
fastingProtocol: z.enum(FASTING_PROTOCOL_VALUES).nullable().optional(),
|
||||||
|
// Schedule allenamento (vedi preferences/training.ts).
|
||||||
|
trainingDays: z.array(z.coerce.number().int().min(0).max(6)).max(7).optional(),
|
||||||
|
trainingType: z.enum(TRAINING_TYPES).nullable().optional(),
|
||||||
|
sessionMinutes: z.coerce.number().int().min(10).max(240).nullable().optional(),
|
||||||
|
// Pasti/giorno preferiti. Il fastingProtocol ha precedenza.
|
||||||
|
mealsPerDay: z.coerce.number().int().min(1).max(4).nullable().optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { extraKcalForSession, isTrainingDay } from './training.js';
|
||||||
|
|
||||||
|
describe('extraKcalForSession', () => {
|
||||||
|
it('cardio 60min @ 80kg ≈ 640 kcal (round 25)', () => {
|
||||||
|
expect(extraKcalForSession({ type: 'CARDIO', durationMinutes: 60, weightKg: 80 })).toBe(650);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strength 60min @ 80kg ≈ 400 kcal', () => {
|
||||||
|
expect(extraKcalForSession({ type: 'STRENGTH', durationMinutes: 60, weightKg: 80 })).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixed 45min @ 70kg', () => {
|
||||||
|
const r = extraKcalForSession({ type: 'MIXED', durationMinutes: 45, weightKg: 70 });
|
||||||
|
expect(r).toBeGreaterThan(250);
|
||||||
|
expect(r).toBeLessThan(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('durata o peso non positivi → 0', () => {
|
||||||
|
expect(extraKcalForSession({ type: 'CARDIO', durationMinutes: 0, weightKg: 80 })).toBe(0);
|
||||||
|
expect(extraKcalForSession({ type: 'CARDIO', durationMinutes: 60, weightKg: 0 })).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isTrainingDay', () => {
|
||||||
|
it('mercoledì in [0,2,4]', () => {
|
||||||
|
expect(isTrainingDay(2, [0, 2, 4])).toBe(true);
|
||||||
|
expect(isTrainingDay(1, [0, 2, 4])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// PRD §5.1 — Schedule allenamento. Sui giorni di allenamento il fabbisogno
|
||||||
|
// energetico aumenta: stimiamo l'energy expenditure (EE) della sessione con
|
||||||
|
// l'equazione MET classica (Ainsworth 2011).
|
||||||
|
//
|
||||||
|
// EE_kcal = MET × peso_kg × ore
|
||||||
|
//
|
||||||
|
// I MET prevalenti per tipo (medie ragionevoli):
|
||||||
|
// - CARDIO ≈ 8 (corsa moderata 8 km/h, ciclismo 20 km/h)
|
||||||
|
// - STRENGTH ≈ 5 (allenamento pesi vigoroso)
|
||||||
|
// - MIXED ≈ 6 (HIIT/cross-training)
|
||||||
|
// - SPORT ≈ 7 (calcio amatoriale, tennis singolo)
|
||||||
|
|
||||||
|
export const TRAINING_TYPES = ['CARDIO', 'STRENGTH', 'MIXED', 'SPORT'] as const;
|
||||||
|
export type TrainingType = (typeof TRAINING_TYPES)[number];
|
||||||
|
|
||||||
|
const MET_BY_TYPE: Record<TrainingType, number> = {
|
||||||
|
CARDIO: 8,
|
||||||
|
STRENGTH: 5,
|
||||||
|
MIXED: 6,
|
||||||
|
SPORT: 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TrainingSessionInput {
|
||||||
|
type: TrainingType;
|
||||||
|
durationMinutes: number;
|
||||||
|
weightKg: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stima delle kcal extra spese in una sessione di allenamento.
|
||||||
|
* Output arrotondato a multipli di 25 per evitare false-precisioni.
|
||||||
|
*/
|
||||||
|
export function extraKcalForSession({
|
||||||
|
type,
|
||||||
|
durationMinutes,
|
||||||
|
weightKg,
|
||||||
|
}: TrainingSessionInput): number {
|
||||||
|
if (durationMinutes <= 0 || weightKg <= 0) return 0;
|
||||||
|
const met = MET_BY_TYPE[type];
|
||||||
|
const kcal = met * weightKg * (durationMinutes / 60);
|
||||||
|
return Math.round(kcal / 25) * 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTrainingDay(dayOfWeek: number, trainingDays: readonly number[]): boolean {
|
||||||
|
return trainingDays.includes(dayOfWeek);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { bmrAdjustForDietHistory } from './diet-history.js';
|
||||||
|
|
||||||
|
describe('bmrAdjustForDietHistory', () => {
|
||||||
|
it('NONE → 1.0', () => {
|
||||||
|
expect(bmrAdjustForDietHistory('NONE')).toBe(1);
|
||||||
|
});
|
||||||
|
it('SOME → 0.97', () => {
|
||||||
|
expect(bmrAdjustForDietHistory('SOME')).toBe(0.97);
|
||||||
|
});
|
||||||
|
it('EXTENSIVE → 0.93', () => {
|
||||||
|
expect(bmrAdjustForDietHistory('EXTENSIVE')).toBe(0.93);
|
||||||
|
});
|
||||||
|
it('SEVERE → 0.85', () => {
|
||||||
|
expect(bmrAdjustForDietHistory('SEVERE')).toBe(0.85);
|
||||||
|
});
|
||||||
|
it('null/undefined → 1.0', () => {
|
||||||
|
expect(bmrAdjustForDietHistory(null)).toBe(1);
|
||||||
|
expect(bmrAdjustForDietHistory(undefined)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// PRD §5.1 — Adattamento metabolico da diete restrittive precedenti.
|
||||||
|
//
|
||||||
|
// Riferimenti: Fothergill 2016 (Biggest Loser, BMR ~500 kcal sotto la
|
||||||
|
// previsione 6 anni dopo), Rosenbaum 2008. Le persone con storia di diete
|
||||||
|
// pesanti hanno BMR misurato 5-15% sotto Mifflin/Katch — applichiamo un
|
||||||
|
// moltiplicatore conservativo per non spingere il deficit oltre il dovuto.
|
||||||
|
|
||||||
|
export const DIET_HISTORIES = ['NONE', 'SOME', 'EXTENSIVE', 'SEVERE'] as const;
|
||||||
|
export type DietHistory = (typeof DIET_HISTORIES)[number];
|
||||||
|
|
||||||
|
export function bmrAdjustForDietHistory(history: DietHistory | null | undefined): number {
|
||||||
|
switch (history) {
|
||||||
|
case 'SOME':
|
||||||
|
return 0.97; // 1-2 diete blande negli ultimi 3 anni
|
||||||
|
case 'EXTENSIVE':
|
||||||
|
return 0.93; // diete frequenti / yo-yo conclamato
|
||||||
|
case 'SEVERE':
|
||||||
|
return 0.85; // semi-fame prolungata, ricoveri, anoressia in remissione
|
||||||
|
case 'NONE':
|
||||||
|
case null:
|
||||||
|
case undefined:
|
||||||
|
default:
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { MEDICAL_CONDITIONS } from '../medical/conditions.js';
|
import { MEDICAL_CONDITIONS } from '../medical/conditions.js';
|
||||||
|
|
||||||
|
import { DIET_HISTORIES } from './diet-history.js';
|
||||||
|
|
||||||
export const GENDERS = ['MALE', 'FEMALE', 'OTHER'] as const;
|
export const GENDERS = ['MALE', 'FEMALE', 'OTHER'] as const;
|
||||||
export const ACTIVITY_LEVELS = ['SEDENTARY', 'LIGHT', 'MODERATE', 'INTENSE'] as const;
|
export const ACTIVITY_LEVELS = ['SEDENTARY', 'LIGHT', 'MODERATE', 'INTENSE'] as const;
|
||||||
|
|
||||||
@@ -23,6 +25,8 @@ export const profileInputSchema = z.object({
|
|||||||
neckCm: z.coerce.number().min(20).max(60).optional(),
|
neckCm: z.coerce.number().min(20).max(60).optional(),
|
||||||
waistCm: z.coerce.number().min(40).max(200).optional(),
|
waistCm: z.coerce.number().min(40).max(200).optional(),
|
||||||
hipsCm: z.coerce.number().min(40).max(200).optional(),
|
hipsCm: z.coerce.number().min(40).max(200).optional(),
|
||||||
|
// Storia delle diete restrittive precedenti (vedi diet-history.ts).
|
||||||
|
dietHistory: z.enum(DIET_HISTORIES).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ProfileInput = z.input<typeof profileInputSchema>;
|
export type ProfileInput = z.input<typeof profileInputSchema>;
|
||||||
|
|||||||
Reference in new issue
Block a user