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

+5
View File
@@ -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",
+18
View File
@@ -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<void> {
export type SwapResult = { ok: true } | { ok: false; error: string };
export async function regenerateSlot(slotId: string): Promise<SwapResult> {
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<SwapResult> {
const res = await fetch(`${API_URL}/me/meal-plans/slots/${slotId}`, {
method: 'PATCH',
+87 -3
View File
@@ -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 (
<div className="space-y-14">
<section className="border-ink/15 bg-carta-light/40 grid grid-cols-2 gap-x-8 gap-y-4 border p-5 sm:grid-cols-4">
<MacroSummary label={t('avgKcal')} value={weekAvg.kcal} unit="kcal" big />
<MacroSummary label={t('avgProtein')} value={weekAvg.proteinG} unit="g" />
<MacroSummary label={t('avgFat')} value={weekAvg.fatG} unit="g" />
<MacroSummary label={t('avgNetCarb')} value={weekAvg.netCarbG} unit="g" />
</section>
{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}
</h2>
<span className="text-ink-soft font-mono text-[11px] uppercase tracking-widest">
{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)}`}
</span>
</header>
{isFastingDay ? (
@@ -98,6 +139,13 @@ function SlotCard({ slot, index }: { slot: PlanSlot; index: number }) {
});
}
function regenerate() {
startTransition(async () => {
await regenerateSlot(slot.id);
setOpen(false);
});
}
return (
<article className="group relative flex flex-col gap-3 py-5 sm:py-2">
<header className="flex items-baseline justify-between gap-3">
@@ -107,6 +155,16 @@ function SlotCard({ slot, index }: { slot: PlanSlot; index: number }) {
</span>
<span>{t(`meals.${slot.meal}`)}</span>
</p>
<button
type="button"
onClick={regenerate}
disabled={pending}
aria-label={t('regenerateSlot')}
title={t('regenerateSlot')}
className="text-ink-dim hover:text-pomodoro font-mono text-xs leading-none transition-colors disabled:opacity-40"
>
↻
</button>
</header>
{slot.selected ? (
<Link
@@ -164,3 +222,29 @@ function SlotCard({ slot, index }: { slot: PlanSlot; index: number }) {
</article>
);
}
function MacroSummary({
label,
value,
unit,
big = false,
}: {
label: string;
value: number;
unit: string;
big?: boolean;
}) {
return (
<div className="flex flex-col gap-1">
<span className="editorial-eyebrow">{label}</span>
<span
className={`font-mono tabular-nums ${
big ? 'text-ink text-3xl font-medium' : 'text-ink text-2xl'
}`}
>
{value}
<span className="font-display text-ink-soft ml-1 text-xs italic">{unit}</span>
</span>
</div>
);
}