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
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>
522 lines
18 KiB
TypeScript
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} •{" "}
|
|
{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>
|
|
);
|
|
}
|