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
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user