From 8a9cffd0767fdaaf2760da5068ec66fa378b7061 Mon Sep 17 00:00:00 2001
From: Chris Parsons
Date: Tue, 16 Jun 2026 22:47:22 -0700
Subject: [PATCH] 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
---
app/components/sport-season/EventSchedule.tsx | 42 ++++++++-----------
.../sport-season/UpcomingCalendarPanel.tsx | 13 +++---
app/hooks/useHasMounted.ts | 7 ++++
app/lib/date-utils.ts | 5 ++-
4 files changed, 34 insertions(+), 33 deletions(-)
create mode 100644 app/hooks/useHasMounted.ts
diff --git a/app/components/sport-season/EventSchedule.tsx b/app/components/sport-season/EventSchedule.tsx
index 1bf4b50..64f4cd8 100644
--- a/app/components/sport-season/EventSchedule.tsx
+++ b/app/components/sport-season/EventSchedule.tsx
@@ -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): 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): 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
- {getDisplayDate(event)}
- {event.eventStartsAt && (
-
- · {format(new Date(event.eventStartsAt), "h:mm a")}
-
+ {getDisplayDate(event)}
+ {isMounted && event.eventStartsAt && (
+ · {format(new Date(event.eventStartsAt), "h:mm a")}
)}
@@ -178,7 +172,7 @@ export function EventSchedule({ upcomingEvents, recentEvents, leagueId, sportsSe
- {getDisplayDate(event)}
+ {getDisplayDate(event)}
{event.eventType !== "schedule_event" && (
diff --git a/app/components/sport-season/UpcomingCalendarPanel.tsx b/app/components/sport-season/UpcomingCalendarPanel.tsx
index 8b954f5..3cdcde3 100644
--- a/app/components/sport-season/UpcomingCalendarPanel.tsx
+++ b/app/components/sport-season/UpcomingCalendarPanel.tsx
@@ -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
{/* Date pill */}
-
+
{dateStr ?? "TBD"}
- {gameDate && (
-
+ {isMounted && gameDate && (
+
{gameDate.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
diff --git a/app/hooks/useHasMounted.ts b/app/hooks/useHasMounted.ts
new file mode 100644
index 0000000..32585a3
--- /dev/null
+++ b/app/hooks/useHasMounted.ts
@@ -0,0 +1,7 @@
+import { useSyncExternalStore } from "react";
+
+const subscribe = () => () => {};
+
+export function useHasMounted(): boolean {
+ return useSyncExternalStore(subscribe, () => true, () => false);
+}
diff --git a/app/lib/date-utils.ts b/app/lib/date-utils.ts
index 3aeddcf..1c69328 100644
--- a/app/lib/date-utils.ts
+++ b/app/lib/date-utils.ts
@@ -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;
}