Fix CS2 Stage 3 QP slot assignment #89
3 changed files with 400 additions and 30 deletions
205
app/models/__tests__/cs2-major-stage.test.ts
Normal file
205
app/models/__tests__/cs2-major-stage.test.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { computeStage3ExitQP } from "../cs2-major-stage";
|
||||
|
||||
function makeStage3QPConfig(): Map<number, number> {
|
||||
const config = new Map<number, number>();
|
||||
// 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")!).toBeGreaterThan(result.get("mid")!);
|
||||
expect(result.get("mid")!).toBeGreaterThan(result.get("low")!);
|
||||
});
|
||||
|
||||
it("handles a single team (gets slot 9 QP)", () => {
|
||||
const config = makeStage3QPConfig();
|
||||
const result = computeStage3ExitQP([{ id: "solo", wins: 2 }], config);
|
||||
expect(result.get("solo")).toBe(config.get(9));
|
||||
});
|
||||
|
||||
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 10 (next highest)
|
||||
];
|
||||
const result = computeStage3ExitQP(elim, makeStage3QPConfig());
|
||||
// slot 9 = 80, slot 10 = 70
|
||||
expect(result.get("best")).toBe(80);
|
||||
expect(result.get("worst")).toBe(70);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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<FakeResult & { advancedFrom?: number }>> {
|
||||
const out: Record<1 | 2 | 3, Array<FakeResult & { advancedFrom?: number }>> = { 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;
|
||||
}
|
||||
|
||||
describe("byStageFullField computation", () => {
|
||||
function makeResult(id: string, entry: number, elim: number | null, wins: number | null = null): FakeResult {
|
||||
return { participantId: id, stageEntry: entry, stageEliminated: elim, stageEliminatedWins: wins };
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,25 @@ 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.
|
||||
*/
|
||||
export async function markCs2StageEliminations(
|
||||
scoringEventId: string,
|
||||
eliminations: Array<{ participantId: string; winsAtElimination: number }>
|
||||
eliminations: Array<{ participantId: string; stageEliminated: number; winsAtElimination: number }>
|
||||
): Promise<void> {
|
||||
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
|
||||
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,
|
||||
})
|
||||
|
|
@ -191,3 +192,109 @@ export async function setCs2FinalPlacements(
|
|||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function computeStage3ExitQP(
|
||||
elimTeams: Array<{ id: string; wins: number }>,
|
||||
qpConfig: Map<number, number>
|
||||
): Map<string, number> {
|
||||
const byWins = new Map<number, string[]>();
|
||||
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<string, number>();
|
||||
const winsGroups = [...byWins.entries()].toSorted((a, b) => b[0] - a[0]);
|
||||
|
||||
let slotStart = 9;
|
||||
for (const [, ids] of winsGroups) {
|
||||
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 to event_results for teams whose elimination stage is
|
||||
* now known. Called after marking stage eliminations so that QP is recorded
|
||||
* progressively (like bracket points) rather than only at event completion.
|
||||
*
|
||||
* - Stage 1 or 2 exits → 0 QP (placements 17–32)
|
||||
* - Stage 3 exits → sub-ranked QP (placements 9–16, split by W-L record)
|
||||
* - Champions Stage participants (stageEliminated = null) are not touched here;
|
||||
* their QP is handled by the bracket admin when Champion Stage results are entered.
|
||||
*
|
||||
* Idempotent: safe to call after each elimination-marking save.
|
||||
*/
|
||||
export async function assignCs2EliminationQP(
|
||||
scoringEventId: string,
|
||||
sportsSeasonId: string
|
||||
): Promise<void> {
|
||||
const db = database();
|
||||
|
||||
const allResults = await getCs2StageResultsForEvent(scoringEventId);
|
||||
|
||||
const stage1or2Exits = allResults.filter(
|
||||
(r) => r.stageEliminated === 1 || r.stageEliminated === 2
|
||||
);
|
||||
const stage3Exits = allResults.filter((r) => r.stageEliminated === 3);
|
||||
|
||||
if (stage1or2Exits.length === 0 && stage3Exits.length === 0) return;
|
||||
|
||||
const qpConfigArray = await getQPConfig(sportsSeasonId);
|
||||
const qpConfig = new Map<number, number>(
|
||||
qpConfigArray.map((c) => [c.placement, parseFloat(c.points)])
|
||||
);
|
||||
|
||||
const qpByParticipant = new Map<string, number>();
|
||||
|
||||
for (const r of stage1or2Exits) {
|
||||
qpByParticipant.set(r.participantId, 0);
|
||||
}
|
||||
|
||||
if (stage3Exits.length > 0) {
|
||||
const stage3QP = computeStage3ExitQP(
|
||||
stage3Exits.map((r) => ({ id: r.participantId, wins: r.stageEliminatedWins ?? 0 })),
|
||||
qpConfig
|
||||
);
|
||||
for (const [id, qp] of stage3QP) {
|
||||
qpByParticipant.set(id, qp);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
for (const [seasonParticipantId, qp] of qpByParticipant) {
|
||||
await db
|
||||
.insert(eventResults)
|
||||
.values({
|
||||
scoringEventId,
|
||||
seasonParticipantId,
|
||||
qualifyingPointsAwarded: qp.toString(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [eventResults.scoringEventId, eventResults.seasonParticipantId],
|
||||
set: {
|
||||
qualifyingPointsAwarded: qp.toString(),
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const seasonParticipantId of qpByParticipant.keys()) {
|
||||
await recalculateParticipantQP(seasonParticipantId, sportsSeasonId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
upsertCs2StageAssignments,
|
||||
markCs2StageEliminations,
|
||||
clearCs2StageAssignments,
|
||||
assignCs2EliminationQP,
|
||||
} from '~/models/cs2-major-stage';
|
||||
import { findSeasonMatchesByScoringEventId } from '~/models/season-match';
|
||||
import { syncMatches } from '~/services/match-sync';
|
||||
|
|
@ -85,19 +86,27 @@ 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 }> = [];
|
||||
// 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).
|
||||
const eliminations: Array<{ participantId: string; stageEliminated: number; winsAtElimination: number }> = [];
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (key.startsWith('elim_')) {
|
||||
const participantId = key.slice('elim_'.length);
|
||||
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(wins) && wins >= 0 && wins <= 2) {
|
||||
eliminations.push({ participantId, winsAtElimination: wins });
|
||||
if (!isNaN(stageEliminated) && stageEliminated >= 1 && stageEliminated <= 3
|
||||
&& participantId.length > 0 && !isNaN(wins) && wins >= 0 && wins <= 2) {
|
||||
eliminations.push({ participantId, stageEliminated, winsAtElimination: wins });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await markCs2StageEliminations(eventId, eliminations);
|
||||
await assignCs2EliminationQP(eventId, params.id);
|
||||
return { success: true, message: `Marked ${eliminations.length} eliminations.` };
|
||||
}
|
||||
|
||||
|
|
@ -117,8 +126,8 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
|
||||
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' },
|
||||
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 +165,14 @@ export default function AdminCs2Setup() {
|
|||
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; });
|
||||
// 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 stage for display
|
||||
// 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);
|
||||
|
|
@ -173,6 +182,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<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">
|
||||
|
|
@ -269,8 +316,10 @@ export default function AdminCs2Setup() {
|
|||
<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).
|
||||
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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -278,7 +327,7 @@ export default function AdminCs2Setup() {
|
|||
<input type="hidden" name="intent" value="mark-eliminations" />
|
||||
|
||||
{[1, 2, 3].map(stage => {
|
||||
const teamsInStage = byStage[stage];
|
||||
const teamsInStage = byStageFullField[stage];
|
||||
if (teamsInStage.length === 0) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -286,17 +335,26 @@ export default function AdminCs2Setup() {
|
|||
<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 isEliminated = eliminatedChecked[r.participantId] ?? false;
|
||||
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}
|
||||
{r.stageEliminated !== null && (
|
||||
{'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">
|
||||
eliminated {r.stageEliminatedWins}-3
|
||||
{r.stageEliminatedWins}-3
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
|
|
@ -305,9 +363,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 +374,8 @@ export default function AdminCs2Setup() {
|
|||
</label>
|
||||
{isEliminated && (
|
||||
<select
|
||||
name={`elim_${r.participantId}`}
|
||||
defaultValue={r.stageEliminatedWins?.toString() ?? '0'}
|
||||
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 => (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue