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
Showing only changes of commit 8a9cffd076 - Show all commits

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 { 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" && (

View file

@ -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",

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