feat: macro tracker, cookingTime nel matching, rigenera singolo pasto (PRD §5.1)

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) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-04-29 23:03:52 +02:00
1 parent a583434c42
commit 2c11b73703
5 files changed
+356 -5

No files matched your search

@@ -16,6 +16,7 @@ export interface RecipeCandidate {
netCarbG: number;
exclusionTags: ReadonlyArray<string>;
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,