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 { 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" && (
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
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
|
* 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue