brackt/app/routes/admin.sports-seasons.$id.events.tsx
Chris Parsons 4bffa40606
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196)
* Fix no-shadow and consistent-function-scoping lint violations

Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint
warnings and promotes both rules to errors in .oxlintrc.json.

no-shadow: renamed Drizzle callback params (sports→s, matches→m,
seasons→s) to avoid shadowing outer imports; removed shadowed
destructures (eq, inArray) from where callbacks; renamed inner
template→bracketTemplate, prev→currentTimers, season→ss, name→teamName
(with name: teamName fix to preserve semantics).

consistent-function-scoping: moved formatDate, getRankBadge,
getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr,
elo (×2), weightedPick, sortByMatchNumber (×2) to module scope;
moved formatTime (×2), isValidLeagueName, getDraftTimes,
makeSeasonQueues to file scope in test files.

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

* Fix no-non-null-assertion lint violations and promote to error

Eliminates all 208 no-non-null-assertion warnings across 38 files.
Promotes typescript/no-non-null-assertion from warn to error in
.oxlintrc.json.

Fix patterns applied:
- Map.get(key)! after .has() check → extract with get() + null guard
- Map.get(key)! on pre-populated count maps → ?? 0 default
- .set(id, map.get(id)! + 1) increment → ?? 0 before adding
- participant1Id!/participant2Id! on DB matches → ?? "" fallback
- array.find()! in tests → guard + throw or expect().toBeDefined()
- bracketTemplateCache.get(id)! → null guard extract
- Various nullable field accesses → optional chain or ?? default

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

* Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers

Resolves all 9 remaining non-console lint warnings and promotes all
three rules to errors in .oxlintrc.json.

- prefer-add-event-listener: converted onchange/onclick/onload
  assignments to addEventListener in useDraftNotifications.ts and
  admin.data-sync.tsx; stored changeHandler ref for proper cleanup
  with removeEventListener
- no-unassigned-import: configured rule with allow list for legitimate
  side-effect imports (*.css, @testing-library/jest-dom,
  @testing-library/cypress/add-commands)
- require-module-specifiers: removed redundant `export {}` from
  cypress/support/e2e.ts (file already has an import)

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

* Fix TypeScript errors from no-non-null-assertion fixes

Two fixes introduced by the non-null assertion cleanup produced type
errors:

- scoring-event.ts: `?? ""` was wrong type for a participant object map;
  restructured to explicit null guards so TypeScript can narrow correctly
- standings-sync/index.ts: `?? null` after name-match lookup lost the
  truthy guarantee, causing TS18047 on the write-back block; added
  `participant &&` guard before accessing its properties

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

* Add npm run typecheck as Stop hook in Claude settings

Runs a full project typecheck at the end of each Claude turn so type
errors surface as feedback before the next message.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -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 };
function 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 !== 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>;
}
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";
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>
);
}