From ad1e4ecaebaeaf602e3ca5ec6c369a7059a98d08 Mon Sep 17 00:00:00 2001 From: luciano Date: Wed, 29 Apr 2026 21:13:00 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20recipe=20detail=20+=20weekly=20shopping?= =?UTF-8?q?=20list=20(PRD=20=C2=A75.1,=20=C2=A75.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recipe detail - Seed catalog of 33 italian ingredients with macros, allergens, avg price. - Each seed recipe now declares its RecipeIngredient links (qty + unit). - /recipes/[id] page: editorial layout with prep meta, ingredients list, macros block, preparation, chef's note. Plan-week meal names linkable. Shopping list - GET /me/shopping-list aggregates RecipeIngredient quantities of the ACTIVE plan, grouped by ingredient category, sorted alphabetically. - /shopping page: total items, estimated cost, per-category checklist with check-off persisted in localStorage per plan id. - Home gets a "Spesa" nav item; /plan links the list when a plan exists. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/src/app.ts | 2 + .../src/modules/shopping/shopping.routes.ts | 99 +++++ apps/web/messages/it.json | 59 +++ apps/web/src/app/[locale]/page.tsx | 1 + apps/web/src/app/[locale]/plan/page.tsx | 11 +- apps/web/src/app/[locale]/plan/plan-week.tsx | 21 +- .../src/app/[locale]/recipes/[id]/page.tsx | 243 +++++++++++ apps/web/src/app/[locale]/shopping/actions.ts | 39 ++ apps/web/src/app/[locale]/shopping/page.tsx | 116 +++++ .../[locale]/shopping/shopping-checklist.tsx | 155 +++++++ packages/db/prisma/seed-ingredients.ts | 401 ++++++++++++++++++ packages/db/prisma/seed-recipes.ts | 101 +++++ packages/db/prisma/seed.ts | 97 ++++- 13 files changed, 1331 insertions(+), 14 deletions(-) create mode 100644 apps/api/src/modules/shopping/shopping.routes.ts create mode 100644 apps/web/src/app/[locale]/recipes/[id]/page.tsx create mode 100644 apps/web/src/app/[locale]/shopping/actions.ts create mode 100644 apps/web/src/app/[locale]/shopping/page.tsx create mode 100644 apps/web/src/app/[locale]/shopping/shopping-checklist.tsx create mode 100644 packages/db/prisma/seed-ingredients.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index c185902..61e51d4 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -11,6 +11,7 @@ import { healthRoutes } from './modules/health/health.routes.js'; import { meRoutes } from './modules/me/me.routes.js'; import { planRoutes } from './modules/plan/plan.routes.js'; import { profileRoutes } from './modules/profile/profile.routes.js'; +import { shoppingRoutes } from './modules/shopping/shopping.routes.js'; import { fastRoutes } from './modules/tracking/fast.routes.js'; import { weightRoutes } from './modules/tracking/weight.routes.js'; import { authPlugin } from './plugins/auth.js'; @@ -52,6 +53,7 @@ export async function buildApp(): Promise { await app.register(weightRoutes); await app.register(fastRoutes); await app.register(planRoutes); + await app.register(shoppingRoutes); if (env.SENTRY_DSN) { app.setErrorHandler((err, request, reply) => { diff --git a/apps/api/src/modules/shopping/shopping.routes.ts b/apps/api/src/modules/shopping/shopping.routes.ts new file mode 100644 index 0000000..7b96309 --- /dev/null +++ b/apps/api/src/modules/shopping/shopping.routes.ts @@ -0,0 +1,99 @@ +// PRD §5.4 — lista della spesa generata aggregando i RecipeIngredient +// del piano settimanale ACTIVE dell'utente. Aggregazione per (ingrediente, unità): +// se la stessa ricetta usa lo stesso ingrediente con unità diverse (raro ma +// possibile), restano righe separate. + +import type { FastifyPluginAsync } from 'fastify'; + +import { requireAuth } from '../../plugins/auth.js'; + +interface ShoppingLine { + ingredientId: string; + name: string; + category: string; + unit: string; + quantity: number; + recipes: string[]; // nomi delle ricette che la richiedono + priceAvgEur: number | null; +} + +interface ShoppingGroup { + category: string; + items: ShoppingLine[]; +} + +export const shoppingRoutes: FastifyPluginAsync = async (fastify) => { + fastify.get('/me/shopping-list', { preHandler: requireAuth() }, async (request, reply) => { + const userId = request.user!.id; + + const plan = await fastify.prisma.mealPlan.findFirst({ + where: { userId, status: 'ACTIVE' }, + orderBy: { weekStart: 'desc' }, + include: { + slots: { + where: { recipeId: { not: null } }, + include: { + selected: { + include: { + ingredients: { + include: { ingredient: true }, + }, + }, + }, + }, + }, + }, + }); + + if (!plan) return reply.code(404).send({ error: 'plan_not_found' }); + + type AggKey = string; // `${ingredientId}::${unit}` + const aggregated = new Map(); + + for (const slot of plan.slots) { + const recipe = slot.selected; + if (!recipe) continue; + for (const ri of recipe.ingredients) { + const key: AggKey = `${ri.ingredientId}::${ri.unit}`; + const existing = aggregated.get(key); + if (existing) { + existing.quantity += ri.quantity; + if (!existing.recipes.includes(recipe.name)) { + existing.recipes.push(recipe.name); + } + } else { + aggregated.set(key, { + ingredientId: ri.ingredientId, + name: ri.ingredient.name, + category: ri.ingredient.category, + unit: ri.unit, + quantity: ri.quantity, + recipes: [recipe.name], + priceAvgEur: ri.ingredient.priceAvgEur ?? null, + }); + } + } + } + + const byCategory = new Map(); + for (const line of aggregated.values()) { + if (!byCategory.has(line.category)) byCategory.set(line.category, []); + byCategory.get(line.category)!.push(line); + } + + const groups: ShoppingGroup[] = Array.from(byCategory.entries()) + .map(([category, items]) => ({ + category, + items: items.sort((a, b) => a.name.localeCompare(b.name, 'it')), + })) + .sort((a, b) => a.category.localeCompare(b.category, 'it')); + + return { + plan: { + id: plan.id, + weekStart: plan.weekStart.toISOString().slice(0, 10), + }, + groups, + }; + }); +}; diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index ae989a2..6e1f465 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -9,6 +9,7 @@ "signOut": "Esci", "completeProfile": "Profilo & calcoli", "viewPlan": "Piano della settimana", + "viewShopping": "Lista della spesa", "viewTracking": "Pesata & misure", "viewFasting": "Digiuno intermittente", "lettura": "Continua la lettura", @@ -164,6 +165,64 @@ "delta": "Variazione", "emptyHistory": "Nessuna rilevazione registrata. Inserisci la prima a destra: la tendenza apparirà dopo due o tre pesate." }, + "Shopping": { + "title": "La spesa della settimana", + "subtitle": "Tutto ciò che ti serve per il piano in corso, raggruppato per reparto.", + "noPlan": "Genera prima un piano della settimana: la lista comparirà qui.", + "viewPlan": "Vai al piano", + "groupSize": "{n} articoli", + "totalLabel": "Totale articoli", + "estimatedCost": "Spesa stimata", + "checked": "{n} di {total}", + "fromRecipes": "Per: {names}", + "categoryLabel": { + "verdura": "Verdura", + "frutta": "Frutta", + "carne": "Carne", + "pesce": "Pesce", + "latticini": "Latticini", + "uova": "Uova", + "condimenti": "Condimenti", + "panetteria": "Panetteria", + "frutta-secca": "Frutta secca", + "erbe": "Erbe e spezie", + "salumi": "Salumi" + }, + "viewCart": "Lista spesa" + }, + "Recipe": { + "title": "Ricetta", + "back": "← Torna al piano", + "ingredientsTitle": "Ingredienti", + "ingredientsSubtitle": "Per una porzione singola", + "preparationTitle": "Preparazione", + "preparationFallback": "Mescola gli ingredienti seguendo l'ordine indicato e cuoci come preferisci.", + "macrosTitle": "Valori per porzione", + "kcal": "Calorie", + "protein": "Proteine", + "fat": "Grassi", + "netCarb": "Carb. netti", + "metaTitle": "Scheda ricetta", + "prepTime": "Preparazione", + "minutes": "{n} min", + "difficulty": "Difficoltà", + "phase": "Fase", + "phasesAll": "Tutte le fasi", + "phasesSome": "Fase {phases}", + "notesChef": "Nota dello chef", + "difficultyLabel": { + "FACILE": "Facile", + "MEDIA": "Media", + "ELABORATA": "Elaborata" + }, + "categoryLabel": { + "COLAZIONE": "Colazione", + "PRANZO": "Pranzo", + "SPUNTINO": "Spuntino", + "CENA": "Cena" + }, + "notFound": "Ricetta non trovata." + }, "Plan": { "title": "Il tuo piano", "weekOf": "Settimana del {date}", diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index de2ae57..780e4ce 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -104,6 +104,7 @@ function SignedInBlock({
    + diff --git a/apps/web/src/app/[locale]/plan/page.tsx b/apps/web/src/app/[locale]/plan/page.tsx index 6805b99..3010666 100644 --- a/apps/web/src/app/[locale]/plan/page.tsx +++ b/apps/web/src/app/[locale]/plan/page.tsx @@ -1,4 +1,5 @@ import { prisma } from '@ketopath/db'; +import Link from 'next/link'; import { redirect } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { setRequestLocale } from 'next-intl/server'; @@ -62,12 +63,20 @@ function PlanPageContent({ plan }: { plan: Awaited -
    +
    + {plan ? ( + + Lista della spesa → + + ) : null}
    diff --git a/apps/web/src/app/[locale]/plan/plan-week.tsx b/apps/web/src/app/[locale]/plan/plan-week.tsx index 08c1ff3..a55c0fd 100644 --- a/apps/web/src/app/[locale]/plan/plan-week.tsx +++ b/apps/web/src/app/[locale]/plan/plan-week.tsx @@ -1,5 +1,6 @@ 'use client'; +import Link from 'next/link'; import { useTranslations } from 'next-intl'; import { useState, useTransition } from 'react'; @@ -87,9 +88,16 @@ function SlotCard({ slot, index }: { slot: PlanSlot; index: number }) { {t(`meals.${slot.meal}`)}

    -

    - {slot.selected?.name ?? t('noRecipe')} -

    + {slot.selected ? ( + + {slot.selected.name} + + ) : ( +

    {t('noRecipe')}

    + )} {slot.selected ? (

    {Math.round(slot.selected.kcal)}{' '} @@ -109,12 +117,15 @@ function SlotCard({ slot, index }: { slot: PlanSlot; index: number }) {

      {slot.alternatives.map((alt) => (
    • - + {alt.name}{' '} {Math.round(alt.kcal)} kcal - + + + )} + + +
      + + {list ? ( +
      + +
      + ) : null} + + +
      +
      + ); +} + +function estimateLineCost(line: { + quantity: number; + unit: string; + priceAvgEur: number | null; +}): number { + if (line.priceAvgEur == null) return 0; + // Prezzo medio è espresso al kg/litro/cad (vedi seed-ingredients). + // Heuristica semplice: g/ml → kg/litro (÷1000); pz / cucchiaio / cucchiaino → conta uno. + if (line.unit === 'g' || line.unit === 'ml') { + return (line.quantity / 1000) * line.priceAvgEur; + } + return line.quantity * line.priceAvgEur; +} diff --git a/apps/web/src/app/[locale]/shopping/shopping-checklist.tsx b/apps/web/src/app/[locale]/shopping/shopping-checklist.tsx new file mode 100644 index 0000000..9b1583f --- /dev/null +++ b/apps/web/src/app/[locale]/shopping/shopping-checklist.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useEffect, useMemo, useState } from 'react'; + +import type { ShoppingLine, ShoppingList } from './actions'; + +const ROMAN = [ + 'I', + 'II', + 'III', + 'IV', + 'V', + 'VI', + 'VII', + 'VIII', + 'IX', + 'X', + 'XI', + 'XII', + 'XIII', + 'XIV', + 'XV', + 'XVI', + 'XVII', + 'XVIII', + 'XIX', + 'XX', +]; + +function storageKey(planId: string): string { + return `ketopath:shopping:${planId}`; +} + +function lineKey(line: ShoppingLine): string { + return `${line.ingredientId}::${line.unit}`; +} + +export function ShoppingChecklist({ list }: { list: ShoppingList }) { + const t = useTranslations('Shopping'); + const [checked, setChecked] = useState>({}); + + // Load locally persisted check state per piano. + useEffect(() => { + try { + const raw = window.localStorage.getItem(storageKey(list.plan.id)); + if (raw) setChecked(JSON.parse(raw) as Record); + } catch { + /* no-op */ + } + }, [list.plan.id]); + + useEffect(() => { + try { + window.localStorage.setItem(storageKey(list.plan.id), JSON.stringify(checked)); + } catch { + /* no-op */ + } + }, [checked, list.plan.id]); + + const totalCount = useMemo( + () => list.groups.reduce((s, g) => s + g.items.length, 0), + [list.groups], + ); + const checkedCount = useMemo(() => Object.values(checked).filter(Boolean).length, [checked]); + + function toggle(line: ShoppingLine): void { + const key = lineKey(line); + setChecked((prev) => ({ ...prev, [key]: !prev[key] })); + } + + return ( +
      +

      + {t('checked', { n: checkedCount, total: totalCount })} +

      + + {list.groups.map((group, groupIdx) => ( +
      +
      + + {ROMAN[groupIdx] ?? groupIdx + 1} + +

      + {t.has(`categoryLabel.${group.category}`) + ? t(`categoryLabel.${group.category}`) + : group.category} +

      + + {t('groupSize', { n: group.items.length })} + +
      + +
        + {group.items.map((line) => { + const key = lineKey(line); + const isChecked = !!checked[key]; + return ( +
      • + +
      • + ); + })} +
      +
      + ))} +
      + ); +} + +function formatQuantity(q: number): string { + if (Number.isInteger(q)) return String(q); + if (q === 0.25) return '¼'; + if (q === 0.5) return '½'; + if (q === 0.75) return '¾'; + return q.toFixed(1); +} diff --git a/packages/db/prisma/seed-ingredients.ts b/packages/db/prisma/seed-ingredients.ts new file mode 100644 index 0000000..849d5ee --- /dev/null +++ b/packages/db/prisma/seed-ingredients.ts @@ -0,0 +1,401 @@ +// Catalogo ingredienti italiani keto/low-carb (PRD §5.1). +// Macros approssimati per 100 g. `exclusionGroups` mappa allergeni e diete: +// `lactose`, `egg`, `fish`, `nuts`, `gluten`, `pork`, `beef`, `vegan`. + +export interface SeedIngredient { + name: string; + category: + | 'verdura' + | 'frutta' + | 'carne' + | 'pesce' + | 'latticini' + | 'uova' + | 'condimenti' + | 'panetteria' + | 'frutta-secca' + | 'erbe' + | 'salumi'; + defaultUnit: 'g' | 'ml' | 'pz' | 'cucchiaio' | 'cucchiaino'; + kcalPer100g: number; + proteinPer100g: number; + fatPer100g: number; + netCarbPer100g: number; + exclusionGroups: string[]; + priceAvgEur?: number; +} + +export const SEED_INGREDIENTS: SeedIngredient[] = [ + // VERDURA + { + name: 'Spinaci freschi', + category: 'verdura', + defaultUnit: 'g', + kcalPer100g: 23, + proteinPer100g: 2.9, + fatPer100g: 0.4, + netCarbPer100g: 1.4, + exclusionGroups: [], + priceAvgEur: 2.5, + }, + { + name: 'Asparagi', + category: 'verdura', + defaultUnit: 'g', + kcalPer100g: 20, + proteinPer100g: 2.2, + fatPer100g: 0.1, + netCarbPer100g: 2.1, + exclusionGroups: [], + priceAvgEur: 4.0, + }, + { + name: 'Zucchine', + category: 'verdura', + defaultUnit: 'g', + kcalPer100g: 17, + proteinPer100g: 1.2, + fatPer100g: 0.3, + netCarbPer100g: 2.1, + exclusionGroups: [], + priceAvgEur: 1.8, + }, + { + name: 'Pomodorini ciliegino', + category: 'verdura', + defaultUnit: 'g', + kcalPer100g: 18, + proteinPer100g: 0.9, + fatPer100g: 0.2, + netCarbPer100g: 3.5, + exclusionGroups: [], + priceAvgEur: 3.2, + }, + { + name: 'Avocado', + category: 'frutta', + defaultUnit: 'pz', + kcalPer100g: 160, + proteinPer100g: 2, + fatPer100g: 15, + netCarbPer100g: 1.8, + exclusionGroups: [], + priceAvgEur: 1.9, + }, + { + name: 'Insalata mista', + category: 'verdura', + defaultUnit: 'g', + kcalPer100g: 15, + proteinPer100g: 1.3, + fatPer100g: 0.2, + netCarbPer100g: 1.8, + exclusionGroups: [], + priceAvgEur: 2.2, + }, + { + name: 'Rucola', + category: 'verdura', + defaultUnit: 'g', + kcalPer100g: 25, + proteinPer100g: 2.6, + fatPer100g: 0.7, + netCarbPer100g: 2.0, + exclusionGroups: [], + priceAvgEur: 2.8, + }, + { + name: 'Cavolfiore', + category: 'verdura', + defaultUnit: 'g', + kcalPer100g: 25, + proteinPer100g: 1.9, + fatPer100g: 0.3, + netCarbPer100g: 3.0, + exclusionGroups: [], + priceAvgEur: 1.5, + }, + { + name: 'Cetrioli', + category: 'verdura', + defaultUnit: 'g', + kcalPer100g: 16, + proteinPer100g: 0.7, + fatPer100g: 0.1, + netCarbPer100g: 3.1, + exclusionGroups: [], + priceAvgEur: 1.6, + }, + { + name: 'Carote', + category: 'verdura', + defaultUnit: 'g', + kcalPer100g: 41, + proteinPer100g: 0.9, + fatPer100g: 0.2, + netCarbPer100g: 6.8, + exclusionGroups: [], + priceAvgEur: 1.2, + }, + // CARNE + { + name: 'Petto di pollo', + category: 'carne', + defaultUnit: 'g', + kcalPer100g: 165, + proteinPer100g: 31, + fatPer100g: 3.6, + netCarbPer100g: 0, + exclusionGroups: [], + priceAvgEur: 9.5, + }, + { + name: 'Tagliata di manzo', + category: 'carne', + defaultUnit: 'g', + kcalPer100g: 250, + proteinPer100g: 26, + fatPer100g: 17, + netCarbPer100g: 0, + exclusionGroups: ['beef'], + priceAvgEur: 22.0, + }, + { + name: 'Pancetta affumicata', + category: 'salumi', + defaultUnit: 'g', + kcalPer100g: 540, + proteinPer100g: 12, + fatPer100g: 53, + netCarbPer100g: 1, + exclusionGroups: ['pork'], + priceAvgEur: 18.0, + }, + // PESCE + { + name: 'Filetto di salmone', + category: 'pesce', + defaultUnit: 'g', + kcalPer100g: 208, + proteinPer100g: 20, + fatPer100g: 13, + netCarbPer100g: 0, + exclusionGroups: ['fish'], + priceAvgEur: 22.0, + }, + { + name: 'Branzino', + category: 'pesce', + defaultUnit: 'pz', + kcalPer100g: 124, + proteinPer100g: 24, + fatPer100g: 2.5, + netCarbPer100g: 0, + exclusionGroups: ['fish'], + priceAvgEur: 18.0, + }, + { + name: "Tonno sott'olio", + category: 'pesce', + defaultUnit: 'g', + kcalPer100g: 200, + proteinPer100g: 25, + fatPer100g: 11, + netCarbPer100g: 0, + exclusionGroups: ['fish'], + priceAvgEur: 12.0, + }, + // LATTICINI + { + name: 'Parmigiano Reggiano', + category: 'latticini', + defaultUnit: 'g', + kcalPer100g: 392, + proteinPer100g: 36, + fatPer100g: 28, + netCarbPer100g: 0, + exclusionGroups: ['lactose'], + priceAvgEur: 24.0, + }, + { + name: 'Yogurt greco intero', + category: 'latticini', + defaultUnit: 'g', + kcalPer100g: 95, + proteinPer100g: 9, + fatPer100g: 5, + netCarbPer100g: 3.6, + exclusionGroups: ['lactose'], + priceAvgEur: 6.0, + }, + { + name: 'Ricotta vaccina', + category: 'latticini', + defaultUnit: 'g', + kcalPer100g: 174, + proteinPer100g: 11, + fatPer100g: 13, + netCarbPer100g: 3.0, + exclusionGroups: ['lactose'], + priceAvgEur: 5.5, + }, + { + name: 'Gorgonzola dolce DOP', + category: 'latticini', + defaultUnit: 'g', + kcalPer100g: 350, + proteinPer100g: 19, + fatPer100g: 30, + netCarbPer100g: 0, + exclusionGroups: ['lactose'], + priceAvgEur: 18.0, + }, + { + name: 'Burro', + category: 'latticini', + defaultUnit: 'g', + kcalPer100g: 720, + proteinPer100g: 0.9, + fatPer100g: 81, + netCarbPer100g: 0.1, + exclusionGroups: ['lactose'], + priceAvgEur: 9.0, + }, + { + name: 'Grana Padano', + category: 'latticini', + defaultUnit: 'g', + kcalPer100g: 384, + proteinPer100g: 33, + fatPer100g: 28, + netCarbPer100g: 0, + exclusionGroups: ['lactose'], + priceAvgEur: 18.0, + }, + // UOVA + { + name: 'Uova', + category: 'uova', + defaultUnit: 'pz', + kcalPer100g: 143, + proteinPer100g: 13, + fatPer100g: 10, + netCarbPer100g: 0.7, + exclusionGroups: ['egg'], + priceAvgEur: 4.5, + }, + // CONDIMENTI + { + name: 'Olio extravergine di oliva', + category: 'condimenti', + defaultUnit: 'cucchiaio', + kcalPer100g: 884, + proteinPer100g: 0, + fatPer100g: 100, + netCarbPer100g: 0, + exclusionGroups: [], + priceAvgEur: 12.0, + }, + { + name: 'Olive nere', + category: 'condimenti', + defaultUnit: 'g', + kcalPer100g: 115, + proteinPer100g: 0.8, + fatPer100g: 11, + netCarbPer100g: 3, + exclusionGroups: [], + priceAvgEur: 8.0, + }, + { + name: 'Olive verdi', + category: 'condimenti', + defaultUnit: 'g', + kcalPer100g: 145, + proteinPer100g: 1, + fatPer100g: 15, + netCarbPer100g: 1.5, + exclusionGroups: [], + priceAvgEur: 8.0, + }, + { + name: 'Capperi sotto sale', + category: 'condimenti', + defaultUnit: 'cucchiaio', + kcalPer100g: 23, + proteinPer100g: 2.4, + fatPer100g: 0.9, + netCarbPer100g: 1.7, + exclusionGroups: [], + priceAvgEur: 6.0, + }, + { + name: 'Limone', + category: 'frutta', + defaultUnit: 'pz', + kcalPer100g: 29, + proteinPer100g: 1.1, + fatPer100g: 0.3, + netCarbPer100g: 6.5, + exclusionGroups: [], + priceAvgEur: 0.6, + }, + // FRUTTA SECCA + { + name: 'Mandorle', + category: 'frutta-secca', + defaultUnit: 'g', + kcalPer100g: 579, + proteinPer100g: 21, + fatPer100g: 50, + netCarbPer100g: 9, + exclusionGroups: ['nuts'], + priceAvgEur: 18.0, + }, + { + name: 'Noci', + category: 'frutta-secca', + defaultUnit: 'g', + kcalPer100g: 654, + proteinPer100g: 15, + fatPer100g: 65, + netCarbPer100g: 7, + exclusionGroups: ['nuts'], + priceAvgEur: 22.0, + }, + // PANETTERIA + { + name: 'Pane proteico di mandorle', + category: 'panetteria', + defaultUnit: 'g', + kcalPer100g: 280, + proteinPer100g: 20, + fatPer100g: 18, + netCarbPer100g: 6, + exclusionGroups: ['nuts'], + priceAvgEur: 14.0, + }, + // ERBE + { + name: 'Basilico fresco', + category: 'erbe', + defaultUnit: 'g', + kcalPer100g: 23, + proteinPer100g: 3.2, + fatPer100g: 0.6, + netCarbPer100g: 1.0, + exclusionGroups: [], + priceAvgEur: 2.0, + }, + { + name: 'Cannella in polvere', + category: 'erbe', + defaultUnit: 'cucchiaino', + kcalPer100g: 247, + proteinPer100g: 4, + fatPer100g: 1.2, + netCarbPer100g: 27, + exclusionGroups: [], + priceAvgEur: 5.0, + }, +]; diff --git a/packages/db/prisma/seed-recipes.ts b/packages/db/prisma/seed-recipes.ts index c259e58..e2ae5ab 100644 --- a/packages/db/prisma/seed-recipes.ts +++ b/packages/db/prisma/seed-recipes.ts @@ -1,9 +1,19 @@ // Seed minimo del catalogo ricette italiane keto/low-carb (PRD §5.1). // 16 ricette distribuite su COLAZIONE/PRANZO/SPUNTINO/CENA, fasi 1/2/3. // Macros stimati per porzione singola. +// +// Ogni ricetta dichiara una lista di `ingredients` con quantità e unità, +// usata dal seeder per creare i record `RecipeIngredient`. Il `name` deve +// corrispondere esattamente a un ingrediente di `seed-ingredients.ts`. import type { Difficulty, MealCategory } from '@prisma/client'; +export interface SeedRecipeIngredient { + name: string; + quantity: number; + unit: string; +} + export interface SeedRecipe { name: string; category: MealCategory; @@ -16,6 +26,7 @@ export interface SeedRecipe { netCarbG: number; phases: number[]; notesChef?: string; + ingredients: SeedRecipeIngredient[]; } export const SEED_RECIPES: SeedRecipe[] = [ @@ -31,6 +42,14 @@ export const SEED_RECIPES: SeedRecipe[] = [ fatG: 26, netCarbG: 4, phases: [1, 2, 3], + notesChef: + 'Cuoci la frittata a fuoco basso, coperta: la consistenza resta morbida e le uova non bruciano.', + ingredients: [ + { name: 'Uova', quantity: 3, unit: 'pz' }, + { name: 'Spinaci freschi', quantity: 80, unit: 'g' }, + { name: 'Parmigiano Reggiano', quantity: 20, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], }, { name: 'Yogurt greco con noci e cannella', @@ -43,6 +62,11 @@ export const SEED_RECIPES: SeedRecipe[] = [ fatG: 22, netCarbG: 8, phases: [1, 2, 3], + ingredients: [ + { name: 'Yogurt greco intero', quantity: 200, unit: 'g' }, + { name: 'Noci', quantity: 20, unit: 'g' }, + { name: 'Cannella in polvere', quantity: 1, unit: 'cucchiaino' }, + ], }, { name: 'Avocado toast keto su pane di mandorle', @@ -55,6 +79,12 @@ export const SEED_RECIPES: SeedRecipe[] = [ fatG: 30, netCarbG: 6, phases: [1, 2, 3], + ingredients: [ + { name: 'Avocado', quantity: 0.5, unit: 'pz' }, + { name: 'Pane proteico di mandorle', quantity: 40, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + { name: 'Limone', quantity: 0.25, unit: 'pz' }, + ], }, { name: 'Uova strapazzate con pancetta', @@ -67,6 +97,11 @@ export const SEED_RECIPES: SeedRecipe[] = [ fatG: 33, netCarbG: 2, phases: [1, 2, 3], + ingredients: [ + { name: 'Uova', quantity: 2, unit: 'pz' }, + { name: 'Pancetta affumicata', quantity: 30, unit: 'g' }, + { name: 'Burro', quantity: 10, unit: 'g' }, + ], }, // PRANZO { @@ -80,6 +115,13 @@ export const SEED_RECIPES: SeedRecipe[] = [ fatG: 36, netCarbG: 9, phases: [1, 2, 3], + ingredients: [ + { name: 'Petto di pollo', quantity: 130, unit: 'g' }, + { name: 'Avocado', quantity: 0.5, unit: 'pz' }, + { name: 'Pomodorini ciliegino', quantity: 100, unit: 'g' }, + { name: 'Insalata mista', quantity: 60, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 2, unit: 'cucchiaio' }, + ], }, { name: 'Salmone al forno con asparagi', @@ -92,6 +134,13 @@ export const SEED_RECIPES: SeedRecipe[] = [ fatG: 35, netCarbG: 6, phases: [1, 2, 3], + notesChef: 'Inforna a 200°C per 12-14 minuti, salmone con la pelle verso il basso.', + ingredients: [ + { name: 'Filetto di salmone', quantity: 150, unit: 'g' }, + { name: 'Asparagi', quantity: 200, unit: 'g' }, + { name: 'Limone', quantity: 0.5, unit: 'pz' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], }, { name: 'Bistecca con zucchine grigliate', @@ -104,6 +153,11 @@ export const SEED_RECIPES: SeedRecipe[] = [ fatG: 38, netCarbG: 5, phases: [1, 2, 3], + ingredients: [ + { name: 'Tagliata di manzo', quantity: 180, unit: 'g' }, + { name: 'Zucchine', quantity: 200, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 2, unit: 'cucchiaio' }, + ], }, { name: 'Insalatona con tonno e olive', @@ -116,6 +170,13 @@ export const SEED_RECIPES: SeedRecipe[] = [ fatG: 32, netCarbG: 7, phases: [1, 2, 3], + ingredients: [ + { name: "Tonno sott'olio", quantity: 120, unit: 'g' }, + { name: 'Insalata mista', quantity: 100, unit: 'g' }, + { name: 'Olive nere', quantity: 30, unit: 'g' }, + { name: 'Capperi sotto sale', quantity: 1, unit: 'cucchiaio' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], }, // SPUNTINO { @@ -129,6 +190,7 @@ export const SEED_RECIPES: SeedRecipe[] = [ fatG: 13, netCarbG: 3, phases: [1, 2, 3], + ingredients: [{ name: 'Mandorle', quantity: 25, unit: 'g' }], }, { name: 'Cubetti di parmigiano', @@ -141,6 +203,7 @@ export const SEED_RECIPES: SeedRecipe[] = [ fatG: 8, netCarbG: 0, phases: [1, 2, 3], + ingredients: [{ name: 'Parmigiano Reggiano', quantity: 30, unit: 'g' }], }, { name: 'Olive verdi marinate', @@ -153,6 +216,10 @@ export const SEED_RECIPES: SeedRecipe[] = [ fatG: 9, netCarbG: 1, phases: [1, 2, 3], + ingredients: [ + { name: 'Olive verdi', quantity: 60, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 0.5, unit: 'cucchiaio' }, + ], }, { name: 'Cetrioli con hummus di zucchine', @@ -165,6 +232,11 @@ export const SEED_RECIPES: SeedRecipe[] = [ fatG: 8, netCarbG: 5, phases: [2, 3], + ingredients: [ + { name: 'Cetrioli', quantity: 100, unit: 'g' }, + { name: 'Zucchine', quantity: 60, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], }, // CENA { @@ -178,6 +250,15 @@ export const SEED_RECIPES: SeedRecipe[] = [ fatG: 28, netCarbG: 7, phases: [1, 2, 3], + notesChef: 'Cottura 18-20 minuti a 190°C; il cartoccio mantiene il pesce umido.', + ingredients: [ + { name: 'Branzino', quantity: 1, unit: 'pz' }, + { name: 'Zucchine', quantity: 100, unit: 'g' }, + { name: 'Carote', quantity: 60, unit: 'g' }, + { name: 'Pomodorini ciliegino', quantity: 80, unit: 'g' }, + { name: 'Limone', quantity: 0.5, unit: 'pz' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], }, { name: 'Polpette di pollo e ricotta', @@ -190,6 +271,14 @@ export const SEED_RECIPES: SeedRecipe[] = [ fatG: 32, netCarbG: 4, phases: [1, 2, 3], + ingredients: [ + { name: 'Petto di pollo', quantity: 150, unit: 'g' }, + { name: 'Ricotta vaccina', quantity: 60, unit: 'g' }, + { name: 'Parmigiano Reggiano', quantity: 20, unit: 'g' }, + { name: 'Basilico fresco', quantity: 10, unit: 'g' }, + { name: 'Uova', quantity: 1, unit: 'pz' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], }, { name: 'Zuppa di cavolfiore e gorgonzola', @@ -202,6 +291,12 @@ export const SEED_RECIPES: SeedRecipe[] = [ fatG: 33, netCarbG: 9, phases: [1, 2, 3], + ingredients: [ + { name: 'Cavolfiore', quantity: 250, unit: 'g' }, + { name: 'Gorgonzola dolce DOP', quantity: 60, unit: 'g' }, + { name: 'Burro', quantity: 10, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], }, { name: 'Tagliata di manzo con rucola e grana', @@ -214,5 +309,11 @@ export const SEED_RECIPES: SeedRecipe[] = [ fatG: 36, netCarbG: 3, phases: [1, 2, 3], + ingredients: [ + { name: 'Tagliata di manzo', quantity: 180, unit: 'g' }, + { name: 'Rucola', quantity: 60, unit: 'g' }, + { name: 'Grana Padano', quantity: 25, unit: 'g' }, + { name: 'Olio extravergine di oliva', quantity: 1, unit: 'cucchiaio' }, + ], }, ]; diff --git a/packages/db/prisma/seed.ts b/packages/db/prisma/seed.ts index 319cab0..b8f65d8 100644 --- a/packages/db/prisma/seed.ts +++ b/packages/db/prisma/seed.ts @@ -1,21 +1,102 @@ import { PrismaClient } from '@prisma/client'; +import { SEED_INGREDIENTS } from './seed-ingredients.js'; import { SEED_RECIPES } from './seed-recipes.js'; const prisma = new PrismaClient(); async function main(): Promise { - // Idempotente: createMany con skipDuplicates su nome (unique implicit nope — - // usiamo upsert per ognuna). - let inserted = 0; - for (const r of SEED_RECIPES) { - const existing = await prisma.recipe.findFirst({ where: { name: r.name } }); + // 1. Ingredienti — upsert sul nome (unique). + let ingredientsInserted = 0; + for (const i of SEED_INGREDIENTS) { + const existing = await prisma.ingredient.findUnique({ where: { name: i.name } }); if (existing) continue; - await prisma.recipe.create({ data: r }); - inserted++; + 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++; } + + const allIngredients = await prisma.ingredient.findMany({ select: { id: true, name: true } }); + const ingredientByName = new Map(allIngredients.map((i) => [i.name, i.id])); + + // 2. Ricette — `Recipe.name` non è unique nel modello, ma il seed garantisce + // unicità per costruzione. Idempotente: se esiste, aggiorniamo i campi + // anagrafici e ricreiamo i RecipeIngredient (così editare il seed propaga). + let recipesInserted = 0; + let linksCreated = 0; + for (const r of SEED_RECIPES) { + let recipe = await prisma.recipe.findFirst({ where: { name: r.name } }); + if (!recipe) { + recipe = await prisma.recipe.create({ + data: { + name: r.name, + category: r.category, + description: r.description, + prepMinutes: r.prepMinutes, + difficulty: r.difficulty, + kcal: r.kcal, + proteinG: r.proteinG, + fatG: r.fatG, + netCarbG: r.netCarbG, + phases: r.phases, + notesChef: r.notesChef ?? null, + }, + }); + recipesInserted++; + } else { + recipe = await prisma.recipe.update({ + where: { id: recipe.id }, + data: { + category: r.category, + description: r.description, + prepMinutes: r.prepMinutes, + difficulty: r.difficulty, + kcal: r.kcal, + proteinG: r.proteinG, + fatG: r.fatG, + netCarbG: r.netCarbG, + phases: r.phases, + notesChef: r.notesChef ?? null, + }, + }); + } + + await prisma.recipeIngredient.deleteMany({ where: { recipeId: recipe.id } }); + for (const ing of r.ingredients) { + const ingredientId = ingredientByName.get(ing.name); + if (!ingredientId) { + // eslint-disable-next-line no-console + console.warn(`[seed] ricetta "${r.name}": ingrediente "${ing.name}" non trovato`); + continue; + } + await prisma.recipeIngredient.create({ + data: { + recipeId: recipe.id, + ingredientId, + quantity: ing.quantity, + unit: ing.unit, + }, + }); + linksCreated++; + } + } + // eslint-disable-next-line no-console - console.info(`[seed] recipes inserted: ${inserted} (catalog total ${SEED_RECIPES.length})`); + console.info( + `[seed] ingredients +${ingredientsInserted} (catalog ${SEED_INGREDIENTS.length}), ` + + `recipes +${recipesInserted} (catalog ${SEED_RECIPES.length}), recipe-ingredient links: ${linksCreated}`, + ); } main()