brackt/app/components/sport-season/EventSchedule.tsx
Chris Parsons 67c7de1924
Fix event date timezone bugs (UTC rollover + same-day status) (#148)
* Fix event date display off-by-one for late-night events in UTC-negative timezones

Events saved at 10 PM PDT (UTC-7) cross UTC midnight, so eventDate is stored
as the next UTC calendar day (e.g. March 28 10 PM PDT → eventDate "2026-03-29").
Displaying that date string via parseISO() gives March 29 local midnight, so
the events list showed "Mar 29" when the user expected "Mar 28".

Fix: when eventStartsAt is available, derive the display date from it directly
(format(new Date(eventStartsAt), ...)) rather than from the stored eventDate
string. This correctly converts the UTC timestamp to the user's local calendar
date. Date-only events (no eventStartsAt) are unchanged and continue to use
parseISO(eventDate).

Also tighten the getDisplayDate() guard: replace a misleading try/catch (new
Date() never throws) with an explicit isNaN check, and replace a non-null
assertion (eventDate!) with null-coalescing in the admin events list.

Tests cover both UTC and PDT environments and are timezone-independent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix same-day events incorrectly showing Upcoming instead of Results Pending

String date comparison (eventDate < today) is strictly less-than, so events
on the current day always showed Upcoming regardless of time. When eventStartsAt
is available, use timestamp comparison (new Date(eventStartsAt) < new Date())
for accurate past/future determination.

Fixed in three locations: admin events list (getStatusBadge call site),
admin event detail isPast check, and EventSchedule upcoming badge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 12:28:39 -07:00

175 lines
6.1 KiB
TypeScript

import { format, parseISO } from "date-fns";
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[];
}
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");
} 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) {
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 }: EventScheduleProps) {
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) => (
<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" />
)}
{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 suppressHydrationWarning>{getDisplayDate(event)}</span>
{event.eventStartsAt && (
<span suppressHydrationWarning>
· {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) => (
<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">
{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 suppressHydrationWarning>{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>
);
}