From c6ca84a7870a067b776f93be40fb2e9910c960ed Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:32:03 +0300 Subject: [PATCH] More compact elim bracket when a lot of first round byes --- .../Bracket/Bracket.browser.test.tsx | 152 ++++++++++++++++++ .../components/Bracket/Elimination.tsx | 64 +++++++- 2 files changed, 213 insertions(+), 3 deletions(-) diff --git a/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx b/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx index 89b35568b..a68523637 100644 --- a/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx +++ b/app/features/tournament-bracket/components/Bracket/Bracket.browser.test.tsx @@ -219,6 +219,120 @@ function createSingleEliminationData(): TournamentManagerDataSet { }; } +function createByeHeavySingleEliminationData(): TournamentManagerDataSet { + return { + stage: [ + { + id: 1, + name: "Main Bracket", + number: 1, + type: "single_elimination", + tournament_id: 1, + settings: { size: 8 }, + }, + ], + group: [{ id: 1, number: 1, stage_id: 1 }], + round: [ + { + id: 1, + group_id: 1, + number: 1, + stage_id: 1, + maps: { count: 3, type: "BEST_OF", pickBan: null }, + }, + { + id: 2, + group_id: 1, + number: 2, + stage_id: 1, + maps: { count: 3, type: "BEST_OF", pickBan: null }, + }, + { + id: 3, + group_id: 1, + number: 3, + stage_id: 1, + maps: { count: 5, type: "BEST_OF", pickBan: null }, + }, + ], + match: [ + // Round 1 - 5 teams in a bracket of 8, only one match is played + { + id: 1, + number: 1, + stage_id: 1, + group_id: 1, + round_id: 1, + status: 2, + opponent1: { id: 1 }, + opponent2: null, + }, + { + id: 2, + number: 2, + stage_id: 1, + group_id: 1, + round_id: 1, + status: 3, + opponent1: { id: 4, score: 1 }, + opponent2: { id: 5, score: 1 }, + }, + { + id: 3, + number: 3, + stage_id: 1, + group_id: 1, + round_id: 1, + status: 2, + opponent1: { id: 2 }, + opponent2: null, + }, + { + id: 4, + number: 4, + stage_id: 1, + group_id: 1, + round_id: 1, + status: 2, + opponent1: { id: 3 }, + opponent2: null, + }, + // Round 2 - Semis + { + id: 5, + number: 1, + stage_id: 1, + group_id: 1, + round_id: 2, + status: 4, + opponent1: { id: 1 }, + opponent2: { id: null }, + }, + { + id: 6, + number: 2, + stage_id: 1, + group_id: 1, + round_id: 2, + status: 4, + opponent1: { id: 2 }, + opponent2: { id: 3 }, + }, + // Round 3 - Finals + { + id: 7, + number: 1, + stage_id: 1, + group_id: 1, + round_id: 3, + status: 4, + opponent1: { id: null }, + opponent2: { id: null }, + }, + ], + }; +} + function createDoubleEliminationData(): TournamentManagerDataSet { return { stage: [ @@ -847,6 +961,44 @@ describe("Single Elimination Bracket", () => { await expect.element(screen.getByText("Finals")).toBeVisible(); }); + test("compacts first round when fewer than half of its matches are played", async () => { + const data = createByeHeavySingleEliminationData(); + const bracket = createMockBracket("single_elimination", data); + + const screen = await renderWithRouter( + , + ); + + // one slot per round 2 match instead of one per potential round 1 match + const firstRoundMatchWrappers = screen.container.querySelectorAll( + `[data-round-id="1"] .${styles.matchWrapper}`, + ); + expect(firstRoundMatchWrappers.length).toBe(2); + + // played match connects to its destination with a straight line + const straightLines = screen.container.querySelectorAll( + `[data-round-id="1"] .${styles.matchLineStraight}`, + ); + expect(straightLines.length).toBeGreaterThan(0); + + await expect.element(screen.getByText("Team Delta")).toBeVisible(); + await expect.element(screen.getByText("Team Epsilon")).toBeVisible(); + }); + + test("does not compact first round when at least half of its matches are played", async () => { + const data = createSingleEliminationData(); + const bracket = createMockBracket("single_elimination", data); + + const screen = await renderWithRouter( + , + ); + + const firstRoundMatchWrappers = screen.container.querySelectorAll( + `[data-round-id="1"] .${styles.matchWrapper}`, + ); + expect(firstRoundMatchWrappers.length).toBe(4); + }); + test("shows early round with ongoing match even when isExpanded is false", async () => { const data = createLargeSingleEliminationData({ ongoingRoundIdx: 0 }); const bracket = createMockBracket("single_elimination", data); diff --git a/app/features/tournament-bracket/components/Bracket/Elimination.tsx b/app/features/tournament-bracket/components/Bracket/Elimination.tsx index 627f5ad4d..8a84f4fa2 100644 --- a/app/features/tournament-bracket/components/Bracket/Elimination.tsx +++ b/app/features/tournament-bracket/components/Bracket/Elimination.tsx @@ -1,4 +1,5 @@ import clsx from "clsx"; +import * as R from "remeda"; import { TOURNAMENT } from "../../../tournament/tournament-constants"; import type { Bracket as BracketType } from "../../core/Bracket"; import { getRounds } from "../../core/rounds"; @@ -52,6 +53,16 @@ export function EliminationBracketSide(props: EliminationBracketSideProps) { (match) => match.round_id === firstVisibleRound?.id, ).length; + const compactedFirstRoundId = resolveCompactedFirstRoundId({ + rounds, + firstVisibleRoundId: firstVisibleRound?.id, + bracketData: props.bracket.data, + }); + const baseRoundMatchCount = + compactedFirstRoundId !== null + ? firstVisibleRoundMatchCount / 2 + : firstVisibleRoundMatchCount; + let atLeastOneColumnHidden = false; return (
{ const bestOf = round.maps?.count; - const matches = props.bracket.data.match.filter( + const allRoundMatches = props.bracket.data.match.filter( (match) => match.round_id === round.id, ); + const matches = + round.id === compactedFirstRoundId + ? R.chunk(allRoundMatches, 2).map( + (pair) => + pair.find((match) => match.opponent1 && match.opponent2) ?? + pair[0], + ) + : allRoundMatches; const isLastRound = roundIdx === rounds.length - 1; const nextRound = rounds[roundIdx + 1]; @@ -103,6 +122,7 @@ export function EliminationBracketSide(props: EliminationBracketSideProps) { className={clsx(styles.elimRoundMatchesContainer, { [styles.elimRoundMatchesContainerTopBye]: !atLeastOneColumnHidden && + compactedFirstRoundId === null && (props.type === "winners" || props.type === "single") && (!props.bracket.data.match[0].opponent1 || !props.bracket.data.match[0].opponent2), @@ -128,8 +148,7 @@ export function EliminationBracketSide(props: EliminationBracketSideProps) { if (matches.length <= 1) return undefined; if (nextRoundMatchCount === matches.length) return undefined; - const spreadFactor = - firstVisibleRoundMatchCount / matches.length; + const spreadFactor = baseRoundMatchCount / matches.length; return GAP / 2 + (spreadFactor - 1) * (MATCH_SPACING / 2); })(); @@ -181,3 +200,42 @@ export function EliminationBracketSide(props: EliminationBracketSideProps) {
); } + +/** + * Resolves whether the first round should be rendered compacted, meaning one + * slot per second round match instead of two. This is possible when fewer + * than half of the potential first round matches are played, as then each + * second round match has at most one first round feeder and the played + * matches can be laid out right next to their destination with a straight + * connector, halving the bracket's height. + */ +function resolveCompactedFirstRoundId(args: { + rounds: ReturnType; + firstVisibleRoundId?: number; + bracketData: BracketType["data"]; +}): number | null { + const [firstRound, secondRound] = args.rounds; + if (!firstRound || !secondRound) return null; + if (firstRound.id !== args.firstVisibleRoundId) return null; + + const firstRoundMatches = args.bracketData.match.filter( + (match) => match.round_id === firstRound.id, + ); + const secondRoundMatchCount = args.bracketData.match.filter( + (match) => match.round_id === secondRound.id, + ).length; + if (firstRoundMatches.length !== secondRoundMatchCount * 2) return null; + + const playedMatchCount = firstRoundMatches.filter( + (match) => match.opponent1 && match.opponent2, + ).length; + if (playedMatchCount >= firstRoundMatches.length / 2) return null; + + const everyPairHasAtMostOnePlayedMatch = R.chunk(firstRoundMatches, 2).every( + (pair) => + pair.filter((match) => match.opponent1 && match.opponent2).length <= 1, + ); + if (!everyPairHasAtMostOnePlayedMatch) return null; + + return firstRound.id; +}