Add CS2 Major Qualifying Points simulator and stage management (#260)
* Add CS2 Major qualifying points simulator
Implements a full CS2 Major tournament simulator with:
- 3-stage Swiss format (Opening Bo1, Elimination Bo1/Bo3, Decider all Bo3)
+ Champions Stage 8-team single-elimination (QF Bo3, SF Bo3, GF Bo5)
- Monte Carlo simulation (10,000 iterations) accumulating QP across 2 majors/season
- Sampled 24-team field per iteration: top 12 guaranteed, remaining weighted by 1/rank
- Stage 3 exits (placements 9-16) sub-ranked by W-L record (2-3 > 1-3 > 0-3)
- Stage assignments stored per-event so actual field composition drives simulation
- Admin CS Elo form for entering team Elo + HLTV world rankings
- Admin CS2 stage setup page for assigning teams to stages and tracking advancement
- Database migration: cs2_major_qualifying_points enum value + cs2_major_stage_results table
- 24 unit tests covering all exported pure functions
https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR
* Consolidate Elo + ranking input into generic elo-ratings page
The darts-elo and cs-elo pages were unreachable from the admin nav,
which always links to the generic elo-ratings page. Extended elo-ratings
to conditionally show world ranking fields for simulator types that need
it (darts_bracket, cs2_major_qualifying_points), then deleted the
redundant sport-specific pages.
https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR
* Consolidate server postgres connections into one shared pool
Four separate postgres() clients were open simultaneously (app, timer,
snapshots, socket), each defaulting to 10 connections, exhausting the
database's max_connections limit. Replaced with a single shared lazy-
initialized client in server/db.ts using a Proxy to defer the
DATABASE_URL check until first use (preserving test compatibility).
Also bumps the CS2 Champions Stage stochastic test from 200 → 1000
iterations to eliminate flakiness.
https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR
* Fix and() bug and add Swiss loop safety guard
- cs2-major-stage.ts: markCs2StageEliminations and setCs2FinalPlacements
were using JS && instead of Drizzle and(), causing WHERE to filter only
by participantId (not scoringEventId), which would update rows across
all events instead of just the target event
- cs-major-simulator.ts: add break guard in simulateSwiss while loop to
prevent infinite loop if pairGroups returns no pairs
https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR
* Fix all remaining code review issues
- cs2-major-stage.ts: use schema column reference for stageEliminated
in markCs2StageEliminations instead of raw SQL string
- cs-major-simulator.ts: simulateOneMajor now locks in known stage
results when a stage is complete (8 recorded eliminations), only
simulating the remaining stages during live events
- admin event page: add CS2 Stage Setup button for cs2_major_qualifying_points
simulator types; expose simulatorType in server loader type cast
- cs2-setup.tsx: replace document.getElementById DOM manipulation with
React state (eliminatedChecked map) for checkbox show/hide logic;
remove unused stageMap and unassignedParticipants variables
https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR
* Fix oxlint errors: non-null assertions, sort→toSorted, unused vars
- cs-major-simulator.ts: replace 5 non-null assertions (!) with safe
optional chaining / if-guards; replace 6 .sort() with .toSorted()
- cs2-major-stage.ts: remove unused `inArray` import
- cs2-setup.tsx: remove unused `assignedIds` variable
https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR
* Fix flaky Champions Stage stochastic test
The makeTeams(8) helper creates only a 70-pt Elo spread (1800→1730).
With the Champions Stage bracket math this gives team-0 a ~19.6% win
rate — right at the 0.2 threshold, causing the test to fail ~63% of
the time in CI despite 1000 iterations.
Use 100-pt steps (1800→1100) instead, giving team-0 a ~40% win rate
and raising the assertion threshold to 0.25 for a clear safety margin.
https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-05 13:40:05 -07:00
|
|
|
|
import { Form, useLoaderData, useActionData, useNavigation, 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 } from '~/models/scoring-event';
|
2026-05-01 16:17:23 +00:00
|
|
|
|
import { findParticipantsBySportsSeasonId } from '~/models/season-participant';
|
Add CS2 Major Qualifying Points simulator and stage management (#260)
* Add CS2 Major qualifying points simulator
Implements a full CS2 Major tournament simulator with:
- 3-stage Swiss format (Opening Bo1, Elimination Bo1/Bo3, Decider all Bo3)
+ Champions Stage 8-team single-elimination (QF Bo3, SF Bo3, GF Bo5)
- Monte Carlo simulation (10,000 iterations) accumulating QP across 2 majors/season
- Sampled 24-team field per iteration: top 12 guaranteed, remaining weighted by 1/rank
- Stage 3 exits (placements 9-16) sub-ranked by W-L record (2-3 > 1-3 > 0-3)
- Stage assignments stored per-event so actual field composition drives simulation
- Admin CS Elo form for entering team Elo + HLTV world rankings
- Admin CS2 stage setup page for assigning teams to stages and tracking advancement
- Database migration: cs2_major_qualifying_points enum value + cs2_major_stage_results table
- 24 unit tests covering all exported pure functions
https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR
* Consolidate Elo + ranking input into generic elo-ratings page
The darts-elo and cs-elo pages were unreachable from the admin nav,
which always links to the generic elo-ratings page. Extended elo-ratings
to conditionally show world ranking fields for simulator types that need
it (darts_bracket, cs2_major_qualifying_points), then deleted the
redundant sport-specific pages.
https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR
* Consolidate server postgres connections into one shared pool
Four separate postgres() clients were open simultaneously (app, timer,
snapshots, socket), each defaulting to 10 connections, exhausting the
database's max_connections limit. Replaced with a single shared lazy-
initialized client in server/db.ts using a Proxy to defer the
DATABASE_URL check until first use (preserving test compatibility).
Also bumps the CS2 Champions Stage stochastic test from 200 → 1000
iterations to eliminate flakiness.
https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR
* Fix and() bug and add Swiss loop safety guard
- cs2-major-stage.ts: markCs2StageEliminations and setCs2FinalPlacements
were using JS && instead of Drizzle and(), causing WHERE to filter only
by participantId (not scoringEventId), which would update rows across
all events instead of just the target event
- cs-major-simulator.ts: add break guard in simulateSwiss while loop to
prevent infinite loop if pairGroups returns no pairs
https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR
* Fix all remaining code review issues
- cs2-major-stage.ts: use schema column reference for stageEliminated
in markCs2StageEliminations instead of raw SQL string
- cs-major-simulator.ts: simulateOneMajor now locks in known stage
results when a stage is complete (8 recorded eliminations), only
simulating the remaining stages during live events
- admin event page: add CS2 Stage Setup button for cs2_major_qualifying_points
simulator types; expose simulatorType in server loader type cast
- cs2-setup.tsx: replace document.getElementById DOM manipulation with
React state (eliminatedChecked map) for checkbox show/hide logic;
remove unused stageMap and unassignedParticipants variables
https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR
* Fix oxlint errors: non-null assertions, sort→toSorted, unused vars
- cs-major-simulator.ts: replace 5 non-null assertions (!) with safe
optional chaining / if-guards; replace 6 .sort() with .toSorted()
- cs2-major-stage.ts: remove unused `inArray` import
- cs2-setup.tsx: remove unused `assignedIds` variable
https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR
* Fix flaky Champions Stage stochastic test
The makeTeams(8) helper creates only a 70-pt Elo spread (1800→1730).
With the Champions Stage bracket math this gives team-0 a ~19.6% win
rate — right at the 0.2 threshold, causing the test to fail ~63% of
the time in CI despite 1000 iterations.
Use 100-pt steps (1800→1100) instead, giving team-0 a ~40% win rate
and raising the assertion threshold to 0.25 for a clear safety margin.
https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-05 13:40:05 -07:00
|
|
|
|
import {
|
|
|
|
|
|
getCs2StageResultsForEvent,
|
|
|
|
|
|
upsertCs2StageAssignments,
|
|
|
|
|
|
markCs2StageEliminations,
|
|
|
|
|
|
clearCs2StageAssignments,
|
|
|
|
|
|
} from '~/models/cs2-major-stage';
|
|
|
|
|
|
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 } 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] = await Promise.all([
|
|
|
|
|
|
findParticipantsBySportsSeasonId(sportsSeasonId),
|
|
|
|
|
|
getCs2StageResultsForEvent(eventId),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
return { sportsSeason, event, participants, stageResults };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
|
|
if (intent === 'reset') {
|
|
|
|
|
|
await clearCs2StageAssignments(eventId);
|
|
|
|
|
|
return { success: true, message: 'Stage assignments 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_{participantId} = winsAtElimination (0, 1, or 2)
|
|
|
|
|
|
const eliminations: Array<{ participantId: string; winsAtElimination: number }> = [];
|
|
|
|
|
|
for (const [key, value] of formData.entries()) {
|
|
|
|
|
|
if (key.startsWith('elim_')) {
|
|
|
|
|
|
const participantId = key.slice('elim_'.length);
|
|
|
|
|
|
const wins = parseInt(value as string, 10);
|
|
|
|
|
|
if (!isNaN(wins) && wins >= 0 && wins <= 2) {
|
|
|
|
|
|
eliminations.push({ participantId, winsAtElimination: wins });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await markCs2StageEliminations(eventId, eliminations);
|
|
|
|
|
|
return { success: true, message: `Marked ${eliminations.length} eliminations.` };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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: 'Elimination Stage (8 Challengers, Bo1/Bo3 Swiss)', maxTeams: 8, badgeVariant: 'secondary' },
|
|
|
|
|
|
3: { label: 'Stage 3', description: 'Decider Stage (8 Legends, all Bo3 Swiss)', maxTeams: 8, badgeVariant: 'default' },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const RECORD_OPTIONS = [
|
|
|
|
|
|
{ value: '2', label: '2-3 (advanced furthest)' },
|
|
|
|
|
|
{ value: '1', label: '1-3' },
|
|
|
|
|
|
{ value: '0', label: '0-3 (earliest exit)' },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
export default function AdminCs2Setup() {
|
|
|
|
|
|
const { sportsSeason, event, participants, stageResults } = useLoaderData<typeof loader>();
|
|
|
|
|
|
const actionData = useActionData<ActionData>();
|
|
|
|
|
|
const navigation = useNavigation();
|
|
|
|
|
|
const isSubmitting = navigation.state === 'submitting';
|
|
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Elimination checkbox state — tracks which teams are checked as eliminated
|
|
|
|
|
|
const [eliminatedChecked, setEliminatedChecked] = useState<Record<string, boolean>>(() => {
|
|
|
|
|
|
const initial: Record<string, boolean> = {};
|
|
|
|
|
|
stageResults.forEach(r => { initial[r.participantId] = r.stageEliminated !== null; });
|
|
|
|
|
|
return initial;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Group participants by stage for display
|
|
|
|
|
|
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])
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
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">
|
|
|
|
|
|
<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.
|
|
|
|
|
|
Teams not listed as eliminated are assumed to have advanced (or are still playing).
|
|
|
|
|
|
Stage 3 W-L records determine QP sub-placements (9–16).
|
|
|
|
|
|
</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 = byStage[stage];
|
|
|
|
|
|
if (teamsInStage.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={stage}>
|
|
|
|
|
|
<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}
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
{teamsInStage.map(r => {
|
|
|
|
|
|
const isEliminated = eliminatedChecked[r.participantId] ?? false;
|
|
|
|
|
|
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}
|
|
|
|
|
|
{r.stageEliminated !== null && (
|
|
|
|
|
|
<Badge variant="outline" className="text-xs">
|
|
|
|
|
|
eliminated {r.stageEliminatedWins}-3
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={isEliminated}
|
|
|
|
|
|
onChange={e =>
|
|
|
|
|
|
setEliminatedChecked(prev => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[r.participantId]: e.target.checked,
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
className="rounded"
|
|
|
|
|
|
/>
|
|
|
|
|
|
Eliminated
|
|
|
|
|
|
</label>
|
|
|
|
|
|
{isEliminated && (
|
|
|
|
|
|
<select
|
|
|
|
|
|
name={`elim_${r.participantId}`}
|
|
|
|
|
|
defaultValue={r.stageEliminatedWins?.toString() ?? '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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|