brackt/app/routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.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

559 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Form, useLoaderData, useActionData, useNavigation, useFetcher, Link } from 'react-router';
import type { Route } from './+types/admin.sports-seasons.$id.events.$eventId.cs2-setup';
import { findSportsSeasonById } from '~/models/sports-season';
import { getScoringEventById, isReadOnlySibling } from '~/models/scoring-event';
import { findParticipantsBySportsSeasonId } from '~/models/season-participant';
import {
getCs2StageResultsForEvent,
upsertCs2StageAssignments,
markCs2StageEliminations,
resetCs2Event,
clearCs2EliminationsAtStage,
assignCs2EliminationQP,
} from '~/models/cs2-major-stage';
import { findSeasonMatchesByScoringEventId } from '~/models/season-match';
import { syncMatches } from '~/services/match-sync';
import { fanOutMajorIfPrimary } from '~/services/sync-tournament-results';
import { Button } from '~/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '~/components/ui/card';
import { Badge } from '~/components/ui/badge';
import { ArrowLeft, Save, Trash2, CheckCircle2, RefreshCw } from 'lucide-react';
import { useState } from 'react';
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `CS2 Stage Setup — ${data?.event?.name ?? "Event"} - Brackt Admin` }];
}
export async function loader({ params }: Route.LoaderArgs) {
const sportsSeasonId = params.id;
const eventId = params.eventId;
const [sportsSeason, event] = await Promise.all([
findSportsSeasonById(sportsSeasonId),
getScoringEventById(eventId),
]);
if (!sportsSeason) throw new Response('Sports season not found', { status: 404 });
if (!event) throw new Response('Event not found', { status: 404 });
const [participants, stageResults, seasonMatches] = await Promise.all([
findParticipantsBySportsSeasonId(sportsSeasonId),
getCs2StageResultsForEvent(eventId),
findSeasonMatchesByScoringEventId(eventId),
]);
return { sportsSeason, event, participants, stageResults, seasonMatches };
}
interface ActionData {
success?: boolean;
message?: string;
}
export async function action({ request, params }: Route.ActionArgs) {
const eventId = params.eventId;
const formData = await request.formData();
const intent = formData.get('intent') as string;
// CS2 stages are set up only on the major's primary window; a tournament-linked
// non-primary event is a read-only mirror fed by fan-out.
const guardEvent = await getScoringEventById(eventId);
if (guardEvent && isReadOnlySibling(guardEvent)) {
return {
success: false,
message:
'This CS2 major is set up on its primary window; results fan out here automatically.',
};
}
if (intent === 'reset') {
// Local recovery tool: clears only the primary's torn state. Siblings are not
// touched here — re-entering results re-propagates via the scoring fan-out.
await resetCs2Event(eventId, params.id);
return { success: true, message: 'Stage assignments and recorded results cleared.' };
}
if (intent === 'assign') {
const assignments: Array<{ scoringEventId: string; participantId: string; stageEntry: number }> = [];
for (const [key, value] of formData.entries()) {
if (key.startsWith('stage_')) {
const participantId = key.slice('stage_'.length);
const stageEntry = parseInt(value as string, 10);
if (!isNaN(stageEntry) && stageEntry >= 1 && stageEntry <= 3) {
assignments.push({ scoringEventId: eventId, participantId, stageEntry });
}
}
}
if (assignments.length === 0) {
return { success: false, message: 'No stage assignments submitted.' };
}
await upsertCs2StageAssignments(assignments);
return { success: true, message: `Saved ${assignments.length} stage assignments.` };
}
if (intent === 'mark-eliminations') {
// Parse elimination data: elim_{stageNum}_{participantId} = winsAtElimination (0, 1, or 2)
// stageNum is the stage the team was eliminated AT (may differ from their stageEntry
// when a Stage 1 team advances to Stage 2 before being eliminated).
//
// stage_displayed_{N} hidden fields indicate which stage sections were rendered.
// Before applying submitted eliminations, we clear those stages so that unchecking
// a team actually removes them from the eliminated list (replacement semantics).
try {
const displayedStages: number[] = [];
for (const [key] of formData.entries()) {
if (key.startsWith('stage_displayed_')) {
const stageNum = parseInt(key.slice('stage_displayed_'.length), 10);
if (!isNaN(stageNum) && stageNum >= 1 && stageNum <= 3) {
displayedStages.push(stageNum);
}
}
}
const eliminations: Array<{ participantId: string; stageEliminated: number; winsAtElimination: number }> = [];
for (const [key, value] of formData.entries()) {
if (key.startsWith('elim_')) {
const rest = key.slice('elim_'.length);
const underscoreIdx = rest.indexOf('_');
if (underscoreIdx < 1) continue;
const stageEliminated = parseInt(rest.slice(0, underscoreIdx), 10);
const participantId = rest.slice(underscoreIdx + 1);
const wins = parseInt(value as string, 10);
if (!isNaN(stageEliminated) && stageEliminated >= 1 && stageEliminated <= 3
&& participantId.length > 0 && !isNaN(wins) && wins >= 0 && wins <= 2) {
eliminations.push({ participantId, stageEliminated, winsAtElimination: wins });
}
}
}
for (const stageNum of displayedStages) {
await clearCs2EliminationsAtStage(eventId, stageNum);
}
await markCs2StageEliminations(eventId, eliminations);
await assignCs2EliminationQP(eventId, params.id);
// Propagate the derived QP/placements to every sibling window (in-progress:
// don't mark complete — that happens when the bracket is finalized).
const event = await getScoringEventById(eventId);
if (event) await fanOutMajorIfPrimary(event, { markComplete: false });
return { success: true, message: `Marked ${eliminations.length} eliminations.` };
} catch (err) {
return { success: false, message: err instanceof Error ? err.message : 'Failed to save eliminations.' };
}
}
if (intent === 'sync-matches') {
try {
const result = await syncMatches(params.id);
const total = result.swissCreated + result.swissUpdated;
const summary = `Synced: ${total} Swiss match(es), ${result.playoffUpdated} playoff match(es).`;
return { success: true, message: summary };
} catch (err) {
return { success: false, message: err instanceof Error ? err.message : 'Sync failed.' };
}
}
return { success: false, message: 'Unknown action.' };
}
const STAGE_LABELS: Record<number, { label: string; description: string; maxTeams: number; badgeVariant: 'default' | 'secondary' | 'outline' }> = {
1: { label: 'Stage 1', description: 'Opening Stage (16 teams, Bo1 Swiss)', maxTeams: 16, badgeVariant: 'outline' },
2: { label: 'Stage 2', description: 'Challengers Stage (8 direct + 8 from Stage 1, Bo1/Bo3 Swiss)', maxTeams: 16, badgeVariant: 'secondary' },
3: { label: 'Stage 3', description: 'Legends Stage (8 direct + 8 from Stage 2, all Bo3 Swiss)', maxTeams: 16, badgeVariant: 'default' },
};
const RECORD_OPTIONS = [
{ value: '2', label: '2-3 (advanced furthest)' },
{ value: '1', label: '1-3' },
{ value: '0', label: '0-3 (earliest exit)' },
];
const MATCH_STATUS_STYLES: Record<string, string> = {
scheduled: 'text-muted-foreground',
in_progress: 'text-amber-400 font-medium',
complete: 'text-emerald-400',
canceled: 'text-destructive line-through',
postponed: 'text-muted-foreground italic',
};
const STAGE_NAMES: Record<number, string> = {
1: 'Opening Stage',
2: 'Challengers Stage',
3: 'Legends Stage',
};
export default function AdminCs2Setup() {
const { sportsSeason, event, participants, stageResults, seasonMatches } = useLoaderData<typeof loader>();
const actionData = useActionData<ActionData>();
const navigation = useNavigation();
const isSubmitting = navigation.state === 'submitting';
const syncFetcher = useFetcher();
const isSyncing = syncFetcher.state !== 'idle';
// Stage assignment state (local, submitted via form)
const [stageSelections, setStageSelections] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
stageResults.forEach(r => { initial[r.participantId] = r.stageEntry.toString(); });
return initial;
});
// Tracks which stage each team is currently eliminated at (null = still active)
const [eliminatedAtStage, setEliminatedAtStage] = useState<Record<string, number | null>>(() => {
const initial: Record<string, number | null> = {};
stageResults.forEach(r => { initial[r.participantId] = r.stageEliminated; });
return initial;
});
// Group participants by starting stage for the Stage Assignments card
const byStage: Record<number, typeof stageResults> = { 1: [], 2: [], 3: [] };
for (const r of stageResults) {
byStage[r.stageEntry]?.push(r);
}
const stageCounts = Object.fromEntries(
Object.entries(byStage).map(([k, v]) => [k, v.length])
);
// Determine stage completion from saved data (used to move advancers into the next stage section)
const stage1EliminatedCount = stageResults.filter(r => r.stageEliminated === 1).length;
const stage1Complete = stage1EliminatedCount >= 8;
const stage2EliminatedCount = stageResults.filter(r => r.stageEliminated === 2).length;
const stage2Complete = stage2EliminatedCount >= 8;
// Full field per stage for the Elimination Tracking section.
// Each team appears in exactly one section — the stage they are currently playing in
// (or were last eliminated from). Once stage N is complete, its advancers move to stage N+1.
type StageResultWithAdvance = typeof stageResults[0] & { advancedFrom?: number };
const byStageFullField: Record<number, StageResultWithAdvance[]> = { 1: [], 2: [], 3: [] };
for (const r of stageResults) {
if (r.stageEliminated !== null) {
// Already eliminated — show in the section for their elimination stage
byStageFullField[r.stageEliminated]?.push(r);
} else if (r.stageEntry === 1) {
if (stage1Complete) {
// Survived Stage 1 — now in Stage 2 (or beyond)
if (stage2Complete) {
byStageFullField[3].push({ ...r, advancedFrom: 1 });
} else {
byStageFullField[2].push({ ...r, advancedFrom: 1 });
}
} else {
byStageFullField[1].push(r);
}
} else if (r.stageEntry === 2) {
if (stage2Complete) {
byStageFullField[3].push({ ...r, advancedFrom: 2 });
} else {
byStageFullField[2].push(r);
}
} else {
// stageEntry === 3, always shown in Stage 3
byStageFullField[3].push(r);
}
}
return (
<div className="container mx-auto py-8">
<div className="mb-6">
<Link
to={`/admin/sports-seasons/${sportsSeason.id}/events/${event.id}`}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground mb-4"
>
<ArrowLeft className="h-4 w-4" />
Back to event
</Link>
<h1 className="text-3xl font-bold mb-1">CS2 Stage Setup</h1>
<p className="text-muted-foreground">{event.name} {sportsSeason.name}</p>
</div>
{actionData?.message && (
<div className={`mb-4 p-3 rounded-md text-sm ${actionData.success ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/30' : 'bg-destructive/10 text-destructive border border-destructive/30'}`}>
{actionData.message}
</div>
)}
{/* Stage Assignment */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center justify-between">
Stage Assignments
{stageResults.length > 0 && (
<Form
method="post"
onSubmit={(e) => {
if (!confirm('Reset this event? This clears all stage assignments AND recorded results/QP for the event so you can set it up again. The Champions Stage bracket is kept.')) {
e.preventDefault();
}
}}
>
<input type="hidden" name="intent" value="reset" />
<Button type="submit" variant="ghost" size="sm" className="text-destructive">
<Trash2 className="h-4 w-4 mr-1" />
Reset all
</Button>
</Form>
)}
</CardTitle>
<CardDescription>
Assign each team to their starting stage. Stage 1 = 16 Opening Stage teams,
Stage 2 = 8 Challengers (skip Stage 1), Stage 3 = 8 Legends (skip Stages 1 and 2).
Total: 32 teams across all three stages.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-6">
<input type="hidden" name="intent" value="assign" />
{/* Stage summary badges */}
<div className="flex gap-3 flex-wrap">
{[1, 2, 3].map(stage => (
<div key={stage} className="flex items-center gap-2 text-sm">
<Badge variant={STAGE_LABELS[stage].badgeVariant}>{STAGE_LABELS[stage].label}</Badge>
<span className="text-muted-foreground">
{stageCounts[stage] ?? 0}/{STAGE_LABELS[stage].maxTeams} teams
</span>
</div>
))}
</div>
{/* Teams listed with stage selectors */}
<div className="space-y-1">
<div className="grid grid-cols-[1fr_160px] gap-x-3 text-xs font-medium text-muted-foreground px-1 mb-2">
<span>Team</span>
<span>Starting Stage</span>
</div>
{participants.map(participant => (
<div key={participant.id} className="grid grid-cols-[1fr_160px] gap-x-3 items-center py-1 border-b border-border/40 last:border-0">
<span className="text-sm">{participant.name}</span>
<select
name={`stage_${participant.id}`}
value={stageSelections[participant.id] ?? ''}
onChange={e => setStageSelections(prev => ({ ...prev, [participant.id]: e.target.value }))}
className="h-8 text-sm rounded-md border border-input bg-background px-2"
>
<option value=""> not assigned </option>
<option value="1">Stage 1 (Opening)</option>
<option value="2">Stage 2 (Elimination)</option>
<option value="3">Stage 3 (Decider)</option>
</select>
</div>
))}
</div>
<Button type="submit" disabled={isSubmitting}>
<Save className="h-4 w-4 mr-2" />
{isSubmitting ? 'Saving...' : 'Save Stage Assignments'}
</Button>
</Form>
</CardContent>
</Card>
{/* Advancement Tracking (only shown when stage assignments exist) */}
{stageResults.length > 0 && (
<Card className="mb-6">
<CardHeader>
<CardTitle>Stage Advancement Tracking</CardTitle>
<CardDescription>
Record which teams were eliminated and their W-L record at elimination.
Each stage shows its full 16-team field direct entries plus teams that advanced
from the previous stage. Teams not checked as eliminated are assumed to have advanced
(or are still playing). Stage 3 W-L records determine QP sub-placements (916), and
QP is written to the event as you save each stage.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-6">
<input type="hidden" name="intent" value="mark-eliminations" />
{[1, 2, 3].map(stage => {
const teamsInStage = byStageFullField[stage];
if (teamsInStage.length === 0) return null;
return (
<div key={stage}>
<input type="hidden" name={`stage_displayed_${stage}`} value="1" />
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Badge variant={STAGE_LABELS[stage].badgeVariant}>{STAGE_LABELS[stage].label}</Badge>
{STAGE_LABELS[stage].description}
<span className="text-xs font-normal text-muted-foreground">
({teamsInStage.length} teams)
</span>
</h3>
<div className="space-y-1">
{teamsInStage.map(r => {
const currentElimStage = eliminatedAtStage[r.participantId] ?? null;
const isEliminated = currentElimStage === stage;
return (
<div key={r.participantId} className="grid grid-cols-[1fr_auto_200px] gap-x-3 items-center py-1 border-b border-border/40 last:border-0">
<span className="text-sm flex items-center gap-2">
{r.participantName}
{'advancedFrom' in r && r.advancedFrom !== undefined && (
<Badge variant="outline" className="text-xs text-muted-foreground">
adv. from Stage {r.advancedFrom}
</Badge>
)}
{r.stageEliminated !== null && r.stageEliminated === stage && (
<Badge variant="outline" className="text-xs">
{r.stageEliminatedWins}-3
</Badge>
)}
</span>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={isEliminated}
onChange={e =>
setEliminatedAtStage(prev => ({
...prev,
[r.participantId]: e.target.checked ? stage : null,
}))
}
className="rounded"
/>
Eliminated
</label>
{isEliminated && (
<select
name={`elim_${stage}_${r.participantId}`}
defaultValue={r.stageEliminated === stage ? (r.stageEliminatedWins?.toString() ?? '0') : '0'}
className="h-8 w-full text-sm rounded-md border border-input bg-background px-2"
>
{RECORD_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
)}
{!isEliminated && <div />}
</div>
);
})}
</div>
</div>
);
})}
<Button type="submit" disabled={isSubmitting}>
<CheckCircle2 className="h-4 w-4 mr-2" />
{isSubmitting ? 'Saving...' : 'Save Eliminations'}
</Button>
</Form>
</CardContent>
</Card>
)}
{/* Swiss Rounds */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center justify-between">
Swiss Rounds
<syncFetcher.Form method="post">
<input type="hidden" name="intent" value="sync-matches" />
<Button
type="submit"
variant="outline"
size="sm"
disabled={isSyncing || !sportsSeason.externalSeasonId}
title={!sportsSeason.externalSeasonId ? "Set External Season ID on the sports season to enable sync" : undefined}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`} />
{isSyncing ? 'Syncing...' : 'Sync from API'}
</Button>
</syncFetcher.Form>
</CardTitle>
<CardDescription>
{sportsSeason.externalSeasonId
? `Syncing from external ID: ${sportsSeason.externalSeasonId}`
: 'Set an External Season ID on the sports season to enable API sync.'}
</CardDescription>
</CardHeader>
<CardContent>
{seasonMatches.length === 0 ? (
<p className="text-sm text-muted-foreground">No match data yet. Sync from the API or use the <Link to={`/admin/sports-seasons/${sportsSeason.id}/events/${event.id}/swiss-matches`} className="underline">manual match entry</Link> page.</p>
) : (
<div className="space-y-6">
{[1, 2, 3].map(stage => {
const stageMatches = seasonMatches.filter(m => m.matchStage === stage);
if (stageMatches.length === 0) return null;
// Group by round
const rounds = new Map<number, typeof stageMatches>();
for (const m of stageMatches) {
const r = m.matchRound ?? 0;
let roundGroup = rounds.get(r);
if (!roundGroup) {
roundGroup = [];
rounds.set(r, roundGroup);
}
roundGroup.push(m);
}
return (
<div key={stage}>
<h3 className="text-sm font-semibold mb-3">
Stage {stage} {STAGE_NAMES[stage] ?? ''}
</h3>
<div className="space-y-4">
{[...rounds.entries()].toSorted(([a], [b]) => a - b).map(([round, matches]) => (
<div key={round}>
<p className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
Round {round}
</p>
<div className="space-y-1">
{matches.map(m => (
<div key={m.id} className={`flex items-center gap-3 text-sm py-1 ${MATCH_STATUS_STYLES[m.status] ?? ''}`}>
<span className="flex-1">{m.participant1?.name ?? '?'}</span>
<span className="tabular-nums font-medium">
{m.status === 'complete' || m.status === 'in_progress'
? `${m.participant1Score ?? ''} ${m.participant2Score ?? ''}`
: 'vs'}
</span>
<span className="flex-1 text-right">{m.participant2?.name ?? '?'}</span>
{m.subGames.length > 0 && (
<span className="text-xs text-muted-foreground ml-2">
[{m.subGames.map(g => `${g.participant1Score}-${g.participant2Score}`).join(', ')}]
</span>
)}
</div>
))}
</div>
</div>
))}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Champions Stage link */}
{stageResults.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Champions Stage (Playoffs)</CardTitle>
<CardDescription>
The 8-team single-elimination bracket. Use the bracket admin to track
Quarterfinals, Semifinals, and Grand Final results.
</CardDescription>
</CardHeader>
<CardContent>
<Link to={`/admin/sports-seasons/${sportsSeason.id}/events/${event.id}/bracket`}>
<Button variant="outline">
Open Bracket Admin
</Button>
</Link>
</CardContent>
</Card>
)}
</div>
);
}