From 2c11b737033d8f819a473cd609ffb1001340fa1b Mon Sep 17 00:00:00 2001 From: luciano Date: Wed, 29 Apr 2026 23:03:52 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20macro=20tracker,=20cookingTime=20nel=20?= =?UTF-8?q?matching,=20rigenera=20singolo=20pasto=20(PRD=20=C2=A75.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tre completamenti del PRD §5.1 lasciati indietro fino a oggi. 1. Macro tracker giornaliero - GET /me/meal-plans/current ora include proteinG/fatG/netCarbG/prepMinutes per ogni recipe selected e alternative - /plan: nuova summary card settimanale (medie kcal/proteine/grassi/carb sui giorni attivi) - Header per ogni giorno: "1390 KCAL · P 100 · G 92 · C 17" 2. cookingTime nel matchmaking - Nuovo prepMinutes su RecipeCandidate - MatchOptions accetta maxPrepMinutes (soft cap, non hard filter) - Score: penalty 0.01 per minuto di sforamento — preserva la diversità ma orienta le scelte - maxPrepMinutesFor(level): LOW=15, MEDIUM=30, HIGH=60 - plan.routes deriva la soglia da Preferences.cookingTime 3. Rigenera singolo pasto - POST /me/meal-plans/slots/:slotId/regenerate - Riusa l'intero pipeline di calcolo (BMR, deficit, condizioni, training, protocollo, mealsPerDay, cookingTime) e ricalcola top5 per quello slot - Passa consumedSoFar (kcal/macros già scelti negli altri pasti del giorno) al matchmaking → coerenza dei macros giornalieri - Aggiunge la ricetta corrente al recentlyConsumedIds per forzare il cambio - UI: pulsante ↻ in ogni SlotCard, accessibile via aria-label Smoke verificato live: 21 bottoni rigenera (3 pasti × 7 giorni), summary "Media kcal/giorno 1397", header giornaliero "1390 KCAL · P 100 · G 92 · C 17". 54/54 unit test verdi. Lint, typecheck, build verdi. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/src/modules/plan/plan.routes.ts | 220 ++++++++++++++++++- apps/web/messages/it.json | 5 + apps/web/src/app/[locale]/plan/actions.ts | 18 ++ apps/web/src/app/[locale]/plan/plan-week.tsx | 90 +++++++- packages/shared/src/planner/matchmaking.ts | 28 +++ 5 files changed, 356 insertions(+), 5 deletions(-) diff --git a/apps/api/src/modules/plan/plan.routes.ts b/apps/api/src/modules/plan/plan.routes.ts index 89ef25c..2f896c5 100644 --- a/apps/api/src/modules/plan/plan.routes.ts +++ b/apps/api/src/modules/plan/plan.routes.ts @@ -10,6 +10,7 @@ import { isTrainingDay, macrosForPhase, matchMeals, + maxPrepMinutesFor, mealShareForFrequency, protocolPlanForDay, type DietHistory, @@ -68,6 +69,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { fatG: true, netCarbG: true, phases: true, + prepMinutes: true, ingredients: { select: { ingredient: { select: { exclusionGroups: true } } }, }, @@ -92,6 +94,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { netCarbG: r.netCarbG, exclusionTags: Array.from(tags), phases: r.phases.filter((p): p is 1 | 2 | 3 => p === 1 || p === 2 || p === 3), + prepMinutes: r.prepMinutes, }; }); @@ -149,6 +152,10 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { const exclusions = profile.user.preferences?.exclusions ?? []; const fastingProtocol = (profile.user.preferences?.fastingProtocol as FastingProtocolKey | null | undefined) ?? null; + const cookingTime = + (profile.user.preferences?.cookingTime as 'LOW' | 'MEDIUM' | 'HIGH' | null | undefined) ?? + null; + const maxPrepMinutes = maxPrepMinutesFor(cookingTime); const weekStart = startOfWeek(new Date()); @@ -199,6 +206,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { recentlyConsumedIds, dailyTarget, mealShare: effectiveShare, + maxPrepMinutes, topN: 5, }); const selected = top[0]; @@ -258,6 +266,194 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { }, ); + // PRD §5.1 — rigenera UN solo slot: ricalcola le top5 ricette tenendo + // conto dei pasti del giorno già scelti (coerenza dei macros). + fastify.post( + '/me/meal-plans/slots/:slotId/regenerate', + { preHandler: requireAuth() }, + async (request, reply) => { + const slotId = (request.params as { slotId: string }).slotId; + const userId = request.user!.id; + + const slot = await fastify.prisma.mealSlot.findFirst({ + where: { id: slotId, plan: { userId } }, + include: { + plan: { + include: { + slots: { + include: { + selected: { + select: { + id: true, + kcal: true, + proteinG: true, + fatG: true, + netCarbG: true, + }, + }, + }, + }, + }, + }, + }, + }); + if (!slot) return reply.code(404).send({ error: 'slot_not_found' }); + + const profile = await fastify.prisma.profile.findUnique({ + where: { userId }, + include: { user: { include: { preferences: true } } }, + }); + if (!profile) return reply.code(409).send({ error: 'profile_required' }); + + const conditions = parseConditions(profile.medicalConditions); + if (hasExcludingCondition(conditions)) { + return reply.code(409).send({ error: 'medical_block', conditions }); + } + + const recipes = await fastify.prisma.recipe.findMany({ + select: { + id: true, + name: true, + category: true, + kcal: true, + proteinG: true, + fatG: true, + netCarbG: true, + phases: true, + prepMinutes: true, + ingredients: { select: { ingredient: { select: { exclusionGroups: true } } } }, + }, + }); + + const candidates: RecipeCandidate[] = recipes.map((r) => { + const tags = new Set(); + for (const ri of r.ingredients) for (const g of ri.ingredient.exclusionGroups) tags.add(g); + return { + id: r.id, + name: r.name, + category: r.category, + kcal: r.kcal, + proteinG: r.proteinG, + fatG: r.fatG, + netCarbG: r.netCarbG, + exclusionTags: Array.from(tags), + phases: r.phases.filter((p): p is 1 | 2 | 3 => p === 1 || p === 2 || p === 3), + prepMinutes: r.prepMinutes, + }; + }); + + const weightCurrentKg = Number(profile.weightCurrentKg); + const bodyFatPct = profile.bodyFatPct ? Number(profile.bodyFatPct) : null; + let bmr = bodyFatPct + ? calculateBmrKatchMcArdle(weightCurrentKg, bodyFatPct) + : calculateBmr({ + weightKg: weightCurrentKg, + heightCm: profile.heightCm, + ageYears: profile.age, + gender: profile.gender, + }); + bmr *= bmrAdjustmentForConditions(conditions); + bmr *= bmrAdjustForDietHistory(profile.dietHistory as DietHistory | null); + const tdee = calculateTdee(bmr, profile.activityLevel); + const phaseInt = phaseToInt(profile.currentPhase); + + const { kcalTarget: baseKcal } = computeDailyKcalTarget({ + tdee, + weightCurrentKg, + targetWeeklyLossKg: profile.targetWeeklyLossKg, + phase: phaseInt, + }); + + const fastingProtocol = + (profile.user.preferences?.fastingProtocol as FastingProtocolKey | null | undefined) ?? + null; + const dayPlan = protocolPlanForDay(fastingProtocol, slot.dayOfWeek); + const mealsPerDay = profile.user.preferences?.mealsPerDay ?? null; + const effectiveShare = + !fastingProtocol && mealsPerDay ? mealShareForFrequency(mealsPerDay) : dayPlan.share; + + // Allenamento — extra del giorno se è training day. + 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 trainingExtra = + isTrainingDay(slot.dayOfWeek, trainingDays) && trainingType && sessionMinutes + ? extraKcalForSession({ + type: trainingType, + durationMinutes: sessionMinutes, + weightKg: weightCurrentKg, + }) + : 0; + const dailyKcal = Math.round(baseKcal * dayPlan.kcalMultiplier + trainingExtra); + const dailyTarget = macrosForPhase({ + kcalTarget: dailyKcal, + weightKg: weightCurrentKg, + phase: phaseInt, + }); + + // Macros già consumati negli altri pasti dello stesso giorno. + const sameDay = slot.plan.slots.filter( + (s) => s.dayOfWeek === slot.dayOfWeek && s.id !== slot.id && s.selected, + ); + const consumedSoFar = sameDay.reduce( + (acc, s) => ({ + kcal: acc.kcal + (s.selected?.kcal ?? 0), + proteinG: acc.proteinG + (s.selected?.proteinG ?? 0), + fatG: acc.fatG + (s.selected?.fatG ?? 0), + netCarbG: acc.netCarbG + (s.selected?.netCarbG ?? 0), + }), + { kcal: 0, proteinG: 0, fatG: 0, netCarbG: 0 }, + ); + + const exclusions = profile.user.preferences?.exclusions ?? []; + const cookingTime = + (profile.user.preferences?.cookingTime as 'LOW' | 'MEDIUM' | 'HIGH' | null | undefined) ?? + null; + + // Le ricette già scelte negli ultimi 2 giorni + lo slot corrente sono "recenti". + const recentlyConsumedIds = slot.plan.slots + .filter((s) => slot.dayOfWeek - s.dayOfWeek <= 2 && s.id !== slot.id && s.recipeId != null) + .map((s) => s.recipeId as string); + // Aggiungo anche la ricetta attualmente scelta in questo slot per + // forzare il matchmaking a proporne un'altra al primo posto. + if (slot.recipeId) recentlyConsumedIds.push(slot.recipeId); + + const top = matchMeals({ + candidates, + meal: slot.meal, + phase: phaseInt, + excludedTags: exclusions, + recentlyConsumedIds, + consumedSoFar, + dailyTarget, + mealShare: effectiveShare, + maxPrepMinutes: maxPrepMinutesFor(cookingTime), + topN: 5, + }); + + if (top.length === 0) { + return reply.code(409).send({ error: 'no_recipes_available' }); + } + + const newSelected = top[0]; + const altIds = top.slice(1).map((r) => r.id); + + // Re-set alternatives via Prisma `set` per pulire la M2M. + await fastify.prisma.mealSlot.update({ + where: { id: slotId }, + data: { + recipeId: newSelected.id, + alternatives: { set: altIds.map((id) => ({ id })) }, + }, + }); + + return reply.code(200).send({ + slot: { id: slotId, recipeId: newSelected.id, alternativeIds: altIds }, + }); + }, + ); + fastify.get('/me/meal-plans/current', { preHandler: requireAuth() }, async (request, reply) => { const userId = request.user!.id; const [plan, prefs] = await Promise.all([ @@ -267,8 +463,28 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { include: { slots: { include: { - selected: { select: { id: true, name: true, kcal: true } }, - alternatives: { select: { id: true, name: true, kcal: true } }, + selected: { + select: { + id: true, + name: true, + kcal: true, + proteinG: true, + fatG: true, + netCarbG: true, + prepMinutes: true, + }, + }, + alternatives: { + select: { + id: true, + name: true, + kcal: true, + proteinG: true, + fatG: true, + netCarbG: true, + prepMinutes: true, + }, + }, }, orderBy: [{ dayOfWeek: 'asc' }, { meal: 'asc' }], }, diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index 90fc2cf..bd1d961 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -432,8 +432,13 @@ "showAlternatives": "Vedi {n} alternative", "hideAlternatives": "Nascondi", "swap": "Scegli", + "regenerateSlot": "Rigenera questo pasto", "dayTotal": "{kcal} kcal totali", "window": "Finestra alimentare", + "avgKcal": "Media kcal/giorno", + "avgProtein": "Proteine medie", + "avgFat": "Grassi medi", + "avgNetCarb": "Carb. netti medi", "fastingDay": "Giorno di digiuno", "fastingDayHint": "Acqua, tè e tisane. Niente cibo solido.", "lightDay": "Giorno leggero", diff --git a/apps/web/src/app/[locale]/plan/actions.ts b/apps/web/src/app/[locale]/plan/actions.ts index 7233490..becc7d7 100644 --- a/apps/web/src/app/[locale]/plan/actions.ts +++ b/apps/web/src/app/[locale]/plan/actions.ts @@ -9,6 +9,10 @@ interface SlotRecipe { id: string; name: string; kcal: number; + proteinG: number; + fatG: number; + netCarbG: number; + prepMinutes: number; } export interface PlanSlot { @@ -67,6 +71,20 @@ export async function regeneratePlan(_formData?: FormData): Promise { export type SwapResult = { ok: true } | { ok: false; error: string }; +export async function regenerateSlot(slotId: string): Promise { + const res = await fetch(`${API_URL}/me/meal-plans/slots/${slotId}/regenerate`, { + method: 'POST', + headers: { cookie: cookieHeader() }, + cache: 'no-store', + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + return { ok: false, error: body.error ?? `api_error_${res.status}` }; + } + revalidatePath('/plan'); + return { ok: true }; +} + export async function swapSlotRecipe(slotId: string, recipeId: string): Promise { const res = await fetch(`${API_URL}/me/meal-plans/slots/${slotId}`, { method: 'PATCH', diff --git a/apps/web/src/app/[locale]/plan/plan-week.tsx b/apps/web/src/app/[locale]/plan/plan-week.tsx index 0910d03..dc87c7d 100644 --- a/apps/web/src/app/[locale]/plan/plan-week.tsx +++ b/apps/web/src/app/[locale]/plan/plan-week.tsx @@ -6,7 +6,7 @@ import { useState, useTransition } from 'react'; import { Button } from '@/components/ui/button'; -import { swapSlotRecipe, type CurrentPlan, type PlanSlot } from './actions'; +import { regenerateSlot, swapSlotRecipe, type CurrentPlan, type PlanSlot } from './actions'; const MEAL_ORDER: PlanSlot['meal'][] = ['COLAZIONE', 'PRANZO', 'SPUNTINO', 'CENA']; const DAY_KEYS = [ @@ -20,6 +20,26 @@ const DAY_KEYS = [ ] as const; const DAY_NUMERAL = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII']; +function aggregateMacros(slots: PlanSlot[]): { + kcal: number; + proteinG: number; + fatG: number; + netCarbG: number; +} { + return slots.reduce( + (acc, s) => { + if (!s.selected) return acc; + return { + kcal: acc.kcal + s.selected.kcal, + proteinG: acc.proteinG + s.selected.proteinG, + fatG: acc.fatG + s.selected.fatG, + netCarbG: acc.netCarbG + s.selected.netCarbG, + }; + }, + { kcal: 0, proteinG: 0, fatG: 0, netCarbG: 0 }, + ); +} + export function PlanWeek({ plan }: { plan: CurrentPlan }) { const t = useTranslations('Plan'); @@ -29,13 +49,32 @@ export function PlanWeek({ plan }: { plan: CurrentPlan }) { byDay.get(slot.dayOfWeek)!.push(slot); } + const weekTotals = aggregateMacros(plan.slots); + const activeDays = Array.from(byDay.values()).filter((s) => s.length > 0).length; + const weekAvg = + activeDays > 0 + ? { + kcal: Math.round(weekTotals.kcal / activeDays), + proteinG: Math.round(weekTotals.proteinG / activeDays), + fatG: Math.round(weekTotals.fatG / activeDays), + netCarbG: Math.round(weekTotals.netCarbG / activeDays), + } + : { kcal: 0, proteinG: 0, fatG: 0, netCarbG: 0 }; + return (
+
+ + + + +
+ {DAY_KEYS.map((dayKey, idx) => { const slots = (byDay.get(idx) ?? []).sort( (a, b) => MEAL_ORDER.indexOf(a.meal) - MEAL_ORDER.indexOf(b.meal), ); - const dayKcal = slots.reduce((sum, s) => sum + (s.selected?.kcal ?? 0), 0); + const dayMacros = aggregateMacros(slots); // Nessuno slot → giorno di digiuno completo (ESE_24). const isFastingDay = slots.length === 0; // Solo CENA con dailyKcal ridotto → giorno "leggero" del 5:2. @@ -63,7 +102,9 @@ export function PlanWeek({ plan }: { plan: CurrentPlan }) { ) : null} - {isFastingDay ? '— kcal' : t('dayTotal', { kcal: Math.round(dayKcal) })} + {isFastingDay + ? '— kcal' + : `${Math.round(dayMacros.kcal)} kcal · P ${Math.round(dayMacros.proteinG)} · G ${Math.round(dayMacros.fatG)} · C ${Math.round(dayMacros.netCarbG)}`} {isFastingDay ? ( @@ -98,6 +139,13 @@ function SlotCard({ slot, index }: { slot: PlanSlot; index: number }) { }); } + function regenerate() { + startTransition(async () => { + await regenerateSlot(slot.id); + setOpen(false); + }); + } + return (
@@ -107,6 +155,16 @@ function SlotCard({ slot, index }: { slot: PlanSlot; index: number }) { {t(`meals.${slot.meal}`)}

+
{slot.selected ? ( ); } + +function MacroSummary({ + label, + value, + unit, + big = false, +}: { + label: string; + value: number; + unit: string; + big?: boolean; +}) { + return ( +
+ {label} + + {value} + {unit} + +
+ ); +} diff --git a/packages/shared/src/planner/matchmaking.ts b/packages/shared/src/planner/matchmaking.ts index 5bd03b2..d840d16 100644 --- a/packages/shared/src/planner/matchmaking.ts +++ b/packages/shared/src/planner/matchmaking.ts @@ -16,6 +16,7 @@ export interface RecipeCandidate { netCarbG: number; exclusionTags: ReadonlyArray; phases: ReadonlyArray<1 | 2 | 3>; + prepMinutes?: number; } export interface MacroTargets { @@ -38,6 +39,11 @@ export interface MatchOptions { // Quote del pasto sul totale giornaliero (sommano a 1.0). mealShare: { COLAZIONE: number; PRANZO: number; SPUNTINO: number; CENA: number }; topN?: number; // default 5 + // Tempo massimo accettabile in cucina per pasto (in minuti). Se la ricetta + // supera la soglia, lo score viene penalizzato proporzionalmente. + // Soft cap, non hard filter: lo SPUNTINO tipicamente è < 5 min comunque, + // mentre la CENA può tollerare elaborazioni più lunghe. + maxPrepMinutes?: number; } export interface MatchResult extends RecipeCandidate { @@ -83,6 +89,12 @@ export function matchMeals(opts: MatchOptions): MatchResult[] { let score = Math.sqrt(dKcal * dKcal + dPro * dPro + dFat * dFat + dCarb * dCarb); if (recentSet.has(r.id)) score += 1.5; + // Penalty proporzionale per ricette oltre il tempo cucina dichiarato. + // 0.05 punti per ogni 5 min di sforamento — cumulativo ma morbido. + if (opts.maxPrepMinutes != null && r.prepMinutes != null) { + const overflow = Math.max(0, r.prepMinutes - opts.maxPrepMinutes); + score += overflow * 0.01; + } results.push({ ...r, score }); } @@ -93,6 +105,22 @@ export function matchMeals(opts: MatchOptions): MatchResult[] { export type MealShare = MatchOptions['mealShare']; +// PRD §5.1 — soglia di tempo cucina per livello dichiarato. +// Soglia ≠ filtro stretto: oltre la soglia il match-score viene penalizzato. +export function maxPrepMinutesFor(level: 'LOW' | 'MEDIUM' | 'HIGH' | null | undefined): number { + switch (level) { + case 'LOW': + return 15; + case 'HIGH': + return 60; + case 'MEDIUM': + case null: + case undefined: + default: + return 30; + } +} + // Default share per pasto (somma 1.0). Può essere sovrascritta dall'utente. export const DEFAULT_MEAL_SHARE: MealShare = { COLAZIONE: 0.25,