Adds a sports_season_tournaments junction table and bidirectional link/unlink UI on both the sports season and tournament admin pages. - New junction table with unique index on (sports_season_id, tournament_id) and a separate index on tournament_id for reverse lookups - New model with link/unlink/query functions and cross-sport validation - Migration includes backfill from existing scoring_events tournament links - Admin sports season page: Tournaments card with add/remove UI - Admin tournament page: Linked Sports Seasons card with add/remove UI - Inline success/error feedback, empty states, aria-labels on remove buttons - cloneSportsSeason now copies tournament links - Fixes darts simulator test timeout (15s for 128-player pre-bracket sim)
519 lines
18 KiB
TypeScript
519 lines
18 KiB
TypeScript
import { Link, useFetcher, Form } from "react-router";
|
|
import { useState } from "react";
|
|
import { auth } from "~/lib/auth.server";
|
|
import type { Route } from "./+types/admin.tournaments.$id";
|
|
|
|
import { logger } from "~/lib/logger";
|
|
import {
|
|
getTournamentById,
|
|
updateTournamentStatus,
|
|
} from "~/models/tournament";
|
|
import {
|
|
getTournamentResults,
|
|
upsertTournamentResult,
|
|
} from "~/models/tournament-result";
|
|
import { findCanonicalParticipantsBySport } from "~/models/participant";
|
|
import { isUserAdmin } from "~/models/user";
|
|
import {
|
|
syncTournamentResults,
|
|
type SyncReport,
|
|
} from "~/services/sync-tournament-results";
|
|
import {
|
|
linkTournamentToSportsSeason,
|
|
unlinkTournamentFromSportsSeason,
|
|
findSportsSeasonsByTournament,
|
|
} from "~/models/sports-season-tournament";
|
|
import { findSportsSeasonsBySportId } from "~/models/sports-season";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "~/components/ui/card";
|
|
import { Badge } from "~/components/ui/badge";
|
|
import { Button } from "~/components/ui/button";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "~/components/ui/select";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "~/components/ui/table";
|
|
import { ArrowLeft, CheckCircle2, AlertTriangle, Plus, X } from "lucide-react";
|
|
import { BatchResultEntry } from "~/components/BatchResultEntry";
|
|
|
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|
return [
|
|
{
|
|
title: `${data?.tournament?.name ?? "Tournament"} - Brackt Admin`,
|
|
},
|
|
];
|
|
}
|
|
|
|
export async function loader({ params }: Route.LoaderArgs) {
|
|
const tournament = await getTournamentById(params.id);
|
|
if (!tournament) {
|
|
throw new Response("Not Found", { status: 404 });
|
|
}
|
|
|
|
const [results, canonicalParticipants, linkedSportsSeasons, allSportsSeasonsForSport] = await Promise.all([
|
|
getTournamentResults(tournament.id),
|
|
findCanonicalParticipantsBySport(tournament.sportId),
|
|
findSportsSeasonsByTournament(tournament.id),
|
|
findSportsSeasonsBySportId(tournament.sportId),
|
|
]);
|
|
|
|
return { tournament, results, canonicalParticipants, linkedSportsSeasons, allSportsSeasonsForSport };
|
|
}
|
|
|
|
export async function action(args: Route.ActionArgs) {
|
|
const { request, params } = args;
|
|
const session = await auth.api.getSession({ headers: request.headers });
|
|
const userId = session?.user.id ?? null;
|
|
const isAdmin = userId ? await isUserAdmin(userId) : false;
|
|
if (!isAdmin) {
|
|
throw new Response("Forbidden", { status: 403 });
|
|
}
|
|
|
|
const tournament = await getTournamentById(params.id);
|
|
if (!tournament) {
|
|
throw new Response("Not Found", { status: 404 });
|
|
}
|
|
|
|
const formData = await request.formData();
|
|
const intent = formData.get("intent");
|
|
|
|
if (intent === "add-sports-season") {
|
|
const sportsSeasonId = formData.get("sportsSeasonId");
|
|
if (typeof sportsSeasonId !== "string" || !sportsSeasonId) {
|
|
return { success: false as const, error: "Sports season ID is required", syncReport: null };
|
|
}
|
|
try {
|
|
await linkTournamentToSportsSeason(sportsSeasonId, tournament.id);
|
|
return { success: true as const, error: null, syncReport: null };
|
|
} catch (error) {
|
|
logger.error("Error linking sports season:", error);
|
|
return { success: false as const, error: "Failed to link sports season. It may already be linked.", syncReport: null };
|
|
}
|
|
}
|
|
|
|
if (intent === "remove-sports-season") {
|
|
const sportsSeasonId = formData.get("sportsSeasonId");
|
|
if (typeof sportsSeasonId !== "string" || !sportsSeasonId) {
|
|
return { success: false as const, error: "Sports season ID is required", syncReport: null };
|
|
}
|
|
try {
|
|
await unlinkTournamentFromSportsSeason(sportsSeasonId, tournament.id);
|
|
return { success: true as const, error: null, syncReport: null };
|
|
} catch (error) {
|
|
logger.error("Error unlinking sports season:", error);
|
|
return { success: false as const, error: "Failed to unlink sports season.", syncReport: null };
|
|
}
|
|
}
|
|
|
|
if (intent === "batch-upsert-results") {
|
|
const resultsRaw = formData.get("results");
|
|
if (typeof resultsRaw !== "string" || !resultsRaw) {
|
|
return {
|
|
success: false as const,
|
|
error: "Missing results payload",
|
|
syncReport: null,
|
|
};
|
|
}
|
|
|
|
let parsed: Array<{ participantId: string; placement: number }>;
|
|
try {
|
|
parsed = JSON.parse(resultsRaw);
|
|
} catch {
|
|
return {
|
|
success: false as const,
|
|
error: "Invalid JSON in results payload",
|
|
syncReport: null,
|
|
};
|
|
}
|
|
|
|
if (!Array.isArray(parsed)) {
|
|
return {
|
|
success: false as const,
|
|
error: "Results payload must be an array",
|
|
syncReport: null,
|
|
};
|
|
}
|
|
|
|
try {
|
|
for (const row of parsed) {
|
|
if (
|
|
!row ||
|
|
typeof row.participantId !== "string" ||
|
|
typeof row.placement !== "number"
|
|
) {
|
|
return {
|
|
success: false as const,
|
|
error: "Each result must have participantId and placement",
|
|
syncReport: null,
|
|
};
|
|
}
|
|
await upsertTournamentResult({
|
|
tournamentId: tournament.id,
|
|
participantId: row.participantId,
|
|
placement: row.placement,
|
|
});
|
|
}
|
|
|
|
if (tournament.status !== "completed") {
|
|
await updateTournamentStatus(tournament.id, "completed");
|
|
}
|
|
|
|
const syncReport = await syncTournamentResults(tournament.id);
|
|
return {
|
|
success: true as const,
|
|
error: null,
|
|
syncReport,
|
|
};
|
|
} catch (error) {
|
|
logger.error("batch-upsert-results failed:", error);
|
|
return {
|
|
success: false as const,
|
|
error:
|
|
error instanceof Error ? error.message : "Failed to save results",
|
|
syncReport: null,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (intent === "retry-window-sync") {
|
|
try {
|
|
const syncReport = await syncTournamentResults(tournament.id);
|
|
return { success: true as const, error: null, syncReport };
|
|
} catch (error) {
|
|
logger.error("retry-window-sync failed:", error);
|
|
return {
|
|
success: false as const,
|
|
error:
|
|
error instanceof Error ? error.message : "Failed to retry sync",
|
|
syncReport: null,
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: false as const,
|
|
error: "Invalid intent",
|
|
syncReport: null,
|
|
};
|
|
}
|
|
|
|
export default function AdminTournamentDetail({
|
|
loaderData,
|
|
actionData,
|
|
}: Route.ComponentProps) {
|
|
const { tournament, results, canonicalParticipants, linkedSportsSeasons, allSportsSeasonsForSport } = loaderData;
|
|
const retryFetcher = useFetcher<typeof action>();
|
|
const [selectedSportsSeasonId, setSelectedSportsSeasonId] = useState("");
|
|
|
|
const linkedSeasonIds = new Set(
|
|
linkedSportsSeasons.map((ls) => ls.sportsSeasonId)
|
|
);
|
|
const availableSportsSeasons = allSportsSeasonsForSport.filter(
|
|
(ss) => !linkedSeasonIds.has(ss.id)
|
|
);
|
|
|
|
// Prefer the latest action/retry response for the sync report
|
|
const liveReport: SyncReport | null =
|
|
(retryFetcher.data?.syncReport ?? actionData?.syncReport) ?? null;
|
|
|
|
const participantById = new Map(
|
|
canonicalParticipants.map((p) => [p.id, p])
|
|
);
|
|
|
|
return (
|
|
<div className="p-8">
|
|
<div className="max-w-5xl">
|
|
<div className="mb-6">
|
|
<Button variant="ghost" size="sm" asChild className="mb-2">
|
|
<Link to="/admin/tournaments">
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Back to tournaments
|
|
</Link>
|
|
</Button>
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-3xl font-bold">{tournament.name}</h1>
|
|
<Badge
|
|
variant={
|
|
tournament.status === "completed"
|
|
? "default"
|
|
: tournament.status === "in_progress"
|
|
? "secondary"
|
|
: "outline"
|
|
}
|
|
>
|
|
{tournament.status}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-muted-foreground mt-1">
|
|
{tournament.year}
|
|
{tournament.location ? ` — ${tournament.location}` : ""}
|
|
{tournament.surface ? ` — ${tournament.surface}` : ""}
|
|
</p>
|
|
</div>
|
|
|
|
{liveReport && (
|
|
<Card className="mb-6 border-emerald-500/30">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-emerald-500">
|
|
<CheckCircle2 className="h-5 w-5" />
|
|
Synced to {liveReport.windowsSynced}{" "}
|
|
{liveReport.windowsSynced === 1 ? "window" : "windows"}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Canonical results were fanned out to every linked scoring
|
|
window.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
{liveReport.failures.length > 0 && (
|
|
<CardContent>
|
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-4 space-y-3">
|
|
<div className="flex items-center gap-2 text-destructive font-medium">
|
|
<AlertTriangle className="h-4 w-4" />
|
|
{liveReport.failures.length}{" "}
|
|
{liveReport.failures.length === 1 ? "window" : "windows"}{" "}
|
|
failed to sync
|
|
</div>
|
|
<div className="space-y-2">
|
|
{liveReport.failures.map((f) => (
|
|
<div
|
|
key={f.scoringEventId}
|
|
className="flex items-start justify-between gap-4 text-sm"
|
|
>
|
|
<div className="space-y-1">
|
|
<div className="font-mono text-xs text-muted-foreground">
|
|
event: {f.scoringEventId}
|
|
</div>
|
|
<div className="font-mono text-xs text-muted-foreground">
|
|
season: {f.sportsSeasonId}
|
|
</div>
|
|
<div className="text-destructive">{f.error}</div>
|
|
</div>
|
|
<retryFetcher.Form method="post">
|
|
<input
|
|
type="hidden"
|
|
name="intent"
|
|
value="retry-window-sync"
|
|
/>
|
|
<input
|
|
type="hidden"
|
|
name="scoringEventId"
|
|
value={f.scoringEventId}
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={retryFetcher.state !== "idle"}
|
|
>
|
|
{retryFetcher.state !== "idle"
|
|
? "Retrying…"
|
|
: "Retry"}
|
|
</Button>
|
|
</retryFetcher.Form>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
)}
|
|
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<CardTitle>Linked Sports Seasons</CardTitle>
|
|
<CardDescription>
|
|
{linkedSportsSeasons.length}{" "}
|
|
{linkedSportsSeasons.length === 1
|
|
? "sports season"
|
|
: "sports seasons"}{" "}
|
|
linked to this tournament
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{actionData?.success && (actionData.error === null) && (
|
|
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
|
Updated successfully!
|
|
</div>
|
|
)}
|
|
{actionData?.error && (
|
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
|
{actionData.error}
|
|
</div>
|
|
)}
|
|
{linkedSportsSeasons.length > 0 && (
|
|
<div className="space-y-2">
|
|
{linkedSportsSeasons.map((link) => (
|
|
<div
|
|
key={link.id}
|
|
className="flex items-center justify-between p-3 border rounded-lg"
|
|
>
|
|
<div>
|
|
<p className="font-medium">
|
|
{link.sportsSeason.sport.name} -{" "}
|
|
{link.sportsSeason.name}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{link.sportsSeason.year} •{" "}
|
|
{link.sportsSeason.scoringType.replace("_", " ")}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Badge
|
|
variant={
|
|
link.sportsSeason.status === "completed"
|
|
? "default"
|
|
: link.sportsSeason.status === "active"
|
|
? "secondary"
|
|
: "outline"
|
|
}
|
|
>
|
|
{link.sportsSeason.status}
|
|
</Badge>
|
|
<Form method="post">
|
|
<input
|
|
type="hidden"
|
|
name="intent"
|
|
value="remove-sports-season"
|
|
/>
|
|
<input
|
|
type="hidden"
|
|
name="sportsSeasonId"
|
|
value={link.sportsSeasonId}
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-destructive hover:text-destructive"
|
|
aria-label={`Remove ${link.sportsSeason.sport.name} - ${link.sportsSeason.name}`}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</Form>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{availableSportsSeasons.length > 0 ? (
|
|
<Form method="post" className="flex gap-2">
|
|
<input
|
|
type="hidden"
|
|
name="intent"
|
|
value="add-sports-season"
|
|
/>
|
|
<Select
|
|
name="sportsSeasonId"
|
|
value={selectedSportsSeasonId}
|
|
onValueChange={setSelectedSportsSeasonId}
|
|
required
|
|
>
|
|
<SelectTrigger className="flex-1">
|
|
<SelectValue placeholder="Select a sports season" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableSportsSeasons.map((ss) => (
|
|
<SelectItem key={ss.id} value={ss.id}>
|
|
{ss.sport.name} - {ss.name} ({ss.year})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button type="submit">
|
|
<Plus className="h-4 w-4" />
|
|
</Button>
|
|
</Form>
|
|
) : (
|
|
linkedSportsSeasons.length > 0 ? (
|
|
<p className="text-sm text-muted-foreground text-center py-2">
|
|
All sports seasons for this sport have been linked
|
|
</p>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground text-center py-2">
|
|
No sports seasons exist for this sport yet. Create one first.
|
|
</p>
|
|
)
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="grid gap-6 md:grid-cols-2">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Current Results</CardTitle>
|
|
<CardDescription>
|
|
{results.length}{" "}
|
|
{results.length === 1 ? "result" : "results"} recorded
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{results.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground py-6 text-center">
|
|
No results recorded yet. Paste a ranked list to the right to
|
|
import.
|
|
</p>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-24">Placement</TableHead>
|
|
<TableHead>Participant</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{results.map((r) => {
|
|
const p = participantById.get(r.participantId);
|
|
return (
|
|
<TableRow key={r.id}>
|
|
<TableCell className="font-semibold">
|
|
{r.placement ?? "—"}
|
|
</TableCell>
|
|
<TableCell>
|
|
{p?.name ?? (
|
|
<span className="text-muted-foreground font-mono text-xs">
|
|
{r.participantId}
|
|
</span>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<BatchResultEntry
|
|
participants={canonicalParticipants.map((p) => ({
|
|
id: p.id,
|
|
name: p.name,
|
|
}))}
|
|
sportsSeasonId=""
|
|
existingResultParticipantIds={
|
|
new Set(results.map((r) => r.participantId))
|
|
}
|
|
intent="batch-upsert-results"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|