brackt/app/components/sport-season/EventSchedule.tsx
Chris Parsons 8a9cffd076
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 4m0s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m26s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Fix SSR/hydration timezone flip in event date and time display
UTC timestamps rendered with date-fns format() or toLocaleTimeString() use the
runtime's local timezone, causing the server (UTC) and client (PDT) to produce
different output. suppressHydrationWarning was hiding the mismatch but the
visual time-flip was still visible on load.

- Add useHasMounted hook (useSyncExternalStore-based, no extra render cycle)
- Gate UTC timestamp formatting behind isMounted in EventSchedule and
  UpcomingCalendarPanel; SSR always renders the stable YYYY-MM-DD eventDate string
- Fix formatEventDate in date-utils.ts (and EventSchedule private copy) to parse
  YYYY-MM-DD as local midnight instead of UTC midnight, fixing the off-by-one day
  bug for UTC-negative users across all callers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 22:47:22 -07:00

195 lines
7 KiB
TypeScript

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";
import { Calendar, CheckCircle2, Clock } from "lucide-react";
interface ScheduleEvent {
id: string;
name: string;
eventDate: string | null;
eventStartsAt?: Date | string | null;
eventType: string;
isComplete: boolean;
completedAt?: Date | string | null;
}
interface EventScheduleProps {
upcomingEvents: ScheduleEvent[];
recentEvents: ScheduleEvent[];
/** When provided, event names link to the event detail page */
leagueId?: string;
sportsSeasonId?: string;
}
function formatEventDate(dateStr: string | null | undefined): string {
if (!dateStr) return "Date TBD";
try {
const [year, month, day] = dateStr.split("-").map(Number);
return format(new Date(year, month - 1, day), "MMM d, yyyy");
} catch {
return dateStr;
}
}
function getEventTypeLabel(eventType: string): string {
switch (eventType) {
case "playoff_game":
return "Bracket";
case "major_tournament":
return "Major";
case "final_standings":
return "Final Standings";
case "schedule_event":
return "Non-Scoring";
default:
return eventType;
}
}
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;
if (!hasUpcoming && !hasRecent) return null;
return (
<div className="grid gap-4 sm:grid-cols-2">
{/* Upcoming Events */}
{hasUpcoming && (
<Card className="border-electric/20">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2 text-electric">
<Clock className="h-4 w-4" />
Upcoming
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<ul className="space-y-3">
{upcomingEvents.map((event, index) => {
const eventDetailUrl = leagueId && sportsSeasonId
? `/leagues/${leagueId}/sports-seasons/${sportsSeasonId}/events/${event.id}`
: null;
return (
<li
key={event.id}
className={`flex items-start justify-between gap-2 ${
index === 0 ? "" : "border-t pt-3"
}`}
>
<div className="min-w-0">
<p
className={`text-sm font-medium truncate ${
index === 0 ? "text-foreground" : "text-muted-foreground"
}`}
>
{index === 0 && (
<span className="inline-block w-1.5 h-1.5 rounded-full bg-electric mr-2 mb-0.5" />
)}
{eventDetailUrl ? (
<Link to={eventDetailUrl} className="hover:underline">
{event.name}
</Link>
) : (
event.name
)}
</p>
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<Calendar className="h-3 w-3" />
<span>{getDisplayDate(event)}</span>
{isMounted && event.eventStartsAt && (
<span>· {format(new Date(event.eventStartsAt), "h:mm a")}</span>
)}
</p>
</div>
{event.eventType === "schedule_event" ? (
!event.isComplete && (() => {
const isPast = event.eventStartsAt
? new Date(event.eventStartsAt) < new Date()
: event.eventDate !== null && event.eventDate < new Date().toISOString().split("T")[0];
return isPast && (
<Badge variant="outline" className="text-xs shrink-0 border-amber-500/30 text-amber-400">
Results Pending
</Badge>
);
})()
) : (
<Badge variant="outline" className="text-xs shrink-0">
{getEventTypeLabel(event.eventType)}
</Badge>
)}
</li>
);
})}
</ul>
</CardContent>
</Card>
)}
{/* Recent Results */}
{hasRecent && (
<Card className="border-emerald-500/20">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2 text-emerald-400">
<CheckCircle2 className="h-4 w-4" />
Recent Results
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<ul className="space-y-3">
{recentEvents.map((event, index) => {
const eventDetailUrl = leagueId && sportsSeasonId
? `/leagues/${leagueId}/sports-seasons/${sportsSeasonId}/events/${event.id}`
: null;
return (
<li
key={event.id}
className={`flex items-start justify-between gap-2 ${
index === 0 ? "" : "border-t pt-3"
}`}
>
<div className="min-w-0">
<p className="text-sm font-medium text-muted-foreground truncate">
{eventDetailUrl ? (
<Link to={eventDetailUrl} className="hover:underline">
{event.name}
</Link>
) : (
event.name
)}
</p>
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<Calendar className="h-3 w-3" />
<span>{getDisplayDate(event)}</span>
</p>
</div>
{event.eventType !== "schedule_event" && (
<Badge
variant="outline"
className="text-xs shrink-0 border-emerald-500/30 text-emerald-400"
>
Done
</Badge>
)}
</li>
);
})}
</ul>
</CardContent>
</Card>
)}
</div>
);
}