brackt/app/routes/admin.sports-seasons.$id.events.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

347 lines
14 KiB
TypeScript

import { Form, Link } from "react-router";
import { useState } from "react";
import type { Route } from "./+types/admin.sports-seasons.$id.events";
import { loader, action } from "./admin.sports-seasons.$id.events.server";
import { localDateTimeToUtcIso } from "~/lib/date-utils";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Badge } from "~/components/ui/badge";
import { Textarea } from "~/components/ui/textarea";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import { Calendar, Trophy, ArrowLeft, Trash2, ListPlus } from "lucide-react";
import { format, parseISO } from "date-fns";
import { QualifyingPointsStandings } from "~/components/scoring/QualifyingPointsStandings";
import { getEventTypeLabel } from "~/models/scoring-event";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Events — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
}
export { loader, action };
export default function SportsSeasonEvents({
loaderData,
actionData,
}: Route.ComponentProps) {
const { sportsSeason, events, qpStandings, scoringRules } = loaderData;
const [createEventStartsAt, setCreateEventStartsAt] = useState("");
const defaultEventType =
sportsSeason.scoringPattern === "qualifying_points"
? "major_tournament"
: sportsSeason.scoringPattern === "season_standings"
? "schedule_event"
: "playoff_game";
const getStatusBadge = (isComplete: boolean, eventType: string, eventDate?: string | null, eventStartsAt?: Date | string | null) => {
if (isComplete) {
return (
<Badge variant="default" className="bg-emerald-500">
Completed
</Badge>
);
}
if (eventType === "schedule_event") {
const isPast = eventStartsAt
? new Date(eventStartsAt) < new Date()
: eventDate != null && eventDate < new Date().toISOString().split("T")[0];
return isPast ? (
<Badge variant="outline" className="text-muted-foreground">Results Pending</Badge>
) : (
<Badge variant="outline">Upcoming</Badge>
);
}
return <Badge variant="secondary">In Progress</Badge>;
};
return (
<div className="p-8">
<div className="max-w-4xl">
<div className="mb-6">
<Button variant="ghost" size="sm" asChild className="mb-2">
<Link to={`/admin/sports-seasons/${sportsSeason.id}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Sports Season
</Link>
</Button>
<h1 className="text-3xl font-bold">Scoring Events</h1>
<p className="text-muted-foreground mt-1">
{sportsSeason.sport.name} - {sportsSeason.name}
</p>
{sportsSeason.scoringPattern === "qualifying_points" && (
<div className="mt-2">
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-300 dark:bg-amber-950 dark:text-amber-300">
Qualifying Points Mode
</Badge>
</div>
)}
</div>
<div className="space-y-6">
{/* Success/Error Messages */}
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
</div>
)}
{actionData?.success && (
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
{actionData.success}
</div>
)}
{/* QP Standings for qualifying points sports */}
{sportsSeason.scoringPattern === "qualifying_points" && qpStandings && (
<QualifyingPointsStandings
standings={qpStandings}
scoringRules={scoringRules}
isFinalized={sportsSeason.qualifyingPointsFinalized || false}
totalMajors={sportsSeason.totalMajors}
majorsCompleted={sportsSeason.majorsCompleted}
canFinalize={
sportsSeason.totalMajors !== null &&
sportsSeason.majorsCompleted >= sportsSeason.totalMajors &&
!sportsSeason.qualifyingPointsFinalized
}
/>
)}
{/* Create New Event Card */}
<Card>
<CardHeader>
<CardTitle>Create New Event</CardTitle>
<CardDescription>
Add a new scoring event (bracket, tournament, standings, etc.)
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Event Name</Label>
<Input
id="name"
name="name"
type="text"
placeholder="e.g., Super Bowl, Round 1, Game 3"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="eventType">Event Type</Label>
<Select
name="eventType"
defaultValue={defaultEventType}
required
>
<SelectTrigger id="eventType">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="schedule_event">Non-Scoring</SelectItem>
<SelectItem value="major_tournament">Major Tournament</SelectItem>
<SelectItem value="playoff_game">Bracket</SelectItem>
</SelectContent>
</Select>
{sportsSeason.scoringPattern === "qualifying_points" && (
<p className="text-sm text-muted-foreground">
Major Tournament events will automatically award qualifying points when completed.
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="eventStartsAtLocal">Event Date & Time (Optional)</Label>
<Input
id="eventStartsAtLocal"
type="datetime-local"
onChange={(e) =>
setCreateEventStartsAt(localDateTimeToUtcIso(e.target.value) ?? "")
}
/>
<input type="hidden" name="eventStartsAt" value={createEventStartsAt} />
</div>
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
</div>
)}
<Button type="submit" className="w-full">
<Trophy className="mr-2 h-4 w-4" />
Create Event
</Button>
</Form>
</CardContent>
</Card>
{/* Bulk Create Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ListPlus className="h-5 w-5" />
Bulk Import Events
</CardTitle>
<CardDescription>
Paste a schedule one event per line. Format:{" "}
<code className="text-xs bg-muted px-1 rounded">
Event Name, YYYY-MM-DD[, HH:MM]
</code>{" "}
(date and time are optional; time is UTC).
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<input type="hidden" name="intent" value="bulk-create" />
<div className="space-y-2">
<Label htmlFor="bulkEventType">Event Type</Label>
<Select name="bulkEventType" defaultValue={defaultEventType}>
<SelectTrigger id="bulkEventType">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="schedule_event">Non-Scoring</SelectItem>
<SelectItem value="major_tournament">Major Tournament</SelectItem>
<SelectItem value="playoff_game">Bracket</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="bulkEvents">Events</Label>
<Textarea
id="bulkEvents"
name="bulkEvents"
rows={6}
placeholder={"Bahrain Grand Prix, 2025-03-02, 15:00\nSaudi Arabian Grand Prix, 2025-03-09, 18:00\nAustralian Grand Prix, 2025-03-23"}
/>
</div>
<Button type="submit" variant="outline" className="w-full">
<ListPlus className="mr-2 h-4 w-4" />
Import Events
</Button>
</Form>
</CardContent>
</Card>
{/* Events List */}
<Card>
<CardHeader>
<CardTitle>Events</CardTitle>
<CardDescription>
{events.length} {events.length === 1 ? "event" : "events"}
</CardDescription>
</CardHeader>
<CardContent>
{events.length === 0 ? (
<p className="text-sm text-muted-foreground">
No events created yet. Create your first event above.
</p>
) : (
<div className="space-y-3">
{events.map((event: { id: string; name: string; eventType: string; eventDate?: string | null; eventStartsAt?: Date | string | null; isComplete: boolean }) => (
<Card key={event.id} className="hover:border-primary/50 transition-colors">
<CardContent className="pt-6">
<div className="flex items-start justify-between gap-4">
<Link
to={`/admin/sports-seasons/${sportsSeason.id}/events/${event.id}`}
className="flex-1 cursor-pointer"
>
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold">{event.name}</h3>
{getStatusBadge(event.isComplete, event.eventType, event.eventDate, event.eventStartsAt)}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>
{getEventTypeLabel(event.eventType)}
</span>
{(event.eventDate || event.eventStartsAt) && (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span suppressHydrationWarning>
{format(
event.eventStartsAt
? new Date(event.eventStartsAt)
: parseISO(event.eventDate ?? ""),
"MMM d, yyyy"
)}
</span>
{event.eventStartsAt && (
<span className="text-muted-foreground/70" suppressHydrationWarning>
· {format(new Date(event.eventStartsAt), "h:mm a")}
</span>
)}
</span>
)}
</div>
</Link>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive shrink-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete "{event.name}"?</AlertDialogTitle>
<AlertDialogDescription>
This will also delete all results for this event. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Form method="post">
<input type="hidden" name="intent" value="delete-event" />
<input type="hidden" name="eventId" value={event.id} />
<AlertDialogAction type="submit" className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete
</AlertDialogAction>
</Form>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}