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];
@@ -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 } : {}),
+28
View File
@@ -308,6 +308,26 @@
"TWENTY_FOUR": "20:4 — avanzato",
"ESE_24": "ESE 24h",
"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": {
@@ -459,6 +479,14 @@
"hipsCm": "Fianchi",
"targetWeeklyLossKg": "Velocità di calo (kg/settimana)",
"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…",
"save": "Salva profilo",
"saving": "Salvataggio…",
@@ -579,5 +579,9 @@ function toFormValues(view: PreferencesView): PreferencesValues {
cuisinePreferences: view.cuisinePreferences as CuisineTag[],
cookingTime: view.cookingTime as CookingTimeLevel,
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[];
cookingTime: 'LOW' | 'MEDIUM' | 'HIGH';
fastingProtocol: string | null;
trainingDays: number[];
trainingType: string | null;
sessionMinutes: number | null;
mealsPerDay: number | null;
}
export async function fetchPreferences(): Promise<PreferencesView | null> {
@@ -46,6 +46,10 @@ export function PreferencesSection({ initial }: { initial: PreferencesView | nul
function toFormValues(view: PreferencesView): PreferencesValues {
return {
trainingDays: view.trainingDays,
trainingType: view.trainingType as PreferencesValues['trainingType'],
sessionMinutes: view.sessionMinutes,
mealsPerDay: view.mealsPerDay,
exclusions: view.exclusions as ExclusionGroup[],
cuisinePreferences: view.cuisinePreferences as CuisineTag[],
cookingTime: view.cookingTime as CookingTimeLevel,
@@ -1,7 +1,13 @@
'use client';
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 { useTranslations } from 'next-intl';
import { useState } from 'react';
@@ -56,6 +62,10 @@ export function ProfileForm({
weightCurrentKg: initial.weightCurrentKg,
weightGoalKg: initial.weightGoalKg,
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 ? (
<p role="alert" className="font-display text-pomodoro text-base italic">
{serverError}
@@ -5,10 +5,12 @@ import {
CUISINE_TAGS,
EXCLUSION_GROUPS,
FASTING_PROTOCOL_VALUES,
TRAINING_TYPES,
type CookingTimeLevel,
type CuisineTag,
type ExclusionGroup,
type PreferencesPatch,
type TrainingType,
} from '@ketopath/shared';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
@@ -27,6 +29,10 @@ export interface PreferencesValues {
cuisinePreferences: CuisineTag[];
cookingTime: CookingTimeLevel;
fastingProtocol: (typeof FASTING_PROTOCOL_VALUES)[number] | null;
trainingDays: number[];
trainingType: TrainingType | null;
sessionMinutes: number | null;
mealsPerDay: number | null;
}
export const DEFAULT_PREFERENCES_VALUES: PreferencesValues = {
@@ -34,8 +40,14 @@ export const DEFAULT_PREFERENCES_VALUES: PreferencesValues = {
cuisinePreferences: [],
cookingTime: 'MEDIUM',
fastingProtocol: null,
trainingDays: [],
trainingType: null,
sessionMinutes: null,
mealsPerDay: null,
};
const DAY_LABELS = ['Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab', 'Dom'];
interface PreferencesFormProps {
initial: PreferencesValues;
onSubmit: (patch: PreferencesPatch) => Promise<{ ok: boolean; error?: string }>;
@@ -86,6 +98,10 @@ export function PreferencesForm({
cuisinePreferences: values.cuisinePreferences,
cookingTime: values.cookingTime,
fastingProtocol: values.fastingProtocol,
trainingDays: values.trainingDays,
trainingType: values.trainingType,
sessionMinutes: values.sessionMinutes,
mealsPerDay: values.mealsPerDay,
});
setPending(false);
if (!result.ok) {
@@ -198,6 +214,108 @@ export function PreferencesForm({
</fieldset>
) : 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 ? (
<p className="font-display text-pomodoro text-base italic" role="alert">
{error}