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,
};
});
};