diff --git a/app/features/tournament-bracket/components/Bracket/Elimination.tsx b/app/features/tournament-bracket/components/Bracket/Elimination.tsx
index 44eaa67d5..734948b18 100644
--- a/app/features/tournament-bracket/components/Bracket/Elimination.tsx
+++ b/app/features/tournament-bracket/components/Bracket/Elimination.tsx
@@ -1,6 +1,7 @@
import * as R from "remeda";
import { TOURNAMENT } from "../../../tournament/tournament-constants";
import type { Bracket as BracketType } from "../../core/Bracket";
+import type { MatchExitGroupsById } from "../../core/Bracket/Bracket";
import { getRounds } from "../../core/rounds";
import {
BracketColumn,
@@ -17,6 +18,7 @@ interface EliminationBracketSideProps {
/** Group shown of a bracket split into groups, defaults to the first. */
groupId?: number;
isExpanded?: boolean;
+ exitGroups?: MatchExitGroupsById;
}
// must match --match-height in index.module.css and the match gap in BracketColumns.module.css
@@ -195,6 +197,7 @@ export function EliminationBracketSide(props: EliminationBracketSideProps) {
lineType={lineType}
lineVerticalExtend={verticalExtend}
exitDestinations={exitDestinations.get(match.id)}
+ exitGroups={props.exitGroups?.[match.id]}
/>
);
})}
diff --git a/app/features/tournament-bracket/components/Bracket/Match.tsx b/app/features/tournament-bracket/components/Bracket/Match.tsx
index 869eae1f8..fc82347b0 100644
--- a/app/features/tournament-bracket/components/Bracket/Match.tsx
+++ b/app/features/tournament-bracket/components/Bracket/Match.tsx
@@ -22,7 +22,10 @@ import {
vodUrl,
} from "~/utils/urls";
import type { Bracket } from "../../core/Bracket";
-import type { MatchExitDestinations } from "../../core/Bracket/Bracket";
+import type {
+ MatchExitDestinations,
+ MatchExitGroups,
+} from "../../core/Bracket/Bracket";
import * as Deadline from "../../core/Deadline";
import type { TournamentData } from "../../core/Tournament.server";
import type { VodsByTournamentId } from "../../TournamentMatchVodRepository.server";
@@ -45,6 +48,8 @@ interface MatchProps {
spoilerCensor?: "full" | "score-only";
/** Brackets the teams leaving the bracket from this match continue to. */
exitDestinations?: MatchExitDestinations;
+ /** Groups of the destination brackets the teams leaving from this match landed in. */
+ exitGroups?: MatchExitGroups;
}
export function Match(props: MatchProps) {
@@ -79,6 +84,7 @@ export function Match(props: MatchProps) {
) : null}
@@ -96,10 +102,12 @@ export function Match(props: MatchProps) {
function MatchExitLabels({
match,
destinations,
+ groups,
hasLine,
}: {
match: MatchProps["match"];
destinations: MatchExitDestinations;
+ groups?: MatchExitGroups;
hasLine: boolean;
}) {
const { t } = useTranslation(["tournament"]);
@@ -108,6 +116,7 @@ function MatchExitLabels({
const exitRow = (type: "winner" | "loser", bracketIdx: number | null) => ({
type,
bracketIdx,
+ groupId: groups?.[type] ?? undefined,
bracket:
bracketIdx === null ? null : tournament.bracketsMeta[bracketIdx]?.name,
isLinkable: tournament.visibleBracketsMeta.some(
@@ -128,7 +137,7 @@ function MatchExitLabels({
})}
data-testid="match-exits"
>
- {rows.map(({ type, bracket, bracketIdx, isLinkable }) => {
+ {rows.map(({ type, bracket, bracketIdx, groupId, isLinkable }) => {
if (!bracket) {
return
;
}
@@ -149,6 +158,7 @@ function MatchExitLabels({
to={tournamentBracketsPage({
tournamentId: tournament.ctx.id,
bracketIdx,
+ groupId,
})}
className={styles.matchExit}
title={title}
diff --git a/app/features/tournament-bracket/components/Bracket/index.tsx b/app/features/tournament-bracket/components/Bracket/index.tsx
index 0d6d3ff65..d65a0f748 100644
--- a/app/features/tournament-bracket/components/Bracket/index.tsx
+++ b/app/features/tournament-bracket/components/Bracket/index.tsx
@@ -4,6 +4,7 @@ import { useBracketExpanded } from "~/features/tournament/routes/to.$id";
import { useDragToScroll } from "~/hooks/useDragToScroll";
import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect";
import type { Bracket as BracketType } from "../../core/Bracket";
+import type { MatchExitGroupsById } from "../../core/Bracket/Bracket";
import { EliminationBracketSide } from "./Elimination";
import { GroupTabs } from "./GroupTabs";
import styles from "./index.module.css";
@@ -14,11 +15,13 @@ export function Bracket({
bracket,
bracketIdx,
groupId,
+ exitGroups,
}: {
bracket: BracketType;
bracketIdx: number;
/** Group whose matches were loaded, for the bracket types shown one group at a time. */
groupId?: number | null;
+ exitGroups?: MatchExitGroupsById;
}) {
const { bracketExpanded } = useBracketExpanded();
@@ -57,6 +60,7 @@ export function Bracket({
bracket={bracket}
groupId={selectedGroupId}
isExpanded={bracketExpanded}
+ exitGroups={exitGroups}
/>
) : (
<>
@@ -65,12 +69,14 @@ export function Bracket({
bracket={bracket}
groupId={selectedGroupId}
isExpanded={bracketExpanded}
+ exitGroups={exitGroups}
/>
>
)}
diff --git a/app/features/tournament-bracket/core/Bracket.test.ts b/app/features/tournament-bracket/core/Bracket.test.ts
index 255efb567..e520c7f5c 100644
--- a/app/features/tournament-bracket/core/Bracket.test.ts
+++ b/app/features/tournament-bracket/core/Bracket.test.ts
@@ -2212,6 +2212,106 @@ describe("follow-up seeding through a redemption bracket", () => {
});
});
+describe("matchExitGroups", () => {
+ const TEAM_IDS = Array.from({ length: 16 }, (_, index) => index + 1);
+ const PROGRESSION: TournamentSettings["bracketProgression"] = [
+ {
+ type: "double_elimination",
+ name: "Main",
+ requiresCheckIn: false,
+ settings: {
+ groupCount: 2,
+ skippedRounds: [
+ "LB_SEMIS",
+ "LB_FINALS",
+ "GRAND_FINALS",
+ "BRACKET_RESET",
+ ],
+ },
+ },
+ {
+ type: "double_elimination",
+ name: "Redemption",
+ requiresCheckIn: false,
+ settings: {
+ groupCount: 2,
+ skippedRounds: ["GRAND_FINALS", "BRACKET_RESET"],
+ },
+ sources: [{ bracketIdx: 0, placements: [2, 3] }],
+ },
+ {
+ type: "single_elimination",
+ name: "Top Cut",
+ requiresCheckIn: false,
+ settings: {},
+ sources: [{ bracketIdx: 0, placements: [1] }],
+ },
+ ];
+
+ const tournamentWith = (data: BracketData) =>
+ testTournament({
+ ctx: {
+ settings: { bracketProgression: PROGRESSION },
+ teams: TEAM_IDS.map((id) => tournamentCtxTeam(id, { seed: id })),
+ },
+ data,
+ });
+
+ const main = playedOut(
+ createResolved({
+ type: "double_elimination",
+ seeding: TEAM_IDS,
+ settings: PROGRESSION[0].settings,
+ }),
+ );
+
+ test("gives the group of the not yet started grouped destination each leaving team is projected in", () => {
+ expectExitGroupsToMatchDestination(tournamentWith(main));
+ });
+
+ test("gives the group of the started grouped destination each leaving team landed in", () => {
+ const redemption = createResolved({
+ type: "double_elimination",
+ seeding: tournamentWith(main).bracketByIdx(1)!.seeding!,
+ settings: PROGRESSION[1].settings,
+ });
+
+ expectExitGroupsToMatchDestination(
+ tournamentWith(mergeStages(main, redemption)),
+ );
+ });
+
+ function expectExitGroupsToMatchDestination(tournament: Tournament) {
+ const redemptionData = tournament.bracketByIdx(1)!.data;
+ const redemptionGroupOf = (teamId: number | null | undefined) =>
+ redemptionData.match.find(
+ (match) =>
+ match.opponent1?.id === teamId || match.opponent2?.id === teamId,
+ )?.groupId ?? null;
+
+ const exitGroups = [...tournament.bracketByIdx(0)!.matchExitGroups()];
+
+ for (const [matchId, groups] of exitGroups) {
+ const match = matchById(main, matchId);
+ const winner =
+ match.winnerSide === "opponent1" ? "opponent1" : "opponent2";
+ const loser = winner === "opponent1" ? "opponent2" : "opponent1";
+
+ expect(groups).toEqual({
+ winner: redemptionGroupOf(match[winner]?.id),
+ loser: redemptionGroupOf(match[loser]?.id),
+ });
+ }
+ expect(
+ new Set(
+ exitGroups.flatMap(([, groups]) => [groups.winner, groups.loser]),
+ ),
+ ).toEqual(
+ new Set([null, ...redemptionData.group.map((group) => group.id)]),
+ );
+ }
+});
+
/** Plays every match of the bracket, the lower team id winning unless the other team is favored. */
function playedOut(data: BracketData, favoredIds: number[] = []): BracketData {
let result = data;
diff --git a/app/features/tournament-bracket/core/Bracket/Bracket.ts b/app/features/tournament-bracket/core/Bracket/Bracket.ts
index 2c5ca945b..b6694433a 100644
--- a/app/features/tournament-bracket/core/Bracket/Bracket.ts
+++ b/app/features/tournament-bracket/core/Bracket/Bracket.ts
@@ -9,6 +9,7 @@ import type {
} from "~/features/tournament-bracket/core/engine/types";
import { invariant } from "~/utils/invariant";
import { logger } from "~/utils/logger";
+import { showsOneGroupAtATime } from "../../tournament-bracket-utils";
import * as AbDivisions from "../AbDivisions";
import * as Engine from "../engine";
import * as Progression from "../Progression";
@@ -68,6 +69,15 @@ export interface MatchExitDestinations {
loser: number | null;
}
+/** Groups of the destination brackets the winner and loser of a match landed in. See {@link Bracket.matchExitGroups}. */
+export interface MatchExitGroups {
+ winner: number | null;
+ loser: number | null;
+}
+
+/** {@link MatchExitGroups} keyed by match id, as shipped to the client. */
+export type MatchExitGroupsById = Partial>;
+
export interface TeamTrackRecord {
wins: number;
losses: number;
@@ -369,6 +379,52 @@ export abstract class Bracket {
return result;
}
+ /**
+ * Groups the winner and loser of each decided match landed in (or are projected in, if not started), keyed by match id.
+ * Only destinations shown one group at a time count. Needs their match data, so not available in views that only got `bracketsMeta`.
+ */
+ matchExitGroups(): Map {
+ const groupIdOfTeam = (
+ bracketIdx: number | null,
+ tournamentTeamId: number | null | undefined,
+ ) => {
+ if (bracketIdx === null || typeof tournamentTeamId !== "number") {
+ return null;
+ }
+
+ const destination = this.tournament.bracketByIdx(bracketIdx);
+ if (!destination || !showsOneGroupAtATime(destination)) return null;
+
+ const teamMatch = destination.data.match.find(
+ (match) =>
+ match.opponent1?.id === tournamentTeamId ||
+ match.opponent2?.id === tournamentTeamId,
+ );
+
+ return teamMatch?.groupId ?? null;
+ };
+
+ const result = new Map();
+ for (const [matchId, destinations] of this.matchExitDestinations()) {
+ const match = this.data.match.find(
+ (candidate) => candidate.id === matchId,
+ );
+ if (!match?.winnerSide) continue;
+
+ const winnerId = match[match.winnerSide]?.id;
+ const loserId =
+ match[match.winnerSide === "opponent1" ? "opponent2" : "opponent1"]?.id;
+
+ const winner = groupIdOfTeam(destinations.winner, winnerId);
+ const loser = groupIdOfTeam(destinations.loser, loserId);
+ if (winner === null && loser === null) continue;
+
+ result.set(matchId, { winner, loser });
+ }
+
+ return result;
+ }
+
/** Teams start their tournament from this bracket. There can be more than one. */
get isStartingBracket() {
return !this.sources || this.sources.length === 0;
diff --git a/app/features/tournament-bracket/loaders/to.$id.brackets.server.ts b/app/features/tournament-bracket/loaders/to.$id.brackets.server.ts
index 7ee23dab4..530df3843 100644
--- a/app/features/tournament-bracket/loaders/to.$id.brackets.server.ts
+++ b/app/features/tournament-bracket/loaders/to.$id.brackets.server.ts
@@ -59,6 +59,8 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
groupId: bracket.preview ? null : groupId,
})
: null,
+ // destination brackets' match data is not shipped, so their groups are resolved here
+ exitGroups: Object.fromEntries(bracket?.matchExitGroups() ?? []),
// the layout does not ship these, standings derived in the view need them
participatedUserIds: tournament.participatedUserIds,
// only its bracket check-in is acted on here, the header status indicator covers the rest
diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx
index 4b5467c57..1b74b6958 100644
--- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx
+++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx
@@ -50,6 +50,7 @@ import { BracketCheckIn } from "../components/BracketCheckIn";
import { BracketMapListDialog } from "../components/BracketMapListDialog";
import * as AbDivisions from "../core/AbDivisions";
import type { Bracket as BracketType } from "../core/Bracket";
+import type { MatchExitGroupsById } from "../core/Bracket/Bracket";
import * as PreparedMaps from "../core/PreparedMaps";
import * as Progression from "../core/Progression";
import type { Tournament } from "../core/Tournament";
@@ -253,6 +254,7 @@ function TournamentBracketsView() {
bracket={bracket}
bracketIdx={data.bracketIdx}
groupId={data.groupId}
+ exitGroups={data.exitGroups}
waitingForTeamsText={waitingForTeamsText}
teamsSourceText={teamsSourceText}
/>
@@ -495,12 +497,14 @@ function BracketTabContent({
bracket,
bracketIdx,
groupId,
+ exitGroups,
waitingForTeamsText,
teamsSourceText,
}: {
bracket: BracketType;
bracketIdx: number;
groupId: number | null;
+ exitGroups: MatchExitGroupsById;
waitingForTeamsText: (bracket: BracketType, bracketIdx: number) => string;
teamsSourceText: (bracket: BracketType) => string | null;
}) {
@@ -517,6 +521,7 @@ function BracketTabContent({
bracket={bracket}
bracketIdx={bracketIdx}
groupId={groupId}
+ exitGroups={exitGroups}
/>
>
) : (
diff --git a/e2e/tournament-bracket-grouped-elimination.spec.ts b/e2e/tournament-bracket-grouped-elimination.spec.ts
index d9d03758f..137d330a0 100644
--- a/e2e/tournament-bracket-grouped-elimination.spec.ts
+++ b/e2e/tournament-bracket-grouped-elimination.spec.ts
@@ -40,9 +40,27 @@ test.describe("Tournament bracket grouped elimination", () => {
await expect(brackets.bracketTeamName("Team 2").first()).toBeVisible();
await isNotVisible(brackets.bracketTeamName("Team 1"));
+ // a pool's winners final loser is linked to the redemption group it lands in, before and after it starts
+ const expectExitLinksToRedemptionGroups = async () => {
+ for (const [pool, team, otherGroupTeam] of [
+ ["A", "Team 4", "Team 3"],
+ ["B", "Team 3", "Team 4"],
+ ]) {
+ await brackets.goto(tournament.id, 0);
+ await brackets.openGroup(pool);
+ await brackets.exitLabel("Redemption [L]").click();
+ await expect(page).toHaveURL(/idx=1.*group=\d+/);
+ await expect(brackets.bracketTeamName(team).first()).toBeVisible();
+ await isNotVisible(brackets.bracketTeamName(otherGroupTeam));
+ }
+ };
+ await expectExitLinksToRedemptionGroups();
+
// Team 3 (pool B) and Team 4 (pool A) win their redemption groups, having lost their pool's winners final
await factories.TournamentFactory.playOut(tournament.id, 1);
+ await expectExitLinksToRedemptionGroups();
+
await brackets.goto(tournament.id, 2);
// pool winners take the top seeds and play a redemption winner from the other pool, not a rematch
expect(await brackets.firstRoundTeamNames(4)).toEqual([