Fix SSR/hydration timezone flip in event date and time display (#95)
## 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 <chrisparsons1127@gmail.com> Reviewed-on: #95
This commit is contained in:
parent
8c9d328301
commit
08ae2bb88a
4 changed files with 34 additions and 33 deletions
|
|
@ -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" && (
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
7
app/hooks/useHasMounted.ts
Normal file
7
app/hooks/useHasMounted.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const subscribe = () => () => {};
|
||||
|
||||
export function useHasMounted(): boolean {
|
||||
return useSyncExternalStore(subscribe, () => true, () => false);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue