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

+76
View File
@@ -0,0 +1,76 @@
'use server';
import { revalidatePath } from 'next/cache';
import { headers } from 'next/headers';
const API_URL = process.env.API_URL ?? 'http://localhost:4000';
interface SlotRecipe {
id: string;
name: string;
kcal: number;
}
export interface PlanSlot {
id: string;
dayOfWeek: number;
meal: 'COLAZIONE' | 'PRANZO' | 'SPUNTINO' | 'CENA';
recipeId: string | null;
status: string;
selected: SlotRecipe | null;
alternatives: SlotRecipe[];
}
export interface CurrentPlan {
id: string;
weekStart: string;
slots: PlanSlot[];
}
function cookieHeader(): string {
return headers().get('cookie') ?? '';
}
export async function fetchCurrentPlan(): Promise<CurrentPlan | null> {
const res = await fetch(`${API_URL}/me/meal-plans/current`, {
headers: { cookie: cookieHeader() },
cache: 'no-store',
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`api_error_${res.status}`);
const data = (await res.json()) as { plan: CurrentPlan };
return data.plan;
}
export type RegenerateResult = { ok: true } | { ok: false; error: string };
export async function regeneratePlan(): Promise<RegenerateResult> {
const res = await fetch(`${API_URL}/me/meal-plans`, {
method: 'POST',
headers: { cookie: cookieHeader() },
cache: 'no-store',
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
return { ok: false, error: body.error ?? `api_error_${res.status}` };
}
revalidatePath('/plan');
return { ok: true };
}
export type SwapResult = { ok: true } | { ok: false; error: string };
export async function swapSlotRecipe(slotId: string, recipeId: string): Promise<SwapResult> {
const res = await fetch(`${API_URL}/me/meal-plans/slots/${slotId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', cookie: cookieHeader() },
body: JSON.stringify({ recipeId }),
cache: 'no-store',
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
return { ok: false, error: body.error ?? `api_error_${res.status}` };
}
revalidatePath('/plan');
return { ok: true };
}