feat: recipe detail + weekly shopping list (PRD §5.1, §5.4)

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) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-04-29 21:13:00 +02:00
1 parent b79340efb7
commit ad1e4ecaeb
13 files changed
+1331 -14

No files matched your search

+2
View File
@@ -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<FastifyInstance> {
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) => {
@@ -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<AggKey, ShoppingLine>();
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<string, ShoppingLine[]>();
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,
};
});
};
+59
View File
@@ -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}",
+1
View File
@@ -104,6 +104,7 @@ function SignedInBlock({
</div>
<ul className="space-y-px">
<NavItem href="/plan" label={t('viewPlan')} eyebrow="Cucina" />
<NavItem href="/shopping" label={t('viewShopping')} eyebrow="Spesa" />
<NavItem href="/fasting" label={t('viewFasting')} eyebrow="Digiuno" />
<NavItem href="/tracking" label={t('viewTracking')} eyebrow="Tracking" />
<NavItem href="/profile" label={t('completeProfile')} eyebrow="Profilo" />
+10 -1
View File
@@ -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<ReturnType<typeof fetchCurren
{plan ? formatWeek(plan.weekStart) : t('noPlan')}
</p>
</div>
<div className="animate-fade-up [animation-delay:360ms] md:col-span-4 md:text-right">
<div className="animate-fade-up flex flex-col items-stretch gap-3 [animation-delay:360ms] md:col-span-4 md:items-end">
<form action={regeneratePlan}>
<Button type="submit" variant={plan ? 'outline' : 'default'} size="lg">
{plan ? t('regenerate') : t('generate')}
</Button>
</form>
{plan ? (
<Link
href="/shopping"
className="text-ink-soft decoration-pomodoro hover:text-ink font-mono text-[11px] uppercase tracking-widest underline decoration-[1.5px] underline-offset-[5px] transition-colors"
>
Lista della spesa →
</Link>
) : null}
</div>
</div>
+16 -5
View File
@@ -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 }) {
<span>{t(`meals.${slot.meal}`)}</span>
</p>
</header>
<p className="font-display text-ink text-lg font-medium leading-tight">
{slot.selected?.name ?? t('noRecipe')}
</p>
{slot.selected ? (
<Link
href={`/recipes/${slot.selected.id}`}
className="font-display text-ink decoration-pomodoro hover:decoration-ink text-lg font-medium leading-tight underline decoration-[1.5px] underline-offset-[5px] transition-colors"
>
{slot.selected.name}
</Link>
) : (
<p className="font-display text-ink-soft text-lg italic leading-tight">{t('noRecipe')}</p>
)}
{slot.selected ? (
<p className="text-ink-soft font-mono text-xs tracking-tight">
{Math.round(slot.selected.kcal)}{' '}
@@ -109,12 +117,15 @@ function SlotCard({ slot, index }: { slot: PlanSlot; index: number }) {
<ul className="border-ink/15 mt-3 space-y-2 border-t pt-3">
{slot.alternatives.map((alt) => (
<li key={alt.id} className="flex items-baseline justify-between gap-2">
<span className="font-display text-ink text-sm leading-snug">
<Link
href={`/recipes/${alt.id}`}
className="font-display text-ink hover:text-pomodoro text-sm leading-snug transition-colors"
>
{alt.name}{' '}
<span className="text-ink-dim font-mono text-[10px]">
{Math.round(alt.kcal)} kcal
</span>
</span>
</Link>
<Button
type="button"
size="sm"
@@ -0,0 +1,243 @@
import { prisma } from '@ketopath/db';
import Link from 'next/link';
import { notFound, redirect } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';
import { Masthead } from '@/components/masthead';
import { getServerSession } from '@/lib/auth';
interface RecipeIngredientView {
id: string;
name: string;
category: string;
quantity: number;
unit: string;
}
interface RecipeView {
id: string;
name: string;
category: 'COLAZIONE' | 'PRANZO' | 'SPUNTINO' | 'CENA';
description: string | null;
prepMinutes: number;
difficulty: 'FACILE' | 'MEDIA' | 'ELABORATA';
kcal: number;
proteinG: number;
fatG: number;
netCarbG: number;
phases: number[];
notesChef: string | null;
ingredients: RecipeIngredientView[];
}
export default async function RecipePage({
params: { locale, id },
}: {
params: { locale: string; id: string };
}) {
setRequestLocale(locale);
const session = await getServerSession();
if (!session?.user) redirect('/sign-in');
const recipe = await prisma.recipe.findUnique({
where: { id },
include: {
ingredients: {
include: {
ingredient: { select: { id: true, name: true, category: true } },
},
},
},
});
if (!recipe) notFound();
const view: RecipeView = {
id: recipe.id,
name: recipe.name,
category: recipe.category,
description: recipe.description,
prepMinutes: recipe.prepMinutes,
difficulty: recipe.difficulty,
kcal: recipe.kcal,
proteinG: recipe.proteinG,
fatG: recipe.fatG,
netCarbG: recipe.netCarbG,
phases: recipe.phases,
notesChef: recipe.notesChef,
ingredients: recipe.ingredients.map((ri) => ({
id: ri.id,
name: ri.ingredient.name,
category: ri.ingredient.category,
quantity: ri.quantity,
unit: ri.unit,
})),
};
return <RecipeContent recipe={view} />;
}
const ROMAN = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI', 'XII'];
function RecipeContent({ recipe }: { recipe: RecipeView }) {
const t = useTranslations('Recipe');
const phasesAll = recipe.phases.length === 3;
return (
<div className="mx-auto min-h-screen max-w-5xl px-6 sm:px-10">
<Masthead issueLabel={`N. 08 — ${t(`categoryLabel.${recipe.category}`)}`} />
<main className="py-10 sm:py-14">
<Link
href="/plan"
className="editorial-eyebrow hover:text-ink text-ink-soft animate-fade-up inline-flex items-center transition-colors"
>
{t('back')}
</Link>
<header className="mt-6 grid gap-8 md:grid-cols-12">
<div className="md:col-span-8">
<p className="editorial-eyebrow animate-fade-up [animation-delay:120ms]">
{t(`categoryLabel.${recipe.category}`)}
</p>
<h1 className="font-display text-display text-ink animate-fade-up mt-3 font-medium leading-[0.95] tracking-tight [animation-delay:180ms]">
{recipe.name}
<span className="text-pomodoro">.</span>
</h1>
{recipe.description ? (
<p className="font-display text-ink-soft animate-fade-up mt-5 max-w-2xl text-xl italic leading-snug [animation-delay:300ms]">
{recipe.description}
</p>
) : null}
</div>
<aside className="md:border-ink/15 animate-fade-up [animation-delay:420ms] md:col-span-4 md:border-l md:pl-8">
<p className="editorial-eyebrow">{t('metaTitle')}</p>
<dl className="mt-5 space-y-4">
<MetaRow label={t('prepTime')} value={t('minutes', { n: recipe.prepMinutes })} />
<MetaRow label={t('difficulty')} value={t(`difficultyLabel.${recipe.difficulty}`)} />
<MetaRow
label={t('phase')}
value={
phasesAll
? t('phasesAll')
: t('phasesSome', { phases: recipe.phases.join(' · ') })
}
/>
</dl>
</aside>
</header>
<div className="rule animate-rule-in my-10 [animation-delay:480ms]" />
<section className="grid gap-12 md:grid-cols-12">
<div className="animate-fade-up [animation-delay:540ms] md:col-span-7">
<p className="editorial-eyebrow">{t('ingredientsTitle')}</p>
<p className="font-display text-ink-soft mt-1 text-sm italic">
{t('ingredientsSubtitle')}
</p>
<ol className="mt-7 space-y-5">
{recipe.ingredients.map((ing, idx) => (
<li
key={ing.id}
className="border-ink/10 grid grid-cols-[2.5rem_1fr_auto] items-baseline gap-4 border-b pb-4"
>
<span className="text-ink-dim font-mono text-xs uppercase tracking-widest">
{ROMAN[idx] ?? idx + 1}
</span>
<div>
<p className="font-display text-ink text-lg leading-tight">{ing.name}</p>
<p className="text-ink-soft mt-1 font-mono text-[10px] uppercase tracking-widest">
{ing.category}
</p>
</div>
<span className="text-ink font-mono text-sm tabular-nums">
{formatQuantity(ing.quantity)} {ing.unit}
</span>
</li>
))}
</ol>
</div>
<aside className="md:border-ink/15 animate-fade-up [animation-delay:660ms] md:col-span-5 md:border-l md:pl-10">
<p className="editorial-eyebrow">{t('macrosTitle')}</p>
<dl className="mt-7 space-y-6">
<MacroRow label={t('kcal')} value={Math.round(recipe.kcal)} unit="kcal" big />
<MacroRow label={t('protein')} value={Math.round(recipe.proteinG)} unit="g" />
<MacroRow label={t('fat')} value={Math.round(recipe.fatG)} unit="g" />
<MacroRow label={t('netCarb')} value={Math.round(recipe.netCarbG)} unit="g" />
</dl>
</aside>
</section>
<div className="rule animate-rule-in my-12 [animation-delay:780ms]" />
<section className="animate-fade-up grid gap-12 [animation-delay:840ms] md:grid-cols-12">
<div className="md:col-span-7">
<p className="editorial-eyebrow">{t('preparationTitle')}</p>
<p className="font-display text-ink mt-6 text-lg leading-relaxed">
<span className="drop-cap">
{(recipe.description ?? t('preparationFallback'))[0]}
</span>
{(recipe.description ?? t('preparationFallback')).slice(1)}
</p>
</div>
{recipe.notesChef ? (
<aside className="md:border-ink/15 md:col-span-5 md:border-l md:pl-10">
<p className="editorial-eyebrow">{t('notesChef')}</p>
<p className="font-display text-ink-soft mt-6 text-base italic leading-relaxed">
“{recipe.notesChef}”
</p>
</aside>
) : null}
</section>
</main>
<div className="rule-thick mt-16" />
</div>
);
}
function MetaRow({ label, value }: { label: string; value: string }) {
return (
<div className="grid grid-cols-2 items-baseline gap-3">
<dt className="editorial-eyebrow">{label}</dt>
<dd className="font-display text-ink text-base leading-tight">{value}</dd>
</div>
);
}
function MacroRow({
label,
value,
unit,
big = false,
}: {
label: string;
value: number;
unit: string;
big?: boolean;
}) {
return (
<div className="border-ink/10 grid grid-cols-2 items-baseline gap-3 border-b pb-4 last:border-b-0">
<dt className="editorial-eyebrow">{label}</dt>
<dd
className={`text-ink font-mono tabular-nums ${
big ? 'text-3xl font-medium' : 'text-xl'
} leading-none`}
>
{value}
<span className="font-display text-ink-soft ml-1 text-xs italic">{unit}</span>
</dd>
</div>
);
}
function formatQuantity(q: number): string {
if (Number.isInteger(q)) return String(q);
if (q < 1) {
if (q === 0.25) return '¼';
if (q === 0.5) return '½';
if (q === 0.75) return '¾';
}
return q.toFixed(1);
}
@@ -0,0 +1,39 @@
'use server';
import { headers } from 'next/headers';
const API_URL = process.env.API_URL ?? 'http://localhost:4000';
export interface ShoppingLine {
ingredientId: string;
name: string;
category: string;
unit: string;
quantity: number;
recipes: string[];
priceAvgEur: number | null;
}
export interface ShoppingGroup {
category: string;
items: ShoppingLine[];
}
export interface ShoppingList {
plan: { id: string; weekStart: string };
groups: ShoppingGroup[];
}
function cookieHeader(): string {
return headers().get('cookie') ?? '';
}
export async function fetchShoppingList(): Promise<ShoppingList | null> {
const res = await fetch(`${API_URL}/me/shopping-list`, {
headers: { cookie: cookieHeader() },
cache: 'no-store',
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`api_error_${res.status}`);
return (await res.json()) as ShoppingList;
}
+116
View File
@@ -0,0 +1,116 @@
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';
import { Masthead } from '@/components/masthead';
import { Button } from '@/components/ui/button';
import { getServerSession } from '@/lib/auth';
import { fetchShoppingList, type ShoppingList } from './actions';
import { ShoppingChecklist } from './shopping-checklist';
export default async function ShoppingPage({ params: { locale } }: { params: { locale: string } }) {
setRequestLocale(locale);
const session = await getServerSession();
if (!session?.user) redirect('/sign-in');
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { disclaimerAcceptedAt: true, profile: { select: { id: true } } },
});
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
if (!user.profile) redirect('/profile');
const list = await fetchShoppingList();
return <ShoppingPageContent list={list} />;
}
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
day: 'numeric',
month: 'long',
});
function formatWeek(weekStart: string): string {
const start = new Date(`${weekStart}T00:00:00`);
const end = new Date(start);
end.setDate(end.getDate() + 6);
return `${ITALIAN_DATE.format(start)} – ${ITALIAN_DATE.format(end)}`;
}
function ShoppingPageContent({ list }: { list: ShoppingList | null }) {
const t = useTranslations('Shopping');
const totalItems = list?.groups.reduce((sum, g) => sum + g.items.length, 0) ?? 0;
const estimatedCost = list?.groups.reduce(
(sum, g) =>
sum + g.items.reduce((s, it) => s + (it.priceAvgEur != null ? estimateLineCost(it) : 0), 0),
0,
);
return (
<div className="mx-auto min-h-screen max-w-5xl px-6 sm:px-10">
<Masthead issueLabel="N. 09 — La spesa" />
<main className="py-12 sm:py-16">
<div className="grid items-end gap-6 md:grid-cols-12">
<div className="md:col-span-8">
<p className="editorial-eyebrow animate-fade-up">Capitolo IX</p>
<h1 className="font-display text-display text-ink animate-fade-up mt-3 font-medium leading-[0.95] tracking-tight [animation-delay:120ms]">
{t('title')}
<span className="text-pomodoro">.</span>
</h1>
<p className="font-display text-ink-soft animate-fade-up mt-5 max-w-2xl text-xl italic leading-snug [animation-delay:240ms]">
{list ? formatWeek(list.plan.weekStart) : t('noPlan')}
</p>
</div>
{list ? (
<aside className="animate-fade-up [animation-delay:360ms] md:col-span-4 md:text-right">
<p className="editorial-eyebrow">{t('totalLabel')}</p>
<p className="font-display text-ink mt-2 font-mono text-3xl tabular-nums">
{totalItems}
</p>
{typeof estimatedCost === 'number' && estimatedCost > 0 ? (
<p className="text-ink-soft mt-3 font-mono text-xs uppercase tracking-widest">
{t('estimatedCost')}{' '}
<span className="text-ink not-italic">€ {estimatedCost.toFixed(2)}</span>
</p>
) : null}
</aside>
) : (
<div className="animate-fade-up [animation-delay:360ms] md:col-span-4 md:text-right">
<Button asChild size="lg">
<Link href="/plan">{t('viewPlan')}</Link>
</Button>
</div>
)}
</div>
<div className="rule animate-rule-in my-10 [animation-delay:420ms]" />
{list ? (
<div className="animate-fade-up [animation-delay:480ms]">
<ShoppingChecklist list={list} />
</div>
) : null}
</main>
<div className="rule-thick mt-16" />
</div>
);
}
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;
}
@@ -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<Record<string, boolean>>({});
// 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<string, boolean>);
} 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 (
<div className="space-y-12">
<p className="font-display text-ink-soft text-base italic leading-snug">
{t('checked', { n: checkedCount, total: totalCount })}
</p>
{list.groups.map((group, groupIdx) => (
<section
key={group.category}
aria-labelledby={`group-${group.category}`}
className="space-y-5"
>
<header className="border-ink/15 grid items-baseline gap-2 border-b pb-3 sm:grid-cols-[auto_1fr_auto]">
<span className="font-display text-pomodoro text-xl font-medium italic leading-none">
{ROMAN[groupIdx] ?? groupIdx + 1}
</span>
<h2
id={`group-${group.category}`}
className="font-display text-ink text-2xl font-medium leading-none tracking-tight sm:text-3xl"
>
{t.has(`categoryLabel.${group.category}`)
? t(`categoryLabel.${group.category}`)
: group.category}
</h2>
<span className="text-ink-soft font-mono text-[11px] uppercase tracking-widest">
{t('groupSize', { n: group.items.length })}
</span>
</header>
<ul className="divide-ink/10 divide-y">
{group.items.map((line) => {
const key = lineKey(line);
const isChecked = !!checked[key];
return (
<li key={key}>
<button
type="button"
onClick={() => toggle(line)}
aria-pressed={isChecked}
className="hover:bg-ink/5 group grid w-full grid-cols-[1.25rem_1fr_auto] items-baseline gap-4 py-4 pr-2 text-left transition-colors"
>
<span
aria-hidden
className={`border-ink h-3 w-3 translate-y-1 border-[1.5px] transition-colors ${
isChecked ? 'bg-ink' : 'bg-transparent'
}`}
/>
<span className="flex flex-col gap-1">
<span
className={`font-display text-ink text-lg leading-tight transition-colors ${
isChecked ? 'text-ink-dim line-through' : ''
}`}
>
{line.name}
</span>
<span className="text-ink-soft font-mono text-[10px] uppercase tracking-widest">
{t('fromRecipes', { names: line.recipes.join(' · ') })}
</span>
</span>
<span
className={`text-ink font-mono text-sm tabular-nums transition-colors ${
isChecked ? 'text-ink-dim' : ''
}`}
>
{formatQuantity(line.quantity)} {line.unit}
</span>
</button>
</li>
);
})}
</ul>
</section>
))}
</div>
);
}
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);
}
+401
View File
@@ -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,
},
];
+101
View File
@@ -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' },
],
},
];
+89 -8
View File
@@ -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<void> {
// 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()