brackt/app/routes/admin.tournaments.$id.tsx
Claude 7f7ea4e29d
Improve shared-tournament/event management UX
Deleting an event from a season only affects that season's scoring
event; the shared canonical tournament and the other linked seasons
survive. The old flow never communicated this and offered no way to
manage the relationship, so it felt like deleting an event might wipe
the whole shared tournament.

- deleteScoringEvent is now shared-tournament-aware: it auto-heals the
  primary window (promotes the earliest remaining window when the
  primary is removed) and optionally deletes the canonical tournament
  when the last linked window is removed. Returns a result describing
  what happened.
- Add deleteTournament model helper.
- Events page: delete confirmation now explains exactly what a delete
  does (removed from this season only vs. last window), with an opt-in
  checkbox to also remove the orphaned shared tournament.
- Tournament page: add a per-window "Remove" control on the Linked
  Sports Seasons card to unlink a season, reusing the auto-heal path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBzjCLVkVaQMj1MF3K54t2
2026-07-02 05:17:37 +00:00

600 lines
21 KiB
TypeScript

import { Link, useFetcher } from "react-router";
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 {
getSportsSeasonsByTournament,
getPrimaryEventForTournament,
setPrimaryEvent,
deleteScoringEvent,
} from "~/models/scoring-event";
import { isBracketMajor } from "~/lib/event-utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { ArrowLeft, CheckCircle2, AlertTriangle } 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, primaryEvent] =
await Promise.all([
getTournamentResults(tournament.id),
findCanonicalParticipantsBySport(tournament.sportId),
getSportsSeasonsByTournament(tournament.id),
getPrimaryEventForTournament(tournament.id),
]);
// Bracket majors (tennis, CS2) are scored on their primary window's bracket /
// stage UI; golf is entered right here. Surface a link for the bracket case.
const simulatorType =
linkedSportsSeasons[0]?.sportsSeason?.sport?.simulatorType ?? null;
const bracketMajor = isBracketMajor(simulatorType);
const isCs2 = simulatorType === "cs2_major_qualifying_points";
return {
tournament,
results,
canonicalParticipants,
linkedSportsSeasons,
primaryEvent,
bracketMajor,
isCs2,
};
}
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 === "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,
});
}
const syncReport = await syncTournamentResults(tournament.id);
// Status reflects reality: completed only when every linked window synced.
// If any window failed, leave it in_progress so the failure is visible and
// retryable rather than masked by a green "completed" badge.
const newStatus =
syncReport.windowsFailed === 0 ? "completed" : "in_progress";
if (tournament.status !== newStatus) {
await updateTournamentStatus(tournament.id, newStatus);
}
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);
const newStatus =
syncReport.windowsFailed === 0 ? "completed" : "in_progress";
if (tournament.status !== newStatus) {
await updateTournamentStatus(tournament.id, newStatus);
}
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,
};
}
}
if (intent === "set-primary") {
const eventId = formData.get("eventId");
if (typeof eventId !== "string" || !eventId) {
return { success: false as const, error: "Event ID is required", syncReport: null };
}
try {
await setPrimaryEvent(eventId);
return { success: true as const, error: null, syncReport: null };
} catch (error) {
logger.error("set-primary failed:", error);
return {
success: false as const,
error: error instanceof Error ? error.message : "Failed to set primary window",
syncReport: null,
};
}
}
if (intent === "unlink-window") {
const eventId = formData.get("eventId");
if (typeof eventId !== "string" || !eventId) {
return { success: false as const, error: "Event ID is required", syncReport: null };
}
try {
// Remove that season's scoring event. The tournament stays (we're on its
// page); if this was the primary window, another is auto-promoted.
await deleteScoringEvent(eventId);
return { success: true as const, error: null, syncReport: null };
} catch (error) {
logger.error("unlink-window failed:", error);
return {
success: false as const,
error: error instanceof Error ? error.message : "Failed to remove season",
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,
primaryEvent,
bracketMajor,
isCs2,
} = loaderData;
const retryFetcher = useFetcher<typeof action>();
const primaryFetcher = useFetcher<typeof action>();
const unlinkFetcher = useFetcher<typeof action>();
// For bracket majors, scoring lives on the primary window's bracket/stage UI.
const primaryScoringHref =
bracketMajor && primaryEvent
? isCs2
? `/admin/sports-seasons/${primaryEvent.sportsSeasonId}/events/${primaryEvent.id}/cs2-setup`
: `/admin/sports-seasons/${primaryEvent.sportsSeasonId}/events/${primaryEvent.id}/bracket`
: null;
// 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 justify-between gap-3">
<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>
{primaryScoringHref && (
<Button asChild>
<Link to={primaryScoringHref}>
{isCs2 ? "Manage stages & bracket →" : "Manage bracket →"}
</Link>
</Button>
)}
</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"}{" "}
use this tournament as a scoring event
</CardDescription>
</CardHeader>
<CardContent>
{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} &bull;{" "}
{link.sportsSeason.scoringType.replace("_", " ")}
</p>
</div>
<div className="flex items-center gap-2">
{bracketMajor &&
(link.isPrimary ? (
<Badge variant="default">Primary</Badge>
) : (
<primaryFetcher.Form method="post">
<input type="hidden" name="intent" value="set-primary" />
<input type="hidden" name="eventId" value={link.id} />
<Button
type="submit"
variant="outline"
size="sm"
disabled={primaryFetcher.state !== "idle"}
>
Make primary
</Button>
</primaryFetcher.Form>
))}
<Badge
variant={
link.sportsSeason.status === "completed"
? "default"
: link.sportsSeason.status === "active"
? "secondary"
: "outline"
}
>
{link.sportsSeason.status}
</Badge>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={unlinkFetcher.state !== "idle"}
>
Remove
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Remove {link.sportsSeason.sport.name} - {link.sportsSeason.name}?
</AlertDialogTitle>
<AlertDialogDescription asChild>
<span>
This deletes that season's scoring event (and its results) but keeps{" "}
<span className="font-medium">{tournament.name}</span> and the other linked seasons.
{link.isPrimary && linkedSportsSeasons.length > 1 && (
<> Because this is the primary scoring window, another season will be promoted to primary automatically.</>
)}
{linkedSportsSeasons.length === 1 && (
<> This is the only linked season the tournament will remain but have no windows until you link one again.</>
)}
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<unlinkFetcher.Form method="post">
<input type="hidden" name="intent" value="unlink-window" />
<input type="hidden" name="eventId" value={link.id} />
<AlertDialogAction
type="submit"
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</unlinkFetcher.Form>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-4">
No sports seasons are using this tournament yet.
</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>
{bracketMajor ? (
<Card>
<CardHeader>
<CardTitle>Scoring</CardTitle>
<CardDescription>
This is a bracket major. Results are derived from the
bracket/stage workflow on the primary window and fan out here
automatically.
</CardDescription>
</CardHeader>
<CardContent>
{primaryScoringHref ? (
<Button asChild>
<Link to={primaryScoringHref}>
{isCs2 ? "Manage stages & bracket →" : "Manage bracket →"}
</Link>
</Button>
) : (
<p className="text-sm text-muted-foreground">
No primary window is set up yet for this tournament.
</p>
)}
</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>
);
}