From a583434c42ebb23eaf7affa27d69ab77db6eff48 Mon Sep 17 00:00:00 2001 From: luciano Date: Wed, 29 Apr 2026 22:56:41 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20storia=20diete,=20schedule=20allenament?= =?UTF-8?q?o,=20frequenza=20pasti=20(PRD=20=C2=A75.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api/src/modules/plan/plan.routes.ts | 38 +++++- .../modules/preferences/preferences.routes.ts | 70 +++++++---- .../api/src/modules/profile/profile.routes.ts | 4 + apps/web/messages/it.json | 28 +++++ .../[locale]/onboarding/onboarding-flow.tsx | 4 + .../[locale]/profile/preferences-actions.ts | 4 + .../[locale]/profile/preferences-section.tsx | 4 + .../src/app/[locale]/profile/profile-form.tsx | 40 +++++- .../preferences/preferences-form.tsx | 118 ++++++++++++++++++ docs/~$D_KetoPath.docx | Bin 0 -> 162 bytes .../migration.sql | 8 ++ packages/db/prisma/schema.prisma | 14 +++ packages/shared/src/index.ts | 2 + packages/shared/src/planner/matchmaking.ts | 21 ++++ packages/shared/src/preferences/schema.ts | 8 ++ .../shared/src/preferences/training.test.ts | 31 +++++ packages/shared/src/preferences/training.ts | 46 +++++++ .../shared/src/profile/diet-history.test.ts | 22 ++++ packages/shared/src/profile/diet-history.ts | 25 ++++ packages/shared/src/profile/schema.ts | 4 + 20 files changed, 460 insertions(+), 31 deletions(-) create mode 100644 docs/~$D_KetoPath.docx create mode 100644 packages/db/prisma/migrations/20260429204611_add_lifestyle_fields/migration.sql create mode 100644 packages/shared/src/preferences/training.test.ts create mode 100644 packages/shared/src/preferences/training.ts create mode 100644 packages/shared/src/profile/diet-history.test.ts create mode 100644 packages/shared/src/profile/diet-history.ts diff --git a/apps/api/src/modules/plan/plan.routes.ts b/apps/api/src/modules/plan/plan.routes.ts index f95b81f..89ef25c 100644 --- a/apps/api/src/modules/plan/plan.routes.ts +++ b/apps/api/src/modules/plan/plan.routes.ts @@ -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]; diff --git a/apps/api/src/modules/preferences/preferences.routes.ts b/apps/api/src/modules/preferences/preferences.routes.ts index b3d6f17..4cec2d8 100644 --- a/apps/api/src/modules/preferences/preferences.routes.ts +++ b/apps/api/src/modules/preferences/preferences.routes.ts @@ -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 = { 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 = {}; 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) }; }); }; diff --git a/apps/api/src/modules/profile/profile.routes.ts b/apps/api/src/modules/profile/profile.routes.ts index 3616604..cf3ea06 100644 --- a/apps/api/src/modules/profile/profile.routes.ts +++ b/apps/api/src/modules/profile/profile.routes.ts @@ -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 } : {}), diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index 3962263..90fc2cf 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -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…", diff --git a/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx b/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx index 36f0e2f..85674ee 100644 --- a/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx +++ b/apps/web/src/app/[locale]/onboarding/onboarding-flow.tsx @@ -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, }; } diff --git a/apps/web/src/app/[locale]/profile/preferences-actions.ts b/apps/web/src/app/[locale]/profile/preferences-actions.ts index f651343..9414490 100644 --- a/apps/web/src/app/[locale]/profile/preferences-actions.ts +++ b/apps/web/src/app/[locale]/profile/preferences-actions.ts @@ -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 { diff --git a/apps/web/src/app/[locale]/profile/preferences-section.tsx b/apps/web/src/app/[locale]/profile/preferences-section.tsx index 61462fc..14c07d1 100644 --- a/apps/web/src/app/[locale]/profile/preferences-section.tsx +++ b/apps/web/src/app/[locale]/profile/preferences-section.tsx @@ -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, diff --git a/apps/web/src/app/[locale]/profile/profile-form.tsx b/apps/web/src/app/[locale]/profile/profile-form.tsx index 43539c3..0c76b1f 100644 --- a/apps/web/src/app/[locale]/profile/profile-form.tsx +++ b/apps/web/src/app/[locale]/profile/profile-form.tsx @@ -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({ )} /> + ( + + {t('dietHistory')} + +

