Fix SSR/hydration timezone flip in event date and time display #95

Merged
chrisp merged 1 commit from fix/event-schedule-timezone-display into main 2026-06-17 06:09:30 +00:00
4 changed files with 34 additions and 33 deletions

View file

@ -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 { Link } from "react-router";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
@ -25,28 +26,13 @@ interface EventScheduleProps {
function formatEventDate(dateStr: string | null | undefined): string { function formatEventDate(dateStr: string | null | undefined): string {
if (!dateStr) return "Date TBD"; if (!dateStr) return "Date TBD";
try { try {
// eventDate is stored as a date string "YYYY-MM-DD" const [year, month, day] = dateStr.split("-").map(Number);
return format(parseISO(dateStr), "MMM d, yyyy"); return format(new Date(year, month - 1, day), "MMM d, yyyy");
} catch { } catch {
return dateStr; 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 { function getEventTypeLabel(eventType: string): string {
switch (eventType) { switch (eventType) {
@ -64,6 +50,16 @@ function getEventTypeLabel(eventType: string): string {
} }
export function EventSchedule({ upcomingEvents, recentEvents, leagueId, sportsSeasonId }: EventScheduleProps) { 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 hasUpcoming = upcomingEvents.length > 0;
const hasRecent = recentEvents.length > 0; const hasRecent = recentEvents.length > 0;
@ -112,11 +108,9 @@ export function EventSchedule({ upcomingEvents, recentEvents, leagueId, sportsSe
</p> </p>
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5"> <p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<Calendar className="h-3 w-3" /> <Calendar className="h-3 w-3" />
<span suppressHydrationWarning>{getDisplayDate(event)}</span> <span>{getDisplayDate(event)}</span>
{event.eventStartsAt && ( {isMounted && event.eventStartsAt && (
<span suppressHydrationWarning> <span>· {format(new Date(event.eventStartsAt), "h:mm a")}</span>
· {format(new Date(event.eventStartsAt), "h:mm a")}
</span>
)} )}
</p> </p>
</div> </div>
@ -178,7 +172,7 @@ export function EventSchedule({ upcomingEvents, recentEvents, leagueId, sportsSe
</p> </p>
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5"> <p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<Calendar className="h-3 w-3" /> <Calendar className="h-3 w-3" />
<span suppressHydrationWarning>{getDisplayDate(event)}</span> <span>{getDisplayDate(event)}</span>
</p> </p>
</div> </div>
{event.eventType !== "schedule_event" && ( {event.eventType !== "schedule_event" && (

View file

@ -1,6 +1,7 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { Calendar, ChevronRight, Users } from "lucide-react"; import { Calendar, ChevronRight, Users } from "lucide-react";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useHasMounted } from "~/hooks/useHasMounted";
import { Link } from "react-router"; import { Link } from "react-router";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { import {
@ -58,9 +59,10 @@ function ParticipantList({ participants }: { participants: Array<{ id: string; n
} }
function EventRow({ event, showLeague }: { event: CalendarPanelEvent; showLeague: boolean }) { function EventRow({ event, showLeague }: { event: CalendarPanelEvent; showLeague: boolean }) {
const isMounted = useHasMounted();
// Prefer game-level scheduledAt over event-level eventDate for display // Prefer game-level scheduledAt over event-level eventDate for display
const gameDate = event.earliestGameTime ? new Date(event.earliestGameTime) : null; const gameDate = event.earliestGameTime ? new Date(event.earliestGameTime) : null;
const dateStr = gameDate const dateStr = isMounted && gameDate
? format(gameDate, "MMM d") ? format(gameDate, "MMM d")
: formatEventDate(event.eventDate); : formatEventDate(event.eventDate);
const participantCount = event.relevantParticipants.length; 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"> <div className="flex items-start gap-3 py-2.5 border-b last:border-0 group">
{/* Date pill */} {/* Date pill */}
<div className="w-14 shrink-0 text-center"> <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"} {dateStr ?? "TBD"}
</span> </span>
{gameDate && ( {isMounted && gameDate && (
<p <p className="text-xs text-muted-foreground leading-tight">
className="text-xs text-muted-foreground leading-tight"
suppressHydrationWarning
>
{gameDate.toLocaleTimeString(undefined, { {gameDate.toLocaleTimeString(undefined, {
hour: "numeric", hour: "numeric",
minute: "2-digit", minute: "2-digit",

View file

@ -0,0 +1,7 @@
import { useSyncExternalStore } from "react";
const subscribe = () => () => {};
export function useHasMounted(): boolean {
return useSyncExternalStore(subscribe, () => true, () => false);
}

View file

@ -16,7 +16,7 @@
* clear the field. Always write the result to a separate hidden input, not back to * clear the field. Always write the result to a separate hidden input, not back to
* the datetime-local input itself. * 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"). * 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 { export function formatEventDate(dateStr: string | null | undefined): string | null {
if (!dateStr) return null; if (!dateStr) return null;
try { 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 { } catch {
return null; return null;
} }