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
-
+