Fix standings public api, refactor for clarity (#3278)
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run

Co-authored-by: Gaming32 <42721887+Gaming32@users.noreply.github.com>
This commit is contained in:
Kalle
2026-08-01 11:49:52 +03:00
committed by GitHub
parent 0968296b43
commit f758a6a585
9 changed files with 150 additions and 111 deletions

View File

@@ -22,10 +22,24 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
if (bracket.preview) throw new Response(null, { status: 404 });
const result: GetTournamentBracketStandingsResponse = {
standings: bracket.standings.map((standing) => ({
finished: bracket.standingsAreFinal,
standings: bracket.liveStandings.map((standing) => ({
tournamentTeamId: standing.team.id,
placement: standing.placement,
stats: standing.stats,
groupId: standing.groupId,
stats: standing.stats
? {
setWins: standing.stats.setWins,
setLosses: standing.stats.setLosses,
mapWins: standing.stats.mapWins,
mapLosses: standing.stats.mapLosses,
koCount: standing.stats.koCount,
winsAgainstTied: standing.stats.winsAgainstTied,
lossesAgainstTied: standing.stats.lossesAgainstTied,
opponentSetWinPercentage: standing.stats.opponentSetWinPercentage,
opponentMapWinPercentage: standing.stats.opponentMapWinPercentage,
}
: undefined,
})),
};

View File

@@ -360,9 +360,12 @@ export interface GetTournamentBracketResponse {
/** GET /api/tournament/{tournamentId}/brackets/{bracketIndex}/standings */
export interface GetTournamentBracketStandingsResponse {
finished: boolean;
standings: Array<{
tournamentTeamId: number;
placement: number;
/** (round robin & swiss only) id of the group the team played in. Placements are shared across groups, meaning e.g. every group's winner has the placement 1. */
groupId?: number;
stats?: {
setWins: number;
setLosses: number;
@@ -374,8 +377,10 @@ export interface GetTournamentBracketStandingsResponse {
koCount?: number;
winsAgainstTied: number;
lossesAgainstTied?: number;
buchholzSets?: number;
buchholzMaps?: number;
/** (swiss only) average win percentage of the team's opponents in sets, used as a tiebreaker */
opponentSetWinPercentage?: number;
/** (swiss only) average win percentage of the team's opponents in maps, used as a tiebreaker */
opponentMapWinPercentage?: number;
};
}>;
}

View File

@@ -88,7 +88,7 @@ export default function BracketTestLayout() {
requiresCheckIn: false,
startTime: null,
simulatedMatch: () => undefined,
currentStandings: () => [],
liveStandings: [],
participantTournamentTeamIds: teamIds,
everyMatchOver: false,
isUnderground: false,

View File

@@ -810,11 +810,43 @@ function createMockBracket(
requiresCheckIn: false,
startTime: null,
simulatedMatch: () => undefined,
currentStandings: () => [],
liveStandings: mockLiveStandings(data),
participantTournamentTeamIds: [1, 2, 3, 4, 5, 6, 7, 8],
} as unknown as BracketType;
}
/** Mirrors what the real implementation returns for a bracket where no match has finished yet: every participant, in seed order, with blank stats. */
function mockLiveStandings(data: BracketData) {
return data.group.flatMap((group) => {
const teamIds = new Set<number>();
for (const match of data.match) {
if (match.groupId !== group.id) continue;
if (match.opponent1?.id) teamIds.add(match.opponent1.id);
if (match.opponent2?.id) teamIds.add(match.opponent2.id);
}
return Array.from(teamIds)
.map((id) => mockTournament.ctx.teams.find((team) => team.id === id)!)
.filter(Boolean)
.sort((a, b) => a.seed - b.seed)
.map((team, i) => ({
team,
placement: i + 1,
groupId: group.id,
stats: {
setWins: 0,
setLosses: 0,
mapWins: 0,
mapLosses: 0,
koCount: 0,
winsAgainstTied: 0,
lossesAgainstTied: 0,
},
}));
});
}
function renderWithRouter(element: React.ReactNode) {
const router = createMemoryRouter([{ path: "/", element }], {
initialEntries: ["/"],

View File

@@ -26,56 +26,7 @@ export function PlacementsTable({
}) {
const user = useUser();
const _standings = bracket
.currentStandings(true)
.filter((s) => s.groupId === groupId);
const missingTeams = bracket.data.match.reduce((acc, cur) => {
if (cur.groupId !== groupId) return acc;
if (
cur.opponent1?.id &&
!_standings.some((s) => s.team.id === cur.opponent1!.id) &&
!acc.includes(cur.opponent1.id)
) {
acc.push(cur.opponent1.id);
}
if (
cur.opponent2?.id &&
!_standings.some((s) => s.team.id === cur.opponent2!.id) &&
!acc.includes(cur.opponent2.id)
) {
acc.push(cur.opponent2.id);
}
return acc;
}, [] as number[]);
const standings = _standings
.concat(
missingTeams.map((id) => ({
team: bracket.tournament.teamById(id)!,
stats: {
mapLosses: 0,
mapWins: 0,
koCount: 0,
setLosses: 0,
setWins: 0,
winsAgainstTied: 0,
lossesAgainstTied: 0,
},
placement: Math.max(..._standings.map((s) => s.placement)) + 1,
groupId,
})),
)
.sort((a, b) => {
if (a.placement === b.placement && a.team.seed && b.team.seed) {
return a.team.seed - b.team.seed;
}
return a.placement - b.placement;
});
const standings = bracket.liveStandings.filter((s) => s.groupId === groupId);
const destinationBracket = (standing: Standing, placement: number) => {
if (bracket.type === "swiss" && bracket.settings?.advanceThreshold) {

View File

@@ -21,8 +21,7 @@ describe("swiss standings - losses against tied", () => {
const standing = tournament
.bracketByIdx(0)
?.currentStandings(false)
.find((standing) => standing.team.id === TEAM_THIS_IS_FINE_ID);
?.standings.find((standing) => standing.team.id === TEAM_THIS_IS_FINE_ID);
invariant(standing, "Standing not found");
@@ -35,7 +34,7 @@ describe("swiss standings - losses against tied", () => {
simulateBrackets: false,
});
const standings = tournament.bracketByIdx(0)!.currentStandings(false);
const standings = tournament.bracketByIdx(0)!.standings;
// Both teams finished 4-2 in the same Swiss group. Team 16872 beat MORE of
// its tied peers (winsAgainstTied=2) than team 17505 (winsAgainstTied=1),
@@ -63,7 +62,7 @@ describe("swiss standings - losses against tied", () => {
simulateBrackets: false,
});
const standings = tournament.bracketByIdx(0)!.currentStandings(false);
const standings = tournament.bracketByIdx(0)!.standings;
// Both teams finished 4-2 in the same Swiss group. Team 16996 lost to none
// of its tied peers while team 17067 lost to one, even though 17067 has the
@@ -90,8 +89,7 @@ describe("swiss standings - losses against tied", () => {
const standing = tournament
.bracketByIdx(0)
?.currentStandings(false)
.find((standing) => standing.team.id === TEAM_ERROR_404_ID);
?.standings.find((standing) => standing.team.id === TEAM_ERROR_404_ID);
invariant(standing, "Standing not found");
expect(standing.stats?.lossesAgainstTied).toBe(0); // they lost against "Tidy Tidings" but that team dropped out before final round
@@ -134,7 +132,7 @@ describe("swiss standings - losses against tied", () => {
it("should handle a team with only one bye", () => {
const tournament = inProgressSwissTestTournament();
const standings = tournament.bracketByIdx(0)!.currentStandings(true);
const standings = tournament.bracketByIdx(0)!.liveStandings;
const teamWithBye = standings.find((standing) => standing.team.id === 3);
@@ -146,14 +144,15 @@ describe("swiss standings - losses against tied", () => {
expect(teamWithBye?.stats?.setLosses).toBe(0);
});
it("team with only unfinished matches should not be in the current standings", () => {
it("team with only unfinished matches should be in the current standings with blank stats", () => {
const tournament = inProgressSwissTestTournament();
const standings = tournament.bracketByIdx(0)!.currentStandings(true);
const standings = tournament.bracketByIdx(0)!.liveStandings;
const playingTeam = standings.find((standing) => standing.team.id === 1);
expect(playingTeam).toBe(undefined);
expect(playingTeam?.stats?.setWins).toBe(0);
expect(playingTeam?.stats?.setLosses).toBe(0);
});
});
@@ -315,7 +314,7 @@ describe("round robin standings - dropped out teams", () => {
it("should not credit wins against a team that dropped out before completing all of their matches", () => {
// Team 4 dropped out before playing their match against team 3.
const tournament = droppedOutTournament({ skipMatchups: ["3-4"] });
const standings = tournament.bracketByIdx(0)!.currentStandings(true);
const standings = tournament.bracketByIdx(0)!.liveStandings;
const team1Standing = standings.find((s) => s.team.id === 1);
const team2Standing = standings.find((s) => s.team.id === 2);
@@ -356,7 +355,7 @@ describe("round robin standings - dropped out teams", () => {
// 4 should still be excluded from tiebreakers — same intent as the
// skipMatchups variant above, but matching the real production shape.
const tournament = droppedOutTournament({ forfeitMatchups: ["3-4"] });
const standings = tournament.bracketByIdx(0)!.currentStandings(true);
const standings = tournament.bracketByIdx(0)!.liveStandings;
const team1Standing = standings.find((s) => s.team.id === 1);
const team2Standing = standings.find((s) => s.team.id === 2);
@@ -461,7 +460,7 @@ describe("round robin A/B divisions standings", () => {
it("filtering by abDivision preserves standard tiebreaker order within each division", () => {
const tournament = abDivisionsTournament();
const standings = tournament.bracketByIdx(0)!.currentStandings(true);
const standings = tournament.bracketByIdx(0)!.liveStandings;
expect(standings.map((s) => s.team.id)).toEqual([1, 2, 3, 4]);

View File

@@ -234,6 +234,10 @@ export abstract class Bracket {
abstract get type(): Tables["TournamentStage"]["type"];
/**
* Standings that are settled i.e. teams still playing are left out. Safe to
* use for deciding who advances to another bracket.
*/
abstract get standings(): Standing[];
/**
@@ -253,7 +257,11 @@ export abstract class Bracket {
) as number[];
}
currentStandings(_includeUnfinishedGroups: boolean) {
/**
* Standings including teams that are still playing. Meant for displaying the
* bracket's current state, not for deciding who advances.
*/
get liveStandings(): Standing[] {
return this.standings;
}
@@ -358,6 +366,14 @@ export abstract class Bracket {
);
}
/**
* Whether the standings of this bracket are final i.e. no further match can
* change them. While false the standings are provisional.
*/
get standingsAreFinal() {
return this.everyMatchOver;
}
get everyMatchOver() {
if (this.preview) return false;

View File

@@ -87,10 +87,18 @@ export class RoundRobinBracket extends Bracket {
}
get standings(): Standing[] {
return this.currentStandings();
return this.computeStandings({ includeUnfinishedGroups: false });
}
currentStandings(includeUnfinishedGroups = false) {
get liveStandings(): Standing[] {
return this.computeStandings({ includeUnfinishedGroups: true });
}
private computeStandings({
includeUnfinishedGroups,
}: {
includeUnfinishedGroups: boolean;
}): Standing[] {
const groupIds = this.data.group.map((group) => group.id);
const placements: (Standing & { groupId: number })[] = [];
@@ -139,18 +147,18 @@ export class RoundRobinBracket extends Bracket {
const updateTeam = ({
teamId,
setWins,
setLosses,
mapWins,
mapLosses,
koCount,
setWins = 0,
setLosses = 0,
mapWins = 0,
mapLosses = 0,
koCount = 0,
}: {
teamId: number;
setWins: number;
setLosses: number;
mapWins: number;
mapLosses: number;
koCount: number;
setWins?: number;
setLosses?: number;
mapWins?: number;
mapLosses?: number;
koCount?: number;
}) => {
const team = teams.find((team) => team.id === teamId);
if (team) {
@@ -173,18 +181,23 @@ export class RoundRobinBracket extends Bracket {
};
for (const match of matches) {
const opp1Id = match.opponent1?.id;
const opp2Id = match.opponent2?.id;
const opponentIds = [opp1Id, opp2Id].filter(
(id) => typeof id === "number",
);
if (!match.winnerSide) {
// teams yet to finish a match still belong in the standings, dropped out ones are seeded further below
for (const teamId of opponentIds) {
if (droppedOutWithIncompleteMatches.has(teamId)) continue;
updateTeam({ teamId });
}
continue;
}
const opp1Id = match.opponent1?.id;
const opp2Id = match.opponent2?.id;
if (
(typeof opp1Id === "number" &&
droppedOutWithIncompleteMatches.has(opp1Id)) ||
(typeof opp2Id === "number" &&
droppedOutWithIncompleteMatches.has(opp2Id))
) {
if (opponentIds.some((id) => droppedOutWithIncompleteMatches.has(id))) {
continue;
}

View File

@@ -31,22 +31,7 @@ export class SwissBracket extends Bracket {
}
const standings = this.standings;
const relevantMatchesFinished = this.data.round.every((round) => {
const roundsMatches = this.data.match.filter(
(match) => match.roundId === round.id,
);
// some round has not started yet
if (roundsMatches.length === 0) return false;
return roundsMatches.every((match) => {
if (match.opponent1 && match.opponent2 && !match.winnerSide) {
return false;
}
return true;
});
});
const relevantMatchesFinished = this.standingsAreFinal;
if (advanceThreshold) {
return {
@@ -86,11 +71,28 @@ export class SwissBracket extends Bracket {
};
}
get standings(): Standing[] {
return this.currentStandings();
/** Swiss rounds are paired one at a time, so a round that has no matches yet can still change the standings. */
get standingsAreFinal() {
const everyRoundPaired = this.data.round.every((round) =>
this.data.match.some((match) => match.roundId === round.id),
);
return everyRoundPaired && this.everyMatchOver;
}
currentStandings(includeUnfinishedGroups = false) {
get standings(): Standing[] {
return this.computeStandings({ includeUnfinishedGroups: false });
}
get liveStandings(): Standing[] {
return this.computeStandings({ includeUnfinishedGroups: true });
}
private computeStandings({
includeUnfinishedGroups,
}: {
includeUnfinishedGroups: boolean;
}): Standing[] {
const groupIds = this.data.group.map((group) => group.id);
const placements: (Standing & { groupId: number })[] = [];
@@ -183,6 +185,13 @@ export class SwissBracket extends Bracket {
}
if (!match.winnerSide) {
// teams yet to finish a match still belong in the standings
if (match.opponent1?.id) {
updateTeam({ teamId: match.opponent1.id });
}
if (match.opponent2?.id) {
updateTeam({ teamId: match.opponent2.id });
}
continue;
}
@@ -225,7 +234,7 @@ export class SwissBracket extends Bracket {
const winner = match.opponent1 ? match.opponent1 : match.opponent2;
if (!winner?.id) {
logger.warn("SwissBracket.currentStandings: winner not found");
logger.warn("SwissBracket.computeStandings: winner not found");
continue;
}
@@ -266,7 +275,7 @@ export class SwissBracket extends Bracket {
for (const teamId of teamsWhoPlayedAgainst) {
const opponent = teams.find((t) => t.id === teamId);
if (!opponent) {
logger.warn("SwissBracket.currentStandings: opponent not found", {
logger.warn("SwissBracket.computeStandings: opponent not found", {
teamId,
});
continue;