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:
1 parent
e7158abbad
commit
f8eaf2f493
6 files changed
+311
-1
No files matched your search
@@ -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' },
|
||||
|
||||
Reference in new issue
Block a user