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:
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,
|
||||
|
||||
Reference in new issue
Block a user