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; }