From 6b2809f32d93ecb3622cbdd1fd24454dda8ff445 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:44:11 +0300 Subject: [PATCH] Swiss TB consider dropped out teams who finished the bracket & reuse check-in --- .../components/ExportDialog.test.ts | 15 +- .../components/ExportDialog.tsx | 54 +++-- .../routes/to.$id.admin._index.tsx | 23 ++- .../tournament-bracket/core/Bracket.test.ts | 184 +++++++++++++++--- .../core/Bracket/SwissBracket.ts | 40 +++- .../tournament-bracket/core/CheckIn.test.ts | 74 +++++++ .../tournament-bracket/core/CheckIn.ts | 48 +++++ .../core/Tournament.test.ts | 88 +++++++++ .../tournament-bracket/core/Tournament.ts | 12 +- .../2026-09-28-shared-bracket-check-in.md | 5 + ...-09-28-swiss-drop-keeps-standings-order.md | 5 + 11 files changed, 463 insertions(+), 85 deletions(-) create mode 100644 app/features/tournament-bracket/core/CheckIn.test.ts create mode 100644 app/features/tournament-bracket/core/CheckIn.ts create mode 100644 changelog/2026-09-28-shared-bracket-check-in.md create mode 100644 changelog/2026-09-28-swiss-drop-keeps-standings-order.md diff --git a/app/features/tournament-admin/components/ExportDialog.test.ts b/app/features/tournament-admin/components/ExportDialog.test.ts index c1ed4654b..9b015d367 100644 --- a/app/features/tournament-admin/components/ExportDialog.test.ts +++ b/app/features/tournament-admin/components/ExportDialog.test.ts @@ -41,8 +41,7 @@ describe("scopedAndSortedTeams() check-in filtering", () => { teams: [checkedInAtEventLevel], status: "checkedIn", sort: "seed", - bracketIdx: 0, - bracketRequiresOwnCheckIn: false, + checkInBracketIdxs: null, bracketParticipantIds, }); @@ -54,8 +53,7 @@ describe("scopedAndSortedTeams() check-in filtering", () => { teams: [checkedInAtEventLevel], status: "notCheckedIn", sort: "seed", - bracketIdx: 0, - bracketRequiresOwnCheckIn: false, + checkInBracketIdxs: null, bracketParticipantIds, }); @@ -80,8 +78,7 @@ describe("scopedAndSortedTeams() check-in filtering", () => { teams: [checkedIntoBracket, onlyEventLevel], status: "checkedIn", sort: "seed", - bracketIdx: 2, - bracketRequiresOwnCheckIn: true, + checkInBracketIdxs: [2], bracketParticipantIds, }); @@ -93,8 +90,7 @@ describe("scopedAndSortedTeams() check-in filtering", () => { teams: [checkedIntoBracket, onlyEventLevel], status: "notCheckedIn", sort: "seed", - bracketIdx: 2, - bracketRequiresOwnCheckIn: true, + checkInBracketIdxs: [2], bracketParticipantIds, }); @@ -108,8 +104,7 @@ describe("scopedAndSortedTeams() check-in filtering", () => { teams: [checkedIntoBracket, onlyEventLevel, notInBracket], status: "notCheckedIn", sort: "seed", - bracketIdx: 2, - bracketRequiresOwnCheckIn: true, + checkInBracketIdxs: [2], // notInBracket is intentionally absent from the bracket's pool bracketParticipantIds, }); diff --git a/app/features/tournament-admin/components/ExportDialog.tsx b/app/features/tournament-admin/components/ExportDialog.tsx index 313205757..2dc77f1a4 100644 --- a/app/features/tournament-admin/components/ExportDialog.tsx +++ b/app/features/tournament-admin/components/ExportDialog.tsx @@ -6,6 +6,7 @@ import { } from "~/components/elements/ChipRadio"; import { SendouDialog } from "~/components/elements/Dialog"; import { useTournament } from "~/features/tournament/tournament-context"; +import * as CheckIn from "~/features/tournament-bracket/core/CheckIn"; import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server"; import * as CSV from "~/modules/csv"; import { databaseTimestampToDate } from "~/utils/dates"; @@ -109,14 +110,18 @@ export function ExportDialog({ }); const selectedBracket = tournament.bracketsMeta[bracketIdx]; - const bracketRequiresOwnCheckIn = - selectedBracket.requiresCheckIn && !selectedBracket.isStartingBracket; + const checkInBracketIdxs = + selectedBracket.requiresCheckIn && !selectedBracket.isStartingBracket + ? CheckIn.sharedBracketIdxs( + bracketIdx, + tournament.ctx.settings.bracketProgression, + ) + : null; const teams = scopedAndSortedTeams({ teams: allTeams, status, sort, - bracketIdx, - bracketRequiresOwnCheckIn, + checkInBracketIdxs, bracketParticipantIds: new Set( tournament.eligibleTeamIdsOfBracket(bracketIdx), ), @@ -126,8 +131,7 @@ export function ExportDialog({ format, fields, captainsOnly: roster === "captains", - bracketIdx, - bracketRequiresOwnCheckIn, + checkInBracketIdxs, checkedInLabel: "Checked in", notCheckedInLabel: "Not checked in", }); @@ -267,15 +271,13 @@ function RadioRow({ ); } +/** `checkInBracketIdxs` null = the bracket uses the event-level check-in */ function hasActiveCheckIn( team: TournamentTeamFull, - bracketIdx: number, - bracketRequiresOwnCheckIn: boolean, + checkInBracketIdxs: number[] | null, ) { - if (bracketRequiresOwnCheckIn) { - return team.checkIns.some( - (checkIn) => checkIn.bracketIdx === bracketIdx && !checkIn.isCheckOut, - ); + if (checkInBracketIdxs) { + return CheckIn.isCheckedInToBrackets(team.checkIns, checkInBracketIdxs); } const eventLevel = team.checkIns.filter( @@ -291,15 +293,13 @@ export function scopedAndSortedTeams({ teams, status, sort, - bracketIdx, - bracketRequiresOwnCheckIn, + checkInBracketIdxs, bracketParticipantIds, }: { teams: TournamentTeamFull[]; status: ExportStatus; sort: ExportSort; - bracketIdx: number; - bracketRequiresOwnCheckIn: boolean; + checkInBracketIdxs: number[] | null; bracketParticipantIds: Set; }) { const filtered = teams.filter((team) => { @@ -308,9 +308,9 @@ export function scopedAndSortedTeams({ } switch (status) { case "checkedIn": - return hasActiveCheckIn(team, bracketIdx, bracketRequiresOwnCheckIn); + return hasActiveCheckIn(team, checkInBracketIdxs); case "notCheckedIn": - return !hasActiveCheckIn(team, bracketIdx, bracketRequiresOwnCheckIn); + return !hasActiveCheckIn(team, checkInBracketIdxs); default: return true; } @@ -338,8 +338,7 @@ function teamFieldValue( opts: { checkedInLabel: string; notCheckedInLabel: string; - bracketIdx: number; - bracketRequiresOwnCheckIn: boolean; + checkInBracketIdxs: number[] | null; }, ) { switch (field) { @@ -350,11 +349,7 @@ function teamFieldValue( case "registeredAt": return databaseTimestampToDate(team.createdAt).toISOString(); case "checkInStatus": - return hasActiveCheckIn( - team, - opts.bracketIdx, - opts.bracketRequiresOwnCheckIn, - ) + return hasActiveCheckIn(team, opts.checkInBracketIdxs) ? opts.checkedInLabel : opts.notCheckedInLabel; case "teamPageUrl": @@ -385,8 +380,7 @@ function buildContent({ format, fields, captainsOnly, - bracketIdx, - bracketRequiresOwnCheckIn, + checkInBracketIdxs, checkedInLabel, notCheckedInLabel, }: { @@ -394,8 +388,7 @@ function buildContent({ format: ExportFormat; fields: Set; captainsOnly: boolean; - bracketIdx: number; - bracketRequiresOwnCheckIn: boolean; + checkInBracketIdxs: number[] | null; checkedInLabel: string; notCheckedInLabel: string; }) { @@ -408,8 +401,7 @@ function buildContent({ const labelOpts = { checkedInLabel, notCheckedInLabel, - bracketIdx, - bracketRequiresOwnCheckIn, + checkInBracketIdxs, }; if (format === "csv") { diff --git a/app/features/tournament-admin/routes/to.$id.admin._index.tsx b/app/features/tournament-admin/routes/to.$id.admin._index.tsx index cdd88bb0c..fa93606b2 100644 --- a/app/features/tournament-admin/routes/to.$id.admin._index.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin._index.tsx @@ -28,6 +28,7 @@ import { import { Table } from "~/components/Table"; import { DroppedOutPopover } from "~/features/tournament/components/DroppedOutPopover"; import { useTournament } from "~/features/tournament/tournament-context"; +import * as CheckIn from "~/features/tournament-bracket/core/CheckIn"; import type { BracketMeta, Tournament, @@ -375,7 +376,13 @@ function TeamRowMenu({ ? bracketsRequiringCheckIn.map((bracket) => { if (!bracket.preview) return null; - const bracketCheckedIn = isBracketCheckedIn(team, bracket.idx); + const bracketCheckedIn = CheckIn.isCheckedInToBrackets( + team.checkIns, + CheckIn.sharedBracketIdxs( + bracket.idx, + tournament.ctx.settings.bracketProgression, + ), + ); return bracketCheckedIn ? ( checkIn.bracketIdx === bracketIdx && !checkIn.isCheckOut, - ); -} - /** Does this bracket have its own opt-in check-in (besides the event check-in)? */ function isCheckInBracket(bracket: BracketMeta) { return bracket.requiresCheckIn; @@ -510,7 +511,13 @@ function checkInScopes(tournament: Tournament, team: TournamentTeamFull) { { label: "Event", checkedIn: isTournamentCheckedIn(team) }, ...checkInBracketsForTeam(tournament, team).map((bracket) => ({ label: bracket.name, - checkedIn: isBracketCheckedIn(team, bracket.idx), + checkedIn: CheckIn.isCheckedInToBrackets( + team.checkIns, + CheckIn.sharedBracketIdxs( + bracket.idx, + tournament.ctx.settings.bracketProgression, + ), + ), })), ]; } diff --git a/app/features/tournament-bracket/core/Bracket.test.ts b/app/features/tournament-bracket/core/Bracket.test.ts index 291146748..0f6cb173a 100644 --- a/app/features/tournament-bracket/core/Bracket.test.ts +++ b/app/features/tournament-bracket/core/Bracket.test.ts @@ -1,6 +1,7 @@ import * as R from "remeda"; import { describe, expect, test } from "vitest"; import { invariant } from "../../../utils/invariant"; +import type { Standing } from "./Bracket"; import * as Engine from "./engine"; import { createResolved } from "./engine/create"; import type { BracketData, MatchData } from "./engine/types"; @@ -93,43 +94,32 @@ describe("swiss standings - losses against tied", () => { expect(standing.stats?.lossesAgainstTied).toBe(0); // they lost against "Tidy Tidings" but that team dropped out before final round }); - test("ignores a dropped out team with an identical record (losses against tied)", () => { + test("counts a dropped out team that finished its run (losses against tied)", () => { const data = Engine.create({ type: "swiss", seeding: [1, 2, 3, 4, 5, 6], settings: { groupCount: 1, roundCount: 3 }, }); - const playedMatch = ( - id: number, - roundIdx: number, - number: number, - winnerId: number, - loserId: number, - ): MatchData => ({ - id, - stageId: data.stage[0].id, - groupId: data.group[0].id, - roundId: data.round[roundIdx].id, - number, - opponent1: { id: winnerId }, - opponent2: { id: loserId }, - winnerSide: "opponent1", - }); - // teams 1 and 6 both finish 2-1; team 1's only loss is to team 6, // who dropped out after the swiss ended - data.match = [ - playedMatch(0, 0, 1, 1, 2), - playedMatch(1, 0, 2, 3, 4), - playedMatch(2, 0, 3, 5, 6), - playedMatch(3, 1, 1, 1, 3), - playedMatch(4, 1, 2, 2, 5), - playedMatch(5, 1, 3, 6, 4), - playedMatch(6, 2, 1, 6, 1), - playedMatch(7, 2, 2, 3, 5), - playedMatch(8, 2, 3, 2, 4), - ]; + data.match = playedSwissMatches(data, [ + [ + [1, 2], + [3, 4], + [5, 6], + ], + [ + [1, 3], + [2, 5], + [6, 4], + ], + [ + [6, 1], + [3, 5], + [2, 4], + ], + ]); const tournament = testTournament({ data, @@ -155,9 +145,122 @@ describe("swiss standings - losses against tied", () => { ?.standings.find((candidate) => candidate.team.id === 1); invariant(standing, "Standing not found"); - expect(standing.stats?.lossesAgainstTied).toBe(0); + expect(standing.stats?.lossesAgainstTied).toBe(1); }); + test("dropping out a team that finished its run keeps the order of the other teams", () => { + const teamOrderByGroup = (standings: Standing[], excludedTeamId: number) => + R.mapValues( + R.groupBy( + standings.filter((standing) => standing.team.id !== excludedTeamId), + (standing) => String(standing.groupId), + ), + (group) => group.map((standing) => standing.team.id), + ); + + const standingsBefore = new Tournament( + LOW_INK_DECEMBER_2024(), + ).bracketByIdx(0)!.standings; + + for (const { team } of standingsBefore) { + if (team.droppedOut) continue; + + const tournamentData = LOW_INK_DECEMBER_2024(); + const standingsAfter = new Tournament({ + ...tournamentData, + ctx: { + ...tournamentData.ctx, + teams: tournamentData.ctx.teams.map((ctxTeam) => + ctxTeam.id === team.id ? { ...ctxTeam, droppedOut: 1 } : ctxTeam, + ), + }, + }).bracketByIdx(0)!.standings; + + expect(teamOrderByGroup(standingsAfter, team.id)).toEqual( + teamOrderByGroup(standingsBefore, team.id), + ); + } + }); + + test.each([ + { + why: "finished early via advance threshold", + advanceThreshold: 3, + expected: 1, + }, + { + why: "mid-run without advance threshold", + advanceThreshold: undefined, + expected: 0, + }, + ])( + "dropped out team whose run $why (losses against tied)", + ({ advanceThreshold, expected }) => { + const data = Engine.create({ + type: "swiss", + seeding: [1, 2, 3, 4, 5, 6, 7, 8], + settings: { groupCount: 1, roundCount: 5 }, + }); + + // teams 3 and 7 are both eliminated at 1-3 after round 4; team 7's only + // loss to a tied team is to team 3, who then dropped out + data.match = playedSwissMatches(data, [ + [ + [1, 5], + [2, 6], + [3, 7], + [4, 8], + ], + [ + [1, 2], + [4, 3], + [7, 5], + [6, 8], + ], + [ + [1, 4], + [2, 3], + [6, 7], + [5, 8], + ], + [ + [4, 2], + [6, 3], + [5, 7], + ], + [[2, 5]], + ]); + + const tournament = testTournament({ + data, + ctx: { + settings: { + bracketProgression: [ + { + type: "swiss", + name: "Main Bracket", + requiresCheckIn: false, + settings: { advanceThreshold }, + }, + ], + }, + teams: [1, 2, 3, 4, 5, 6, 7, 8].map((teamId) => + tournamentCtxTeam(teamId, { droppedOut: teamId === 3 ? 1 : 0 }), + ), + }, + }); + + const standing = tournament + .bracketByIdx(0) + ?.standings.find((candidate) => candidate.team.id === 7); + invariant(standing, "Standing not found"); + + expect(standing.stats?.setWins).toBe(1); + expect(standing.stats?.setLosses).toBe(3); + expect(standing.stats?.lossesAgainstTied).toBe(expected); + }, + ); + const inProgressSwissTestTournament = () => { const data = Engine.create({ type: "swiss", @@ -1575,6 +1678,27 @@ describe("single elimination sourcing - placements are tiers", () => { }); }); +/** Matches of a single group swiss where each round lists its sets as [winnerId, loserId] */ +function playedSwissMatches( + data: BracketData, + rounds: Array>, +): MatchData[] { + let id = 0; + + return rounds.flatMap((sets, roundIdx) => + sets.map(([winnerId, loserId], setIdx) => ({ + id: id++, + stageId: data.stage[0].id, + groupId: data.group[0].id, + roundId: data.round[roundIdx].id, + number: setIdx + 1, + opponent1: { id: winnerId }, + opponent2: { id: loserId }, + winnerSide: "opponent1", + })), + ); +} + function reportLowerIdWinner(data: BracketData, matchId: number): BracketData { const match = matchById(data, matchId); const opponent1Lower = match.opponent1!.id! < match.opponent2!.id!; diff --git a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts index e6b123742..796d4c46b 100644 --- a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts @@ -319,13 +319,23 @@ export class SwissBracket extends Bracket { .filter((t) => t.droppedOut) .map((t) => t.id); - // wins against tied, results against dropped out teams don't count + // teams that finished their run keep counting so dropping them only moves the teams below up + const droppedOutMidRunTeamIds = new Set( + teams + .filter( + (team) => + droppedOutTeams.includes(team.id) && !this.runIsComplete(team), + ) + .map((team) => team.id), + ); + + // wins against tied, results against teams that dropped out mid-run don't count for (const team of teams) { - if (droppedOutTeams.includes(team.id)) continue; + if (droppedOutMidRunTeamIds.has(team.id)) continue; for (const team2 of teams) { if (team.id === team2.id) continue; - if (droppedOutTeams.includes(team2.id)) continue; + if (droppedOutMidRunTeamIds.has(team2.id)) continue; if ( team.setWins !== team2.setWins || // check also set losses to account for dropped teams @@ -476,6 +486,30 @@ export class SwissBracket extends Bracket { ); } + /** Has the team played every round or, with an advance threshold, already advanced or been eliminated? */ + private runIsComplete({ + setWins, + setLosses, + }: { + setWins: number; + setLosses: number; + }) { + const roundCount = this.swissRoundCount; + if (setWins + setLosses >= roundCount) return true; + + const advanceThreshold = this.settings?.advanceThreshold; + if (!advanceThreshold) return false; + + return ( + calculateTeamStatus({ + wins: setWins, + losses: setLosses, + advanceThreshold, + roundCount, + }) !== "active" + ); + } + private trackRecordToWinPercentage(trackRecord: TeamTrackRecord) { const onlyByes = trackRecord.wins === 0 && trackRecord.losses === 0; if (onlyByes) { diff --git a/app/features/tournament-bracket/core/CheckIn.test.ts b/app/features/tournament-bracket/core/CheckIn.test.ts new file mode 100644 index 000000000..43ea33fb4 --- /dev/null +++ b/app/features/tournament-bracket/core/CheckIn.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "vitest"; +import * as CheckIn from "./CheckIn"; +import type { ParsedBracket } from "./Progression"; + +const DAY_2_START = 1_790_528_400; + +const bracket = (partial: Partial): ParsedBracket => ({ + name: "Bracket", + type: "double_elimination", + requiresCheckIn: true, + settings: {}, + sources: [{ bracketIdx: 0, placements: [1] }], + ...partial, +}); + +const progression: ParsedBracket[] = [ + bracket({ type: "swiss", requiresCheckIn: false, sources: undefined }), + bracket({ startTime: DAY_2_START }), + bracket({ startTime: DAY_2_START }), + bracket({ startTime: DAY_2_START + 3600 }), + bracket({ startTime: DAY_2_START, requiresCheckIn: false }), + bracket({}), +]; + +describe("CheckIn.sharedBracketIdxs", () => { + test.each([ + { why: "same start time", bracketIdx: 1, expected: [1, 2] }, + { why: "own start time", bracketIdx: 3, expected: [3] }, + { why: "no start time", bracketIdx: 5, expected: [5] }, + ])("$why", ({ bracketIdx, expected }) => { + expect(CheckIn.sharedBracketIdxs(bracketIdx, progression)).toEqual( + expected, + ); + }); +}); + +describe("CheckIn.isCheckedInToBrackets", () => { + test.each([ + { why: "no check-ins", checkIns: [], expected: false }, + { + why: "event check-in only", + checkIns: [{ bracketIdx: null, checkedInAt: 1, isCheckOut: 0 }], + expected: false, + }, + { + why: "checked in to a shared bracket", + checkIns: [{ bracketIdx: 2, checkedInAt: 1, isCheckOut: 0 }], + expected: true, + }, + { + why: "checked in to a bracket not shared", + checkIns: [{ bracketIdx: 3, checkedInAt: 1, isCheckOut: 0 }], + expected: false, + }, + { + why: "checked out after checking in to a shared bracket", + checkIns: [ + { bracketIdx: 2, checkedInAt: 1, isCheckOut: 0 }, + { bracketIdx: 1, checkedInAt: 2, isCheckOut: 1 }, + ], + expected: false, + }, + { + why: "checked in again after checking out", + checkIns: [ + { bracketIdx: 1, checkedInAt: 1, isCheckOut: 1 }, + { bracketIdx: 2, checkedInAt: 2, isCheckOut: 0 }, + ], + expected: true, + }, + ])("$why", ({ checkIns, expected }) => { + expect(CheckIn.isCheckedInToBrackets(checkIns, [1, 2])).toBe(expected); + }); +}); diff --git a/app/features/tournament-bracket/core/CheckIn.ts b/app/features/tournament-bracket/core/CheckIn.ts new file mode 100644 index 000000000..8e3e35ff7 --- /dev/null +++ b/app/features/tournament-bracket/core/CheckIn.ts @@ -0,0 +1,48 @@ +import * as R from "remeda"; +import type { ParsedBracket } from "./Progression"; + +interface CheckInRow { + bracketIdx: number | null; + checkedInAt: number; + isCheckOut: number; +} + +/** + * Brackets that share their check-in with the given bracket: every bracket requiring check-in that starts at the same time. + * A team checked in to one of them is checked in to all, so a team moving between them keeps its check-in. + * + * @example + * // Alpha (1) and Beta (2) both start at 18:00, Gamma (3) at 20:00 + * CheckIn.sharedBracketIdxs(1, progression) // [1, 2] + */ +export function sharedBracketIdxs( + bracketIdx: number, + progression: ParsedBracket[], +): number[] { + const bracket = progression[bracketIdx]; + if (!bracket.startTime) return [bracketIdx]; + + return progression.flatMap((candidate, candidateIdx) => + candidate.requiresCheckIn && candidate.startTime === bracket.startTime + ? [candidateIdx] + : [], + ); +} + +/** + * Is the team checked in to the brackets (from {@link sharedBracketIdxs})? The latest check-in or check-out among them decides. + */ +export function isCheckedInToBrackets( + checkIns: CheckInRow[], + bracketIdxs: number[], +): boolean { + const latest = R.firstBy( + checkIns.filter( + (checkIn) => + checkIn.bracketIdx !== null && bracketIdxs.includes(checkIn.bracketIdx), + ), + [(checkIn) => checkIn.checkedInAt, "desc"], + ); + + return Boolean(latest && !latest.isCheckOut); +} diff --git a/app/features/tournament-bracket/core/Tournament.test.ts b/app/features/tournament-bracket/core/Tournament.test.ts index 7c92ee7d7..0824bf4a6 100644 --- a/app/features/tournament-bracket/core/Tournament.test.ts +++ b/app/features/tournament-bracket/core/Tournament.test.ts @@ -682,6 +682,94 @@ describe("teamMemberOfProgressStatus with a follow-up bracket check-in", () => { }); }); +describe("follow-up bracket check-in shared between brackets", () => { + const DAY_2_START = 1_790_528_400; + + const progressionWithBetaStartingAt = ( + betaStartTime: number, + ): Progression.ParsedBracket[] => [ + { + name: "Groups", + type: "round_robin", + requiresCheckIn: false, + settings: {}, + }, + { + name: "Alpha", + type: "single_elimination", + requiresCheckIn: true, + startTime: DAY_2_START, + settings: {}, + sources: [{ bracketIdx: 0, placements: [1, 2] }], + }, + { + name: "Beta", + type: "single_elimination", + requiresCheckIn: true, + startTime: betaStartTime, + settings: {}, + sources: [{ bracketIdx: 0, placements: [3, 4] }], + }, + ]; + + const alphaAfterMovingBetaTeamThere = (betaStartTime: number) => { + const data = Engine.create({ + type: "round_robin", + seeding: [1, 2, 3, 4], + settings: { groupCount: 1 }, + }); + finishPendingMatches(data); + const bracketProgression = progressionWithBetaStartingAt(betaStartTime); + + const betaTeamId = testTournament({ + data, + ctx: { settings: { bracketProgression } }, + }).bracketByIdx(2)!.teamsPendingCheckIn![0]; + + const alpha = testTournament({ + data, + ctx: { + settings: { bracketProgression }, + teams: [1, 2, 3, 4].map((teamId) => + tournamentCtxTeam(teamId, { + checkIns: [ + { checkedInAt: 1, bracketIdx: null, isCheckOut: 0 }, + ...(teamId === betaTeamId + ? [{ checkedInAt: 2, bracketIdx: 2, isCheckOut: 0 as const }] + : []), + ], + }), + ), + bracketProgressionOverrides: [ + { + sourceBracketIdx: 0, + destinationBracketIdx: 1, + tournamentTeamId: betaTeamId, + }, + ], + }, + }).bracketByIdx(1)!; + + return { alpha, betaTeamId }; + }; + + test("keeps the check-in of a team moved to a bracket starting at the same time", () => { + const { alpha, betaTeamId } = alphaAfterMovingBetaTeamThere(DAY_2_START); + + expect(alpha.seeding).toContain(betaTeamId); + expect(alpha.teamsPendingCheckIn).not.toContain(betaTeamId); + }); + + test("requires a new check-in from a team moved to a bracket starting at a different time", () => { + const { alpha, betaTeamId } = alphaAfterMovingBetaTeamThere( + DAY_2_START + 3600, + ); + + expect(alpha.seeding).not.toContain(betaTeamId); + expect(alpha.teamsPendingCheckIn).toContain(betaTeamId); + }); +}); + describe("Swiss early advance bracket sourcing", () => { const progressionWithConsolation: Progression.ParsedBracket[] = [ { diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index 3efe6ed55..99f77c436 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -28,6 +28,7 @@ import { logger } from "~/utils/logger"; import { assertUnreachable } from "~/utils/types"; import { groupNumberToLetters } from "../tournament-bracket-utils"; import { type Bracket, createBracket } from "./Bracket"; +import * as CheckIn from "./CheckIn"; import { calculateTeamStatus } from "./engine/swiss/team-status"; import { getRounds } from "./rounds"; import * as Seeding from "./Seeding"; @@ -617,6 +618,11 @@ export class Tournament { usesRegularCheckIn: boolean; requiresCheckIn: boolean; }) { + const sharedCheckInBracketIdxs = CheckIn.sharedBracketIdxs( + bracketIdx, + this.ctx.settings.bracketProgression, + ); + return teams.reduce<{ checkedInTeams: number[]; notCheckedInTeams: number[]; @@ -632,9 +638,9 @@ export class Tournament { acc.notCheckedInTeams.push(cur); } } else if (requiresCheckIn) { - const isCheckedIn = team.checkIns.some( - (checkIn) => - checkIn.bracketIdx === bracketIdx && !checkIn.isCheckOut, + const isCheckedIn = CheckIn.isCheckedInToBrackets( + team.checkIns, + sharedCheckInBracketIdxs, ); if (isCheckedIn) { diff --git a/changelog/2026-09-28-shared-bracket-check-in.md b/changelog/2026-09-28-shared-bracket-check-in.md new file mode 100644 index 000000000..45845937c --- /dev/null +++ b/changelog/2026-09-28-shared-bracket-check-in.md @@ -0,0 +1,5 @@ +--- +navItem: medal +type: feature +--- +Checking in to a bracket also counts for other brackets starting at the same time, so a team moved from e.g. Beta to Alpha stays checked in diff --git a/changelog/2026-09-28-swiss-drop-keeps-standings-order.md b/changelog/2026-09-28-swiss-drop-keeps-standings-order.md new file mode 100644 index 000000000..8ba123750 --- /dev/null +++ b/changelog/2026-09-28-swiss-drop-keeps-standings-order.md @@ -0,0 +1,5 @@ +--- +navItem: medal +type: feature +--- +Dropping out a team that finished its Swiss rounds only moves the teams below it up, the rest of the standings stay in the same order