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

@@ -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}