From 08ae2bb88abbe41b17b9f183703ccf77fa10ab22 Mon Sep 17 00:00:00 2001
From: chrisp
Date: Wed, 17 Jun 2026 06:09:29 +0000
Subject: [PATCH] Fix SSR/hydration timezone flip in event date and time
display (#95)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
- Adds `useHasMounted` hook (using `useSyncExternalStore` — no extra render cycle) to gate UTC timestamp formatting to client-only
- Fixes the visible time-flip in `EventSchedule` and `UpcomingCalendarPanel` where SSR rendered UTC time (e.g. "9:00 PM") and the client re-rendered with local time (e.g. "2:00 PM PDT") after hydration, with `suppressHydrationWarning` silently hiding the mismatch
- Fixes `formatEventDate` in `date-utils.ts` (and `EventSchedule`'s private copy) to parse `YYYY-MM-DD` strings as local midnight instead of UTC midnight, correcting an off-by-one day bug for UTC-negative users across all callers
## Test plan
- [ ] Navigate to a sports season page — confirm event times show in local timezone with no flash/flip on load
- [ ] Hard-reload the page — confirm no brief UTC time visible before settling on local time
- [ ] Check the home page `UpcomingCalendarPanel` — confirm same behavior
- [ ] Verify dates display correctly (e.g. no "Jun 17" showing for a "Jun 18" event near midnight UTC)
Co-authored-by: Chris Parsons
Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/95
---
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;
}