SoS bracket format

This commit is contained in:
Kalle 2024-03-02 14:02:38 +02:00
parent 1bbac292d5
commit a1c0f5519b
13 changed files with 321 additions and 142 deletions

View File

@ -5,19 +5,21 @@ import clsx from "clsx";
export function Menu({
button,
items,
className,
}: {
button: React.ElementType;
items: {
// type: "button"; TODO: type: "link"
text: string;
id: string;
icon: React.ReactNode;
icon?: React.ReactNode;
onClick: () => void;
disabled?: boolean;
}[];
className?: string;
}) {
return (
<HeadlessUIMenu as="div" className="menu-container">
<HeadlessUIMenu as="div" className={clsx("menu-container", className)}>
<HeadlessUIMenu.Button as={button} />
<Transition
as={React.Fragment}
@ -41,7 +43,9 @@ export function Menu({
onClick={item.onClick}
data-testid={`menu-item-${item.id}`}
>
<span className="menu__item__icon">{item.icon}</span>
{item.icon ? (
<span className="menu__item__icon">{item.icon}</span>
) : null}
{item.text}
</button>
)}

View File

@ -80,11 +80,18 @@ export const newCalendarEventActionSchema = z
.min(TOURNAMENT.MIN_GROUP_SIZE)
.max(TOURNAMENT.MAX_GROUP_SIZE)
.nullish(),
advancingCount: z.coerce
.number()
.min(1)
.max(TOURNAMENT.MAX_GROUP_SIZE)
.nullish(),
followUpBrackets: z.preprocess(
safeJSONParse,
z
.array(
z.object({
name: z.string(),
placements: z.array(z.number()),
}),
)
.min(1)
.nullish(),
),
})
.refine(
async (schema) => {

View File

@ -0,0 +1,4 @@
export interface FollowUpBracket {
name: string;
placements: Array<number>;
}

View File

@ -2,8 +2,8 @@ import type { z } from "zod";
import { isAdmin } from "~/permissions";
import type { TournamentSettings } from "~/db/tables";
import { BRACKET_NAMES } from "../tournament/tournament-constants";
import { nullFilledArray } from "~/utils/arrays";
import type { newCalendarEventActionSchema } from "./calendar-schemas.server";
import { validateFollowUpBrackets } from "./calendar-utils";
const usersWithTournamentPerms =
process.env["TOURNAMENT_PERMS"]?.split(",").map(Number) ?? [];
@ -36,48 +36,23 @@ export function formValuesToBracketProgression(
if (
args.format === "RR_TO_SE" &&
args.advancingCount &&
args.teamsPerGroup &&
args.advancingCount <= args.teamsPerGroup
args.followUpBrackets
) {
if (validateFollowUpBrackets(args.followUpBrackets, args.teamsPerGroup)) {
return null;
}
result.push({
name: BRACKET_NAMES.GROUPS,
type: "round_robin",
});
const allPlacements = nullFilledArray(args.teamsPerGroup).map(
(_, i) => i + 1,
);
const advancingPlacements = nullFilledArray(args.advancingCount).map(
(_, i) => i + 1,
);
result.push({
name: BRACKET_NAMES.FINALS,
type: "single_elimination",
sources: [
{
bracketIdx: 0,
placements: advancingPlacements,
},
],
});
if (
args.withUndergroundBracket &&
advancingPlacements.length !== allPlacements.length
) {
for (const bracket of args.followUpBrackets) {
result.push({
name: BRACKET_NAMES.UNDERGROUND,
name: bracket.name,
type: "single_elimination",
sources: [
{
bracketIdx: 0,
placements: allPlacements.filter(
(p) => !advancingPlacements.includes(p),
),
},
],
sources: [{ bracketIdx: 0, placements: bracket.placements }],
});
}
}

View File

@ -1,6 +1,7 @@
import type { TournamentSettings } from "~/db/tables";
import { userDiscordIdIsAged } from "~/utils/users";
import type { TournamentFormatShort } from "../tournament/tournament-constants";
import type { FollowUpBracket } from "./calendar-types";
export const canAddNewEvent = (user: { discordId: string }) =>
userDiscordIdIsAged(user);
@ -19,3 +20,47 @@ export const calendarEventMaxDate = () => {
result.setFullYear(result.getFullYear() + 1);
return result;
};
export function validateFollowUpBrackets(
brackets: FollowUpBracket[],
teamsPerGroup: number,
) {
const placementsFound: number[] = [];
for (const bracket of brackets) {
for (const placement of bracket.placements) {
if (placementsFound.includes(placement)) {
return `Duplicate group placement for two different brackets: ${placement}`;
}
placementsFound.push(placement);
}
}
for (
let placement = 1;
placement <= Math.max(...placementsFound);
placement++
) {
if (!placementsFound.includes(placement)) {
return `No bracket for placement ${placement}`;
}
}
if (placementsFound.some((p) => p > teamsPerGroup)) {
return `Placement higher than teams per group`;
}
if (brackets.some((b) => !b.name)) {
return "Bracket name can't be empty";
}
if (brackets.some((b) => b.placements.length === 0)) {
return "Bracket must have at least one placement";
}
if (new Set(brackets.map((b) => b.name)).size !== brackets.length) {
return "Duplicate bracket name";
}
return null;
}

View File

@ -36,7 +36,6 @@ import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import {
BRACKET_NAMES,
TOURNAMENT,
type TournamentFormatShort,
} from "~/features/tournament/tournament-constants";
import { useIsMounted } from "~/hooks/useIsMounted";
@ -44,7 +43,7 @@ import { i18next } from "~/modules/i18n/i18next.server";
import type { RankedModeShort } from "~/modules/in-game-lists";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import { canEditCalendarEvent } from "~/permissions";
import { isDefined } from "~/utils/arrays";
import { isDefined, nullFilledArray } from "~/utils/arrays";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
@ -65,6 +64,7 @@ import {
calendarEventMaxDate,
calendarEventMinDate,
canAddNewEvent,
validateFollowUpBrackets,
} from "../calendar-utils";
import {
canCreateTournament,
@ -74,6 +74,8 @@ import { Tags } from "../components/Tags";
import "~/styles/calendar-new.css";
import "~/styles/maps.css";
import { Placement } from "~/components/Placement";
import type { FollowUpBracket } from "../calendar-types";
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader> | null;
@ -815,33 +817,6 @@ function TournamentFormatSelector() {
data.tournamentCtx?.settings.teamsPerGroup ?? 4,
);
const undergroundBracketExplanation = () => {
if (format === "RR_TO_SE") {
return "Optional bracket for teams that don't make it to the final stage";
}
return "Optional bracket for teams who lose in the first two rounds of losers bracket.";
};
const advancingPerGroup = () => {
const DEFAULT = 2;
const hasRR = data.tournamentCtx?.settings.bracketProgression.some(
(b) => b.type === "round_robin",
);
if (!hasRR) return DEFAULT;
const finalBracket = data.tournamentCtx?.settings.bracketProgression.find(
(b) => b.name === BRACKET_NAMES.FINALS,
);
if (!finalBracket) return DEFAULT;
return Math.max(
...(finalBracket.sources?.flatMap((s) => s.placements) ?? [DEFAULT]),
);
};
return (
<div className="stack md">
<Divider>Tournament format</Divider>
@ -861,16 +836,23 @@ function TournamentFormatSelector() {
</select>
</div>
<div>
<Label htmlFor="withUndergroundBracket">With underground bracket</Label>
<Toggle
checked={withUndergroundBracket}
setChecked={setWithUndergroundBracket}
name="withUndergroundBracket"
id="withUndergroundBracket"
/>
<FormMessage type="info">{undergroundBracketExplanation()}</FormMessage>
</div>
{format === "DE" ? (
<div>
<Label htmlFor="withUndergroundBracket">
With underground bracket
</Label>
<Toggle
checked={withUndergroundBracket}
setChecked={setWithUndergroundBracket}
name="withUndergroundBracket"
id="withUndergroundBracket"
/>
<FormMessage type="info">
Optional bracket for teams who lose in the first two rounds of
losers bracket.
</FormMessage>
</div>
) : null}
{format === "RR_TO_SE" ? (
<div>
@ -889,31 +871,129 @@ function TournamentFormatSelector() {
</select>
</div>
) : null}
{format === "RR_TO_SE" ? (
<div>
<Label htmlFor="advancingCount">
Amount of teams advancing per group
</Label>
<select
defaultValue={advancingPerGroup()}
className="w-max"
name="advancingCount"
id="advancingCount"
>
{new Array(TOURNAMENT.MAX_GROUP_SIZE).fill(null).map((_, i) => {
const advancingCount = i + 1;
if (advancingCount > teamsPerGroup) return null;
return (
<option key={i} value={advancingCount}>
{advancingCount}
</option>
);
})}
</select>
</div>
<FollowUpBrackets teamsPerGroup={teamsPerGroup} />
) : null}
</div>
);
}
function FollowUpBrackets({ teamsPerGroup }: { teamsPerGroup: number }) {
const [_brackets, setBrackets] = React.useState<Array<FollowUpBracket>>([
{ name: "Top cut", placements: [1, 2] },
]);
const brackets = _brackets.map((b) => ({
...b,
// handle teams per group changing after group placements have been set
placements: b.placements.filter((p) => p <= teamsPerGroup),
}));
const validationErrorMsg = validateFollowUpBrackets(brackets, teamsPerGroup);
return (
<div>
<RequiredHiddenInput
isValid={!validationErrorMsg}
name="followUpBrackets"
value={JSON.stringify(brackets)}
/>
<Label>Follow-up brackets</Label>
<div className="stack lg">
{brackets.map((b, i) => (
<FollowUpBracketInputs
key={i}
teamsPerGroup={teamsPerGroup}
onChange={(newBracket) => {
setBrackets(
brackets.map((oldBracket, j) =>
j === i ? newBracket : oldBracket,
),
);
}}
bracket={b}
nth={i + 1}
/>
))}
<div className="stack sm horizontal">
<Button
size="tiny"
onClick={() => {
setBrackets([...brackets, { name: "", placements: [] }]);
}}
data-testid="add-bracket"
>
Add bracket
</Button>
<Button
size="tiny"
variant="destructive"
onClick={() => {
setBrackets(brackets.slice(0, -1));
}}
disabled={brackets.length === 1}
>
Remove bracket
</Button>
</div>
{validationErrorMsg ? (
<FormMessage type="error">{validationErrorMsg}</FormMessage>
) : null}
</div>
</div>
);
}
function FollowUpBracketInputs({
teamsPerGroup,
bracket,
onChange,
nth,
}: {
teamsPerGroup: number;
bracket: FollowUpBracket;
onChange: (bracket: FollowUpBracket) => void;
nth: number;
}) {
const id = React.useId();
return (
<div className="stack sm">
<div className="stack items-center horizontal sm">
<Label spaced={false} htmlFor={id}>
{nth}. Name
</Label>
<Input
value={bracket.name}
onChange={(e) => onChange({ ...bracket, name: e.target.value })}
id={id}
/>
</div>
<div className="stack items-center horizontal md flex-wrap">
<Label spaced={false}>Group placements</Label>
{nullFilledArray(teamsPerGroup).map((_, i) => {
const placement = i + 1;
return (
<div key={i} className="stack horizontal items-center xs">
<Label spaced={false} htmlFor={`${id}-${i}`}>
<Placement placement={placement} />
</Label>
<input
id={`${id}-${i}`}
data-testid={`placement-${nth}-${placement}`}
type="checkbox"
checked={bracket.placements.includes(placement)}
onChange={(e) => {
const newPlacements = e.target.checked
? [...bracket.placements, placement]
: bracket.placements.filter((p) => p !== placement);
onChange({ ...bracket, placements: newPlacements });
}}
/>
</div>
);
})}
</div>
</div>
);
}

View File

@ -6,7 +6,6 @@ import { assertUnreachable } from "~/utils/types";
import type { OptionalIdObject, Tournament } from "./Tournament";
import type { TournamentDataTeam } from "./Tournament.server";
import { removeDuplicates } from "~/utils/arrays";
import { BRACKET_NAMES } from "~/features/tournament/tournament-constants";
import { logger } from "~/utils/logger";
import type { Round } from "~/modules/brackets-model";
import { getTournamentManager } from "./brackets-manager";
@ -239,7 +238,14 @@ export abstract class Bracket {
}
get isUnderground() {
return this.name === BRACKET_NAMES.UNDERGROUND;
return Boolean(
this.sources &&
this.sources.flatMap((s) => s.placements).every((p) => p !== 1),
);
}
get isFinals() {
return Boolean(this.sources?.some((s) => s.placements.includes(1)));
}
get everyMatchOver() {

View File

@ -358,7 +358,7 @@ export class Tournament {
return bracket.standings;
}
if (bracket.name === BRACKET_NAMES.FINALS) {
if (bracket.isFinals) {
const finalsStandings = bracket.standings;
const firstStageStandings = this.brackets[0].standings;

View File

@ -28,7 +28,6 @@ import {
import { currentSeason } from "~/features/mmr/season";
import { TOURNAMENT, tournamentIdFromParams } from "~/features/tournament";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { BRACKET_NAMES } from "~/features/tournament/tournament-constants";
import { HACKY_isInviteOnlyEvent } from "~/features/tournament/tournament-utils";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { useVisibilityChange } from "~/hooks/useVisibilityChange";
@ -64,6 +63,7 @@ import {
import "../components/Bracket/bracket.css";
import "../tournament-bracket.css";
import { Menu } from "~/components/Menu";
export const action: ActionFunction = async ({ params, request }) => {
const user = await requireUser(request);
@ -110,9 +110,7 @@ export const action: ActionFunction = async ({ params, request }) => {
// check in teams to the final stage ahead of time so they don't have to do it
// separately, but also allow for TO's to check them out if needed
if (data.bracketIdx === 0 && tournament.brackets.length > 1) {
const finalStageIdx = tournament.brackets.findIndex(
(b) => b.name === BRACKET_NAMES.FINALS,
);
const finalStageIdx = tournament.brackets.findIndex((b) => b.isFinals);
if (finalStageIdx !== -1) {
await TournamentRepository.checkInMany({
@ -246,7 +244,9 @@ export default function TournamentBracketsPage() {
tournament.brackets[0].type === "round_robin" &&
bracket.isUnderground
) {
return "Teams that don't advance to the final stage can play in this bracket (optional)";
const placements = bracket.sources?.flatMap((s) => s.placements) ?? [];
return `Teams that don't advance to the final stage can play in this bracket (placements: ${placements.join(", ")})`;
}
if (
@ -629,31 +629,62 @@ function BracketNav({
if (tournament.ctx.settings.bracketProgression.length < 2) return null;
return (
<div className="tournament-bracket__bracket-nav">
{tournament.ctx.settings.bracketProgression.map((bracket, i) => {
// underground bracket was never played despite being in the format
if (
tournament.bracketByIdxOrDefault(i).preview &&
tournament.ctx.isFinalized
) {
return null;
}
const visibleBrackets = tournament.ctx.settings.bracketProgression.filter(
// an underground bracket was never played despite being in the format
(_, i) =>
!tournament.ctx.isFinalized ||
!tournament.bracketByIdxOrDefault(i).preview,
);
return (
<Button
key={bracket.name}
onClick={() => setBracketIdx(i)}
className={clsx("tournament-bracket__bracket-nav__link", {
"tournament-bracket__bracket-nav__link__selected":
bracketIdx === i,
})}
>
{bracket.name.replace("bracket", "")}
</Button>
);
})}
</div>
const bracketNameForButton = (name: string) => name.replace("bracket", "");
const button = React.forwardRef(function (props, ref) {
return (
<Button
className="tournament-bracket__bracket-nav__link"
_ref={ref}
{...props}
>
{bracketNameForButton(
tournament.bracketByIdxOrDefault(bracketIdx).name,
)}
<span className="tournament-bracket__bracket-nav__chevron"></span>
</Button>
);
});
return (
<>
{/** MOBILE */}
<Menu
items={visibleBrackets.map((bracket, i) => {
return {
id: bracket.name,
onClick: () => setBracketIdx(i),
text: bracketNameForButton(bracket.name),
};
})}
button={button}
className="tournament-bracket__menu"
/>
{/** DESKTOP */}
<div className="tournament-bracket__bracket-nav tournament-bracket__button-row">
{visibleBrackets.map((bracket, i) => {
return (
<Button
key={bracket.name}
onClick={() => setBracketIdx(i)}
className={clsx("tournament-bracket__bracket-nav__link", {
"tournament-bracket__bracket-nav__link__selected":
bracketIdx === i,
})}
>
{bracketNameForButton(bracket.name)}
</Button>
);
})}
</div>
</>
);
}

View File

@ -68,7 +68,7 @@ export const matchSchema = z.union([
}),
]);
export const bracketIdx = z.coerce.number().int().min(0).max(2);
export const bracketIdx = z.coerce.number().int().min(0).max(100);
export const bracketSchema = z.union([
z.object({

View File

@ -435,6 +435,26 @@
background-color: var(--bg-lighter);
}
.tournament-bracket__bracket-nav__chevron {
margin-inline-start: var(--s-2);
font-size: var(--fonts-xxxs);
margin-block-end: -2px;
}
.tournament-bracket__button-row {
display: none;
}
@media screen and (min-width: 600px) {
.tournament-bracket__menu {
display: none;
}
.tournament-bracket__button-row {
display: inherit;
}
}
.tournament-bracket__compactify-button {
font-size: var(--fonts-xxs);
color: var(--text-lighter);

View File

@ -290,9 +290,17 @@ test.describe("Tournament bracket", () => {
await page.getByTestId("edit-event-info-button").click();
await page
.getByLabel("Amount of teams advancing per group")
.selectOption("1");
await page.getByTestId("add-bracket").click();
await page.getByLabel("2. Name").fill("Underground bracket");
for (const testId of [
"placement-1-2",
"placement-2-2",
"placement-2-3",
"placement-2-4",
]) {
await page.getByTestId(testId).click();
}
await submit(page);

View File

@ -25,8 +25,7 @@ const config: PlaywrightTestConfig = {
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env["CI"],
/* Retry on CI only */
retries: process.env["CI"] ? 2 : 0,
retries: 2,
/* Opt out of parallel tests. */
workers: 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */