From a1c0f5519bf7ccd2b548daa4e83dfe16e8b6d521 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 2 Mar 2024 14:02:38 +0200 Subject: [PATCH] SoS bracket format --- app/components/Menu.tsx | 10 +- .../calendar/calendar-schemas.server.ts | 17 +- app/features/calendar/calendar-types.ts | 4 + .../calendar/calendar-utils.server.ts | 43 +--- app/features/calendar/calendar-utils.ts | 45 ++++ app/features/calendar/routes/calendar.new.tsx | 204 ++++++++++++------ .../tournament-bracket/core/Bracket.ts | 10 +- .../tournament-bracket/core/Tournament.ts | 2 +- .../routes/to.$id.brackets.tsx | 89 +++++--- .../tournament-bracket-schemas.server.ts | 2 +- .../tournament-bracket/tournament-bracket.css | 20 ++ e2e/tournament-bracket.spec.ts | 14 +- playwright.config.ts | 3 +- 13 files changed, 321 insertions(+), 142 deletions(-) create mode 100644 app/features/calendar/calendar-types.ts diff --git a/app/components/Menu.tsx b/app/components/Menu.tsx index e4fa3bf9e..606157166 100644 --- a/app/components/Menu.tsx +++ b/app/components/Menu.tsx @@ -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 ( - + - {item.icon} + {item.icon ? ( + {item.icon} + ) : null} {item.text} )} diff --git a/app/features/calendar/calendar-schemas.server.ts b/app/features/calendar/calendar-schemas.server.ts index 049c117db..46154fcb5 100644 --- a/app/features/calendar/calendar-schemas.server.ts +++ b/app/features/calendar/calendar-schemas.server.ts @@ -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) => { diff --git a/app/features/calendar/calendar-types.ts b/app/features/calendar/calendar-types.ts new file mode 100644 index 000000000..21d25f9ac --- /dev/null +++ b/app/features/calendar/calendar-types.ts @@ -0,0 +1,4 @@ +export interface FollowUpBracket { + name: string; + placements: Array; +} diff --git a/app/features/calendar/calendar-utils.server.ts b/app/features/calendar/calendar-utils.server.ts index 723c7944f..5707b60ca 100644 --- a/app/features/calendar/calendar-utils.server.ts +++ b/app/features/calendar/calendar-utils.server.ts @@ -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 }], }); } } diff --git a/app/features/calendar/calendar-utils.ts b/app/features/calendar/calendar-utils.ts index e5a80218a..94b81b8ac 100644 --- a/app/features/calendar/calendar-utils.ts +++ b/app/features/calendar/calendar-utils.ts @@ -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; +} diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx index 6d6d4346b..5521b6e9a 100644 --- a/app/features/calendar/routes/calendar.new.tsx +++ b/app/features/calendar/routes/calendar.new.tsx @@ -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 | 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 (
Tournament format @@ -861,16 +836,23 @@ function TournamentFormatSelector() {
-
- - - {undergroundBracketExplanation()} -
+ {format === "DE" ? ( +
+ + + + Optional bracket for teams who lose in the first two rounds of + losers bracket. + +
+ ) : null} {format === "RR_TO_SE" ? (
@@ -889,31 +871,129 @@ function TournamentFormatSelector() {
) : null} - {format === "RR_TO_SE" ? ( -
- - -
+ ) : null} ); } + +function FollowUpBrackets({ teamsPerGroup }: { teamsPerGroup: number }) { + const [_brackets, setBrackets] = React.useState>([ + { 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 ( +
+ + +
+ {brackets.map((b, i) => ( + { + setBrackets( + brackets.map((oldBracket, j) => + j === i ? newBracket : oldBracket, + ), + ); + }} + bracket={b} + nth={i + 1} + /> + ))} +
+ + +
+ + {validationErrorMsg ? ( + {validationErrorMsg} + ) : null} +
+
+ ); +} + +function FollowUpBracketInputs({ + teamsPerGroup, + bracket, + onChange, + nth, +}: { + teamsPerGroup: number; + bracket: FollowUpBracket; + onChange: (bracket: FollowUpBracket) => void; + nth: number; +}) { + const id = React.useId(); + return ( +
+
+ + onChange({ ...bracket, name: e.target.value })} + id={id} + /> +
+
+ + {nullFilledArray(teamsPerGroup).map((_, i) => { + const placement = i + 1; + return ( +
+ + { + const newPlacements = e.target.checked + ? [...bracket.placements, placement] + : bracket.placements.filter((p) => p !== placement); + onChange({ ...bracket, placements: newPlacements }); + }} + /> +
+ ); + })} +
+
+ ); +} diff --git a/app/features/tournament-bracket/core/Bracket.ts b/app/features/tournament-bracket/core/Bracket.ts index 22ca9cabf..e7e93dfa4 100644 --- a/app/features/tournament-bracket/core/Bracket.ts +++ b/app/features/tournament-bracket/core/Bracket.ts @@ -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() { diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index b7a855373..ced2c8e74 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -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; diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index 94c0525e1..6e80a1ad4 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -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 ( -
- {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 ( - - ); - })} -
+ const bracketNameForButton = (name: string) => name.replace("bracket", ""); + + const button = React.forwardRef(function (props, ref) { + return ( + + ); + }); + + return ( + <> + {/** MOBILE */} + { + return { + id: bracket.name, + onClick: () => setBracketIdx(i), + text: bracketNameForButton(bracket.name), + }; + })} + button={button} + className="tournament-bracket__menu" + /> + {/** DESKTOP */} +
+ {visibleBrackets.map((bracket, i) => { + return ( + + ); + })} +
+ ); } diff --git a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts index 3379ee072..c54365738 100644 --- a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts +++ b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts @@ -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({ diff --git a/app/features/tournament-bracket/tournament-bracket.css b/app/features/tournament-bracket/tournament-bracket.css index b184802c1..a91381c07 100644 --- a/app/features/tournament-bracket/tournament-bracket.css +++ b/app/features/tournament-bracket/tournament-bracket.css @@ -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); diff --git a/e2e/tournament-bracket.spec.ts b/e2e/tournament-bracket.spec.ts index 0dacc330f..ceb4ad648 100644 --- a/e2e/tournament-bracket.spec.ts +++ b/e2e/tournament-bracket.spec.ts @@ -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); diff --git a/playwright.config.ts b/playwright.config.ts index 95ed3aa82..f1987ed20 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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 */