brackt/app/routes/admin.sports-seasons.$id.events.tsx

348 lines
14 KiB
TypeScript
Raw Normal View History

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";
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
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") {
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
const isPast = eventStartsAt
? new Date(eventStartsAt) < new Date()
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
: eventDate !== null && eventDate !== undefined && 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 && (
Redesign to dark-mode-only with navy palette and accent colors (#13) Removes light mode entirely in favour of a permanent dark theme with a navy-tinted background and three signature accents (electric blue, amber/gold, coral) exposed as CSS custom properties and Tailwind utilities (bg-electric, text-amber-accent, text-coral-accent). - Set class="dark" on <html> and apply Clerk dark base theme - Rewrite app.css: single :root palette (oklch navy values), custom --electric / --amber-accent / --coral-accent variables, remove duplicate .dark block and light-mode bg-white/bg-gray-950 rule - Install @clerk/themes for Clerk dark modal support - Replace hardcoded Tailwind colors across 30+ files: - Draft grid cells: blue-50/blue-950 → electric/15, green-50/950 → emerald/10 - Timer: green-600/yellow-600/red-600 → emerald-400/amber-accent/coral-accent - Status badges: blue-50/green-50/gray-50 → electric/emerald/muted variants - Success messages: green-500/15 text-green-700 dark:text-green-400 → emerald-500/15 text-emerald-400 - Info cards: blue-50 dark:bg-blue-950 → electric/10 - Warning cards: yellow-500 → amber-accent variants - Medal/placement badges: yellow-500/orange-600 → amber-accent/coral-accent - Movement indicators: green-600/red-600 → emerald-400/coral-accent - Connection dots: green-500/red-500 → emerald-500/coral-accent - Remove dark:hidden/dark:block logo toggle in welcome.tsx (always dark) - Update DraftGrid test assertions to match new class names Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 19:26:11 -08:00
<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>
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
{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>
);
}