* 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>
347 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|