From bda96a3550aca51a17a67af8ca93eb989cc5a290 Mon Sep 17 00:00:00 2001 From: luciano Date: Wed, 29 Apr 2026 23:18:47 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20catalogo+30,=20varianti,=20sostituti,?= =?UTF-8?q?=20reintroduzione=20Fase=202,=20free=20meal=20Fase=203=20(PRD?= =?UTF-8?q?=20=C2=A75.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quattro completamenti del PRD §5.1: catalogo, varianti/sostituti, fase 2, free meal. Catalogo ricette 16 → 46 - seed-recipes.ts: +30 ricette italiane keto bilanciate (8 colazione · 8 pranzo · 6 spuntino · 8 cena) - seed-ingredients.ts: 33 → 55 ingredienti (verdura/carne/pesce/latticini/condimenti + categorie nuove `legumi` e `cereali` per Fase 2) - seed.ts: ora fa upsert anche degli ingredienti pre-esistenti per propagare campi nuovi (es. phase2Week) Varianti + sostituti - Recipe.variants Json (array { name, description, kcalDelta? }) - RecipeIngredient.substitutes String[] - /recipes/[id]: nuova sezione "Varianti suggerite" + riga "Sostituzioni:" sotto ogni ingrediente quando presenti Reintroduzione progressiva Fase 2 - Ingredient.phase2Week (settimana minima di reintroduzione, NULL=sempre) - User.phase2StartedAt: settato automaticamente quando si passa a TRANSITION - @ketopath/shared/planner/phase-progression: currentPhase2Week, isRecipeAllowedForPhaseWeek, weeksSince - plan.routes filtra fuori le ricette con ingredienti non ancora reintrodotti - POST /me/profile/phase per avanzare di fase (idempotente sul timestamp) - 12 unit test su phase-progression - Esempi seed: lenticchie phase2Week=3, ceci=4, mela=5, pera=6, quinoa=7 Free Meal pianificato Fase 3 - MealSlot.isFreeMeal Boolean (default false) - POST /me/meal-plans/slots/:id/free-meal toggle, valido solo in MAINTENANCE - @ketopath/shared/planner/phase-progression: freeMealKcalAdjustment (compensazione clamp -200/+50 kcal per pasto sui rimanenti) - /plan: bottone ☆ in ogni SlotCard quando phase=MAINTENANCE; lo slot marcato come pasto libero rende "Pasto libero pianificato" + ~750 kcal stimate; macro tracker ne tiene conto Indicatori di fase nel piano - /plan ora include currentPhase + phase2Week dalla API - Header del piano: "Settimana N di Fase 2" o "Mantenimento — Fase 3" i18n: namespaces Plan + Recipe arricchiti Schema migration 20260429XXXXXX_add_meal_plan_extras. 66/66 unit test verdi (12 nuovi). Smoke verificato live: API ritorna correctly i campi nuovi, UI rende badge fase, bottoni ☆/↻, free-meal slot. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/src/modules/plan/plan.routes.ts | 63 ++- .../api/src/modules/profile/profile.routes.ts | 36 ++ apps/web/messages/it.json | 8 + apps/web/src/app/[locale]/plan/actions.ts | 17 + apps/web/src/app/[locale]/plan/page.tsx | 12 + apps/web/src/app/[locale]/plan/plan-week.tsx | 93 ++- .../src/app/[locale]/recipes/[id]/page.tsx | 57 ++ .../migration.sql | 14 + packages/db/prisma/schema.prisma | 34 +- packages/db/prisma/seed-ingredients.ts | 261 ++++++++- packages/db/prisma/seed-recipes.ts | 533 ++++++++++++++++++ packages/db/prisma/seed.ts | 35 +- packages/shared/src/index.ts | 1 + .../src/planner/phase-progression.test.ts | 91 +++ .../shared/src/planner/phase-progression.ts | 77 +++ 15 files changed, 1288 insertions(+), 44 deletions(-) create mode 100644 packages/db/prisma/migrations/20260429210647_add_meal_plan_extras/migration.sql create mode 100644 packages/shared/src/planner/phase-progression.test.ts create mode 100644 packages/shared/src/planner/phase-progression.ts diff --git a/apps/api/src/modules/plan/plan.routes.ts b/apps/api/src/modules/plan/plan.routes.ts index 2f896c5..deb0f4a 100644 --- a/apps/api/src/modules/plan/plan.routes.ts +++ b/apps/api/src/modules/plan/plan.routes.ts @@ -5,8 +5,10 @@ import { calculateBmrKatchMcArdle, calculateTdee, computeDailyKcalTarget, + currentPhase2Week, extraKcalForSession, hasExcludingCondition, + isRecipeAllowedForPhaseWeek, isTrainingDay, macrosForPhase, matchMeals, @@ -71,7 +73,9 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { phases: true, prepMinutes: true, ingredients: { - select: { ingredient: { select: { exclusionGroups: true } } }, + select: { + ingredient: { select: { exclusionGroups: true, phase2Week: true } }, + }, }, }, }); @@ -79,7 +83,20 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { return reply.code(409).send({ error: 'recipe_catalog_empty' }); } - const candidates: RecipeCandidate[] = recipes.map((r) => { + // PRD §5.1 — Reintroduzione progressiva: in fase 2 filtriamo le ricette + // che contengono ingredienti non ancora reintrodotti per la settimana + // corrente di fase 2. + const phaseIntForFilter = phaseToInt(profile.currentPhase); + const phase2WeekForFilter = currentPhase2Week(profile.user.phase2StartedAt); + const allowedRecipes = recipes.filter((r) => + isRecipeAllowedForPhaseWeek( + r.ingredients.map((ri) => ({ phase2Week: ri.ingredient.phase2Week })), + phaseIntForFilter, + phase2WeekForFilter, + ), + ); + + const candidates: RecipeCandidate[] = allowedRecipes.map((r) => { const tags = new Set(); for (const ri of r.ingredients) { for (const g of ri.ingredient.exclusionGroups) tags.add(g); @@ -266,6 +283,31 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { }, ); + // PRD §5.1 — toggle "pasto libero". Disponibile solo in Fase 3. + fastify.post( + '/me/meal-plans/slots/:slotId/free-meal', + { 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: true }, + }); + if (!slot) return reply.code(404).send({ error: 'slot_not_found' }); + const profile = await fastify.prisma.profile.findUnique({ where: { userId } }); + if (!profile) return reply.code(409).send({ error: 'profile_required' }); + if (profile.currentPhase !== 'MAINTENANCE') { + return reply.code(409).send({ error: 'free_meal_phase3_only' }); + } + const updated = await fastify.prisma.mealSlot.update({ + where: { id: slotId }, + data: { isFreeMeal: !slot.isFreeMeal }, + }); + return { slot: { id: updated.id, isFreeMeal: updated.isFreeMeal } }; + }, + ); + // PRD §5.1 — rigenera UN solo slot: ricalcola le top5 ricette tenendo // conto dei pasti del giorno già scelti (coerenza dei macros). fastify.post( @@ -456,7 +498,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { fastify.get('/me/meal-plans/current', { preHandler: requireAuth() }, async (request, reply) => { const userId = request.user!.id; - const [plan, prefs] = await Promise.all([ + const [plan, prefs, profileForPhase, userForPhase2] = await Promise.all([ fastify.prisma.mealPlan.findFirst({ where: { userId, status: 'ACTIVE' }, orderBy: { weekStart: 'desc' }, @@ -494,14 +536,29 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => { where: { userId }, select: { fastingProtocol: true }, }), + fastify.prisma.profile.findUnique({ + where: { userId }, + select: { currentPhase: true }, + }), + fastify.prisma.user.findUnique({ + where: { id: userId }, + select: { phase2StartedAt: true }, + }), ]); if (!plan) return reply.code(404).send({ error: 'plan_not_found' }); + const currentPhase = profileForPhase?.currentPhase ?? null; + const phase2Week = + currentPhase === 'TRANSITION' + ? currentPhase2Week(userForPhase2?.phase2StartedAt ?? null) + : null; return { plan: { id: plan.id, weekStart: plan.weekStart.toISOString().slice(0, 10), slots: plan.slots, fastingProtocol: prefs?.fastingProtocol ?? null, + currentPhase, + phase2Week, }, }; }); diff --git a/apps/api/src/modules/profile/profile.routes.ts b/apps/api/src/modules/profile/profile.routes.ts index cf3ea06..200ecfe 100644 --- a/apps/api/src/modules/profile/profile.routes.ts +++ b/apps/api/src/modules/profile/profile.routes.ts @@ -153,6 +153,42 @@ export const profileRoutes: FastifyPluginAsync = async (fastify) => { return { profile: serialize(profile) }; }); + // PRD §5.1 — avanzamento di fase. Quando l'utente passa da INTENSIVE a + // TRANSITION (Fase 2), salviamo automaticamente phase2StartedAt sull'utente + // così la reintroduzione progressiva degli alimenti funziona dalla settimana + // corretta. + fastify.post('/me/profile/phase', { preHandler: requireAuth() }, async (request, reply) => { + const body = request.body as { phase?: unknown }; + const phase = body.phase; + if (phase !== 'INTENSIVE' && phase !== 'TRANSITION' && phase !== 'MAINTENANCE') { + return reply.code(400).send({ error: 'invalid_phase' }); + } + const userId = request.user!.id; + const profile = await fastify.prisma.profile.findUnique({ where: { userId } }); + if (!profile) return reply.code(404).send({ error: 'profile_not_found' }); + + const transitioning = profile.currentPhase !== 'TRANSITION' && phase === 'TRANSITION'; + + await fastify.prisma.profile.update({ + where: { userId }, + data: { currentPhase: phase }, + }); + if (transitioning) { + // Setta phase2StartedAt solo se non già impostato (idempotente). + const user = await fastify.prisma.user.findUnique({ + where: { id: userId }, + select: { phase2StartedAt: true }, + }); + if (!user?.phase2StartedAt) { + await fastify.prisma.user.update({ + where: { id: userId }, + data: { phase2StartedAt: new Date() }, + }); + } + } + return { phase }; + }); + // PATCH dedicato per le condizioni mediche: l'onboarding lo usa per salvare // solo questo delta senza richiedere tutti i campi obbligatori del PUT. fastify.patch('/me/profile/conditions', { preHandler: requireAuth() }, async (request, reply) => { diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index bd1d961..e024b0a 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -409,6 +409,8 @@ "phasesAll": "Tutte le fasi", "phasesSome": "Fase {phases}", "notesChef": "Nota dello chef", + "variantsTitle": "Varianti suggerite", + "substitutesPrefix": "Sostituzioni:", "difficultyLabel": { "FACILE": "Facile", "MEDIA": "Media", @@ -433,6 +435,12 @@ "hideAlternatives": "Nascondi", "swap": "Scegli", "regenerateSlot": "Rigenera questo pasto", + "freeMealMark": "Segna come pasto libero", + "freeMealUndo": "Torna a una ricetta keto", + "freeMealLabel": "Pasto libero pianificato", + "phase2Eyebrow": "Reintroduzione", + "phase2Week": "Settimana {n} di Fase 2", + "phase3Eyebrow": "Mantenimento — Fase 3", "dayTotal": "{kcal} kcal totali", "window": "Finestra alimentare", "avgKcal": "Media kcal/giorno", diff --git a/apps/web/src/app/[locale]/plan/actions.ts b/apps/web/src/app/[locale]/plan/actions.ts index becc7d7..bb77e93 100644 --- a/apps/web/src/app/[locale]/plan/actions.ts +++ b/apps/web/src/app/[locale]/plan/actions.ts @@ -21,6 +21,7 @@ export interface PlanSlot { meal: 'COLAZIONE' | 'PRANZO' | 'SPUNTINO' | 'CENA'; recipeId: string | null; status: string; + isFreeMeal: boolean; selected: SlotRecipe | null; alternatives: SlotRecipe[]; } @@ -37,6 +38,8 @@ export interface CurrentPlan { | 'ESE_24' | 'FIVE_TWO' | null; + currentPhase: 'INTENSIVE' | 'TRANSITION' | 'MAINTENANCE' | null; + phase2Week: number | null; } function cookieHeader(): string { @@ -85,6 +88,20 @@ export async function regenerateSlot(slotId: string): Promise { return { ok: true }; } +export async function toggleFreeMeal(slotId: string): Promise { + const res = await fetch(`${API_URL}/me/meal-plans/slots/${slotId}/free-meal`, { + 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/page.tsx b/apps/web/src/app/[locale]/plan/page.tsx index 14ce471..dd403e9 100644 --- a/apps/web/src/app/[locale]/plan/page.tsx +++ b/apps/web/src/app/[locale]/plan/page.tsx @@ -70,6 +70,18 @@ function PlanPageContent({ plan }: { plan: Awaited

) : null} + {plan?.currentPhase === 'TRANSITION' && plan.phase2Week ? ( +

+ {t('phase2Eyebrow')}{' '} + + {t('phase2Week', { n: plan.phase2Week })} + +

+ ) : plan?.currentPhase === 'MAINTENANCE' ? ( +

+ {t('phase3Eyebrow')} +

+ ) : null}
diff --git a/apps/web/src/app/[locale]/plan/plan-week.tsx b/apps/web/src/app/[locale]/plan/plan-week.tsx index dc87c7d..e7764d4 100644 --- a/apps/web/src/app/[locale]/plan/plan-week.tsx +++ b/apps/web/src/app/[locale]/plan/plan-week.tsx @@ -6,7 +6,13 @@ import { useState, useTransition } from 'react'; import { Button } from '@/components/ui/button'; -import { regenerateSlot, swapSlotRecipe, type CurrentPlan, type PlanSlot } from './actions'; +import { + regenerateSlot, + swapSlotRecipe, + toggleFreeMeal, + type CurrentPlan, + type PlanSlot, +} from './actions'; const MEAL_ORDER: PlanSlot['meal'][] = ['COLAZIONE', 'PRANZO', 'SPUNTINO', 'CENA']; const DAY_KEYS = [ @@ -28,6 +34,17 @@ function aggregateMacros(slots: PlanSlot[]): { } { return slots.reduce( (acc, s) => { + // Per i free meal usiamo una stima fissa kcal e azzeriamo i macros + // (non li conosciamo davvero) — il PRD parla di "compensazione" non di + // tracking puntuale del pasto libero. + if (s.isFreeMeal) { + return { + kcal: acc.kcal + FREE_MEAL_DEFAULT_KCAL, + proteinG: acc.proteinG, + fatG: acc.fatG, + netCarbG: acc.netCarbG, + }; + } if (!s.selected) return acc; return { kcal: acc.kcal + s.selected.kcal, @@ -40,6 +57,8 @@ function aggregateMacros(slots: PlanSlot[]): { ); } +const FREE_MEAL_DEFAULT_KCAL = 750; + export function PlanWeek({ plan }: { plan: CurrentPlan }) { const t = useTranslations('Plan'); @@ -49,6 +68,7 @@ export function PlanWeek({ plan }: { plan: CurrentPlan }) { byDay.get(slot.dayOfWeek)!.push(slot); } + const isPhase3 = plan.currentPhase === 'MAINTENANCE'; const weekTotals = aggregateMacros(plan.slots); const activeDays = Array.from(byDay.values()).filter((s) => s.length > 0).length; const weekAvg = @@ -114,7 +134,7 @@ export function PlanWeek({ plan }: { plan: CurrentPlan }) { ) : (
{slots.map((slot, slotIdx) => ( - + ))}
)} @@ -127,7 +147,15 @@ export function PlanWeek({ plan }: { plan: CurrentPlan }) { const MEAL_ROMAN = ['I', 'II', 'III', 'IV']; -function SlotCard({ slot, index }: { slot: PlanSlot; index: number }) { +function SlotCard({ + slot, + index, + canFreeMeal, +}: { + slot: PlanSlot; + index: number; + canFreeMeal: boolean; +}) { const t = useTranslations('Plan'); const [open, setOpen] = useState(false); const [pending, startTransition] = useTransition(); @@ -146,6 +174,13 @@ function SlotCard({ slot, index }: { slot: PlanSlot; index: number }) { }); } + function freeMeal() { + startTransition(async () => { + await toggleFreeMeal(slot.id); + setOpen(false); + }); + } + return (
@@ -155,18 +190,46 @@ function SlotCard({ slot, index }: { slot: PlanSlot; index: number }) { {t(`meals.${slot.meal}`)}

- +
+ {canFreeMeal ? ( + + ) : null} + {!slot.isFreeMeal ? ( + + ) : null} +
- {slot.selected ? ( + {slot.isFreeMeal ? ( + <> +

+ {t('freeMealLabel')} +

+

+ ~{FREE_MEAL_DEFAULT_KCAL}{' '} + kcal stimate +

+ + ) : slot.selected ? ( {t('noRecipe')}

)} - {slot.selected ? ( + {!slot.isFreeMeal && slot.selected ? (

{Math.round(slot.selected.kcal)}{' '} kcal diff --git a/apps/web/src/app/[locale]/recipes/[id]/page.tsx b/apps/web/src/app/[locale]/recipes/[id]/page.tsx index f08e5f5..040b5f0 100644 --- a/apps/web/src/app/[locale]/recipes/[id]/page.tsx +++ b/apps/web/src/app/[locale]/recipes/[id]/page.tsx @@ -13,6 +13,13 @@ interface RecipeIngredientView { category: string; quantity: number; unit: string; + substitutes: string[]; +} + +interface RecipeVariant { + name: string; + description: string; + kcalDelta?: number; } interface RecipeView { @@ -29,6 +36,7 @@ interface RecipeView { phases: number[]; notesChef: string | null; ingredients: RecipeIngredientView[]; + variants: RecipeVariant[]; } export default async function RecipePage({ @@ -72,7 +80,9 @@ export default async function RecipePage({ category: ri.ingredient.category, quantity: ri.quantity, unit: ri.unit, + substitutes: ri.substitutes ?? [], })), + variants: parseVariants(recipe.variants), }; return ; @@ -80,6 +90,21 @@ export default async function RecipePage({ const ROMAN = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI', 'XII']; +function parseVariants(value: unknown): RecipeVariant[] { + if (!Array.isArray(value)) return []; + return value + .filter((v): v is { name: string; description: string; kcalDelta?: number } => { + if (!v || typeof v !== 'object') return false; + const o = v as Record; + return typeof o.name === 'string' && typeof o.description === 'string'; + }) + .map((v) => ({ + name: v.name, + description: v.description, + ...(typeof v.kcalDelta === 'number' ? { kcalDelta: v.kcalDelta } : {}), + })); +} + function RecipeContent({ recipe }: { recipe: RecipeView }) { const t = useTranslations('Recipe'); const phasesAll = recipe.phases.length === 3; @@ -149,6 +174,11 @@ function RecipeContent({ recipe }: { recipe: RecipeView }) {

{ing.category}

+ {ing.substitutes.length > 0 ? ( +

+ {t('substitutesPrefix')} {ing.substitutes.join(', ')} +

+ ) : null}
{formatQuantity(ing.quantity)} {ing.unit} @@ -190,6 +220,33 @@ function RecipeContent({ recipe }: { recipe: RecipeView }) { ) : null} + + {recipe.variants.length > 0 ? ( + <> +
+
+

{t('variantsTitle')}

+
    + {recipe.variants.map((v, i) => ( +
  • +

    + {v.name} +

    +

    + {v.description} +

    + {v.kcalDelta != null ? ( +

    + {v.kcalDelta > 0 ? '+' : ''} + {v.kcalDelta} kcal +

    + ) : null} +
  • + ))} +
+
+ + ) : null}
diff --git a/packages/db/prisma/migrations/20260429210647_add_meal_plan_extras/migration.sql b/packages/db/prisma/migrations/20260429210647_add_meal_plan_extras/migration.sql new file mode 100644 index 0000000..65fdcc9 --- /dev/null +++ b/packages/db/prisma/migrations/20260429210647_add_meal_plan_extras/migration.sql @@ -0,0 +1,14 @@ +-- AlterTable +ALTER TABLE "ingredients" ADD COLUMN "phase2_week" INTEGER; + +-- AlterTable +ALTER TABLE "meal_slots" ADD COLUMN "is_free_meal" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "recipe_ingredients" ADD COLUMN "substitutes" TEXT[] DEFAULT ARRAY[]::TEXT[]; + +-- AlterTable +ALTER TABLE "recipes" ADD COLUMN "variants" JSONB NOT NULL DEFAULT '[]'; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "phase2_started_at" TIMESTAMP(3); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index f7a2224..bc500cd 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -86,6 +86,9 @@ model User { // PRD §14.3 — consenso esplicito al disclaimer medico. NULL finché l'utente // non lo accetta; bloccante per le pagine di profilo/tracking. disclaimerAcceptedAt DateTime? @map("disclaimer_accepted_at") + // PRD §5.1 — momento in cui l'utente è entrato nella fase 2 (TRANSITION). + // Serve a calcolare la "settimana di fase" per la reintroduzione progressiva. + phase2StartedAt DateTime? @map("phase2_started_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -282,6 +285,9 @@ model Ingredient { netCarbPer100g Float @map("net_carb_per_100g") exclusionGroups String[] @default([]) @map("exclusion_groups") priceAvgEur Float? @map("price_avg_eur") + // PRD §5.1 — settimana di Fase 2 dalla quale l'ingrediente diventa + // disponibile (1=da subito, 5=quinta settimana, ecc.). NULL = sempre. + phase2Week Int? @map("phase2_week") recipeIngredients RecipeIngredient[] @@ -304,6 +310,9 @@ model Recipe { phases Int[] @default([1, 2, 3]) servings Int @default(1) notesChef String? @map("notes_chef") + // PRD §5.1 — varianti della ricetta (es. "versione vegetariana"). Array + // JSON di { name, description, kcalDelta? }. Schema applicativo zod-validato. + variants Json @default("[]") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -316,11 +325,14 @@ model Recipe { } model RecipeIngredient { - id String @id @default(cuid()) - recipeId String @map("recipe_id") - ingredientId String @map("ingredient_id") + id String @id @default(cuid()) + recipeId String @map("recipe_id") + ingredientId String @map("ingredient_id") quantity Float unit String + // PRD §5.1 — suggerimenti di sostituzione per quell'ingrediente in questa + // ricetta. Stringhe libere ("ricotta intera", "tofu strapazzato"…). + substitutes String[] @default([]) recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) ingredient Ingredient @relation(fields: [ingredientId], references: [id]) @@ -372,12 +384,16 @@ model DeviceToken { } model MealSlot { - id String @id @default(cuid()) - planId String @map("plan_id") - dayOfWeek Int @map("day_of_week") - meal MealCategory - recipeId String? @map("recipe_id") - status String @default("pending") + id String @id @default(cuid()) + planId String @map("plan_id") + dayOfWeek Int @map("day_of_week") + meal MealCategory + recipeId String? @map("recipe_id") + status String @default("pending") + // PRD §5.1 — "pasto libero" pianificato in Fase 3. Quando true, lo slot + // è un placeholder (no ricetta KetoPath, kcal stimate ~750) e il piano + // adatta gli altri pasti della settimana. + isFreeMeal Boolean @default(false) @map("is_free_meal") plan MealPlan @relation(fields: [planId], references: [id], onDelete: Cascade) selected Recipe? @relation("SelectedRecipe", fields: [recipeId], references: [id]) diff --git a/packages/db/prisma/seed-ingredients.ts b/packages/db/prisma/seed-ingredients.ts index 849d5ee..2f0f74e 100644 --- a/packages/db/prisma/seed-ingredients.ts +++ b/packages/db/prisma/seed-ingredients.ts @@ -15,7 +15,9 @@ export interface SeedIngredient { | 'panetteria' | 'frutta-secca' | 'erbe' - | 'salumi'; + | 'salumi' + | 'legumi' + | 'cereali'; defaultUnit: 'g' | 'ml' | 'pz' | 'cucchiaio' | 'cucchiaino'; kcalPer100g: number; proteinPer100g: number; @@ -23,6 +25,8 @@ export interface SeedIngredient { netCarbPer100g: number; exclusionGroups: string[]; priceAvgEur?: number; + // PRD §5.1 — settimana di Fase 2 dalla quale è disponibile (1+). + phase2Week?: number; } export const SEED_INGREDIENTS: SeedIngredient[] = [ @@ -398,4 +402,259 @@ export const SEED_INGREDIENTS: SeedIngredient[] = [ exclusionGroups: [], priceAvgEur: 5.0, }, + // VERDURA — extra + { + name: 'Broccoli', + category: 'verdura', + defaultUnit: 'g', + kcalPer100g: 34, + proteinPer100g: 2.8, + fatPer100g: 0.4, + netCarbPer100g: 4.0, + exclusionGroups: [], + priceAvgEur: 2.0, + }, + { + name: 'Funghi champignon', + category: 'verdura', + defaultUnit: 'g', + kcalPer100g: 22, + proteinPer100g: 3.1, + fatPer100g: 0.3, + netCarbPer100g: 2.3, + exclusionGroups: [], + priceAvgEur: 4.5, + }, + { + name: 'Spinaci surgelati', + category: 'verdura', + defaultUnit: 'g', + kcalPer100g: 23, + proteinPer100g: 3.0, + fatPer100g: 0.4, + netCarbPer100g: 1.5, + exclusionGroups: [], + priceAvgEur: 1.5, + }, + { + name: 'Melanzane', + category: 'verdura', + defaultUnit: 'g', + kcalPer100g: 25, + proteinPer100g: 1.0, + fatPer100g: 0.2, + netCarbPer100g: 3.0, + exclusionGroups: [], + priceAvgEur: 2.0, + }, + { + name: 'Peperoni', + category: 'verdura', + defaultUnit: 'g', + kcalPer100g: 31, + proteinPer100g: 1.0, + fatPer100g: 0.3, + netCarbPer100g: 4.6, + exclusionGroups: [], + priceAvgEur: 2.8, + }, + // CARNE & PESCE — extra + { + name: 'Petto di tacchino', + category: 'carne', + defaultUnit: 'g', + kcalPer100g: 135, + proteinPer100g: 30, + fatPer100g: 1.0, + netCarbPer100g: 0, + exclusionGroups: [], + priceAvgEur: 12.0, + }, + { + name: 'Gamberi', + category: 'pesce', + defaultUnit: 'g', + kcalPer100g: 99, + proteinPer100g: 24, + fatPer100g: 0.3, + netCarbPer100g: 0.2, + exclusionGroups: ['fish'], + priceAvgEur: 18.0, + }, + { + name: 'Sgombro', + category: 'pesce', + defaultUnit: 'g', + kcalPer100g: 205, + proteinPer100g: 19, + fatPer100g: 14, + netCarbPer100g: 0, + exclusionGroups: ['fish'], + priceAvgEur: 12.0, + }, + { + name: 'Prosciutto crudo', + category: 'salumi', + defaultUnit: 'g', + kcalPer100g: 270, + proteinPer100g: 27, + fatPer100g: 18, + netCarbPer100g: 0, + exclusionGroups: ['pork'], + priceAvgEur: 32.0, + }, + { + name: 'Bresaola', + category: 'salumi', + defaultUnit: 'g', + kcalPer100g: 151, + proteinPer100g: 33, + fatPer100g: 2.0, + netCarbPer100g: 0, + exclusionGroups: ['beef'], + priceAvgEur: 38.0, + }, + // LATTICINI — extra + { + name: 'Mozzarella di bufala', + category: 'latticini', + defaultUnit: 'g', + kcalPer100g: 288, + proteinPer100g: 17, + fatPer100g: 24, + netCarbPer100g: 0.4, + exclusionGroups: ['lactose'], + priceAvgEur: 14.0, + }, + { + name: 'Stracchino', + category: 'latticini', + defaultUnit: 'g', + kcalPer100g: 298, + proteinPer100g: 18, + fatPer100g: 25, + netCarbPer100g: 1.0, + exclusionGroups: ['lactose'], + priceAvgEur: 12.0, + }, + { + name: 'Mascarpone', + category: 'latticini', + defaultUnit: 'g', + kcalPer100g: 429, + proteinPer100g: 4.8, + fatPer100g: 44, + netCarbPer100g: 4.8, + exclusionGroups: ['lactose'], + priceAvgEur: 8.0, + }, + // CONDIMENTI / SEMI — extra + { + name: 'Aceto balsamico', + category: 'condimenti', + defaultUnit: 'cucchiaio', + kcalPer100g: 88, + proteinPer100g: 0.5, + fatPer100g: 0, + netCarbPer100g: 17, + exclusionGroups: [], + priceAvgEur: 8.0, + }, + { + name: 'Semi di chia', + category: 'frutta-secca', + defaultUnit: 'g', + kcalPer100g: 486, + proteinPer100g: 17, + fatPer100g: 31, + netCarbPer100g: 1.4, + exclusionGroups: [], + priceAvgEur: 18.0, + }, + { + name: 'Cocco rapè non zuccherato', + category: 'frutta-secca', + defaultUnit: 'g', + kcalPer100g: 660, + proteinPer100g: 7, + fatPer100g: 64, + netCarbPer100g: 7, + exclusionGroups: [], + priceAvgEur: 10.0, + }, + // FRUTTA — phase2-introduced + { + name: 'Frutti di bosco misti', + category: 'frutta', + defaultUnit: 'g', + kcalPer100g: 47, + proteinPer100g: 0.9, + fatPer100g: 0.3, + netCarbPer100g: 7, + exclusionGroups: [], + priceAvgEur: 8.0, + phase2Week: 1, // disponibile da subito anche in fase 2 (low-carb) + }, + { + name: 'Mela', + category: 'frutta', + defaultUnit: 'pz', + kcalPer100g: 52, + proteinPer100g: 0.3, + fatPer100g: 0.2, + netCarbPer100g: 11, + exclusionGroups: [], + priceAvgEur: 2.5, + phase2Week: 5, // si reintroduce dalla 5ª settimana di fase 2 + }, + { + name: 'Pera', + category: 'frutta', + defaultUnit: 'pz', + kcalPer100g: 57, + proteinPer100g: 0.4, + fatPer100g: 0.1, + netCarbPer100g: 12, + exclusionGroups: [], + priceAvgEur: 2.8, + phase2Week: 6, + }, + // LEGUMI — phase2-introduced + { + name: 'Lenticchie cotte', + category: 'legumi', + defaultUnit: 'g', + kcalPer100g: 116, + proteinPer100g: 9, + fatPer100g: 0.4, + netCarbPer100g: 12, + exclusionGroups: [], + priceAvgEur: 2.5, + phase2Week: 3, + }, + { + name: 'Ceci cotti', + category: 'legumi', + defaultUnit: 'g', + kcalPer100g: 164, + proteinPer100g: 9, + fatPer100g: 2.6, + netCarbPer100g: 17, + exclusionGroups: [], + priceAvgEur: 2.5, + phase2Week: 4, + }, + // CEREALI — phase2-introduced (avanzato) + { + name: 'Quinoa cotta', + category: 'cereali', + defaultUnit: 'g', + kcalPer100g: 120, + proteinPer100g: 4.4, + fatPer100g: 1.9, + netCarbPer100g: 18, + exclusionGroups: [], + priceAvgEur: 6.0, + phase2Week: 7, + }, ]; diff --git a/packages/db/prisma/seed-recipes.ts b/packages/db/prisma/seed-recipes.ts index e2ae5ab..7732320 100644 --- a/packages/db/prisma/seed-recipes.ts +++ b/packages/db/prisma/seed-recipes.ts @@ -316,4 +316,537 @@ export const SEED_RECIPES: SeedRecipe[] = [ { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, ], }, + // ─── Espansione catalogo ────────────────────────────────────────────── + // COLAZIONE + { + name: 'Pancake proteici alla cannella', + category: 'COLAZIONE', + description: 'Tre pancake con uova, ricotta e cannella. Senza farina.', + prepMinutes: 10, + difficulty: 'FACILE', + kcal: 350, + proteinG: 26, + fatG: 22, + netCarbG: 5, + phases: [1, 2, 3], + notesChef: 'Per dolcezza naturale aggiungi qualche frutto di bosco da fase 2.', + ingredients: [ + { name: 'Uova', quantity: 2, unit: 'pz' }, + { name: 'Ricotta vaccina', quantity: 60, unit: 'g' }, + { name: 'Cannella in polvere', quantity: 1, unit: 'cucchiaino' }, + { name: 'Burro', quantity: 5, unit: 'g' }, + ], + }, + { + name: 'Smoothie verde con avocado e spinaci', + category: 'COLAZIONE', + description: 'Spinaci, mezzo avocado, yogurt greco e semi di chia frullati.', + prepMinutes: 5, + difficulty: 'FACILE', + kcal: 340, + proteinG: 14, + fatG: 24, + netCarbG: 8, + phases: [1, 2, 3], + ingredients: [ + { name: 'Spinaci freschi', quantity: 50, unit: 'g' }, + { name: 'Avocado', quantity: 0.5, unit: 'pz' }, + { name: 'Yogurt greco intero', quantity: 100, unit: 'g' }, + { name: 'Semi di chia', quantity: 10, unit: 'g' }, + ], + }, + { + name: 'Uova alla coque con bresaola', + category: 'COLAZIONE', + description: 'Due uova alla coque con 30 g di bresaola e olio EVO.', + prepMinutes: 8, + difficulty: 'FACILE', + kcal: 280, + proteinG: 28, + fatG: 17, + netCarbG: 1, + phases: [1, 2, 3], + ingredients: [ + { name: 'Uova', quantity: 2, unit: 'pz' }, + { name: 'Bresaola', quantity: 30, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 0.5, unit: 'cucchiaio' }, + ], + }, + { + name: 'Yogurt greco con frutti di bosco e semi', + category: 'COLAZIONE', + description: 'Yogurt greco intero, mirtilli e lamponi, semi di chia.', + prepMinutes: 3, + difficulty: 'FACILE', + kcal: 260, + proteinG: 17, + fatG: 13, + netCarbG: 12, + phases: [1, 2, 3], + ingredients: [ + { name: 'Yogurt greco intero', quantity: 200, unit: 'g' }, + { name: 'Frutti di bosco misti', quantity: 50, unit: 'g' }, + { name: 'Semi di chia', quantity: 10, unit: 'g' }, + ], + }, + { + name: 'Frittatina ai funghi e prezzemolo', + category: 'COLAZIONE', + description: 'Due uova con champignon trifolati e prezzemolo.', + prepMinutes: 12, + difficulty: 'FACILE', + kcal: 290, + proteinG: 22, + fatG: 21, + netCarbG: 3, + phases: [1, 2, 3], + ingredients: [ + { name: 'Uova', quantity: 2, unit: 'pz' }, + { name: 'Funghi champignon', quantity: 80, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], + }, + { + name: 'Avocado con uovo strapazzato', + category: 'COLAZIONE', + description: 'Mezzo avocado svuotato e farcito con un uovo strapazzato.', + prepMinutes: 7, + difficulty: 'FACILE', + kcal: 320, + proteinG: 13, + fatG: 26, + netCarbG: 4, + phases: [1, 2, 3], + ingredients: [ + { name: 'Avocado', quantity: 0.5, unit: 'pz' }, + { name: 'Uova', quantity: 1, unit: 'pz' }, + { name: 'Burro', quantity: 5, unit: 'g' }, + ], + }, + { + name: 'Mascarpone con noci e cocco', + category: 'COLAZIONE', + description: 'Mascarpone leggero con noci tritate e cocco rapè.', + prepMinutes: 3, + difficulty: 'FACILE', + kcal: 380, + proteinG: 7, + fatG: 36, + netCarbG: 6, + phases: [1, 2, 3], + ingredients: [ + { name: 'Mascarpone', quantity: 60, unit: 'g' }, + { name: 'Noci', quantity: 15, unit: 'g' }, + { name: 'Cocco rapè non zuccherato', quantity: 10, unit: 'g' }, + ], + }, + { + name: 'Omelette con prosciutto e mozzarella', + category: 'COLAZIONE', + description: 'Tre uova con prosciutto crudo e mozzarella di bufala.', + prepMinutes: 10, + difficulty: 'FACILE', + kcal: 460, + proteinG: 32, + fatG: 35, + netCarbG: 2, + phases: [1, 2, 3], + ingredients: [ + { name: 'Uova', quantity: 3, unit: 'pz' }, + { name: 'Prosciutto crudo', quantity: 30, unit: 'g' }, + { name: 'Mozzarella di bufala', quantity: 40, unit: 'g' }, + ], + }, + // PRANZO + { + name: 'Tacchino con broccoli al vapore', + category: 'PRANZO', + description: 'Petto di tacchino alla piastra con broccoli e olio EVO.', + prepMinutes: 20, + difficulty: 'FACILE', + kcal: 410, + proteinG: 42, + fatG: 22, + netCarbG: 6, + phases: [1, 2, 3], + ingredients: [ + { name: 'Petto di tacchino', quantity: 150, unit: 'g' }, + { name: 'Broccoli', quantity: 200, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1.5, unit: 'cucchiaio' }, + ], + }, + { + name: 'Insalata di sgombro e olive', + category: 'PRANZO', + description: 'Insalata mista, sgombro al naturale, olive nere, capperi.', + prepMinutes: 8, + difficulty: 'FACILE', + kcal: 480, + proteinG: 30, + fatG: 36, + netCarbG: 5, + phases: [1, 2, 3], + ingredients: [ + { name: 'Sgombro', quantity: 130, unit: 'g' }, + { name: 'Insalata mista', quantity: 100, unit: 'g' }, + { name: 'Olive nere', quantity: 25, unit: 'g' }, + { name: 'Capperi sotto sale', quantity: 1, unit: 'cucchiaio' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], + }, + { + name: 'Pollo al limone con asparagi', + category: 'PRANZO', + description: 'Petto di pollo marinato al limone con asparagi al forno.', + prepMinutes: 22, + difficulty: 'FACILE', + kcal: 470, + proteinG: 40, + fatG: 28, + netCarbG: 6, + phases: [1, 2, 3], + ingredients: [ + { name: 'Petto di pollo', quantity: 150, unit: 'g' }, + { name: 'Asparagi', quantity: 180, unit: 'g' }, + { name: 'Limone', quantity: 0.5, unit: 'pz' }, + { name: 'Olio extravergine di oliva', quantity: 1.5, unit: 'cucchiaio' }, + ], + }, + { + name: 'Caprese con bufala e pomodorini', + category: 'PRANZO', + description: 'Mozzarella di bufala con pomodorini, basilico e olio EVO.', + prepMinutes: 6, + difficulty: 'FACILE', + kcal: 500, + proteinG: 25, + fatG: 40, + netCarbG: 6, + phases: [1, 2, 3], + ingredients: [ + { name: 'Mozzarella di bufala', quantity: 125, unit: 'g' }, + { name: 'Pomodorini ciliegino', quantity: 150, unit: 'g' }, + { name: 'Basilico fresco', quantity: 10, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1.5, unit: 'cucchiaio' }, + ], + }, + { + name: 'Insalata di gamberi e avocado', + category: 'PRANZO', + description: 'Gamberi al vapore, avocado, rucola e limone.', + prepMinutes: 15, + difficulty: 'FACILE', + kcal: 420, + proteinG: 28, + fatG: 28, + netCarbG: 5, + phases: [1, 2, 3], + ingredients: [ + { name: 'Gamberi', quantity: 150, unit: 'g' }, + { name: 'Avocado', quantity: 0.5, unit: 'pz' }, + { name: 'Rucola', quantity: 60, unit: 'g' }, + { name: 'Limone', quantity: 0.5, unit: 'pz' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], + }, + { + name: 'Carpaccio di bresaola con grana', + category: 'PRANZO', + description: 'Bresaola con scaglie di grana, rucola e limone.', + prepMinutes: 5, + difficulty: 'FACILE', + kcal: 360, + proteinG: 38, + fatG: 22, + netCarbG: 2, + phases: [1, 2, 3], + ingredients: [ + { name: 'Bresaola', quantity: 80, unit: 'g' }, + { name: 'Grana Padano', quantity: 25, unit: 'g' }, + { name: 'Rucola', quantity: 50, unit: 'g' }, + { name: 'Limone', quantity: 0.5, unit: 'pz' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], + }, + { + name: 'Frittata di melanzane', + category: 'PRANZO', + description: 'Frittata con melanzane grigliate e parmigiano.', + prepMinutes: 25, + difficulty: 'MEDIA', + kcal: 450, + proteinG: 26, + fatG: 33, + netCarbG: 6, + phases: [1, 2, 3], + ingredients: [ + { name: 'Uova', quantity: 3, unit: 'pz' }, + { name: 'Melanzane', quantity: 150, unit: 'g' }, + { name: 'Parmigiano Reggiano', quantity: 25, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1.5, unit: 'cucchiaio' }, + ], + }, + { + name: 'Insalata di lenticchie e tonno', + category: 'PRANZO', + description: "Lenticchie cotte con tonno sott'olio, pomodorini e prezzemolo.", + prepMinutes: 10, + difficulty: 'FACILE', + kcal: 430, + proteinG: 32, + fatG: 18, + netCarbG: 22, + phases: [2, 3], + ingredients: [ + { name: 'Lenticchie cotte', quantity: 130, unit: 'g' }, + { name: "Tonno sott'olio", quantity: 100, unit: 'g' }, + { name: 'Pomodorini ciliegino', quantity: 80, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], + }, + // SPUNTINO + { + name: 'Noci e cubetti di parmigiano', + category: 'SPUNTINO', + description: 'Mix di noci e cubetti di parmigiano stagionato.', + prepMinutes: 1, + difficulty: 'FACILE', + kcal: 200, + proteinG: 11, + fatG: 15, + netCarbG: 2, + phases: [1, 2, 3], + ingredients: [ + { name: 'Noci', quantity: 15, unit: 'g' }, + { name: 'Parmigiano Reggiano', quantity: 25, unit: 'g' }, + ], + }, + { + name: 'Stracchino con cetrioli', + category: 'SPUNTINO', + description: 'Stracchino spalmato su bastoncini di cetriolo.', + prepMinutes: 3, + difficulty: 'FACILE', + kcal: 130, + proteinG: 7, + fatG: 10, + netCarbG: 3, + phases: [1, 2, 3], + ingredients: [ + { name: 'Stracchino', quantity: 40, unit: 'g' }, + { name: 'Cetrioli', quantity: 100, unit: 'g' }, + ], + }, + { + name: 'Frutti di bosco con cocco', + category: 'SPUNTINO', + description: 'Frutti di bosco freschi cosparsi di cocco rapè.', + prepMinutes: 2, + difficulty: 'FACILE', + kcal: 110, + proteinG: 1, + fatG: 7, + netCarbG: 9, + phases: [1, 2, 3], + ingredients: [ + { name: 'Frutti di bosco misti', quantity: 80, unit: 'g' }, + { name: 'Cocco rapè non zuccherato', quantity: 10, unit: 'g' }, + ], + }, + { + name: 'Prosciutto crudo e mozzarella', + category: 'SPUNTINO', + description: 'Fettina di prosciutto crudo arrotolata su mozzarella.', + prepMinutes: 1, + difficulty: 'FACILE', + kcal: 180, + proteinG: 14, + fatG: 13, + netCarbG: 1, + phases: [1, 2, 3], + ingredients: [ + { name: 'Prosciutto crudo', quantity: 30, unit: 'g' }, + { name: 'Mozzarella di bufala', quantity: 40, unit: 'g' }, + ], + }, + { + name: 'Mela e mandorle', + category: 'SPUNTINO', + description: 'Mezza mela a spicchi con mandorle al naturale.', + prepMinutes: 2, + difficulty: 'FACILE', + kcal: 160, + proteinG: 4, + fatG: 10, + netCarbG: 13, + phases: [2, 3], + ingredients: [ + { name: 'Mela', quantity: 0.5, unit: 'pz' }, + { name: 'Mandorle', quantity: 15, unit: 'g' }, + ], + }, + { + name: 'Hummus di ceci con verdure crude', + category: 'SPUNTINO', + description: 'Crema di ceci con bastoncini di sedano e peperoni.', + prepMinutes: 5, + difficulty: 'FACILE', + kcal: 170, + proteinG: 6, + fatG: 10, + netCarbG: 14, + phases: [2, 3], + ingredients: [ + { name: 'Ceci cotti', quantity: 60, unit: 'g' }, + { name: 'Peperoni', quantity: 80, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], + }, + // CENA + { + name: 'Sgombro al cartoccio con melanzane', + category: 'CENA', + description: 'Sgombro intero al cartoccio con melanzane e pomodorini.', + prepMinutes: 30, + difficulty: 'MEDIA', + kcal: 510, + proteinG: 32, + fatG: 38, + netCarbG: 6, + phases: [1, 2, 3], + notesChef: 'Cottura 20 min a 200°C. Se ti piace lo zenzero, aggiunto crudo è perfetto.', + ingredients: [ + { name: 'Sgombro', quantity: 200, unit: 'g' }, + { name: 'Melanzane', quantity: 150, unit: 'g' }, + { name: 'Pomodorini ciliegino', quantity: 80, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1.5, unit: 'cucchiaio' }, + ], + }, + { + name: 'Tagliolini di zucchine al pesto', + category: 'CENA', + description: 'Zucchine spiralizzate con pesto fresco di basilico e pinoli.', + prepMinutes: 15, + difficulty: 'FACILE', + kcal: 380, + proteinG: 12, + fatG: 32, + netCarbG: 8, + phases: [1, 2, 3], + ingredients: [ + { name: 'Zucchine', quantity: 250, unit: 'g' }, + { name: 'Basilico fresco', quantity: 20, unit: 'g' }, + { name: 'Parmigiano Reggiano', quantity: 20, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 2, unit: 'cucchiaio' }, + ], + }, + { + name: 'Pollo al curry con cavolfiore', + category: 'CENA', + description: 'Bocconcini di pollo al curry con cavolfiore "riso".', + prepMinutes: 25, + difficulty: 'MEDIA', + kcal: 470, + proteinG: 38, + fatG: 28, + netCarbG: 8, + phases: [1, 2, 3], + ingredients: [ + { name: 'Petto di pollo', quantity: 150, unit: 'g' }, + { name: 'Cavolfiore', quantity: 200, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1.5, unit: 'cucchiaio' }, + ], + }, + { + name: 'Salmone su crema di broccoli', + category: 'CENA', + description: 'Filetto di salmone scottato su vellutata di broccoli.', + prepMinutes: 25, + difficulty: 'MEDIA', + kcal: 530, + proteinG: 38, + fatG: 36, + netCarbG: 8, + phases: [1, 2, 3], + ingredients: [ + { name: 'Filetto di salmone', quantity: 150, unit: 'g' }, + { name: 'Broccoli', quantity: 200, unit: 'g' }, + { name: 'Burro', quantity: 10, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], + }, + { + name: 'Polpettone di tacchino e ricotta', + category: 'CENA', + description: 'Polpettone con tacchino macinato, ricotta e spinaci.', + prepMinutes: 35, + difficulty: 'MEDIA', + kcal: 480, + proteinG: 42, + fatG: 30, + netCarbG: 4, + phases: [1, 2, 3], + ingredients: [ + { name: 'Petto di tacchino', quantity: 150, unit: 'g' }, + { name: 'Ricotta vaccina', quantity: 60, unit: 'g' }, + { name: 'Spinaci surgelati', quantity: 80, unit: 'g' }, + { name: 'Uova', quantity: 1, unit: 'pz' }, + { name: 'Parmigiano Reggiano', quantity: 20, unit: 'g' }, + ], + }, + { + name: 'Funghi ripieni di salsiccia (alternativa)', + category: 'CENA', + description: 'Funghi champignon farciti con tacchino e parmigiano.', + prepMinutes: 25, + difficulty: 'MEDIA', + kcal: 420, + proteinG: 32, + fatG: 28, + netCarbG: 4, + phases: [1, 2, 3], + ingredients: [ + { name: 'Funghi champignon', quantity: 200, unit: 'g' }, + { name: 'Petto di tacchino', quantity: 100, unit: 'g' }, + { name: 'Parmigiano Reggiano', quantity: 25, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], + }, + { + name: 'Insalata di quinoa con pollo e verdure', + category: 'CENA', + description: 'Quinoa cotta con pollo, peperoni e zucchine grigliate.', + prepMinutes: 25, + difficulty: 'FACILE', + kcal: 510, + proteinG: 38, + fatG: 22, + netCarbG: 26, + phases: [3], + ingredients: [ + { name: 'Quinoa cotta', quantity: 100, unit: 'g' }, + { name: 'Petto di pollo', quantity: 130, unit: 'g' }, + { name: 'Peperoni', quantity: 100, unit: 'g' }, + { name: 'Zucchine', quantity: 100, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], + }, + { + name: 'Branzino in crosta di erbe', + category: 'CENA', + description: 'Branzino al forno con crosta di basilico, prezzemolo e mandorle.', + prepMinutes: 30, + difficulty: 'MEDIA', + kcal: 460, + proteinG: 42, + fatG: 28, + netCarbG: 5, + phases: [1, 2, 3], + ingredients: [ + { name: 'Branzino', quantity: 1, unit: 'pz' }, + { name: 'Basilico fresco', quantity: 15, unit: 'g' }, + { name: 'Mandorle', quantity: 20, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1.5, unit: 'cucchiaio' }, + ], + }, ]; diff --git a/packages/db/prisma/seed.ts b/packages/db/prisma/seed.ts index b8f65d8..e4271ec 100644 --- a/packages/db/prisma/seed.ts +++ b/packages/db/prisma/seed.ts @@ -6,25 +6,28 @@ import { SEED_RECIPES } from './seed-recipes.js'; const prisma = new PrismaClient(); async function main(): Promise { - // 1. Ingredienti — upsert sul nome (unique). + // 1. Ingredienti — upsert sul nome (unique). Aggiorniamo phase2Week e gli + // altri campi anche per quelli già presenti, così il seed resta autoritativo. let ingredientsInserted = 0; for (const i of SEED_INGREDIENTS) { + const data = { + category: i.category, + defaultUnit: i.defaultUnit, + kcalPer100g: i.kcalPer100g, + proteinPer100g: i.proteinPer100g, + fatPer100g: i.fatPer100g, + netCarbPer100g: i.netCarbPer100g, + exclusionGroups: i.exclusionGroups, + priceAvgEur: i.priceAvgEur ?? null, + phase2Week: i.phase2Week ?? null, + }; const existing = await prisma.ingredient.findUnique({ where: { name: i.name } }); - if (existing) continue; - await prisma.ingredient.create({ - data: { - name: i.name, - category: i.category, - defaultUnit: i.defaultUnit, - kcalPer100g: i.kcalPer100g, - proteinPer100g: i.proteinPer100g, - fatPer100g: i.fatPer100g, - netCarbPer100g: i.netCarbPer100g, - exclusionGroups: i.exclusionGroups, - priceAvgEur: i.priceAvgEur ?? null, - }, - }); - ingredientsInserted++; + if (existing) { + await prisma.ingredient.update({ where: { id: existing.id }, data }); + } else { + await prisma.ingredient.create({ data: { ...data, name: i.name } }); + ingredientsInserted++; + } } const allIngredients = await prisma.ingredient.findMany({ select: { id: true, name: true } }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 753be90..60ec1db 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -8,6 +8,7 @@ export * from './nutrition/tdee.js'; export * from './nutrition/types.js'; export * from './planner/macros.js'; export * from './planner/matchmaking.js'; +export * from './planner/phase-progression.js'; export * from './planner/types.js'; export * from './preferences/training.js'; export * from './profile/diet-history.js'; diff --git a/packages/shared/src/planner/phase-progression.test.ts b/packages/shared/src/planner/phase-progression.test.ts new file mode 100644 index 0000000..58a6569 --- /dev/null +++ b/packages/shared/src/planner/phase-progression.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; + +import { + currentPhase2Week, + freeMealKcalAdjustment, + isRecipeAllowedForPhaseWeek, + weeksSince, +} from './phase-progression.js'; + +describe('weeksSince / currentPhase2Week', () => { + it('oggi → 0 settimane intere; current = 1', () => { + const today = new Date(); + expect(weeksSince(today)).toBe(0); + expect(currentPhase2Week(today)).toBe(1); + }); + + it('14 giorni fa → 2 settimane; current = 3', () => { + const start = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000); + expect(weeksSince(start)).toBe(2); + expect(currentPhase2Week(start)).toBe(3); + }); + + it('null → settimana 1', () => { + expect(currentPhase2Week(null)).toBe(1); + expect(currentPhase2Week(undefined)).toBe(1); + }); +}); + +describe('isRecipeAllowedForPhaseWeek', () => { + const lentils = { phase2Week: 3 }; + const oliveOil = { phase2Week: null }; + const apple = { phase2Week: 5 }; + + it('Fase 1 → tutto permesso', () => { + expect(isRecipeAllowedForPhaseWeek([lentils, apple], 1, 1)).toBe(true); + }); + + it('Fase 3 → tutto permesso', () => { + expect(isRecipeAllowedForPhaseWeek([lentils, apple], 3, 1)).toBe(true); + }); + + it('Fase 2 settimana 2 → ricetta con lenticchie (week 3) bloccata', () => { + expect(isRecipeAllowedForPhaseWeek([lentils, oliveOil], 2, 2)).toBe(false); + }); + + it('Fase 2 settimana 3 → ricetta con lenticchie sblocca', () => { + expect(isRecipeAllowedForPhaseWeek([lentils, oliveOil], 2, 3)).toBe(true); + }); + + it('Fase 2 settimana 4 → mela ancora bloccata (week 5)', () => { + expect(isRecipeAllowedForPhaseWeek([apple], 2, 4)).toBe(false); + }); +}); + +describe('freeMealKcalAdjustment', () => { + it('free meal a 750 invece di 500 → spalma -250 sui rimanenti', () => { + const adj = freeMealKcalAdjustment({ + freeMealKcal: 750, + originalSlotKcal: 500, + remainingSlots: 5, + }); + // delta = 250, perSlot = 50, adjustment = -50 + expect(adj).toBe(-50); + }); + + it('cap di sicurezza a -200 kcal/pasto', () => { + const adj = freeMealKcalAdjustment({ + freeMealKcal: 2000, + originalSlotKcal: 500, + remainingSlots: 3, + }); + // perSlot = 500, ma cap -200 + expect(adj).toBe(-200); + }); + + it('free meal più piccolo del normale → leggero +', () => { + const adj = freeMealKcalAdjustment({ + freeMealKcal: 300, + originalSlotKcal: 500, + remainingSlots: 4, + }); + // delta = -200, perSlot = -50, adjustment = +50 (cap) + expect(adj).toBe(50); + }); + + it('remainingSlots 0 → 0', () => { + expect( + freeMealKcalAdjustment({ freeMealKcal: 750, originalSlotKcal: 500, remainingSlots: 0 }), + ).toBe(0); + }); +}); diff --git a/packages/shared/src/planner/phase-progression.ts b/packages/shared/src/planner/phase-progression.ts new file mode 100644 index 0000000..37b173d --- /dev/null +++ b/packages/shared/src/planner/phase-progression.ts @@ -0,0 +1,77 @@ +// PRD §5.1 — Reintroduzione progressiva degli alimenti in Fase 2. +// +// In Fase 2 (TRANSITION) alcuni ingredienti vengono reintrodotti gradualmente +// (legumi → cereali integrali → frutta più zuccherina). Ciascun `Ingredient` +// ha un `phase2Week` che indica la settimana minima di Fase 2 dalla quale +// è disponibile. NULL = sempre. + +export interface IngredientPhase2 { + phase2Week: number | null | undefined; +} + +export function weeksSince(start: Date | string, now: Date = new Date()): number { + const startMs = typeof start === 'string' ? new Date(start).getTime() : start.getTime(); + const elapsed = Math.max(0, now.getTime() - startMs); + return Math.floor(elapsed / (7 * 24 * 60 * 60 * 1000)); +} + +/** + * Settimana corrente della Fase 2. 1 = prima settimana. + * Se `phase2StartedAt` è null o nel futuro, ritorna 1. + */ +export function currentPhase2Week(phase2StartedAt: Date | string | null | undefined): number { + if (!phase2StartedAt) return 1; + return Math.max(1, weeksSince(phase2StartedAt) + 1); +} + +/** + * Vero se tutti gli ingredienti della ricetta sono disponibili nella + * settimana di fase 2 corrente (o se non siamo in fase 2). + * + * Il filtro è hard: ricetta con almeno un ingrediente non ancora reintrodotto + * viene esclusa. Per la Fase 1 (INTENSIVE) il filtro non si applica perché + * `Recipe.phases` esclude già le ricette non keto-compatibili. + */ +export function isRecipeAllowedForPhaseWeek( + ingredients: ReadonlyArray, + phase: 1 | 2 | 3, + weekInPhase: number, +): boolean { + if (phase !== 2) return true; + for (const i of ingredients) { + if (i.phase2Week != null && i.phase2Week > weekInPhase) return false; + } + return true; +} + +// PRD §5.1 — Compensazione per Free Meal in Fase 3. +// +// L'utente segna uno slot della settimana come "pasto libero" (es. cena al +// ristorante). Il free meal "pesa" un valore stimato (default 750 kcal) e +// gli altri pasti della settimana vengono ricalibrati per restare nel budget +// settimanale. + +export const FREE_MEAL_DEFAULT_KCAL = 750; + +/** + * Spalma la differenza fra freeMealKcal e il target normale del pasto sui + * pasti rimanenti della stessa settimana, fino a un limite di sicurezza. + * + * delta = freeMealKcal − originalSlotKcal + * adjustment per pasto = clamp(delta / remainingSlots, -200, +50) + * + * Il negativo (riduzione) è limitato a 200 kcal/pasto per non rendere i + * pasti troppo magri. La compensazione è morbida: il free meal *non* viene + * mai compensato al 100% del suo extra. + */ +export function freeMealKcalAdjustment(args: { + freeMealKcal: number; + originalSlotKcal: number; + remainingSlots: number; +}): number { + const { freeMealKcal, originalSlotKcal, remainingSlots } = args; + if (remainingSlots <= 0) return 0; + const delta = freeMealKcal - originalSlotKcal; + const perSlot = delta / remainingSlots; + return Math.max(-200, Math.min(50, -perSlot)); +}