fix(ui): hydration mismatch su /tracking e /plan (date calcolate client-side)

I React errors #418/#423/#425 segnalati dal browser nel flow di salvataggio check-in: l'HTML server-rendered divergeva dal client-rendered, causando `Text content does not match`. La submit del check-in falliva silenziosamente.

Cause:
- `weight-entry-form.tsx` calcolava `todayISO()` (basato su `new Date()`) dentro `defaultValues` di `useForm`. Il render avviene sia SSR che hydration, e fra i due il valore può differire (anche di pochi ms attraverso la mezzanotte locale, oppure per fuso orario server vs client).
- `plan-week.tsx` con `todayDayOfWeek()` aveva lo stesso problema sul pannello macros.

Fix: il valore viene calcolato server-side nel rispettivo Server Component (`tracking/page.tsx`, `plan/page.tsx`) e passato come prop al Client Component. Stabile attraverso SSR e hydration perché serializzato nell'HTML iniziale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-05-07 15:23:56 +02:00
1 parent f7f6410559
commit 221b567d45
4 files changed
+33 -18

No files matched your search

+8 -2
View File
@@ -26,7 +26,11 @@ export default async function PlanPage({ params: { locale } }: { params: { local
const [plan, dailyTarget] = await Promise.all([fetchCurrentPlan(), fetchDailyTarget()]);
return <PlanPageContent plan={plan} dailyTarget={dailyTarget} />;
// Calcolato server-side per evitare hydration mismatch nel pannello macros.
const dow = new Date().getDay();
const todayDayOfWeek = dow === 0 ? 6 : dow - 1;
return <PlanPageContent plan={plan} dailyTarget={dailyTarget} todayDayOfWeek={todayDayOfWeek} />;
}
const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
@@ -49,9 +53,11 @@ function formatWeek(weekStart: string): string {
function PlanPageContent({
plan,
dailyTarget,
todayDayOfWeek,
}: {
plan: Awaited<ReturnType<typeof fetchCurrentPlan>>;
dailyTarget: Awaited<ReturnType<typeof fetchDailyTarget>>;
todayDayOfWeek: number;
}) {
const t = useTranslations('Plan');
@@ -154,7 +160,7 @@ function PlanPageContent({
{plan ? (
<div className="animate-fade-up relative [animation-delay:480ms]">
<PlanWeek plan={plan} dailyTarget={dailyTarget} />
<PlanWeek plan={plan} dailyTarget={dailyTarget} todayDayOfWeek={todayDayOfWeek} />
</div>
) : null}
</main>
+5 -7
View File
@@ -62,18 +62,16 @@ function aggregateMacros(slots: PlanSlot[]): {
const FREE_MEAL_DEFAULT_KCAL = 750;
// PRD §6 — il pannello macros laterale ancora oggi (lun=0…dom=6).
function todayDayOfWeek(): number {
const d = new Date().getDay(); // 0 = Sunday
return d === 0 ? 6 : d - 1;
}
export function PlanWeek({
plan,
dailyTarget,
todayDayOfWeek,
}: {
plan: CurrentPlan;
dailyTarget: DailyTarget | null;
/** Indice giorno corrente (lun=0…dom=6) calcolato server-side per evitare
* hydration mismatch fra render SSR e client. */
todayDayOfWeek: number;
}) {
const t = useTranslations('Plan');
@@ -83,7 +81,7 @@ export function PlanWeek({
byDay.get(slot.dayOfWeek)!.push(slot);
}
const today = todayDayOfWeek();
const today = todayDayOfWeek;
const todaySlots = byDay.get(today) ?? [];
const todayConsumed = aggregateMacros(todaySlots.filter((s) => s.consumed));
const todayPlanned = aggregateMacros(todaySlots);
+8 -1
View File
@@ -38,11 +38,16 @@ export default async function TrackingPage({ params: { locale } }: { params: { l
fetchTodayCheckIn(),
]);
// Calcolato server-side per evitare hydration mismatch su WeightEntryForm.
const now = new Date();
const todayISO = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
return (
<TrackingPageContent
entries={entries}
profile={profile as ProfileLike}
todayCheckIn={todayCheckIn}
todayISO={todayISO}
/>
);
}
@@ -51,10 +56,12 @@ function TrackingPageContent({
entries,
profile,
todayCheckIn,
todayISO,
}: {
entries: Awaited<ReturnType<typeof fetchWeightEntries>>;
profile: ProfileLike;
todayCheckIn: Awaited<ReturnType<typeof fetchTodayCheckIn>>;
todayISO: string;
}) {
const t = useTranslations('Tracking');
const latest = entries[0] ?? null;
@@ -135,7 +142,7 @@ function TrackingPageContent({
<div className="relative grid gap-12 md:grid-cols-12">
<section className="animate-fade-up [animation-delay:300ms] md:col-span-7">
<p className="editorial-eyebrow mb-6">{t('newEntry')}</p>
<WeightEntryForm latest={latest} />
<WeightEntryForm latest={latest} todayISO={todayISO} />
</section>
<aside className="md:border-ink/15 animate-fade-up [animation-delay:420ms] md:col-span-5 md:border-l md:pl-10">
<p className="editorial-eyebrow mb-6">{t('history')}</p>
@@ -24,12 +24,16 @@ import { Input } from '@/components/ui/input';
import { saveWeightEntry, type WeightEntryRow } from './actions';
function todayISO(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
export function WeightEntryForm({ latest }: { latest: WeightEntryRow | null }) {
export function WeightEntryForm({
latest,
todayISO,
}: {
latest: WeightEntryRow | null;
/** Data odierna in formato YYYY-MM-DD calcolata server-side. Passata
* come prop per evitare hydration mismatch (server e client possono
* differire di fuso orario o di pochi ms attraverso la mezzanotte). */
todayISO: string;
}) {
const t = useTranslations('Tracking');
const [serverError, setServerError] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<string | null>(null);
@@ -41,7 +45,7 @@ export function WeightEntryForm({ latest }: { latest: WeightEntryRow | null }) {
const form = useForm<WeightEntryInput>({
resolver: zodResolver(weightEntryInputSchema),
defaultValues: {
date: todayISO(),
date: todayISO,
weightKg: latest?.weightKg,
} as unknown as WeightEntryInput,
});
@@ -54,7 +58,7 @@ export function WeightEntryForm({ latest }: { latest: WeightEntryRow | null }) {
return;
}
setSavedAt(new Date().toISOString());
form.reset({ date: todayISO(), weightKg: input.weightKg } as unknown as WeightEntryInput);
form.reset({ date: todayISO, weightKg: input.weightKg } as unknown as WeightEntryInput);
});
return (