diff --git a/app/models/__tests__/cs2-major-stage.test.ts b/app/models/__tests__/cs2-major-stage.test.ts new file mode 100644 index 0000000..e83595c --- /dev/null +++ b/app/models/__tests__/cs2-major-stage.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect } from "vitest"; +import { computeStage3ExitQP } from "../cs2-major-stage"; + +function makeStage3QPConfig(): Map { + const config = new Map(); + // Slots 9–16 with descending QP: 9→80, 10→70, ..., 16→10 + for (let i = 9; i <= 16; i++) { + config.set(i, (17 - i) * 10); + } + return config; +} + +describe("computeStage3ExitQP", () => { + it("returns empty map for empty input", () => { + const result = computeStage3ExitQP([], new Map()); + expect(result.size).toBe(0); + }); + + it("tie-splits QP within each wins group", () => { + const elim = [ + { id: "a", wins: 2 }, { id: "b", wins: 2 }, // slots 9-10: avg (80+70)/2 = 75 + { id: "c", wins: 1 }, { id: "d", wins: 1 }, // slots 11-12: avg (60+50)/2 = 55 + { id: "e", wins: 0 }, { id: "f", wins: 0 }, + { id: "g", wins: 0 }, { id: "h", wins: 0 }, // slots 13-16: avg (40+30+20+10)/4 = 25 + ]; + const result = computeStage3ExitQP(elim, makeStage3QPConfig()); + + expect(result.get("a")).toBeCloseTo(75); + expect(result.get("b")).toBeCloseTo(75); + expect(result.get("c")).toBeCloseTo(55); + expect(result.get("d")).toBeCloseTo(55); + expect(result.get("e")).toBeCloseTo(25); + expect(result.get("h")).toBeCloseTo(25); + }); + + it("all teams with the same wins get identical averaged QP", () => { + const elim = Array.from({ length: 8 }, (_, i) => ({ id: `t-${i}`, wins: 1 })); + const result = computeStage3ExitQP(elim, makeStage3QPConfig()); + const expected = (80 + 70 + 60 + 50 + 40 + 30 + 20 + 10) / 8; + for (const [, qp] of result) { + expect(qp).toBeCloseTo(expected); + } + }); + + it("higher wins groups get higher QP than lower wins groups", () => { + const elim = [ + { id: "high", wins: 2 }, + { id: "mid", wins: 1 }, + { id: "low", wins: 0 }, + ...Array.from({ length: 5 }, (_, i) => ({ id: `pad-${i}`, wins: 0 })), + ]; + const result = computeStage3ExitQP(elim, makeStage3QPConfig()); + expect(result.get("high") ?? 0).toBeGreaterThan(result.get("mid") ?? 0); + expect(result.get("mid") ?? 0).toBeGreaterThan(result.get("low") ?? 0); + }); + + it("handles a single 2-wins team (gets slot 9 QP, best position)", () => { + const config = makeStage3QPConfig(); + const result = computeStage3ExitQP([{ id: "solo", wins: 2 }], config); + expect(result.get("solo")).toBe(config.get(9)); + }); + + it("handles a single 0-wins team (gets slot 16 QP, worst position)", () => { + const config = makeStage3QPConfig(); + const result = computeStage3ExitQP([{ id: "solo", wins: 0 }], config); + expect(result.get("solo")).toBe(config.get(16)); + }); + + it("two 0-wins teams get slots 15–16 even when no other exits are known yet", () => { + const config = makeStage3QPConfig(); + // This is the progressive case: only 0-3 teams marked so far in Stage 3. + // They should get avg of slots 15+16, not slots 9+10. + const result = computeStage3ExitQP([{ id: "a", wins: 0 }, { id: "b", wins: 0 }], config); + const expected = ((config.get(15) ?? 0) + (config.get(16) ?? 0)) / 2; // (20+10)/2 = 15 + expect(result.get("a")).toBeCloseTo(expected); + expect(result.get("b")).toBeCloseTo(expected); + }); + + it("returns 0 for teams when qpConfig has no entries for those slots", () => { + const result = computeStage3ExitQP( + [{ id: "x", wins: 0 }], + new Map() // empty config → all 0 + ); + expect(result.get("x")).toBe(0); + }); + + it("assigns slots in order: highest wins group gets lowest slot numbers (highest QP)", () => { + const elim = [ + { id: "best", wins: 2 }, // → slot 9 (highest QP) + { id: "worst", wins: 0 }, // → slot 16 (lowest QP — 0-wins always fills from bottom) + ]; + const result = computeStage3ExitQP(elim, makeStage3QPConfig()); + // slot 9 = 80, slot 16 = 10 + expect(result.get("best")).toBe(80); + expect(result.get("worst")).toBe(10); + }); +}); + +// ─── byStageFullField logic (pure computation, no DB) ──────────────────────── +// +// The cs2-setup route computes which elimination section each team belongs to +// based on stageEntry, stageEliminated, and stage completion thresholds. +// We test the same logic here as a pure function to keep it verifiable. + +interface FakeResult { + participantId: string; + stageEntry: number; + stageEliminated: number | null; + stageEliminatedWins: number | null; +} + +function computeFullField( + results: FakeResult[], + stage1Complete: boolean, + stage2Complete: boolean +): Record<1 | 2 | 3, Array> { + const out: Record<1 | 2 | 3, Array> = { 1: [], 2: [], 3: [] }; + for (const r of results) { + if (r.stageEliminated !== null) { + (out[r.stageEliminated as 1 | 2 | 3] ?? out[3]).push(r); + } else if (r.stageEntry === 1) { + if (stage1Complete) { + if (stage2Complete) { + out[3].push({ ...r, advancedFrom: 1 }); + } else { + out[2].push({ ...r, advancedFrom: 1 }); + } + } else { + out[1].push(r); + } + } else if (r.stageEntry === 2) { + if (stage2Complete) { + out[3].push({ ...r, advancedFrom: 2 }); + } else { + out[2].push(r); + } + } else { + out[3].push(r); + } + } + return out; +} + +function makeResult(id: string, entry: number, elim: number | null, wins: number | null = null): FakeResult { + return { participantId: id, stageEntry: entry, stageEliminated: elim, stageEliminatedWins: wins }; +} + +describe("byStageFullField computation", () => { + it("before any stage completes, each team appears in their starting stage", () => { + const results = [ + makeResult("s1a", 1, null), makeResult("s1b", 1, null), + makeResult("s2a", 2, null), + makeResult("s3a", 3, null), + ]; + const full = computeFullField(results, false, false); + expect(full[1].map(r => r.participantId)).toContain("s1a"); + expect(full[2].map(r => r.participantId)).toContain("s2a"); + expect(full[3].map(r => r.participantId)).toContain("s3a"); + expect(full[2].map(r => r.participantId)).not.toContain("s1a"); + }); + + it("stage 1 eliminated teams stay in section 1 regardless of stage completion", () => { + const results = [makeResult("t", 1, 1, 0)]; + const full = computeFullField(results, true, true); + expect(full[1].map(r => r.participantId)).toContain("t"); + expect(full[2].map(r => r.participantId)).not.toContain("t"); + }); + + it("stage 1 advancers move to section 2 when stage 1 is complete", () => { + const results = [makeResult("adv", 1, null)]; + const full = computeFullField(results, true, false); + expect(full[2].map(r => r.participantId)).toContain("adv"); + expect(full[1].map(r => r.participantId)).not.toContain("adv"); + expect((full[2].find(r => r.participantId === "adv") as { advancedFrom?: number })?.advancedFrom).toBe(1); + }); + + it("stage 1 advancers move to section 3 when both stages 1 and 2 are complete", () => { + const results = [makeResult("adv", 1, null)]; + const full = computeFullField(results, true, true); + expect(full[3].map(r => r.participantId)).toContain("adv"); + expect(full[2].map(r => r.participantId)).not.toContain("adv"); + }); + + it("stage 2 advancers move to section 3 when stage 2 is complete", () => { + const results = [makeResult("s2adv", 2, null)]; + const full = computeFullField(results, true, true); + expect(full[3].map(r => r.participantId)).toContain("s2adv"); + expect(full[2].map(r => r.participantId)).not.toContain("s2adv"); + }); + + it("stage 2 eliminated teams always appear in section 2", () => { + const results = [makeResult("e2", 1, 2, 1)]; // Stage 1 entrant eliminated in Stage 2 + const full = computeFullField(results, true, false); + expect(full[2].map(r => r.participantId)).toContain("e2"); + expect(full[1].map(r => r.participantId)).not.toContain("e2"); + }); + + it("stage 3 eliminated teams always appear in section 3", () => { + const results = [makeResult("e3", 1, 3, 2)]; // Stage 1 entrant eliminated in Stage 3 + const full = computeFullField(results, true, true); + expect(full[3].map(r => r.participantId)).toContain("e3"); + expect(full[1].map(r => r.participantId)).not.toContain("e3"); + expect(full[2].map(r => r.participantId)).not.toContain("e3"); + }); + + it("each team appears in exactly one section", () => { + const results = [ + makeResult("a", 1, 1, 0), // eliminated Stage 1 + makeResult("b", 1, null), // Stage 1 advancer (Stage 1 complete) + makeResult("c", 2, 2, 1), // eliminated Stage 2 + makeResult("d", 2, null), // Stage 2 advancer (Stage 2 complete) + makeResult("e", 3, 3, 2), // eliminated Stage 3 + makeResult("f", 3, null), // Stage 3 survivor (Champions) + ]; + const full = computeFullField(results, true, true); + const allIds = [...full[1], ...full[2], ...full[3]].map(r => r.participantId); + const uniqueIds = new Set(allIds); + expect(uniqueIds.size).toBe(results.length); + expect(allIds.length).toBe(results.length); + }); +}); diff --git a/app/models/cs2-major-stage.ts b/app/models/cs2-major-stage.ts index bbaede8..3a9e55c 100644 --- a/app/models/cs2-major-stage.ts +++ b/app/models/cs2-major-stage.ts @@ -13,8 +13,9 @@ */ import { database } from "~/database/context"; -import { cs2MajorStageResults, seasonParticipants } from "~/database/schema"; +import { cs2MajorStageResults, seasonParticipants, eventResults } from "~/database/schema"; import { eq, and, sql } from "drizzle-orm"; +import { getQPConfig, recalculateParticipantQP } from "~/models/qualifying-points"; export interface Cs2StageResult { id: string; @@ -135,25 +136,38 @@ export async function clearCs2StageAssignments( /** * Mark teams as eliminated from a specific stage. - * Records stageEliminated and stageEliminatedWins for the specified seasonParticipants. - * Teams NOT in eliminatedEntries that are in this stage are implicitly considered - * to have advanced (stageEliminated remains null). + * Each elimination entry specifies which stage the team was eliminated at — + * this may differ from stageEntry for teams that advanced from an earlier stage + * (e.g. a Stage 1 team eliminated in Stage 2 has stageEntry=1, stageEliminated=2). + * Teams NOT in eliminatedEntries keep their existing stageEliminated value. + * + * Throws if stageEliminated < stageEntry for any participant (a team cannot be + * eliminated in a stage they haven't reached yet). */ export async function markCs2StageEliminations( scoringEventId: string, - eliminations: Array<{ participantId: string; winsAtElimination: number }> + eliminations: Array<{ participantId: string; stageEliminated: number; winsAtElimination: number }> ): Promise { if (eliminations.length === 0) return; const db = database(); const now = new Date(); - // Use individual upserts for each eliminated team to set their specific win count + const existingResults = await getCs2StageResultsMapForEvent(scoringEventId); + for (const { participantId, stageEliminated } of eliminations) { + const existing = existingResults.get(participantId); + if (existing && stageEliminated < existing.stageEntry) { + throw new Error( + `Team ${participantId} (stageEntry=${existing.stageEntry}) cannot be eliminated at stage ${stageEliminated}` + ); + } + } + await Promise.all( - eliminations.map(({ participantId, winsAtElimination }) => + eliminations.map(({ participantId, stageEliminated, winsAtElimination }) => db .update(cs2MajorStageResults) .set({ - stageEliminated: sql`${cs2MajorStageResults.stageEntry}`, // eliminated at their current stage + stageEliminated, stageEliminatedWins: winsAtElimination, updatedAt: now, }) @@ -165,6 +179,26 @@ export async function markCs2StageEliminations( ); } +/** + * Clear elimination records for all teams currently marked as eliminated at a + * specific stage. Used to implement replacement semantics in the admin form: + * before re-saving eliminations for a stage, wipe the existing records so that + * unchecking a team actually removes them from the eliminated list. + */ +export async function clearCs2EliminationsAtStage( + scoringEventId: string, + stageNum: number +): Promise { + const db = database(); + await db + .update(cs2MajorStageResults) + .set({ stageEliminated: null, stageEliminatedWins: null, updatedAt: new Date() }) + .where(and( + eq(cs2MajorStageResults.scoringEventId, scoringEventId), + eq(cs2MajorStageResults.stageEliminated, stageNum) + )); +} + /** * Set final placements for all seasonParticipants in a CS2 Major event. * Called after the Champions Stage is complete. @@ -191,3 +225,173 @@ export async function setCs2FinalPlacements( ) ); } + +// Stage 3 always eliminates 8 teams (16 Swiss teams → 8 advance, 8 eliminated → slots 9–16). +const STAGE3_TOTAL_EXITS = 8; + +/** + * Compute QP for Stage 3 exits based on W-L record. + * Teams are ranked within slots 9–16 by wins descending (2-3 > 1-3 > 0-3). + * Within the same wins count QP is tie-split (averaged) across placement slots. + * + * 0-wins teams always occupy the BOTTOM of the slot range (slots 15–16 for 2 + * teams) because they can never rank above any team with at least 1 win. + * Higher-wins groups fill from slot 9 upward as before. This means the + * progressive intermediate QP values for 0-3 teams are correct even before + * the other Stage 3 exits have been marked. + * + * Exported for unit testing. + */ +export function computeStage3ExitQP( + elimTeams: Array<{ id: string; wins: number }>, + qpConfig: Map, + totalStage3Exits: number = STAGE3_TOTAL_EXITS +): Map { + const byWins = new Map(); + for (const t of elimTeams) { + if (!byWins.has(t.wins)) byWins.set(t.wins, []); + byWins.get(t.wins)?.push(t.id); + } + + const result = new Map(); + const slotRangeEnd = 9 + totalStage3Exits - 1; // = 16 for 8 exits + + // 0-wins teams always fill the bottom of the range. + const zeroWinsIds = byWins.get(0) ?? []; + const zeroWinsSlotStart = slotRangeEnd - zeroWinsIds.length + 1; + if (zeroWinsIds.length > 0) { + const slots = Array.from({ length: zeroWinsIds.length }, (_, i) => zeroWinsSlotStart + i); + const avgQP = slots.reduce((sum, s) => sum + (qpConfig.get(s) ?? 0), 0) / slots.length; + for (const id of zeroWinsIds) result.set(id, avgQP); + } + + // Higher-wins groups fill from slot 9 upward. + const higherWinsGroups = [...byWins.entries()] + .filter(([wins]) => wins > 0) + .toSorted((a, b) => b[0] - a[0]); + + let slotStart = 9; + for (const [, ids] of higherWinsGroups) { + const slots = Array.from({ length: ids.length }, (_, i) => slotStart + i); + const avgQP = slots.reduce((sum, s) => sum + (qpConfig.get(s) ?? 0), 0) / slots.length; + for (const id of ids) result.set(id, avgQP); + slotStart += ids.length; + } + + return result; +} + +/** + * Write qualifying points and placements to event_results for teams whose + * elimination stage is now known. Called after marking stage eliminations so + * that QP is recorded progressively rather than only at event completion. + * + * - Stage 1 exits → 0 QP, placement = 25 (group start for slots 25–32) + * - Stage 2 exits → 0 QP, placement = 17 (group start for slots 17–24) + * - Stage 3 exits → sub-ranked QP, placement = W-L group start slot (9–16) + * - Champions Stage participants (stageEliminated = null) are not touched here; + * their QP is handled by the bracket admin when Champions Stage results are entered. + * + * Writing placement enables processQualifyingEvent to correctly re-derive QP at + * finalization via its own tie-split logic (grouping rows by placement value). + * Idempotent: safe to call after each elimination-marking save. + */ +export async function assignCs2EliminationQP( + scoringEventId: string, + sportsSeasonId: string +): Promise { + const db = database(); + + const allResults = await getCs2StageResultsForEvent(scoringEventId); + + const stage1Exits = allResults.filter((r) => r.stageEliminated === 1); + const stage2Exits = allResults.filter((r) => r.stageEliminated === 2); + const stage3Exits = allResults.filter((r) => r.stageEliminated === 3); + + if (stage1Exits.length === 0 && stage2Exits.length === 0 && stage3Exits.length === 0) return; + + const qpConfigArray = await getQPConfig(sportsSeasonId); + const qpConfig = new Map( + qpConfigArray.map((c) => [c.placement, parseFloat(c.points)]) + ); + + // Map from participantId → { qp, placement } + const resultsByParticipant = new Map(); + + for (const r of stage1Exits) { + resultsByParticipant.set(r.participantId, { qp: 0, placement: 25 }); + } + for (const r of stage2Exits) { + resultsByParticipant.set(r.participantId, { qp: 0, placement: 17 }); + } + + if (stage3Exits.length > 0) { + if (stage3Exits.length === STAGE3_TOTAL_EXITS) { + // All exits known — compute correct QP and placements. + const stage3QP = computeStage3ExitQP( + stage3Exits.map((r) => ({ id: r.participantId, wins: r.stageEliminatedWins ?? 0 })), + qpConfig + ); + + // All teams in a W-L group share the same placement so processQualifyingEvent + // groups them and tie-splits QP identically to computeStage3ExitQP. + // 0-wins teams fill the bottom of the range; higher-wins groups fill from slot 9 up. + const byWins = new Map(); + for (const r of stage3Exits) { + const wins = r.stageEliminatedWins ?? 0; + if (!byWins.has(wins)) byWins.set(wins, []); + byWins.get(wins)?.push(r.participantId); + } + const slotRangeEnd = 9 + STAGE3_TOTAL_EXITS - 1; // = 16 + const zeroWinIds = byWins.get(0) ?? []; + const zeroWinSlotStart = slotRangeEnd - zeroWinIds.length + 1; + for (const id of zeroWinIds) { + resultsByParticipant.set(id, { qp: stage3QP.get(id) ?? 0, placement: zeroWinSlotStart }); + } + let slotStart = 9; + for (const [wins, ids] of [...byWins.entries()].toSorted((a, b) => b[0] - a[0])) { + if (wins === 0) continue; + for (const id of ids) { + resultsByParticipant.set(id, { qp: stage3QP.get(id) ?? 0, placement: slotStart }); + } + slotStart += ids.length; + } + } else { + // Partial — write 0 QP so rows exist but defer correct values until all exits are known. + for (const r of stage3Exits) { + resultsByParticipant.set(r.participantId, { qp: 0, placement: 9 }); + } + } + } + + const now = new Date(); + + await Promise.all( + [...resultsByParticipant.entries()].map(([seasonParticipantId, { qp, placement }]) => + db + .insert(eventResults) + .values({ + scoringEventId, + seasonParticipantId, + qualifyingPointsAwarded: qp.toString(), + placement, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [eventResults.scoringEventId, eventResults.seasonParticipantId], + set: { + qualifyingPointsAwarded: qp.toString(), + placement, + updatedAt: now, + }, + }) + ) + ); + + await Promise.all( + [...resultsByParticipant.keys()].map((seasonParticipantId) => + recalculateParticipantQP(seasonParticipantId, sportsSeasonId) + ) + ); +} diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx index db4e759..8795161 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.cs2-setup.tsx @@ -9,6 +9,8 @@ import { upsertCs2StageAssignments, markCs2StageEliminations, clearCs2StageAssignments, + clearCs2EliminationsAtStage, + assignCs2EliminationQP, } from '~/models/cs2-major-stage'; import { findSeasonMatchesByScoringEventId } from '~/models/season-match'; import { syncMatches } from '~/services/match-sync'; @@ -85,20 +87,49 @@ export async function action({ request, params }: Route.ActionArgs) { } 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 }); + // 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); + } } } - } - await markCs2StageEliminations(eventId, eliminations); - return { success: true, message: `Marked ${eliminations.length} eliminations.` }; + 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); + 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') { @@ -117,8 +148,8 @@ export async function action({ request, params }: Route.ActionArgs) { const STAGE_LABELS: Record = { 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' }, + 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 = [ @@ -156,14 +187,14 @@ export default function AdminCs2Setup() { return initial; }); - // Elimination checkbox state — tracks which teams are checked as eliminated - const [eliminatedChecked, setEliminatedChecked] = useState>(() => { - const initial: Record = {}; - stageResults.forEach(r => { initial[r.participantId] = r.stageEliminated !== null; }); + // Tracks which stage each team is currently eliminated at (null = still active) + const [eliminatedAtStage, setEliminatedAtStage] = useState>(() => { + const initial: Record = {}; + stageResults.forEach(r => { initial[r.participantId] = r.stageEliminated; }); return initial; }); - // Group participants by stage for display + // Group participants by starting stage for the Stage Assignments card const byStage: Record = { 1: [], 2: [], 3: [] }; for (const r of stageResults) { byStage[r.stageEntry]?.push(r); @@ -173,6 +204,44 @@ export default function AdminCs2Setup() { 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 = { 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 (
@@ -269,8 +338,10 @@ export default function AdminCs2Setup() { Stage Advancement Tracking 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). + 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 (9–16), and + QP is written to the event as you save each stage. @@ -278,25 +349,35 @@ export default function AdminCs2Setup() { {[1, 2, 3].map(stage => { - const teamsInStage = byStage[stage]; + const teamsInStage = byStageFullField[stage]; if (teamsInStage.length === 0) return null; return (
+

{STAGE_LABELS[stage].label} {STAGE_LABELS[stage].description} + + ({teamsInStage.length} teams) +

{teamsInStage.map(r => { - const isEliminated = eliminatedChecked[r.participantId] ?? false; + const currentElimStage = eliminatedAtStage[r.participantId] ?? null; + const isEliminated = currentElimStage === stage; return (
{r.participantName} - {r.stageEliminated !== null && ( + {'advancedFrom' in r && r.advancedFrom !== undefined && ( + + adv. from Stage {r.advancedFrom} + + )} + {r.stageEliminated !== null && r.stageEliminated === stage && ( - eliminated {r.stageEliminatedWins}-3 + {r.stageEliminatedWins}-3 )} @@ -305,9 +386,9 @@ export default function AdminCs2Setup() { type="checkbox" checked={isEliminated} onChange={e => - setEliminatedChecked(prev => ({ + setEliminatedAtStage(prev => ({ ...prev, - [r.participantId]: e.target.checked, + [r.participantId]: e.target.checked ? stage : null, })) } className="rounded" @@ -316,8 +397,8 @@ export default function AdminCs2Setup() { {isEliminated && (