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;
+}