diff --git a/app/features/tournament/core/Standings.test.ts b/app/features/tournament/core/Standings.test.ts index 7cf25a284..bdf5e1761 100644 --- a/app/features/tournament/core/Standings.test.ts +++ b/app/features/tournament/core/Standings.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "vitest"; +import type { Standing } from "~/features/tournament-bracket/core/Bracket"; import * as Engine from "~/features/tournament-bracket/core/engine"; import { createResolved } from "~/features/tournament-bracket/core/engine/create"; import type { BracketData } from "~/features/tournament-bracket/core/engine/types"; @@ -10,8 +11,9 @@ import { } from "~/features/tournament-bracket/core/tests/test-utils"; import invariant from "~/utils/invariant"; import { - matchesPlayed, + matchesPlayedByTeamId, reNumberPlacements, + sprByTeamId, tournamentStandings, } from "./Standings"; @@ -230,11 +232,67 @@ describe("reNumberPlacements", () => { }); }); -describe("matchesPlayed", () => { +describe("sprByTeamId", () => { + test("gives every team an SPR of 0 when they all place exactly as seeded", () => { + const result = sprByTeamId( + standings([ + { id: 1, seed: 1, placement: 1 }, + { id: 2, seed: 2, placement: 2 }, + { id: 3, seed: 3, placement: 3 }, + { id: 4, seed: 4, placement: 4 }, + ]), + ); + + expect([...result.values()]).toEqual([0, 0, 0, 0]); + }); + + test("rewards a team for every placement it beat its seed by", () => { + const result = sprByTeamId( + standings([ + { id: 4, seed: 4, placement: 1 }, + { id: 1, seed: 1, placement: 2 }, + { id: 2, seed: 2, placement: 3 }, + { id: 3, seed: 3, placement: 4 }, + ]), + ); + + expect(result.get(4)).toBe(3); + expect(result.get(1)).toBe(-1); + expect(result.get(2)).toBe(-1); + expect(result.get(3)).toBe(-1); + }); + + test("counts tied placements as one step", () => { + const result = sprByTeamId( + standings([ + { id: 3, seed: 3, placement: 1 }, + { id: 1, seed: 1, placement: 2 }, + { id: 2, seed: 2, placement: 3 }, + { id: 4, seed: 4, placement: 3 }, + ]), + ); + + expect(result.get(3)).toBe(2); + expect(result.get(4)).toBe(0); + }); + + test("returns 0 for a team whose seed is outside the standings", () => { + const result = sprByTeamId( + standings([ + { id: 1, seed: 1, placement: 1 }, + { id: 2, seed: 9, placement: 2 }, + ]), + ); + + expect(result.get(2)).toBe(0); + }); +}); + +describe("matchesPlayedByTeamId", () => { test("tags each match with the bracket index it was actually played in", () => { const tournament = roundRobinToSingleEliminationTournament(); - const matches = matchesPlayed({ tournament, teamId: 1 }); + const matches = matchesPlayedByTeamId(tournament).get(1) ?? []; // team 1 plays 3 round robin matches (bracket idx 0) // and 1 single elimination match (bracket idx 1) @@ -248,7 +306,7 @@ describe("matchesPlayed", () => { test("includes matches of brackets that are not part of the standings, in the order they were played", () => { const tournament = roundRobinWithRedemptionTournament(); - const matches = matchesPlayed({ tournament, teamId: 4 }); + const matches = matchesPlayedByTeamId(tournament).get(4) ?? []; // 3 round robin matches, the redemption bracket match and the final stage match expect(matches.map((match) => match.bracketIdx)).toEqual([0, 0, 0, 2, 1]); @@ -621,3 +679,12 @@ function playOut( return played; } + +function standings( + teams: Array<{ id: number; seed: number; placement: number }>, +): Standing[] { + return teams.map(({ id, seed, placement }) => ({ + team: tournamentCtxTeam(id, { seed }), + placement, + })); +} diff --git a/app/features/tournament/core/Standings.ts b/app/features/tournament/core/Standings.ts index a903b5988..e09ae5460 100644 --- a/app/features/tournament/core/Standings.ts +++ b/app/features/tournament/core/Standings.ts @@ -5,6 +5,8 @@ import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; import invariant from "~/utils/invariant"; import { getBracketProgressionLabel } from "../tournament-utils"; +const MATCH_SIDES = ["opponent1", "opponent2"] as const; + export type TournamentStandingsResult = | { type: "single"; standings: Standing[] } | { @@ -52,87 +54,98 @@ export function reNumberPlacements( }); } -/** Calculates SPR (Seed Performance Rating) - see https://web.archive.org/web/20250513034545/https://www.pgstats.com/articles/introducing-spr-and-uf */ -export function calculateSPR({ - standings, - teamId, -}: { - standings: Standing[]; - teamId: number; -}) { - const uniquePlacements = R.unique( - standings.map((standing) => standing.placement), - ).sort((a, b) => a - b); - - const teamStanding = standings.find( - (standing) => standing.team.id === teamId, +/** + * SPR (Seed Performance Rating) of every team in the standings, keyed by tournament team id. + * See https://web.archive.org/web/20250513034545/https://www.pgstats.com/articles/introducing-spr-and-uf + */ +export function sprByTeamId(standings: Standing[]): Map { + const indexByPlacement = new Map( + R.unique(standings.map((standing) => standing.placement)) + .sort((a, b) => a - b) + .map((placement, index) => [placement, index]), ); - // defensive check to avoid crashing - if (!teamStanding) { - return 0; + + const result = new Map(); + + for (const standing of standings) { + const expectedPlacement = + standings[(standing.team.seed ?? 0) - 1]?.placement; + const expectedIndex = expectedPlacement + ? indexByPlacement.get(expectedPlacement) + : undefined; + const actualIndex = indexByPlacement.get(standing.placement); + + // defensive check to avoid crashing + if (typeof expectedIndex !== "number" || typeof actualIndex !== "number") { + result.set(standing.team.id, 0); + continue; + } + + result.set(standing.team.id, expectedIndex - actualIndex); } - const expectedPlacement = - standings[(teamStanding.team.seed ?? 0) - 1]?.placement; - // defensive check to avoid crashing - if (!expectedPlacement) { - return 0; - } - - const teamPlacement = teamStanding.placement; - const actualIndex = uniquePlacements.indexOf(teamPlacement); - const expectedIndex = uniquePlacements.indexOf(expectedPlacement); - - return expectedIndex - actualIndex; + return result; } -/** Every match the team played, in the order they were played in */ -export function matchesPlayed({ - tournament, - teamId, -}: { - tournament: Tournament; - teamId: number; -}) { +export type MatchPlayed = { + id: number; + vsSeed: number; + result: "win" | "loss"; + bracketIdx: number; +}; + +/** + * Every match each team played, in the order they were played in, keyed by tournament team id. + * Teams that played no match are absent from the map. + */ +export function matchesPlayedByTeamId( + tournament: Tournament, +): Map { const bracketsInPlayedOrder = R.sortBy( tournament.brackets, (bracket) => bracket.createdAt ?? Number.POSITIVE_INFINITY, (bracket) => bracket.idx, ); - const matches = bracketsInPlayedOrder.flatMap((bracket) => - bracket.data.match - .filter( - (match) => - match.opponent1 && - match.opponent2 && - (match.opponent1?.id === teamId || match.opponent2?.id === teamId) && - match.winnerSide, - ) - .map((match) => ({ - ...match, - bracketIdx: bracket.idx, - })), - ); + const seeds = new Map(); + const seedOf = (teamId: number) => { + const cached = seeds.get(teamId); + if (typeof cached === "number") return cached; - return matches.map((match) => { - const opponentId = ( - match.opponent1?.id === teamId ? match.opponent2?.id : match.opponent1?.id - )!; - const team = tournament.teamById(opponentId); + // defensive fallback + const seed = tournament.teamById(teamId)?.seed ?? 0; + seeds.set(teamId, seed); - const teamSide = match.opponent1?.id === teamId ? "opponent1" : "opponent2"; - const result: "win" | "loss" = - match.winnerSide === teamSide ? "win" : "loss"; + return seed; + }; - return { - id: match.id, - // defensive fallback - vsSeed: team?.seed ?? 0, - result, - bracketIdx: match.bracketIdx, - }; - }); + const result = new Map(); + + for (const bracket of bracketsInPlayedOrder) { + for (const match of bracket.data.match) { + if (!match.winnerSide) continue; + + for (const side of MATCH_SIDES) { + const teamId = match[side]?.id; + const opponentId = + match[side === "opponent1" ? "opponent2" : "opponent1"]?.id; + if (typeof teamId !== "number" || typeof opponentId !== "number") { + continue; + } + + const played = result.get(teamId) ?? []; + played.push({ + id: match.id, + vsSeed: seedOf(opponentId), + result: match.winnerSide === side ? "win" : "loss", + bracketIdx: bracket.idx, + }); + result.set(teamId, played); + } + } + } + + return result; } type PersistedResultRow = { diff --git a/app/features/tournament/loaders/to.$id.results.server.ts b/app/features/tournament/loaders/to.$id.results.server.ts index 58d82b1bc..3c35ddb95 100644 --- a/app/features/tournament/loaders/to.$id.results.server.ts +++ b/app/features/tournament/loaders/to.$id.results.server.ts @@ -28,20 +28,26 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { ]), ); - const toRow = (standings: Standing[]) => (standing: Standing) => ({ - placement: standing.placement, - spr: Standings.calculateSPR({ standings, teamId: standing.team.id }), - team: { - id: standing.team.id, - name: standing.team.name, - seed: standing.team.seed, - logoUrl: standing.team.logoUrl, - }, - roster: (rosterByTeamId.get(standing.team.id) ?? []).filter((member) => - standing.team.memberUserIds.includes(member.userId), - ), - matches: Standings.matchesPlayed({ tournament, teamId: standing.team.id }), - }); + const matchesByTeamId = Standings.matchesPlayedByTeamId(tournament); + + const toRows = (standings: Standing[]) => { + const sprByTeamId = Standings.sprByTeamId(standings); + + return standings.map((standing) => ({ + placement: standing.placement, + spr: sprByTeamId.get(standing.team.id) ?? 0, + team: { + id: standing.team.id, + name: standing.team.name, + seed: standing.team.seed, + logoUrl: standing.team.logoUrl, + }, + roster: (rosterByTeamId.get(standing.team.id) ?? []).filter((member) => + standing.team.memberUserIds.includes(member.userId), + ), + matches: matchesByTeamId.get(standing.team.id) ?? [], + })); + }; const persistedStandings = tournament.ctx.isFinalized ? Standings.standingsFromPersistedResults({ @@ -58,13 +64,13 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { result.type === "single" ? { type: "single" as const, - standings: result.standings.map(toRow(result.standings)), + standings: toRows(result.standings), } : { type: "multi" as const, standings: result.standings.map(({ div, standings }) => ({ div, - standings: standings.map(toRow(standings)), + standings: toRows(standings), })), }, }; diff --git a/changelog/2026-08-29-performance-improvements.md b/changelog/2026-08-29-performance-improvements.md new file mode 100644 index 000000000..92dab05f9 --- /dev/null +++ b/changelog/2026-08-29-performance-improvements.md @@ -0,0 +1,4 @@ +--- +type: feature +--- +Various performance improvements