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()]); 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', { const ITALIAN_DATE = new Intl.DateTimeFormat('it-IT', {
@@ -49,9 +53,11 @@ function formatWeek(weekStart: string): string {
function PlanPageContent({ function PlanPageContent({
plan, plan,
dailyTarget, dailyTarget,
todayDayOfWeek,
}: { }: {
plan: Awaited<ReturnType<typeof fetchCurrentPlan>>; plan: Awaited<ReturnType<typeof fetchCurrentPlan>>;
dailyTarget: Awaited<ReturnType<typeof fetchDailyTarget>>; dailyTarget: Awaited<ReturnType<typeof fetchDailyTarget>>;
todayDayOfWeek: number;
}) { }) {
const t = useTranslations('Plan'); const t = useTranslations('Plan');
@@ -154,7 +160,7 @@ function PlanPageContent({
{plan ? ( {plan ? (
<div className="animate-fade-up relative [animation-delay:480ms]"> <div className="animate-fade-up relative [animation-delay:480ms]">
<PlanWeek plan={plan} dailyTarget={dailyTarget} /> <PlanWeek plan={plan} dailyTarget={dailyTarget} todayDayOfWeek={todayDayOfWeek} />
</div> </div>
) : null} ) : null}
</main> </main>
+5 -7
View File
@@ -62,18 +62,16 @@ function aggregateMacros(slots: PlanSlot[]): {
const FREE_MEAL_DEFAULT_KCAL = 750; 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({ export function PlanWeek({
plan, plan,
dailyTarget, dailyTarget,
todayDayOfWeek,
}: { }: {
plan: CurrentPlan; plan: CurrentPlan;
dailyTarget: DailyTarget | null; 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'); const t = useTranslations('Plan');
@@ -83,7 +81,7 @@ export function PlanWeek({
byDay.get(slot.dayOfWeek)!.push(slot); byDay.get(slot.dayOfWeek)!.push(slot);
} }
const today = todayDayOfWeek(); const today = todayDayOfWeek;
const todaySlots = byDay.get(today) ?? []; const todaySlots = byDay.get(today) ?? [];
const todayConsumed = aggregateMacros(todaySlots.filter((s) => s.consumed)); const todayConsumed = aggregateMacros(todaySlots.filter((s) => s.consumed));
const todayPlanned = aggregateMacros(todaySlots); const todayPlanned = aggregateMacros(todaySlots);
+8 -1
View File
@@ -38,11 +38,16 @@ export default async function TrackingPage({ params: { locale } }: { params: { l
fetchTodayCheckIn(), 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 ( return (
<TrackingPageContent <TrackingPageContent
entries={entries} entries={entries}
profile={profile as ProfileLike} profile={profile as ProfileLike}
todayCheckIn={todayCheckIn} todayCheckIn={todayCheckIn}
todayISO={todayISO}
/> />
); );
} }
@@ -51,10 +56,12 @@ function TrackingPageContent({
entries, entries,
profile, profile,
todayCheckIn, todayCheckIn,
todayISO,
}: { }: {
entries: Awaited<ReturnType<typeof fetchWeightEntries>>; entries: Awaited<ReturnType<typeof fetchWeightEntries>>;
profile: ProfileLike; profile: ProfileLike;
todayCheckIn: Awaited<ReturnType<typeof fetchTodayCheckIn>>; todayCheckIn: Awaited<ReturnType<typeof fetchTodayCheckIn>>;
todayISO: string;
}) { }) {
const t = useTranslations('Tracking'); const t = useTranslations('Tracking');
const latest = entries[0] ?? null; const latest = entries[0] ?? null;
@@ -135,7 +142,7 @@ function TrackingPageContent({
<div className="relative grid gap-12 md:grid-cols-12"> <div className="relative grid gap-12 md:grid-cols-12">
<section className="animate-fade-up [animation-delay:300ms] md:col-span-7"> <section className="animate-fade-up [animation-delay:300ms] md:col-span-7">
<p className="editorial-eyebrow mb-6">{t('newEntry')}</p> <p className="editorial-eyebrow mb-6">{t('newEntry')}</p>
<WeightEntryForm latest={latest} /> <WeightEntryForm latest={latest} todayISO={todayISO} />
</section> </section>
<aside className="md:border-ink/15 animate-fade-up [animation-delay:420ms] md:col-span-5 md:border-l md:pl-10"> <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> <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'; import { saveWeightEntry, type WeightEntryRow } from './actions';
function todayISO(): string { export function WeightEntryForm({
const d = new Date(); latest,
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; todayISO,
} }: {
latest: WeightEntryRow | null;
export function WeightEntryForm({ latest }: { 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 t = useTranslations('Tracking');
const [serverError, setServerError] = useState<string | null>(null); const [serverError, setServerError] = useState<string | null>(null);
const [savedAt, setSavedAt] = 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>({ const form = useForm<WeightEntryInput>({
resolver: zodResolver(weightEntryInputSchema), resolver: zodResolver(weightEntryInputSchema),
defaultValues: { defaultValues: {
date: todayISO(), date: todayISO,
weightKg: latest?.weightKg, weightKg: latest?.weightKg,
} as unknown as WeightEntryInput, } as unknown as WeightEntryInput,
}); });
@@ -54,7 +58,7 @@ export function WeightEntryForm({ latest }: { latest: WeightEntryRow | null }) {
return; return;
} }
setSavedAt(new Date().toISOString()); 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 ( return (