+ {t('dietHistoryHint')} +

+ +
+ )} + /> + {serverError ? (

{serverError} diff --git a/apps/web/src/components/preferences/preferences-form.tsx b/apps/web/src/components/preferences/preferences-form.tsx index 8adb4e7..5079591 100644 --- a/apps/web/src/components/preferences/preferences-form.tsx +++ b/apps/web/src/components/preferences/preferences-form.tsx @@ -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({ ) : null} +

+ + {t('trainingTitle')} + +

+ {t('trainingHint')} +

+
+ {DAY_LABELS.map((label, idx) => ( + + setValues((prev) => ({ + ...prev, + trainingDays: prev.trainingDays.includes(idx) + ? prev.trainingDays.filter((d) => d !== idx) + : [...prev.trainingDays, idx].sort((a, b) => a - b), + })) + } + /> + ))} +
+
+
+

{t('trainingType')}

+ +
+
+

{t('sessionMinutes')}

+ + 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" + /> +
+
+
+ +
+ + {t('mealsPerDayTitle')} + +

+ {t('mealsPerDayHint')} +

+
+ +
+
+ {error ? (

{error} diff --git a/docs/~$D_KetoPath.docx b/docs/~$D_KetoPath.docx new file mode 100644 index 0000000000000000000000000000000000000000..533a3bc45652b66592242048ea9e3d31ec63b3f4 GIT binary patch literal 162 icmZScO)M!%&1WDDurc^DBr=o$VJZWudP%d1fdK$`b_C%7 literal 0 HcmV?d00001 diff --git a/packages/db/prisma/migrations/20260429204611_add_lifestyle_fields/migration.sql b/packages/db/prisma/migrations/20260429204611_add_lifestyle_fields/migration.sql new file mode 100644 index 0000000..76bb5c5 --- /dev/null +++ b/packages/db/prisma/migrations/20260429204611_add_lifestyle_fields/migration.sql @@ -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; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index da22fc5..f7a2224 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -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") diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0b936db..753be90 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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'; diff --git a/packages/shared/src/planner/matchmaking.ts b/packages/shared/src/planner/matchmaking.ts index ce968a1..5bd03b2 100644 --- a/packages/shared/src/planner/matchmaking.ts +++ b/packages/shared/src/planner/matchmaking.ts @@ -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; + } +} diff --git a/packages/shared/src/preferences/schema.ts b/packages/shared/src/preferences/schema.ts index 0e051c9..8a72b0c 100644 --- a/packages/shared/src/preferences/schema.ts +++ b/packages/shared/src/preferences/schema.ts @@ -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(); diff --git a/packages/shared/src/preferences/training.test.ts b/packages/shared/src/preferences/training.test.ts new file mode 100644 index 0000000..27afd7b --- /dev/null +++ b/packages/shared/src/preferences/training.test.ts @@ -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); + }); +}); diff --git a/packages/shared/src/preferences/training.ts b/packages/shared/src/preferences/training.ts new file mode 100644 index 0000000..181f97b --- /dev/null +++ b/packages/shared/src/preferences/training.ts @@ -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 = { + 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); +} diff --git a/packages/shared/src/profile/diet-history.test.ts b/packages/shared/src/profile/diet-history.test.ts new file mode 100644 index 0000000..3b99a02 --- /dev/null +++ b/packages/shared/src/profile/diet-history.test.ts @@ -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); + }); +}); diff --git a/packages/shared/src/profile/diet-history.ts b/packages/shared/src/profile/diet-history.ts new file mode 100644 index 0000000..07876c3 --- /dev/null +++ b/packages/shared/src/profile/diet-history.ts @@ -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; + } +} diff --git a/packages/shared/src/profile/schema.ts b/packages/shared/src/profile/schema.ts index 375a2fd..1605d2a 100644 --- a/packages/shared/src/profile/schema.ts +++ b/packages/shared/src/profile/schema.ts @@ -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;