SE/DE exit link: link to the correct group too
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled

This commit is contained in:
Kalle
2026-10-11 19:47:17 +03:00
parent 5dde981281
commit 2f6f5b51ca
8 changed files with 202 additions and 2 deletions

View File

@@ -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]}
/>
);
})}

View File

@@ -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) {
<MatchExitLabels
match={props.match}
destinations={props.exitDestinations}
groups={props.exitGroups}
hasLine={Boolean(props.lineType) && props.lineType !== "none"}
/>
) : 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 <div key={type} className={styles.matchExitRow} />;
}
@@ -149,6 +158,7 @@ function MatchExitLabels({
to={tournamentBracketsPage({
tournamentId: tournament.ctx.id,
bracketIdx,
groupId,
})}
className={styles.matchExit}
title={title}

View File

@@ -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}
/>
<EliminationBracketSide
type="losers"
bracket={bracket}
groupId={selectedGroupId}
isExpanded={bracketExpanded}
exitGroups={exitGroups}
/>
</>
)}

View File

@@ -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;

View File

@@ -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<Record<number, MatchExitGroups>>;
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<number, MatchExitGroups> {
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<number, MatchExitGroups>();
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;

View File

@@ -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

View File

@@ -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}
/>
</>
) : (

View File

@@ -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([