feat(web): weekly meal plan UI with regenerate + per-slot swap

apps/api:
- New PATCH /me/meal-plans/slots/:slotId — accepts { recipeId } and validates
  ownership + that the new recipe is one of the slot's existing alternatives
  (or already selected). Returns the updated slot with the new selected recipe.

apps/web — /plan route:
- Server component runs the same auth+disclaimer+profile guard chain as
  /profile, then redirects to /profile if no profile exists yet (you can't
  generate a plan without macros)
- actions.ts exposes three server actions: fetchCurrentPlan, regeneratePlan
  and swapSlotRecipe; all proxy to api with the user's session cookie and
  revalidatePath('/plan') after writes
- plan-week.tsx renders day-by-day sections (Lun→Dom) with a 4-card meal
  row (1/2/4 columns at sm/lg breakpoints), recipe name + kcal, daily kcal
  totals, expandable list of alternatives with one-click "Scegli"
- "Genera piano" CTA when there's no plan yet, "Rigenera" when there is one
- Italian copy under Plan.* with day, meal and kcal-total interpolations
- Home gains a "Vedi il piano settimanale" CTA next to "Completa profilo"

Verified end-to-end in Chrome:
- generate plan → 7 sections (Lun-Dom), each with 4 meal cards and per-day
  kcal sum (e.g. Lunedì 1510 kcal = 360+540+150+460)
- recently-consumed penalty visible: Mon/Tue/Wed have different breakfasts
- swap: clicked "Vedi 3 alternative" on Mon Colazione → "Scegli" on
  "Uova strapazzate" → card refreshed to that recipe and 410 kcal

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 16:23:50 +02:00
1 parent e7158abbad
commit f8eaf2f493
6 files changed
+311 -1

No files matched your search

+33
View File
@@ -131,6 +131,39 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
});
});
fastify.patch(
'/me/meal-plans/slots/:slotId',
{ preHandler: requireAuth() },
async (request, reply) => {
const slotId = (request.params as { slotId: string }).slotId;
const body = request.body as { recipeId?: unknown };
if (typeof body.recipeId !== 'string') {
return reply.code(400).send({ error: 'invalid_body' });
}
const userId = request.user!.id;
const slot = await fastify.prisma.mealSlot.findFirst({
where: { id: slotId, plan: { userId } },
include: { alternatives: { select: { id: true } } },
});
if (!slot) return reply.code(404).send({ error: 'slot_not_found' });
const allowedIds = new Set([slot.recipeId, ...slot.alternatives.map((a) => a.id)]);
if (!allowedIds.has(body.recipeId)) {
return reply.code(400).send({ error: 'recipe_not_in_alternatives' });
}
const updated = await fastify.prisma.mealSlot.update({
where: { id: slotId },
data: { recipeId: body.recipeId },
include: {
selected: { select: { id: true, name: true, kcal: true } },
},
});
return { slot: updated };
},
);
fastify.get('/me/meal-plans/current', { preHandler: requireAuth() }, async (request, reply) => {
const plan = await fastify.prisma.mealPlan.findFirst({
where: { userId: request.user!.id, status: 'ACTIVE' },