brackt/app/routes/admin.tournaments.$id.tsx
Chris Parsons 9480501932
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m12s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m24s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 3m27s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m25s
🚀 Deploy / 🐳 Build (push) Successful in 1m14s
🚀 Deploy / 🚀 Deploy (push) Successful in 14s
Unify majors: score once, fan out across windows + tennis bracket EV
Make a "major" (golf/tennis/CS2) scored once on its canonical tournament
and fan out to every linked sports_season window and league.

Fan-out & completion (app/services/sync-tournament-results.ts):
- syncTournamentResults now marks each synced window's event complete
  (gated by markComplete), recalculates affected leagues, and counts
  recalc failures so a stale league can't hide behind a "completed" badge
- syncMajorFromPrimaryEvent promotes a primary window's derived results to
  canonical tournament_results (deleting rows for dropped placements) and
  fans out to siblings; fanOutMajorIfPrimary guards on the primary
- placement removals now propagate (stale rows reset to filler)

Primary-event model (scoring_events.is_primary, migration 0122):
- getPrimaryEventForTournament / isReadOnlySibling / ensurePrimaryEvent /
  setPrimaryEvent; event creation auto-seeds a primary for bracket majors;
  "Make primary" button on the tournament page
- per-window event/bracket/cs2 pages are read-only for non-primary linked
  events (not-participating stays editable)

Tennis Grand Slam bracket (tennis_128 template + TEMPLATE_ROUND_CONFIG):
- bracket-scored qualifying major via the existing bracket pipeline
- simulator conditions in-progress EV on the real bracket (honoring
  completed matches, walkover for withdrawals), QP derived from config,
  round structure read from the template; CS2 + tennis share resolveStructureSource

Backfill (scripts/backfill-major-linking.ts): one-time idempotent reconcile
of existing majors (link orphans, designate primary, promote canonical, sync).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:32:22 -07:00

522 lines
18 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,
} from "~/models/scoring-event";
import { isBracketMajor } from "~/lib/event-utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
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,
};
}
}
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>();
// 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>
</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>
);
}