Fix SSR/hydration timezone flip in event date and time display
UTC timestamps rendered with date-fns format() or toLocaleTimeString() use the runtime's local timezone, causing the server (UTC) and client (PDT) to produce different output. suppressHydrationWarning was hiding the mismatch but the visual time-flip was still visible on load. - Add useHasMounted hook (useSyncExternalStore-based, no extra render cycle) - Gate UTC timestamp formatting behind isMounted in EventSchedule and UpcomingCalendarPanel; SSR always renders the stable YYYY-MM-DD eventDate string - Fix formatEventDate in date-utils.ts (and EventSchedule private copy) to parse YYYY-MM-DD as local midnight instead of UTC midnight, fixing the off-by-one day bug for UTC-negative users across all callers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8c9d328301
commit
8a9cffd076
4 changed files with 34 additions and 33 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import { format, parseISO } from "date-fns";
|
||||
import { format } from "date-fns";
|
||||
import { useHasMounted } from "~/hooks/useHasMounted";
|
||||
import { Link } from "react-router";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
|
|
@ -25,28 +26,13 @@ interface EventScheduleProps {
|
|||
function formatEventDate(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return "Date TBD";
|
||||
try {
|
||||
// eventDate is stored as a date string "YYYY-MM-DD"
|
||||
return format(parseISO(dateStr), "MMM d, yyyy");
|
||||
const [year, month, day] = dateStr.split("-").map(Number);
|
||||
return format(new Date(year, month - 1, day), "MMM d, yyyy");
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the display date for an event.
|
||||
* Prefers eventStartsAt (a full UTC timestamp → correct local date) over
|
||||
* eventDate (the UTC calendar date, which is off by one day for late-night
|
||||
* events in UTC-negative timezones like PDT).
|
||||
*/
|
||||
function getDisplayDate(event: Pick<ScheduleEvent, "eventDate" | "eventStartsAt">): string {
|
||||
if (event.eventStartsAt) {
|
||||
const d = new Date(event.eventStartsAt);
|
||||
if (!isNaN(d.getTime())) {
|
||||
return format(d, "MMM d, yyyy");
|
||||
}
|
||||
}
|
||||
return formatEventDate(event.eventDate);
|
||||
}
|
||||
|
||||
function getEventTypeLabel(eventType: string): string {
|
||||
switch (eventType) {
|
||||
|
|
@ -64,6 +50,16 @@ function getEventTypeLabel(eventType: string): string {
|
|||
}
|
||||
|
||||
export function EventSchedule({ upcomingEvents, recentEvents, leagueId, sportsSeasonId }: EventScheduleProps) {
|
||||
const isMounted = useHasMounted();
|
||||
|
||||
function getDisplayDate(event: Pick<ScheduleEvent, "eventDate" | "eventStartsAt">): string {
|
||||
if (isMounted && event.eventStartsAt) {
|
||||
const d = new Date(event.eventStartsAt as string);
|
||||
if (!isNaN(d.getTime())) return format(d, "MMM d, yyyy");
|
||||
}
|
||||
return formatEventDate(event.eventDate);
|
||||
}
|
||||
|
||||
const hasUpcoming = upcomingEvents.length > 0;
|
||||
const hasRecent = recentEvents.length > 0;
|
||||
|
||||
|
|
@ -112,11 +108,9 @@ export function EventSchedule({ upcomingEvents, recentEvents, leagueId, sportsSe
|
|||
</p>
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span suppressHydrationWarning>{getDisplayDate(event)}</span>
|
||||
{event.eventStartsAt && (
|
||||
<span suppressHydrationWarning>
|
||||
· {format(new Date(event.eventStartsAt), "h:mm a")}
|
||||
</span>
|
||||
<span>{getDisplayDate(event)}</span>
|
||||
{isMounted && event.eventStartsAt && (
|
||||
<span>· {format(new Date(event.eventStartsAt), "h:mm a")}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -178,7 +172,7 @@ export function EventSchedule({ upcomingEvents, recentEvents, leagueId, sportsSe
|
|||
</p>
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span suppressHydrationWarning>{getDisplayDate(event)}</span>
|
||||
<span>{getDisplayDate(event)}</span>
|
||||
</p>
|
||||
</div>
|
||||
{event.eventType !== "schedule_event" && (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { format } from "date-fns";
|
||||
import { Calendar, ChevronRight, Users } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useHasMounted } from "~/hooks/useHasMounted";
|
||||
import { Link } from "react-router";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import {
|
||||
|
|
@ -58,9 +59,10 @@ function ParticipantList({ participants }: { participants: Array<{ id: string; n
|
|||
}
|
||||
|
||||
function EventRow({ event, showLeague }: { event: CalendarPanelEvent; showLeague: boolean }) {
|
||||
const isMounted = useHasMounted();
|
||||
// Prefer game-level scheduledAt over event-level eventDate for display
|
||||
const gameDate = event.earliestGameTime ? new Date(event.earliestGameTime) : null;
|
||||
const dateStr = gameDate
|
||||
const dateStr = isMounted && gameDate
|
||||
? format(gameDate, "MMM d")
|
||||
: formatEventDate(event.eventDate);
|
||||
const participantCount = event.relevantParticipants.length;
|
||||
|
|
@ -73,14 +75,11 @@ function EventRow({ event, showLeague }: { event: CalendarPanelEvent; showLeague
|
|||
<div className="flex items-start gap-3 py-2.5 border-b last:border-0 group">
|
||||
{/* Date pill */}
|
||||
<div className="w-14 shrink-0 text-center">
|
||||
<span className="text-xs font-semibold text-electric" suppressHydrationWarning>
|
||||
<span className="text-xs font-semibold text-electric">
|
||||
{dateStr ?? "TBD"}
|
||||
</span>
|
||||
{gameDate && (
|
||||
<p
|
||||
className="text-xs text-muted-foreground leading-tight"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{isMounted && gameDate && (
|
||||
<p className="text-xs text-muted-foreground leading-tight">
|
||||
{gameDate.toLocaleTimeString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
|
|
|
|||
7
app/hooks/useHasMounted.ts
Normal file
7
app/hooks/useHasMounted.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const subscribe = () => () => {};
|
||||
|
||||
export function useHasMounted(): boolean {
|
||||
return useSyncExternalStore(subscribe, () => true, () => false);
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
* clear the field. Always write the result to a separate hidden input, not back to
|
||||
* the datetime-local input itself.
|
||||
*/
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { format } from "date-fns";
|
||||
|
||||
/**
|
||||
* Format a YYYY-MM-DD date string for display (e.g. "Apr 9").
|
||||
|
|
@ -25,7 +25,8 @@ import { format, parseISO } from "date-fns";
|
|||
export function formatEventDate(dateStr: string | null | undefined): string | null {
|
||||
if (!dateStr) return null;
|
||||
try {
|
||||
return format(parseISO(dateStr), "MMM d");
|
||||
const [year, month, day] = dateStr.split("-").map(Number);
|
||||
return format(new Date(year, month - 1, day), "MMM d");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue