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
@@ -10,6 +10,7 @@ import {
|
|||||||
isTrainingDay,
|
isTrainingDay,
|
||||||
macrosForPhase,
|
macrosForPhase,
|
||||||
matchMeals,
|
matchMeals,
|
||||||
|
maxPrepMinutesFor,
|
||||||
mealShareForFrequency,
|
mealShareForFrequency,
|
||||||
protocolPlanForDay,
|
protocolPlanForDay,
|
||||||
type DietHistory,
|
type DietHistory,
|
||||||
@@ -68,6 +69,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
fatG: true,
|
fatG: true,
|
||||||
netCarbG: true,
|
netCarbG: true,
|
||||||
phases: true,
|
phases: true,
|
||||||
|
prepMinutes: true,
|
||||||
ingredients: {
|
ingredients: {
|
||||||
select: { ingredient: { select: { exclusionGroups: true } } },
|
select: { ingredient: { select: { exclusionGroups: true } } },
|
||||||
},
|
},
|
||||||
@@ -92,6 +94,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
netCarbG: r.netCarbG,
|
netCarbG: r.netCarbG,
|
||||||
exclusionTags: Array.from(tags),
|
exclusionTags: Array.from(tags),
|
||||||
phases: r.phases.filter((p): p is 1 | 2 | 3 => p === 1 || p === 2 || p === 3),
|
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 exclusions = profile.user.preferences?.exclusions ?? [];
|
||||||
const fastingProtocol =
|
const fastingProtocol =
|
||||||
(profile.user.preferences?.fastingProtocol as FastingProtocolKey | null | undefined) ?? null;
|
(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());
|
const weekStart = startOfWeek(new Date());
|
||||||
|
|
||||||
@@ -199,6 +206,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
recentlyConsumedIds,
|
recentlyConsumedIds,
|
||||||
dailyTarget,
|
dailyTarget,
|
||||||
mealShare: effectiveShare,
|
mealShare: effectiveShare,
|
||||||
|
maxPrepMinutes,
|
||||||
topN: 5,
|
topN: 5,
|
||||||
});
|
});
|
||||||
const selected = top[0];
|
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<string>();
|
||||||
|
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) => {
|
fastify.get('/me/meal-plans/current', { preHandler: requireAuth() }, async (request, reply) => {
|
||||||
const userId = request.user!.id;
|
const userId = request.user!.id;
|
||||||
const [plan, prefs] = await Promise.all([
|
const [plan, prefs] = await Promise.all([
|
||||||
@@ -267,8 +463,28 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
include: {
|
include: {
|
||||||
slots: {
|
slots: {
|
||||||
include: {
|
include: {
|
||||||
selected: { select: { id: true, name: true, kcal: true } },
|
selected: {
|
||||||
alternatives: { select: { id: true, name: true, kcal: true } },
|
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' }],
|
orderBy: [{ dayOfWeek: 'asc' }, { meal: 'asc' }],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -432,8 +432,13 @@
|
|||||||
"showAlternatives": "Vedi {n} alternative",
|
"showAlternatives": "Vedi {n} alternative",
|
||||||
"hideAlternatives": "Nascondi",
|
"hideAlternatives": "Nascondi",
|
||||||
"swap": "Scegli",
|
"swap": "Scegli",
|
||||||
|
"regenerateSlot": "Rigenera questo pasto",
|
||||||
"dayTotal": "{kcal} kcal totali",
|
"dayTotal": "{kcal} kcal totali",
|
||||||
"window": "Finestra alimentare",
|
"window": "Finestra alimentare",
|
||||||
|
"avgKcal": "Media kcal/giorno",
|
||||||
|
"avgProtein": "Proteine medie",
|
||||||
|
"avgFat": "Grassi medi",
|
||||||
|
"avgNetCarb": "Carb. netti medi",
|
||||||
"fastingDay": "Giorno di digiuno",
|
"fastingDay": "Giorno di digiuno",
|
||||||
"fastingDayHint": "Acqua, tè e tisane. Niente cibo solido.",
|
"fastingDayHint": "Acqua, tè e tisane. Niente cibo solido.",
|
||||||
"lightDay": "Giorno leggero",
|
"lightDay": "Giorno leggero",
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ interface SlotRecipe {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
kcal: number;
|
kcal: number;
|
||||||
|
proteinG: number;
|
||||||
|
fatG: number;
|
||||||
|
netCarbG: number;
|
||||||
|
prepMinutes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlanSlot {
|
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 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> {
|
export async function swapSlotRecipe(slotId: string, recipeId: string): Promise<SwapResult> {
|
||||||
const res = await fetch(`${API_URL}/me/meal-plans/slots/${slotId}`, {
|
const res = await fetch(`${API_URL}/me/meal-plans/slots/${slotId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useState, useTransition } from 'react';
|
|||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
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 MEAL_ORDER: PlanSlot['meal'][] = ['COLAZIONE', 'PRANZO', 'SPUNTINO', 'CENA'];
|
||||||
const DAY_KEYS = [
|
const DAY_KEYS = [
|
||||||
@@ -20,6 +20,26 @@ const DAY_KEYS = [
|
|||||||
] as const;
|
] as const;
|
||||||
const DAY_NUMERAL = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII'];
|
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 }) {
|
export function PlanWeek({ plan }: { plan: CurrentPlan }) {
|
||||||
const t = useTranslations('Plan');
|
const t = useTranslations('Plan');
|
||||||
|
|
||||||
@@ -29,13 +49,32 @@ export function PlanWeek({ plan }: { plan: CurrentPlan }) {
|
|||||||
byDay.get(slot.dayOfWeek)!.push(slot);
|
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 (
|
return (
|
||||||
<div className="space-y-14">
|
<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) => {
|
{DAY_KEYS.map((dayKey, idx) => {
|
||||||
const slots = (byDay.get(idx) ?? []).sort(
|
const slots = (byDay.get(idx) ?? []).sort(
|
||||||
(a, b) => MEAL_ORDER.indexOf(a.meal) - MEAL_ORDER.indexOf(b.meal),
|
(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).
|
// Nessuno slot → giorno di digiuno completo (ESE_24).
|
||||||
const isFastingDay = slots.length === 0;
|
const isFastingDay = slots.length === 0;
|
||||||
// Solo CENA con dailyKcal ridotto → giorno "leggero" del 5:2.
|
// Solo CENA con dailyKcal ridotto → giorno "leggero" del 5:2.
|
||||||
@@ -63,7 +102,9 @@ export function PlanWeek({ plan }: { plan: CurrentPlan }) {
|
|||||||
) : null}
|
) : null}
|
||||||
</h2>
|
</h2>
|
||||||
<span className="text-ink-soft font-mono text-[11px] uppercase tracking-widest">
|
<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>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
{isFastingDay ? (
|
{isFastingDay ? (
|
||||||
@@ -98,6 +139,13 @@ function SlotCard({ slot, index }: { slot: PlanSlot; index: number }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function regenerate() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await regenerateSlot(slot.id);
|
||||||
|
setOpen(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="group relative flex flex-col gap-3 py-5 sm:py-2">
|
<article className="group relative flex flex-col gap-3 py-5 sm:py-2">
|
||||||
<header className="flex items-baseline justify-between gap-3">
|
<header className="flex items-baseline justify-between gap-3">
|
||||||
@@ -107,6 +155,16 @@ function SlotCard({ slot, index }: { slot: PlanSlot; index: number }) {
|
|||||||
</span>
|
</span>
|
||||||
<span>{t(`meals.${slot.meal}`)}</span>
|
<span>{t(`meals.${slot.meal}`)}</span>
|
||||||
</p>
|
</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>
|
</header>
|
||||||
{slot.selected ? (
|
{slot.selected ? (
|
||||||
<Link
|
<Link
|
||||||
@@ -164,3 +222,29 @@ function SlotCard({ slot, index }: { slot: PlanSlot; index: number }) {
|
|||||||
</article>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export interface RecipeCandidate {
|
|||||||
netCarbG: number;
|
netCarbG: number;
|
||||||
exclusionTags: ReadonlyArray<string>;
|
exclusionTags: ReadonlyArray<string>;
|
||||||
phases: ReadonlyArray<1 | 2 | 3>;
|
phases: ReadonlyArray<1 | 2 | 3>;
|
||||||
|
prepMinutes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MacroTargets {
|
export interface MacroTargets {
|
||||||
@@ -38,6 +39,11 @@ export interface MatchOptions {
|
|||||||
// Quote del pasto sul totale giornaliero (sommano a 1.0).
|
// Quote del pasto sul totale giornaliero (sommano a 1.0).
|
||||||
mealShare: { COLAZIONE: number; PRANZO: number; SPUNTINO: number; CENA: number };
|
mealShare: { COLAZIONE: number; PRANZO: number; SPUNTINO: number; CENA: number };
|
||||||
topN?: number; // default 5
|
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 {
|
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);
|
let score = Math.sqrt(dKcal * dKcal + dPro * dPro + dFat * dFat + dCarb * dCarb);
|
||||||
if (recentSet.has(r.id)) score += 1.5;
|
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 });
|
results.push({ ...r, score });
|
||||||
}
|
}
|
||||||
@@ -93,6 +105,22 @@ export function matchMeals(opts: MatchOptions): MatchResult[] {
|
|||||||
|
|
||||||
export type MealShare = MatchOptions['mealShare'];
|
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.
|
// Default share per pasto (somma 1.0). Può essere sovrascritta dall'utente.
|
||||||
export const DEFAULT_MEAL_SHARE: MealShare = {
|
export const DEFAULT_MEAL_SHARE: MealShare = {
|
||||||
COLAZIONE: 0.25,
|
COLAZIONE: 0.25,
|
||||||
|
|||||||
Reference in new issue
Block a user