Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/components/TeamTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,20 @@ const groupName = (id: string | null) => {
background-color: rgb(31, 202, 131);
}
}

&.play-in {
position: relative;

::before {
content: "";
position: absolute;
top: 2px;
left: 0;
width: 2px;
height: calc(100% - 4px);
background-color: var(--color-brand-blue);
}
}
}

@media screen and (max-width: 600px) {
Expand Down
19 changes: 17 additions & 2 deletions src/components/TeamTableEntry.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,30 +18,45 @@ const teamName = computed(() => {

const progress = computed(() => {
let proceedingTeams = 0;
let byeTeams = 0;
const phaseI = props.tournament.phases.findIndex((p) => p.id === props.phaseId);

if (phaseI < props.tournament.phases.length - 1) {
const nextPhase = props.tournament.phases[phaseI + 1]!;

if (nextPhase.teamCount) {
const currentPhase = props.tournament.phases[phaseI] as GroupTournamentPhase;
proceedingTeams = nextPhase.teamCount / (currentPhase.groups?.length ?? 1);
const groupCount = currentPhase.groups?.length ?? 1;
proceedingTeams = nextPhase.teamCount / groupCount;

if (nextPhase.type === "knockout") {
const powerOfTwo = nextPowerOfTwo(nextPhase.teamCount);
byeTeams = (powerOfTwo - nextPhase.teamCount) / groupCount;
}
} else {
proceedingTeams = props.rank;
}
}

const def = Math.floor(proceedingTeams);
const maybe = Math.ceil(proceedingTeams);
const byeDef = Math.floor(byeTeams);

if (props.rank <= def) {
if (byeDef > 0 && props.rank <= byeDef) {
return "progress";
} else if (props.rank <= def) {
return "play-in";
} else if (props.rank === maybe) {
return "maybe";
}

return null;
});

const nextPowerOfTwo = (value: number): number => {
if (value <= 1) return 1;
return 2 ** Math.ceil(Math.log2(value));
};
</script>

<template>
Expand Down
41 changes: 41 additions & 0 deletions src/helpers/matchplan/knockoutPhase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,47 @@ describe("Knockout Phase Generation", () => {
expect(thirdPlaceMatch!.teams[0].link?.type).toBe("loser");
expect(thirdPlaceMatch!.teams[1].link?.type).toBe("loser");
});

it("should create a play-in round when team count is not a power of two", () => {
tournament = generateTestTournament({ teamCount: 24 });
const phase: KnockoutTournamentPhase = {
id: "phase-knockout",
name: "Knockout Phase",
type: "knockout",
teamCount: 24,
rounds: [],
};

tournament.phases = [phase];
const rounds = generateKnockoutBracket(phase, tournament);

expect(rounds[0]!.name).toBe("Play-in");
expect(rounds[1]!.name).toBe("Round of 16");
expect(rounds.length).toBe(6);
expect(rounds[0]!.matches.length).toBe(8);
});

it("should mix league and winner links in the post-bye round", () => {
tournament = generateTestTournament({ teamCount: 24 });
const phase: KnockoutTournamentPhase = {
id: "phase-knockout",
name: "Knockout Phase",
type: "knockout",
teamCount: 24,
rounds: [],
};

tournament.phases = [phase];
const rounds = generateKnockoutBracket(phase, tournament);

const postByeRound = rounds[1]!;
const linkTypes = postByeRound.matches.flatMap((match) =>
match.teams.map((team) => team.link?.type),
);

expect(linkTypes).toContain("league");
expect(linkTypes).toContain("winner");
});
});

describe("generateKnockoutBrackets", () => {
Expand Down
168 changes: 130 additions & 38 deletions src/helpers/matchplan/knockoutPhase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,32 +38,101 @@ export const generateKnockoutBracket = (
const previous = previousPhase(tournament, phase);
const startTime = new Date(getPreviousPhaseEndDate(tournament, phase));

let teamsInRound = knockoutTeamCount;
const powerOfTwo = nextPowerOfTwo(knockoutTeamCount);
const hasByes = powerOfTwo !== knockoutTeamCount;
const byes = powerOfTwo - knockoutTeamCount;
const teamFromPhase = previous ?? phase;

let roundNumber = 1;

while (teamsInRound > 1) {
const firstRound = teamsInRound === knockoutTeamCount;
const teamFromPhase = firstRound ? (previous ?? phase) : phase;
if (hasByes) {
const playInMatchCount = knockoutTeamCount - powerOfTwo / 2;
const playInTeamCount = playInMatchCount * 2;
const playInSlots = buildLeagueSlots(byes, playInTeamCount);

startTime.setMinutes(startTime.getMinutes() + roundDuration);

const matches = createRoundMatches({
teamFromPhase,
teamsInRound,
roundNumber,
startTime,
roundDuration,
courts: tournament.config.courts,
rounds.push({
id: generateId(),
name: "Play-in",
matches: createRoundMatches({
teamFromPhase,
slots: playInSlots,
roundNumber,
startTime,
roundDuration,
courts: tournament.config.courts,
}),
});

roundNumber++;

const mixedSlots = [...buildLeagueSlots(0, byes), ...buildWinnerSlots(playInMatchCount)];

startTime.setMinutes(startTime.getMinutes() + roundDuration);

rounds.push({
id: generateId(),
name: ROUND_NAME[teamsInRound] || `Round ${roundNumber}`,
matches,
name: ROUND_NAME[powerOfTwo / 2] || `Round ${roundNumber}`,
matches: createRoundMatches({
teamFromPhase,
slots: mixedSlots,
roundNumber,
startTime,
roundDuration,
courts: tournament.config.courts,
}),
});

teamsInRound /= 2;
roundNumber++;

let teamsInRound = powerOfTwo / 4;

while (teamsInRound > 1) {
startTime.setMinutes(startTime.getMinutes() + roundDuration);

rounds.push({
id: generateId(),
name: ROUND_NAME[teamsInRound] || `Round ${roundNumber}`,
matches: createRoundMatches({
teamFromPhase: phase,
slots: buildWinnerSlots(teamsInRound),
roundNumber,
startTime,
roundDuration,
courts: tournament.config.courts,
}),
});

teamsInRound /= 2;
roundNumber++;
}
} else {
let teamsInRound = knockoutTeamCount;

while (teamsInRound > 1) {
const isFirstRound = teamsInRound === knockoutTeamCount;

startTime.setMinutes(startTime.getMinutes() + roundDuration);

rounds.push({
id: generateId(),
name: ROUND_NAME[teamsInRound] || `Round ${roundNumber}`,
matches: createRoundMatches({
teamFromPhase: isFirstRound ? teamFromPhase : phase,
slots: isFirstRound
? buildLeagueSlots(0, teamsInRound)
: buildWinnerSlots(teamsInRound),
roundNumber,
startTime,
roundDuration,
courts: tournament.config.courts,
}),
});

teamsInRound /= 2;
roundNumber++;
}
}

return insertThirdPlacePlayoff(rounds, startTime, roundDuration);
Expand All @@ -85,7 +154,7 @@ const getPreviousPhaseEndDate = (tournament: Tournament, phase: TournamentPhase)

type RoundMatchConfig = {
teamFromPhase: TournamentPhase;
teamsInRound: number;
slots: RoundSlot[];
roundNumber: number;
startTime: Date;
roundDuration: number;
Expand All @@ -94,15 +163,15 @@ type RoundMatchConfig = {

const createRoundMatches = ({
teamFromPhase,
teamsInRound,
slots,
roundNumber,
startTime,
roundDuration,
courts,
}: RoundMatchConfig): Match[] => {
const matches: Match[] = [];
let court = 1;
const pairCount = teamsInRound / 2;
const pairCount = slots.length / 2;

for (let i = 0; i < pairCount; i++) {
if (court > courts) {
Expand All @@ -113,8 +182,8 @@ const createRoundMatches = ({
matches.push(
createKnockoutMatch({
teamFromPhase,
placement: i,
oppositePlacement: teamsInRound - 1 - i,
slot: slots[i]!,
oppositeSlot: slots[slots.length - 1 - i]!,
roundNumber,
court: court++,
date: new Date(startTime),
Expand All @@ -127,44 +196,31 @@ const createRoundMatches = ({

type KnockoutMatchConfig = {
teamFromPhase: TournamentPhase;
placement: number;
oppositePlacement: number;
slot: RoundSlot;
oppositeSlot: RoundSlot;
roundNumber: number;
court: number;
date: Date;
};

const createKnockoutMatch = ({
teamFromPhase,
placement,
oppositePlacement,
slot,
oppositeSlot,
roundNumber,
court,
date,
}: KnockoutMatchConfig): Match => {
const isFirstRound = roundNumber === 1;
const linkType = isFirstRound ? "league" : "winner";

return {
id: generateId(),
court,
teams: [
{
link: {
placement,
label: getPlacement(teamFromPhase, placement),
type: linkType,
fromRound: roundNumber - 2,
},
link: buildRoundLink(teamFromPhase, slot, roundNumber),
score: 0,
},
{
link: {
placement: oppositePlacement,
label: getPlacement(teamFromPhase, oppositePlacement),
type: linkType,
fromRound: roundNumber - 2,
},
link: buildRoundLink(teamFromPhase, oppositeSlot, roundNumber),
score: 0,
},
],
Expand All @@ -173,6 +229,42 @@ const createKnockoutMatch = ({
};
};

type RoundSlot = {
type: "league" | "winner";
placement: number;
};

const buildLeagueSlots = (start: number, count: number): RoundSlot[] =>
Array.from({ length: count }, (_, i) => ({
type: "league",
placement: start + i,
}));

const buildWinnerSlots = (count: number): RoundSlot[] =>
Array.from({ length: count }, (_, i) => ({
type: "winner",
placement: i,
}));

const buildRoundLink = (teamFromPhase: TournamentPhase, slot: RoundSlot, roundNumber: number) => {
const label =
slot.type === "league"
? getPlacement(teamFromPhase, slot.placement)
: ALPHABET[slot.placement]!;

return {
placement: slot.placement,
label,
type: slot.type,
fromRound: slot.type === "winner" ? roundNumber - 2 : undefined,
};
};

const nextPowerOfTwo = (value: number): number => {
if (value <= 1) return 1;
return 2 ** Math.ceil(Math.log2(value));
};

const insertThirdPlacePlayoff = (
rounds: TournamentRound[],
startTime: Date,
Expand Down