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
@@ -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.
|
||||
/// @encrypted
|
||||
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")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
@@ -198,6 +204,14 @@ model Preferences {
|
||||
cookingTime CookingTime @default(MEDIUM) @map("cooking_time")
|
||||
fastingProtocol FastingProtocol? @map("fasting_protocol")
|
||||
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")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
|
||||
@@ -9,5 +9,7 @@ export * from './nutrition/types.js';
|
||||
export * from './planner/macros.js';
|
||||
export * from './planner/matchmaking.js';
|
||||
export * from './planner/types.js';
|
||||
export * from './preferences/training.js';
|
||||
export * from './profile/diet-history.js';
|
||||
export * from './profile/schema.js';
|
||||
export * from './tracking/schema.js';
|
||||
@@ -179,3 +179,24 @@ export function protocolPlanForDay(
|
||||
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 { TRAINING_TYPES } from './training.js';
|
||||
|
||||
// PRD §5.1 — gruppi di esclusione legati agli ingredienti.
|
||||
// Devono coincidere con i tag su `Ingredient.exclusionGroups` nel seed
|
||||
// (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(),
|
||||
cookingTime: z.enum(COOKING_TIMES).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();
|
||||
|
||||
|
||||
@@ -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 { DIET_HISTORIES } from './diet-history.js';
|
||||
|
||||
export const GENDERS = ['MALE', 'FEMALE', 'OTHER'] 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(),
|
||||
waistCm: 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>;
|
||||
|
||||
Reference in new issue
Block a user