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:
1 parent
b79340efb7
commit
ad1e4ecaeb
13 files changed
+1331
-14
No files matched your search
@@ -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,
|
||||
};
|
||||
});
|
||||
};
|
||||
Reference in new issue
Block a user