From 86b50ced566d8fd6cbb7a253f0d96a2bd75b8b9e Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 13 Jan 2025 22:57:08 +0200 Subject: [PATCH] League support (#2030) * Initial * Create league divs script works * Progress * Progress * Prevent round from starting * Finalized? * Tweaks * linter --- .env.example | 2 + app/components/layout/NavDialog.tsx | 2 +- app/components/layout/nav-items.json | 97 --------- app/components/layout/nav-items.ts | 104 ++++++++++ app/db/seed/index.ts | 75 +++++-- app/db/sql.ts | 19 +- app/db/tables.ts | 3 + app/features/admin/routes/admin.tsx | 5 - .../calendar/CalendarRepository.server.ts | 6 + app/features/front-page/routes/index.tsx | 17 +- .../components/Bracket/RoundHeader.tsx | 22 ++- .../components/MatchActions.tsx | 14 ++ .../components/StartedMatch.tsx | 25 +++ .../tournament-bracket/core/Tournament.ts | 15 +- .../tournament-bracket/core/tests/mocks-li.ts | 1 + .../core/tests/mocks-sos.ts | 1 + .../tournament-bracket/core/tests/mocks.ts | 3 + .../core/tests/test-utils.ts | 1 + .../loader/to.$id.divisions.server.ts | 49 +++++ .../queries/findMatchById.server.ts | 3 + .../routes/to.$id.brackets.tsx | 4 + .../routes/to.$id.divisions.tsx | 55 ++++++ .../tournament/TournamentRepository.server.ts | 35 ++++ .../TournamentTeamRepository.server.ts | 84 ++++++++ .../actions/to.$id.register.server.ts | 4 + app/features/tournament/routes/luti.tsx | 12 ++ .../tournament/routes/to.$id.admin.tsx | 83 ++++++-- .../tournament/routes/to.$id.register.tsx | 84 +++++--- app/features/tournament/routes/to.$id.tsx | 45 ++++- .../tournament/tournament-constants.ts | 25 +++ app/features/tournament/tournament-utils.ts | 40 +++- app/features/tournament/tournament.css | 36 ++++ app/routes.ts | 5 + app/styles/front.css | 11 ++ app/utils/remix.server.ts | 4 +- app/utils/urls.ts | 6 +- docs/tournament-leagues.md | 14 ++ locales/en/common.json | 1 + migrations/078-parent-tournament-id.js | 11 ++ public/static-assets/img/layout/luti.avif | Bin 0 -> 3451 bytes public/static-assets/img/layout/luti.png | Bin 0 -> 13244 bytes scripts/create-league-divisions.ts | 184 ++++++++++++++++++ 42 files changed, 1022 insertions(+), 185 deletions(-) delete mode 100644 app/components/layout/nav-items.json create mode 100644 app/components/layout/nav-items.ts create mode 100644 app/features/tournament-bracket/loader/to.$id.divisions.server.ts create mode 100644 app/features/tournament-bracket/routes/to.$id.divisions.tsx create mode 100644 app/features/tournament/routes/luti.tsx create mode 100644 docs/tournament-leagues.md create mode 100644 migrations/078-parent-tournament-id.js create mode 100644 public/static-assets/img/layout/luti.avif create mode 100644 public/static-assets/img/layout/luti.png create mode 100644 scripts/create-league-divisions.ts diff --git a/.env.example b/.env.example index 3cce938f2..612229989 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,5 @@ VITE_SKALOP_WS_URL=ws://localhost:5900 // trunc, full or none (default: none) SQL_LOG=trunc + +VITE_SHOW_LUTI_NAV_ITEM=false diff --git a/app/components/layout/NavDialog.tsx b/app/components/layout/NavDialog.tsx index d3b379ef0..25503c5a3 100644 --- a/app/components/layout/NavDialog.tsx +++ b/app/components/layout/NavDialog.tsx @@ -1,6 +1,6 @@ import { Link } from "@remix-run/react"; import { useTranslation } from "react-i18next"; -import navItems from "~/components/layout/nav-items.json"; +import { navItems } from "~/components/layout/nav-items"; import { useUser } from "~/features/auth/core/user"; import { LOG_OUT_URL, navIconUrl, userPage } from "~/utils/urls"; import { Avatar } from "../Avatar"; diff --git a/app/components/layout/nav-items.json b/app/components/layout/nav-items.json deleted file mode 100644 index d317ea235..000000000 --- a/app/components/layout/nav-items.json +++ /dev/null @@ -1,97 +0,0 @@ -[ - { - "name": "settings", - "url": "settings", - "prefetch": true - }, - { - "name": "sendouq", - "url": "q", - "prefetch": false - }, - { - "name": "analyzer", - "url": "analyzer", - "prefetch": true - }, - { - "name": "builds", - "url": "builds", - "prefetch": true - }, - { - "name": "object-damage-calculator", - "url": "object-damage-calculator", - "prefetch": true - }, - { - "name": "leaderboards", - "url": "leaderboards", - "prefetch": false - }, - { - "name": "lfg", - "url": "lfg", - "prefetch": false - }, - { - "name": "plans", - "url": "plans", - "prefetch": false - }, - { - "name": "badges", - "url": "badges", - "prefetch": false - }, - { - "name": "calendar", - "url": "calendar", - "prefetch": false - }, - { - "name": "plus", - "url": "plus/suggestions", - "prefetch": false - }, - { - "name": "u", - "url": "u", - "prefetch": false - }, - { - "name": "xsearch", - "url": "xsearch", - "prefetch": false - }, - { - "name": "articles", - "url": "a", - "prefetch": false - }, - { - "name": "vods", - "url": "vods", - "prefetch": false - }, - { - "name": "art", - "url": "art", - "prefetch": false - }, - { - "name": "t", - "url": "t", - "prefetch": false - }, - { - "name": "links", - "url": "links", - "prefetch": true - }, - { - "name": "maps", - "url": "maps", - "prefetch": false - } -] diff --git a/app/components/layout/nav-items.ts b/app/components/layout/nav-items.ts new file mode 100644 index 000000000..75e874599 --- /dev/null +++ b/app/components/layout/nav-items.ts @@ -0,0 +1,104 @@ +export const navItems = [ + { + name: "settings", + url: "settings", + prefetch: true, + }, + import.meta.env.VITE_SHOW_LUTI_NAV_ITEM === "true" + ? { + name: "luti", + url: "luti", + prefetch: false, + } + : null, + { + name: "sendouq", + url: "q", + prefetch: false, + }, + { + name: "analyzer", + url: "analyzer", + prefetch: true, + }, + { + name: "builds", + url: "builds", + prefetch: true, + }, + { + name: "object-damage-calculator", + url: "object-damage-calculator", + prefetch: true, + }, + { + name: "leaderboards", + url: "leaderboards", + prefetch: false, + }, + { + name: "lfg", + url: "lfg", + prefetch: false, + }, + { + name: "plans", + url: "plans", + prefetch: false, + }, + { + name: "badges", + url: "badges", + prefetch: false, + }, + { + name: "calendar", + url: "calendar", + prefetch: false, + }, + { + name: "plus", + url: "plus/suggestions", + prefetch: false, + }, + { + name: "u", + url: "u", + prefetch: false, + }, + { + name: "xsearch", + url: "xsearch", + prefetch: false, + }, + { + name: "articles", + url: "a", + prefetch: false, + }, + { + name: "vods", + url: "vods", + prefetch: false, + }, + { + name: "art", + url: "art", + prefetch: false, + }, + { + name: "t", + url: "t", + prefetch: false, + }, + { + name: "links", + url: "links", + prefetch: true, + }, + { + name: "maps", + url: "maps", + prefetch: false, + }, +].filter((item) => item !== null); diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 2888d8347..0239c0aef 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -98,6 +98,10 @@ const calendarEventWithToToolsDepths = () => calendarEventWithToTools("DEPTHS"); const calendarEventWithToToolsTeamsDepths = () => calendarEventWithToToolsTeams("DEPTHS"); +const calendarEventWithToToolsLUTI = () => calendarEventWithToTools("LUTI"); +const calendarEventWithToToolsTeamsLUTI = () => + calendarEventWithToToolsTeams("LUTI"); + const basicSeeds = (variation?: SeedVariation | null) => [ adminUser, makeAdminPatron, @@ -145,6 +149,8 @@ const basicSeeds = (variation?: SeedVariation | null) => [ calendarEventWithToToolsToSetMapPool, calendarEventWithToToolsDepths, calendarEventWithToToolsTeamsDepths, + calendarEventWithToToolsLUTI, + calendarEventWithToToolsTeamsLUTI, tournamentSubs, adminBuilds, manySplattershotBuilds, @@ -218,6 +224,12 @@ function wipeDB() { ]; for (const table of tablesToDelete) { + if (table === "Tournament") { + // foreign key constraint reasons + sql + .prepare("delete from Tournament where parentTournamentId is not null") + .run(); + } sql.prepare(`delete from "${table}"`).run(); } } @@ -861,7 +873,7 @@ async function calendarEventResults() { const TO_TOOLS_CALENDAR_EVENT_ID = 201; function calendarEventWithToTools( - event: "PICNIC" | "ITZ" | "PP" | "SOS" | "DEPTHS" = "PICNIC", + event: "PICNIC" | "ITZ" | "PP" | "SOS" | "DEPTHS" | "LUTI" = "PICNIC", registrationOpen = false, ) { const tournamentId = { @@ -870,6 +882,7 @@ function calendarEventWithToTools( PP: 3, SOS: 4, DEPTHS: 5, + LUTI: 6, }[event]; const eventId = { PICNIC: TO_TOOLS_CALENDAR_EVENT_ID + 0, @@ -877,6 +890,7 @@ function calendarEventWithToTools( PP: TO_TOOLS_CALENDAR_EVENT_ID + 2, SOS: TO_TOOLS_CALENDAR_EVENT_ID + 3, DEPTHS: TO_TOOLS_CALENDAR_EVENT_ID + 4, + LUTI: TO_TOOLS_CALENDAR_EVENT_ID + 5, }[event]; const name = { PICNIC: "PICNIC #2", @@ -884,6 +898,7 @@ function calendarEventWithToTools( PP: "Paddling Pool 253", SOS: "Swim or Sink 101", DEPTHS: "The Depths 5", + LUTI: "Leagues Under The Ink Season 15", }[event]; const settings: Tables["Tournament"]["settings"] = @@ -987,16 +1002,34 @@ function calendarEventWithToTools( }, ], } - : { - bracketProgression: [ - { - type: "double_elimination", - name: "Main bracket", - requiresCheckIn: false, - settings: {}, - }, - ], - }; + : event === "LUTI" + ? { + bracketProgression: [ + { + type: "round_robin", + name: "Groups stage", + requiresCheckIn: false, + settings: {}, + }, + { + type: "single_elimination", + name: "Play-offs", + requiresCheckIn: false, + settings: {}, + sources: [{ bracketIdx: 0, placements: [1, 2] }], + }, + ], + } + : { + bracketProgression: [ + { + type: "double_elimination", + name: "Main bracket", + requiresCheckIn: false, + settings: {}, + }, + ], + }; sql .prepare( @@ -1016,7 +1049,11 @@ function calendarEventWithToTools( id: tournamentId, settings: JSON.stringify(settings), mapPickingStyle: - event === "SOS" ? "TO" : event === "ITZ" ? "AUTO_SZ" : "AUTO_ALL", + event === "SOS" || event === "LUTI" + ? "TO" + : event === "ITZ" + ? "AUTO_SZ" + : "AUTO_ALL", }); sql @@ -1156,7 +1193,7 @@ const availablePairs = rankedModesShort ) .filter((pair) => !tiebreakerPicks.has(pair)); function calendarEventWithToToolsTeams( - event: "PICNIC" | "ITZ" | "PP" | "SOS" | "DEPTHS" = "PICNIC", + event: "PICNIC" | "ITZ" | "PP" | "SOS" | "DEPTHS" | "LUTI" = "PICNIC", isSmall = false, ) { const userIds = userIdsInAscendingOrderById(); @@ -1170,6 +1207,7 @@ function calendarEventWithToToolsTeams( PP: 3, SOS: 4, DEPTHS: 5, + LUTI: 6, }[event]; const teamIdAddition = { @@ -1178,6 +1216,7 @@ function calendarEventWithToToolsTeams( PP: 200, SOS: 300, DEPTHS: 400, + LUTI: 500, }[event]; for (let id = 1; id <= (isSmall ? 4 : 16); id++) { @@ -1212,8 +1251,8 @@ function calendarEventWithToToolsTeams( inviteCode: nanoid(INVITE_CODE_LENGTH), }); - // in PICNIC & PP Chimera is not checked in - if (teamId !== 1 && teamId !== 201) { + // in PICNIC & PP Chimera is not checked in + in LUTI no check-ins at all + if (teamId !== 1 && teamId !== 201 && event !== "LUTI") { sql .prepare( ` @@ -1268,7 +1307,11 @@ function calendarEventWithToToolsTeams( }); } - if (event !== "SOS" && (Math.random() < 0.8 || id === 1)) { + if ( + event !== "SOS" && + event !== "LUTI" && + (Math.random() < 0.8 || id === 1) + ) { const shuffledPairs = shuffle(availablePairs.slice()); let SZ = 0; diff --git a/app/db/sql.ts b/app/db/sql.ts index fea15f4d9..bf08f2d6b 100644 --- a/app/db/sql.ts +++ b/app/db/sql.ts @@ -31,7 +31,7 @@ export const db = new Kysely({ dialect: new SqliteDialect({ database: sql, }), - log: LOG_LEVEL === "trunc" || LOG_LEVEL === "full" ? logQuery : undefined, + log: LOG_LEVEL === "trunc" || LOG_LEVEL === "full" ? logQuery : logError, plugins: [new ParseJSONResultsPlugin()], }); @@ -54,6 +54,23 @@ function logQuery(event: LogEvent) { ); // biome-ignore lint/suspicious/noConsoleLog: dev only console.log(formatSql(event.query.sql, event.query.parameters)); + } else { + logError(event); + } +} + +function logError(event: LogEvent) { + if ( + event.level === "error" && + // it seems that this error happens everytime something goes wrong inside transaction + // my guess is that the transaction is already implicitly rolled back in the case of error + // but kysely also does it explicitly -> fails because there is no transaction to rollback. + // this `logError` function at least makes it so that due to that the error doesn't get logged + // but of course the best solution would also avoid useless rollbacks, something for the future + // btw this particular check is here just to avoid the double "no transaction is active" log + !(event.error as any).message.includes("no transaction is active") + ) { + console.error(event.error); } } diff --git a/app/db/tables.ts b/app/db/tables.ts index 8e523c2b0..6296064ed 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -129,6 +129,7 @@ export interface CalendarEvent { name: string; participantCount: number | null; tags: string | null; + hidden: Generated; tournamentId: number | null; organizationId: number | null; avatarImgId: number | null; @@ -455,6 +456,8 @@ export interface Tournament { string | null >; rules: string | null; + /** Related "parent tournament", the tournament that contains the original sign-ups (for leagues) */ + parentTournamentId: number | null; } export interface PreparedMaps { diff --git a/app/features/admin/routes/admin.tsx b/app/features/admin/routes/admin.tsx index bb5a8baa1..0a82014e9 100644 --- a/app/features/admin/routes/admin.tsx +++ b/app/features/admin/routes/admin.tsx @@ -15,7 +15,6 @@ import { UserSearch } from "~/components/UserSearch"; import { useUser } from "~/features/auth/core/user"; import { FRIEND_CODE_REGEXP_PATTERN } from "~/features/sendouq/q-constants"; import { isAdmin, isMod } from "~/permissions"; -import type { SendouRouteHandle } from "~/utils/remix.server"; import { makeTitle } from "~/utils/strings"; import { SEED_URL, STOP_IMPERSONATING_URL, impersonateUrl } from "~/utils/urls"; @@ -27,10 +26,6 @@ export const meta: MetaFunction = () => { return [{ title: makeTitle("Admin page") }]; }; -export const handle: SendouRouteHandle = { - navItemName: "admin", -}; - export default function AdminPage() { const user = useUser(); diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index f4dc98712..08f9886c9 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -215,6 +215,7 @@ export async function findAllBetweenTwoTimestamps({ "<=", dateToDatabaseTimestamp(endTime), ) + .where("CalendarEvent.hidden", "=", 0) .orderBy("CalendarEventDate.startTime", "asc"); for (const tag of tagsToFilterBy) { @@ -362,6 +363,7 @@ export async function eventsToReport(authorId: number) { fn.max("CalendarEventDate.startTime").as("startTime"), ]) .where("CalendarEvent.authorId", "=", authorId) + .where("CalendarEvent.hidden", "=", 0) .where("startTime", ">=", dateToDatabaseTimestamp(oneMonthAgo)) .where("startTime", "<=", dateToDatabaseTimestamp(new Date())) .where("CalendarEvent.participantCount", "is", null) @@ -382,6 +384,7 @@ export async function findRecentMapPoolsByAuthorId(authorId: number) { withMapPool(eb), ]) .where("CalendarEvent.authorId", "=", authorId) + .where("CalendarEvent.hidden", "=", 0) .orderBy("CalendarEvent.id", "desc") .groupBy("CalendarEvent.id") .limit(5) @@ -484,6 +487,7 @@ type CreateArgs = Pick< avatarFileName?: string; avatarImgId?: number; autoValidateAvatar?: boolean; + parentTournamentId?: number; }; export async function create(args: CreateArgs) { const copiedStaff = args.tournamentToCopyId @@ -527,6 +531,7 @@ export async function create(args: CreateArgs) { .values({ mapPickingStyle: args.mapPickingStyle, settings: JSON.stringify(settings), + parentTournamentId: args.parentTournamentId, rules: args.rules, }) .returning("id") @@ -568,6 +573,7 @@ export async function create(args: CreateArgs) { bracketUrl: args.bracketUrl, avatarImgId: args.avatarImgId ?? avatarImgId, organizationId: args.organizationId, + hidden: args.parentTournamentId ? 1 : 0, tournamentId, }) .returning("id") diff --git a/app/features/front-page/routes/index.tsx b/app/features/front-page/routes/index.tsx index 2ffdaea48..b2da0138e 100644 --- a/app/features/front-page/routes/index.tsx +++ b/app/features/front-page/routes/index.tsx @@ -18,7 +18,7 @@ import { KeyIcon } from "~/components/icons/Key"; import { LogOutIcon } from "~/components/icons/LogOut"; import { SearchIcon } from "~/components/icons/Search"; import { UsersIcon } from "~/components/icons/Users"; -import navItems from "~/components/layout/nav-items.json"; +import { navItems } from "~/components/layout/nav-items"; import { useUser } from "~/features/auth/core/user"; import type * as Changelog from "~/features/front-page/core/Changelog.server"; import { @@ -34,6 +34,7 @@ import { BLANK_IMAGE_URL, CALENDAR_TOURNAMENTS_PAGE, LOG_OUT_URL, + LUTI_PAGE, SENDOUQ_PAGE, leaderboardsPage, navIconUrl, @@ -55,6 +56,7 @@ export const handle: SendouRouteHandle = { export default function FrontPage() { return (
+ @@ -158,6 +160,19 @@ function SeasonBanner() { ); } +function LeagueBanner() { + const showBannerFor = import.meta.env.VITE_SHOW_BANNER_FOR_SEASON; + if (!showBannerFor) return null; + + return ( + + + Registration now open for Leagues Under The Ink (LUTI) Season{" "} + {showBannerFor}! + + ); +} + function TournamentCards() { const { t } = useTranslation(["front"]); const data = useLoaderData(); diff --git a/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx b/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx index 2d60303fc..24b2b7660 100644 --- a/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx +++ b/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx @@ -1,5 +1,7 @@ import clsx from "clsx"; import type { TournamentRoundMaps } from "~/db/tables"; +import { useTournament } from "~/features/tournament/routes/to.$id"; +import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-utils"; import { useAutoRerender } from "~/hooks/useAutoRerender"; import { useIsMounted } from "~/hooks/useIsMounted"; import { useDeadline } from "./useDeadline"; @@ -17,6 +19,8 @@ export function RoundHeader({ showInfos?: boolean; maps?: TournamentRoundMaps | null; }) { + const leagueRoundStartDate = useLeagueWeekStart(roundId); + const hasDeadline = ![ "WB Finals", "Grand Finals", @@ -36,7 +40,7 @@ export function RoundHeader({ return (
{name}
- {showInfos && bestOf ? ( + {showInfos && bestOf && !leagueRoundStartDate ? (
{countPrefix} @@ -45,6 +49,16 @@ export function RoundHeader({
{hasDeadline ? : null}
+ ) : leagueRoundStartDate ? ( +
+
+ {leagueRoundStartDate.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })}{" "} + → +
+
) : (
Hidden @@ -75,3 +89,9 @@ function Deadline({ roundId, bestOf }: { roundId: number; bestOf: number }) {
); } + +function useLeagueWeekStart(roundId: number) { + const tournament = useTournament(); + + return resolveLeagueRoundStartDate(tournament, roundId); +} diff --git a/app/features/tournament-bracket/components/MatchActions.tsx b/app/features/tournament-bracket/components/MatchActions.tsx index 9cea07513..48d224177 100644 --- a/app/features/tournament-bracket/components/MatchActions.tsx +++ b/app/features/tournament-bracket/components/MatchActions.tsx @@ -6,6 +6,7 @@ import { SubmitButton } from "~/components/SubmitButton"; import { EditIcon } from "~/components/icons/Edit"; import { useUser } from "~/features/auth/core/user"; import { useTournament } from "~/features/tournament/routes/to.$id"; +import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-utils"; import invariant from "~/utils/invariant"; import * as PickBan from "../core/PickBan"; import type { TournamentDataTeam } from "../core/Tournament.server"; @@ -223,6 +224,7 @@ function ReportScoreButtons({ matchLocked: boolean; newScore: [number, number]; }) { + const data = useLoaderData(); const user = useUser(); const tournament = useTournament(); const confirmCheckId = React.useId(); @@ -230,6 +232,18 @@ function ReportScoreButtons({ const [endConfirmation, setEndConfirmation] = React.useState(false); const [pointConfirmation, setPointConfirmation] = React.useState(false); + const leagueRoundStartDate = resolveLeagueRoundStartDate( + tournament, + data.match.roundId, + ); + if (leagueRoundStartDate && leagueRoundStartDate > new Date()) { + return ( +

+ League round has not started yet +

+ ); + } + if (matchLocked) { return (

diff --git a/app/features/tournament-bracket/components/StartedMatch.tsx b/app/features/tournament-bracket/components/StartedMatch.tsx index d9c486fc3..3eae22688 100644 --- a/app/features/tournament-bracket/components/StartedMatch.tsx +++ b/app/features/tournament-bracket/components/StartedMatch.tsx @@ -14,6 +14,7 @@ import { PickIcon } from "~/components/icons/Pick"; import { useUser } from "~/features/auth/core/user"; import { Chat, useChat } from "~/features/chat/components/Chat"; import { useTournament } from "~/features/tournament/routes/to.$id"; +import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-utils"; import { useIsMounted } from "~/hooks/useIsMounted"; import { useSearchParamState } from "~/hooks/useSearchParamState"; import type { StageId } from "~/modules/in-game-lists"; @@ -311,6 +312,14 @@ function FancyStageBanner({ return null; })(); + const waitingForLeagueRoundToStart = (() => { + const date = resolveLeagueRoundStartDate(tournament, data.match.roundId); + + if (!date) return false; + + return date > new Date(); + })(); + return ( <> {inBanPhase ? ( @@ -337,6 +346,22 @@ function FancyStageBanner({

Please wait for staff to unlock
+ ) : waitingForLeagueRoundToStart ? ( +
+
+
+ Waiting for league round to start +
+
+ Round playable from{" "} + {resolveLeagueRoundStartDate( + tournament, + data.match.roundId, + )!.toLocaleDateString()}{" "} + onwards +
+
+
) : waitingForActiveRosterSelectionFor ? (
diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index b2c21762e..c4e56c859 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -6,6 +6,7 @@ import type { import { TOURNAMENT } from "~/features/tournament"; import type * as Progression from "~/features/tournament-bracket/core/Progression"; import * as Standings from "~/features/tournament/core/Standings"; +import { LEAGUES } from "~/features/tournament/tournament-constants"; import { tournamentIsRanked } from "~/features/tournament/tournament-utils"; import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; import type { Match, Stage } from "~/modules/brackets-model"; @@ -588,7 +589,9 @@ export class Tournament { return { groupCount: Math.ceil(participantsCount / teamsPerGroup), - seedOrdering: ["groups.seed_optimized"], + seedOrdering: [ + this.isLeagueDivision ? "natural" : "groups.seed_optimized", + ], }; } case "swiss": { @@ -903,6 +906,16 @@ export class Tournament { return this.ctx.settings.autonomousSubs ?? true; } + get isLeagueSignup() { + return Object.values(LEAGUES) + .flat() + .some((entry) => entry.tournamentId === this.ctx.id); + } + + get isLeagueDivision() { + return Boolean(this.ctx.parentTournamentId); + } + matchNameById(matchId: number) { let bracketName: string | undefined; let roundName: string | undefined; diff --git a/app/features/tournament-bracket/core/tests/mocks-li.ts b/app/features/tournament-bracket/core/tests/mocks-li.ts index eff01d39c..1e370d78a 100644 --- a/app/features/tournament-bracket/core/tests/mocks-li.ts +++ b/app/features/tournament-bracket/core/tests/mocks-li.ts @@ -7154,6 +7154,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ ctx: { id: 815, eventId: 2614, + parentTournamentId: null, discordUrl: "https://discord.gg/F7RaNUR", tags: "LOW", settings: { diff --git a/app/features/tournament-bracket/core/tests/mocks-sos.ts b/app/features/tournament-bracket/core/tests/mocks-sos.ts index b22fa47b5..4eaba920d 100644 --- a/app/features/tournament-bracket/core/tests/mocks-sos.ts +++ b/app/features/tournament-bracket/core/tests/mocks-sos.ts @@ -1999,6 +1999,7 @@ export const SWIM_OR_SINK_167 = ( }, ctx: { id: 672, + parentTournamentId: null, eventId: 2425, discordUrl: "https://discord.gg/F7RaNUR", tags: null, diff --git a/app/features/tournament-bracket/core/tests/mocks.ts b/app/features/tournament-bracket/core/tests/mocks.ts index 7e27ab731..51effda35 100644 --- a/app/features/tournament-bracket/core/tests/mocks.ts +++ b/app/features/tournament-bracket/core/tests/mocks.ts @@ -1979,6 +1979,7 @@ export const PADDLING_POOL_257 = () => ctx: { id: 27, organization: null, + parentTournamentId: null, tags: null, eventId: 1352, bracketProgressionOverrides: [], @@ -7916,6 +7917,7 @@ export const PADDLING_POOL_255 = () => ctx: { id: 18, organization: null, + parentTournamentId: null, tags: null, eventId: 1286, bracketProgressionOverrides: [], @@ -14188,6 +14190,7 @@ export const IN_THE_ZONE_32 = ({ }, ctx: { id: 11, + parentTournamentId: null, organization: null, tags: null, eventId: 1134, diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts index 16aa461e1..6693c8b34 100644 --- a/app/features/tournament-bracket/core/tests/test-utils.ts +++ b/app/features/tournament-bracket/core/tests/test-utils.ts @@ -63,6 +63,7 @@ export const testTournament = ({ tags: null, description: null, organization: null, + parentTournamentId: null, rules: null, logoUrl: null, logoSrc: "/test.png", diff --git a/app/features/tournament-bracket/loader/to.$id.divisions.server.ts b/app/features/tournament-bracket/loader/to.$id.divisions.server.ts new file mode 100644 index 000000000..df570e53f --- /dev/null +++ b/app/features/tournament-bracket/loader/to.$id.divisions.server.ts @@ -0,0 +1,49 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { getUser } from "~/features/auth/core/user.server"; +import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import { tournamentIdFromParams } from "~/features/tournament/tournament-utils"; +import { notFoundIfFalsy } from "~/utils/remix.server"; +import type { Unwrapped } from "../../../utils/types"; +import { tournamentFromDB } from "../core/Tournament.server"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const user = await getUser(request); + const tournamentId = tournamentIdFromParams(params); + + const divisions = notFoundIfFalsy(await divisionsCached(tournamentId)); + + return { + divisions, + divsParticipantOf: user + ? divisions + .filter((division) => division.participantUserIds.has(user?.id)) + .map((division) => division.tournamentId) + : [], + }; +}; + +// no purge mechanism in code but new divisions are created so rarely we just reboot the server when it is done +const tournamentDivisionsCache = new Map< + number, + Array> +>(); + +async function divisionsCached(tournamentId: number) { + if (!tournamentDivisionsCache.has(tournamentId)) { + const tournament = await tournamentFromDB({ + tournamentId, + user: undefined, + }); + + if (!tournament.isLeagueSignup) { + return null; + } + + tournamentDivisionsCache.set( + tournamentId, + await TournamentRepository.findChildTournaments(tournamentId), + ); + } + + return tournamentDivisionsCache.get(tournamentId)!; +} diff --git a/app/features/tournament-bracket/queries/findMatchById.server.ts b/app/features/tournament-bracket/queries/findMatchById.server.ts index d0ab4d600..dbec6961c 100644 --- a/app/features/tournament-bracket/queries/findMatchById.server.ts +++ b/app/features/tournament-bracket/queries/findMatchById.server.ts @@ -18,6 +18,7 @@ const stm = sql.prepare(/* sql */ ` "TournamentMatch"."bestOf", "TournamentMatch"."chatCode", "Tournament"."mapPickingStyle", + "TournamentRound"."id" as "roundId", "TournamentRound"."maps" as "roundMaps", json_group_array( json_object( @@ -60,6 +61,7 @@ export const findMatchById = (id: number) => { "id" | "groupId" | "opponentOne" | "opponentTwo" | "bestOf" | "chatCode" > & Pick & { players: string }) & { + roundId: number; roundMaps: string | null; }) | undefined; @@ -73,6 +75,7 @@ export const findMatchById = (id: number) => { return { ...row, bestOf: (roundMaps?.count ?? row.bestOf) as 3 | 5 | 7, + roundId: row.roundId, roundMaps, opponentOne: JSON.parse(row.opponentOne) as Match["opponent1"], opponentTwo: JSON.parse(row.opponentTwo) as Match["opponent2"], diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index 64d9cf2ba..4e4d66c29 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -430,6 +430,10 @@ export default function TournamentBracketsPage() { ).length; }; + if (tournament.isLeagueSignup) { + return null; + } + return (
{visibility !== "hidden" && !tournament.everyBracketOver ? ( diff --git a/app/features/tournament-bracket/routes/to.$id.divisions.tsx b/app/features/tournament-bracket/routes/to.$id.divisions.tsx new file mode 100644 index 000000000..8a9b73c28 --- /dev/null +++ b/app/features/tournament-bracket/routes/to.$id.divisions.tsx @@ -0,0 +1,55 @@ +import type { SerializeFrom } from "@remix-run/node"; +import { Link, useLoaderData } from "@remix-run/react"; +import clsx from "clsx"; +import { useTranslation } from "react-i18next"; +import { UsersIcon } from "../../../components/icons/Users"; +import { tournamentBracketsPage } from "../../../utils/urls"; +import { loader } from "../loader/to.$id.divisions.server"; +export { loader }; + +export default function TournamentDivisionsPage() { + const data = useLoaderData(); + + if (data.divisions.length === 0) { + return ( +
+ Divisions have not been released yet, check back later +
+ ); + } + + return ( +
+ {data.divisions.map((div) => ( + + ))} +
+ ); +} + +function DivisionLink({ + div, +}: { div: SerializeFrom["divisions"][number] }) { + const data = useLoaderData(); + const { t } = useTranslation(["calendar"]); + const shortName = div.name.split("-").at(-1); + + return ( + + {shortName} +
+ {" "} + {t("calendar:count.teams", { + count: div.teamsCount, + })} +
+ + ); +} diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 6ca086d5a..5b46bc0a2 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -46,6 +46,7 @@ export async function findById(id: number) { "Tournament.castedMatchesInfo", "Tournament.mapPickingStyle", "Tournament.rules", + "Tournament.parentTournamentId", "CalendarEvent.name", "CalendarEvent.description", "CalendarEventDate.startTime", @@ -321,6 +322,40 @@ function nullifyingAvg(values: number[]) { return values.reduce((acc, cur) => acc + cur, 0) / values.length; } +export async function findChildTournaments(parentTournamentId: number) { + const rows = await db + .selectFrom("Tournament") + .innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId") + .select((eb) => [ + "Tournament.id as tournamentId", + "CalendarEvent.name", + eb + .selectFrom("TournamentTeam") + .select(({ fn }) => [fn.countAll().as("teamsCount")]) + .whereRef("TournamentTeam.tournamentId", "=", "Tournament.id") + .as("teamsCount"), + jsonArrayFrom( + eb + .selectFrom("TournamentTeam") + .innerJoin( + "TournamentTeamMember", + "TournamentTeamMember.tournamentTeamId", + "TournamentTeam.id", + ) + .select(["TournamentTeamMember.userId"]) + .whereRef("TournamentTeam.tournamentId", "=", "Tournament.id"), + ).as("teamMembers"), + ]) + .where("Tournament.parentTournamentId", "=", parentTournamentId) + .$narrowType<{ teamsCount: NotNull }>() + .execute(); + + return rows.map((row) => ({ + ...row, + participantUserIds: new Set(row.teamMembers.map((member) => member.userId)), + })); +} + export async function findTOSetMapPoolById(tournamentId: number) { return ( await db diff --git a/app/features/tournament/TournamentTeamRepository.server.ts b/app/features/tournament/TournamentTeamRepository.server.ts index e48a7af25..758bfbf94 100644 --- a/app/features/tournament/TournamentTeamRepository.server.ts +++ b/app/features/tournament/TournamentTeamRepository.server.ts @@ -7,6 +7,7 @@ import { INVITE_CODE_LENGTH } from "~/constants"; import { db } from "~/db/sql"; import type { DB, Tables } from "~/db/tables"; import { databaseTimestampNow } from "~/utils/dates"; +import invariant from "~/utils/invariant"; export function setActiveRoster({ teamId, @@ -141,6 +142,89 @@ export function create({ }); } +export function copyFromAnotherTournament({ + tournamentTeamId, + destinationTournamentId, +}: { tournamentTeamId: number; destinationTournamentId: number }) { + return db.transaction().execute(async (trx) => { + const oldTeam = await trx + .selectFrom("TournamentTeam") + .select([ + "TournamentTeam.avatarImgId", + "TournamentTeam.createdAt", + "TournamentTeam.name", + "TournamentTeam.noScreen", + "TournamentTeam.prefersNotToHost", + "TournamentTeam.teamId", + + // -- exclude these + // "TournamentTeam.id" + // "TournamentTeam.droppedOut" + // "TournamentTeam.activeRosterUserIds" + // "TournamentTeam.seed" + // "TournamentTeam.startingBracketIdx" + // "TournamentTeam.inviteCode" + // "TournamentTeam.tournamentId" + // "TournamentTeam.activeRosterUserIds", + ]) + .where("id", "=", tournamentTeamId) + .executeTakeFirstOrThrow(); + + const oldMembers = await trx + .selectFrom("TournamentTeamMember") + .select([ + "TournamentTeamMember.createdAt", + "TournamentTeamMember.inGameName", + "TournamentTeamMember.isOwner", + "TournamentTeamMember.userId", + + // -- exclude these + // "TournamentTeamMember.tournamentTeamId" + ]) + .where("tournamentTeamId", "=", tournamentTeamId) + .execute(); + invariant(oldMembers.length > 0, "Team has no members"); + + const oldMapPool = await trx + .selectFrom("MapPoolMap") + .select(["MapPoolMap.mode", "MapPoolMap.stageId"]) + .where("tournamentTeamId", "=", tournamentTeamId) + .execute(); + + const newTeam = await trx + .insertInto("TournamentTeam") + .values({ + ...oldTeam, + tournamentId: destinationTournamentId, + inviteCode: nanoid(INVITE_CODE_LENGTH), + }) + .returning("id") + .executeTakeFirstOrThrow(); + + await trx + .insertInto("TournamentTeamMember") + .values( + oldMembers.map((member) => ({ + ...member, + tournamentTeamId: newTeam.id, + })), + ) + .execute(); + + if (oldMapPool.length > 0) { + await trx + .insertInto("MapPoolMap") + .values( + oldMapPool.map((mapPoolMap) => ({ + ...mapPoolMap, + tournamentTeamId: newTeam.id, + })), + ) + .execute(); + } + }); +} + export function update({ team, avatarFileName, diff --git a/app/features/tournament/actions/to.$id.register.server.ts b/app/features/tournament/actions/to.$id.register.server.ts index fc510d08a..1ac5d7c26 100644 --- a/app/features/tournament/actions/to.$id.register.server.ts +++ b/app/features/tournament/actions/to.$id.register.server.ts @@ -255,6 +255,10 @@ export const action: ActionFunction = async ({ request, params }) => { case "UNREGISTER": { validate(ownTeam, "You are not registered to this tournament"); validate(!ownTeamCheckedIn, "You cannot unregister after checking in"); + validate( + !tournament.isLeagueSignup || tournament.registrationOpen, + "Unregistering from leagues is not possible after registration has closed", + ); deleteTeam(ownTeam.id); diff --git a/app/features/tournament/routes/luti.tsx b/app/features/tournament/routes/luti.tsx new file mode 100644 index 000000000..8f7213e1c --- /dev/null +++ b/app/features/tournament/routes/luti.tsx @@ -0,0 +1,12 @@ +import { redirect } from "@remix-run/node"; +import { notFoundIfFalsy } from "../../../utils/remix.server"; +import { tournamentPage } from "../../../utils/urls"; +import { LEAGUES } from "../tournament-constants"; + +const maybeLatest = LEAGUES.LUTI?.at(-1); + +export const loader = () => { + const latest = notFoundIfFalsy(maybeLatest); + + return redirect(tournamentPage(latest.tournamentId)); +}; diff --git a/app/features/tournament/routes/to.$id.admin.tsx b/app/features/tournament/routes/to.$id.admin.tsx index a6127b54e..a5cf14dc5 100644 --- a/app/features/tournament/routes/to.$id.admin.tsx +++ b/app/features/tournament/routes/to.$id.admin.tsx @@ -23,6 +23,7 @@ import invariant from "~/utils/invariant"; import { assertUnreachable } from "~/utils/types"; import { calendarEventPage, + teamPage, tournamentEditPage, tournamentPage, } from "~/utils/urls"; @@ -62,22 +63,24 @@ export default function TournamentAdminPage() { > Edit event info - - - + + + ) : null}
) : null} {tournament.isAdmin(user) && @@ -111,8 +114,12 @@ export default function TournamentAdminPage() { Participant list download - Bracket reset - + {!tournament.isLeagueSignup ? ( + <> + Bracket reset + + + ) : null}
); } @@ -609,6 +616,35 @@ function DownloadParticipants() { .join("\n"); } + function leagueFormat() { + const memberColumnsCount = tournament.ctx.teams.reduce( + (max, team) => Math.max(max, team.members.length), + 0, + ); + const header = `Team id,Team name,Team page URL,Div${Array.from({ + length: memberColumnsCount, + }) + .map((_, i) => `,Member ${i + 1} name,Member${i + 1} URL`) + .join("")}`; + + return `${header}\n${tournament.ctx.teams + .map((team) => { + return `${team.id},${team.name},${team.team ? teamPage(team.team.customUrl) : ""},,${team.members + .map( + (member) => + `${member.username},https://sendou.ink/u/${member.discordId}`, + ) + .join(",")}${Array( + memberColumnsCount - team.members.length === 0 + ? 0 + : memberColumnsCount - team.members.length + 1, + ) + .fill(",") + .join("")}`; + }) + .join("\n")}`; + } + return (
@@ -656,6 +692,19 @@ function DownloadParticipants() { > Simple list in seeded order + {tournament.isLeagueSignup ? ( + + ) : null}
); diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index a6a2c29ed..d8f31b71b 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -119,20 +119,22 @@ export default function TournamentRegisterPage() { )}
-
-
- {" "} - {isMounted - ? tournament.ctx.startTime.toLocaleString(i18n.language, { - timeZoneName: "short", - minute: startsAtEvenHour ? undefined : "numeric", - hour: "numeric", - day: "numeric", - month: "long", - }) - : null} + {!tournament.isLeagueSignup ? ( +
+
+ {" "} + {isMounted + ? tournament.ctx.startTime.toLocaleString(i18n.language, { + timeZoneName: "short", + minute: startsAtEvenHour ? undefined : "numeric", + hour: "numeric", + day: "numeric", + month: "long", + }) + : null} +
-
+ ) : null}
{tournament.ranked ? (
@@ -411,10 +413,12 @@ function RegistrationProgress({ completed: mapPool && mapPool.length > 0, } : null, - { - name: t("tournament:pre.steps.check-in"), - completed: checkedIn, - }, + !tournament.isLeagueSignup + ? { + name: t("tournament:pre.steps.check-in"), + completed: checkedIn, + } + : null, ]); const regClosesBeforeStart = @@ -422,7 +426,10 @@ function RegistrationProgress({ tournament.ctx.startTime.getTime(); const registrationClosesAtString = isMounted - ? tournament.registrationClosesAt.toLocaleTimeString(i18n.language, { + ? (tournament.isLeagueSignup + ? tournament.ctx.startTime + : tournament.registrationClosesAt + ).toLocaleTimeString(i18n.language, { minute: "numeric", hour: "numeric", day: "2-digit", @@ -456,22 +463,24 @@ function RegistrationProgress({ ); })}
- !step.completed).length === 1} - status={ - tournament.regularCheckInIsOpen - ? "OPEN" - : tournament.regularCheckInHasEnded - ? "OVER" - : "UPCOMING" - } - startDate={tournament.regularCheckInStartsAt} - endDate={tournament.regularCheckInEndsAt} - checkedIn={checkedIn} - /> + {!tournament.isLeagueSignup ? ( + !step.completed).length === 1} + status={ + tournament.regularCheckInIsOpen + ? "OPEN" + : tournament.regularCheckInHasEnded + ? "OVER" + : "UPCOMING" + } + startDate={tournament.regularCheckInStartsAt} + endDate={tournament.regularCheckInEndsAt} + checkedIn={checkedIn} + /> + ) : null}
- {regClosesBeforeStart ? ( + {regClosesBeforeStart || tournament.isLeagueSignup ? ( Registration closes at {registrationClosesAtString} @@ -672,6 +681,17 @@ function TeamInfo({ ) : null} + {canUnregister && + tournament.isLeagueSignup && + !tournament.registrationOpen ? ( + + Unregistration from a league after the registration has ended is + handled by the organizers + + ) : null}
diff --git a/app/features/tournament/routes/to.$id.tsx b/app/features/tournament/routes/to.$id.tsx index f9eee557c..0365466f3 100644 --- a/app/features/tournament/routes/to.$id.tsx +++ b/app/features/tournament/routes/to.$id.tsx @@ -25,8 +25,10 @@ import type { SendouRouteHandle } from "~/utils/remix.server"; import { makeTitle } from "~/utils/strings"; import { assertUnreachable } from "~/utils/types"; import { + tournamentDivisionsPage, tournamentOrganizationPage, tournamentPage, + tournamentRegisterPage, userSubmittedImage, } from "~/utils/urls"; import { streamsByTournamentId } from "../core/streams.server"; @@ -245,12 +247,37 @@ export function TournamentLayout() { return (
- - {tournament.hasStarted ? "Info" : t("tournament:tabs.register")} - - - {t("tournament:tabs.brackets")} + + {tournament.hasStarted || tournament.isLeagueDivision + ? "Info" + : t("tournament:tabs.register")} + {!tournament.isLeagueSignup ? ( + + {t("tournament:tabs.brackets")} + + ) : null} + {tournament.isLeagueSignup || tournament.isLeagueDivision ? ( + + Divisions + + ) : null} ) : null} - {tournament.isOrganizer(user) && !tournament.hasStarted && ( - {t("tournament:tabs.seeds")} - )} + {tournament.isOrganizer(user) && + !tournament.hasStarted && + !tournament.isLeagueSignup && ( + {t("tournament:tabs.seeds")} + )} {tournament.isOrganizer(user) && !tournament.everyBracketOver && ( {t("tournament:tabs.admin")} diff --git a/app/features/tournament/tournament-constants.ts b/app/features/tournament/tournament-constants.ts index 8296b1d9f..50f289210 100644 --- a/app/features/tournament/tournament-constants.ts +++ b/app/features/tournament/tournament-constants.ts @@ -14,3 +14,28 @@ export const TOURNAMENT = { SWISS_DEFAULT_GROUP_COUNT: 1, SWISS_DEFAULT_ROUND_COUNT: 5, } as const; + +export const LEAGUES = + process.env.NODE_ENV === "development" + ? { + LUTI: [ + { + tournamentId: 6, + weeks: [ + { + weekNumber: 2, + year: 2025, + }, + { + weekNumber: 3, + year: 2025, + }, + { + weekNumber: 4, + year: 2025, + }, + ], + }, + ], + } + : {}; diff --git a/app/features/tournament/tournament-utils.ts b/app/features/tournament/tournament-utils.ts index e50e29033..6ded940ea 100644 --- a/app/features/tournament/tournament-utils.ts +++ b/app/features/tournament/tournament-utils.ts @@ -2,14 +2,16 @@ import type { Params } from "@remix-run/react"; import type { Tournament } from "~/db/types"; import type { ModeShort, StageId } from "~/modules/in-game-lists"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; +import { weekNumberToDate } from "~/utils/dates"; import invariant from "~/utils/invariant"; import { tournamentLogoUrl } from "~/utils/urls"; import { MapPool } from "../map-list-generator/core/map-pool"; import { currentSeason } from "../mmr/season"; import { BANNED_MAPS } from "../sendouq-settings/banned-maps"; +import type { Tournament as TournamentClass } from "../tournament-bracket/core/Tournament"; import type { TournamentData } from "../tournament-bracket/core/Tournament.server"; import type { PlayedSet } from "./core/sets.server"; -import { TOURNAMENT } from "./tournament-constants"; +import { LEAGUES, TOURNAMENT } from "./tournament-constants"; export function tournamentIdFromParams(params: Params) { const result = Number(params.id); @@ -269,3 +271,39 @@ export function tournamentIsRanked({ return isSetAsRanked ?? true; } + +export function resolveLeagueRoundStartDate( + tournament: TournamentClass, + roundId: number, +) { + if (!tournament.isLeagueDivision) return null; + + const league = Object.values(LEAGUES) + .flat() + .find( + (league) => league.tournamentId === tournament.ctx.parentTournamentId, + ); + if (!league) return null; + + const bracket = tournament.brackets.find((b) => + b.data.round.some((r) => r.id === roundId), + ); + + const round = bracket?.data.round.find((r) => r.id === roundId); + const onlyRelevantRounds = bracket?.data.round.filter( + (r) => r.group_id === round?.group_id, + ); + + const roundIdx = onlyRelevantRounds?.findIndex((r) => r.id === roundId); + if (roundIdx === undefined) return null; + + const week = league.weeks[roundIdx]; + if (!week) return null; + + const date = weekNumberToDate({ + week: week.weekNumber, + year: week.year, + }); + + return date; +} diff --git a/app/features/tournament/tournament.css b/app/features/tournament/tournament.css index 90a02081c..29d1d48b9 100644 --- a/app/features/tournament/tournament.css +++ b/app/features/tournament/tournament.css @@ -651,3 +651,39 @@ background-color: var(--theme-transparent); border-radius: var(--rounded); } + +.tournament__div__link { + background-color: var(--bg-lighter-solid); + padding: var(--s-2) var(--s-4); + border-radius: var(--rounded); + width: max-content; + font-size: var(--fonts-lg); +} + +.tournament__div__link:focus-visible { + outline: 3px solid var(--theme); + outline-offset: 3px; +} + +.tournament__div__link__participant { + outline: 3px solid var(--bg-lightest-solid); + outline-offset: 3px; +} + +.tournament__div__link__participant svg { + fill: var(--theme); +} + +.tournament__div__participant-counts { + display: flex; + align-items: center; + color: var(--text-lighter); + font-size: var(--fonts-sm); + gap: var(--s-2); + margin-block-end: var(--s-1); +} + +.tournament__div__participant-counts > svg { + width: 1rem; + margin-block-end: 1px; +} diff --git a/app/routes.ts b/app/routes.ts index dc0195644..3e53b1f61 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -79,6 +79,10 @@ export default [ route("subs/new", "features/tournament-subs/routes/to.$id.subs.new.tsx"), route("brackets", "features/tournament-bracket/routes/to.$id.brackets.tsx"), + route( + "divisions", + "features/tournament-bracket/routes/to.$id.divisions.tsx", + ), route( "brackets/subscribe", "features/tournament-bracket/routes/to.$id.brackets.subscribe.tsx", @@ -92,6 +96,7 @@ export default [ "features/tournament-bracket/routes/to.$id.matches.$mid.subscribe.tsx", ), ]), + route("luti", "features/tournament/routes/luti.tsx"), ...prefix("/org/:slug", [ index("features/tournament-organization/routes/org.$slug.tsx"), diff --git a/app/styles/front.css b/app/styles/front.css index 99d29e44a..dc5937a32 100644 --- a/app/styles/front.css +++ b/app/styles/front.css @@ -347,3 +347,14 @@ gap: var(--s-1); padding: var(--s-0-5) var(--s-1-5); } + +.front__luti-banner { + background-color: #4874a0; + color: #fff; + border-radius: var(--rounded); + padding: var(--s-2); + justify-content: center; + display: flex; + gap: var(--s-2); + flex-wrap: wrap; +} diff --git a/app/utils/remix.server.ts b/app/utils/remix.server.ts index b691bbac2..71d6f238c 100644 --- a/app/utils/remix.server.ts +++ b/app/utils/remix.server.ts @@ -8,7 +8,7 @@ import type { Params, UIMatch } from "@remix-run/react"; import type { Namespace, TFunction } from "i18next"; import { nanoid } from "nanoid"; import { z } from "zod"; -import type navItems from "~/components/layout/nav-items.json"; +import type { navItems } from "~/components/layout/nav-items"; import { s3UploadHandler } from "~/features/img-upload"; import invariant from "./invariant"; @@ -247,7 +247,7 @@ export type SendouRouteHandle = { t: TFunction<"common", undefined>; }) => Breadcrumb | Array | undefined; - /** The name of a navItem that is active on this route. See nav-items.json */ + /** The name of a navItem that is active on this route. See nav-items.ts */ navItemName?: (typeof navItems)[number]["name"]; }; diff --git a/app/utils/urls.ts b/app/utils/urls.ts index e6f075bfc..0b543ec90 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -1,5 +1,4 @@ import slugify from "slugify"; -import type navItems from "~/components/layout/nav-items.json"; import type { Preference } from "~/db/tables"; import type { Art, @@ -135,6 +134,7 @@ export const TIERS_PAGE = "/tiers"; export const SUSPENDED_PAGE = "/suspended"; export const LFG_PAGE = "/lfg"; export const SETTINGS_PAGE = "/settings"; +export const LUTI_PAGE = "/luti"; export const BLANK_IMAGE_URL = "/static-assets/img/blank.gif"; export const COMMON_PREVIEW_IMAGE = @@ -314,6 +314,8 @@ export const tournamentBracketsPage = ({ query.size > 0 ? `?${query.toString()}` : "" }`; }; +export const tournamentDivisionsPage = (tournamentId: number) => + `/to/${tournamentId}/divisions`; export const tournamentResultsPage = (tournamentId: number) => `/to/${tournamentId}/results`; export const tournamentBracketsSubscribePage = (tournamentId: number) => @@ -420,7 +422,7 @@ export const badgeUrl = ({ export const articlePreviewUrl = (slug: string) => `/static-assets/img/article-previews/${slug}.png`; -export const navIconUrl = (navItem: (typeof navItems)[number]["name"]) => +export const navIconUrl = (navItem: string) => `/static-assets/img/layout/${navItem}`; export const gearImageUrl = (gearType: GearType, gearSplId: number) => `/static-assets/img/gear/${gearType.toLowerCase()}/${gearSplId}`; diff --git a/docs/tournament-leagues.md b/docs/tournament-leagues.md new file mode 100644 index 000000000..5d57c6ae0 --- /dev/null +++ b/docs/tournament-leagues.md @@ -0,0 +1,14 @@ +Leagues are a variety of sendou.ink tournaments where each division is a separate competition. Participants sign-up to one "entry tournament" then get divided into divisions by the organizers and one winner emerges per division. + +## Creating a league + +Note: leagues are not an open feature available for everyone and require some amount of manual admin work from Sendou. + +1) Create tournament as normal on sendou.ink. This will be the tournament where users sign up to. Set registration closing time as you wish. +2) Link the bracket to sendou. +3) Once the registration closes from the admin tab download participant list as "league format" (.csv file). +4) Order the teams as you wish. Fill the "div" column with the desired division per participant. +5) Give sendou back the .csv file. +6) Division brackets will be generated. You get the chance to edit each as you wish before starting it. If you want to use the same maps for each division, just do them for one bracket then let Sendou know. There is an admin script that can be ran that copies them across all divisions. +7) (Optional): let Sendou know the start time of each bracket to control during which week which round of groups is played. +8) When everything looks good start divisions and play out the tournament as normal. diff --git a/locales/en/common.json b/locales/en/common.json index cab9440d1..85879adf4 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -24,6 +24,7 @@ "pages.sendouq": "SendouQ", "pages.lfg": "LFG", "pages.settings": "Settings", + "pages.luti": "LUTI", "header.profile": "Profile", "header.logout": "Log out", diff --git a/migrations/078-parent-tournament-id.js b/migrations/078-parent-tournament-id.js new file mode 100644 index 000000000..486073fc0 --- /dev/null +++ b/migrations/078-parent-tournament-id.js @@ -0,0 +1,11 @@ +export function up(db) { + db.transaction(() => { + db.prepare( + /* sql */ `alter table "Tournament" add "parentTournamentId" integer references "Tournament"("id") on delete restrict`, + ).run(); + + db.prepare( + /* sql */ `alter table "CalendarEvent" add "hidden" integer default 0`, + ).run(); + })(); +} diff --git a/public/static-assets/img/layout/luti.avif b/public/static-assets/img/layout/luti.avif new file mode 100644 index 0000000000000000000000000000000000000000..1299627b48f494cd49a7dcc4badcd08bd1e5897c GIT binary patch literal 3451 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 literal 0 HcmV?d00001 diff --git a/public/static-assets/img/layout/luti.png b/public/static-assets/img/layout/luti.png new file mode 100644 index 0000000000000000000000000000000000000000..711ab8411866060008c45543684a473b613ceb46 GIT binary patch literal 13244 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 P7Zzm&4f!fri}3#gcw2eb literal 0 HcmV?d00001 diff --git a/scripts/create-league-divisions.ts b/scripts/create-league-divisions.ts new file mode 100644 index 000000000..f2794af3d --- /dev/null +++ b/scripts/create-league-divisions.ts @@ -0,0 +1,184 @@ +// for testing use the command `npx tsx ./scripts/create-league-divisions.ts 6 'https://gist.githubusercontent.com/Sendouc/38aa4d5d8426035ce178c09598ae627f/raw/17be9bb53a9f017c2097d0624f365d1c5a029f01/league.csv'` + +import "dotenv/config"; +import { z } from "zod"; +import { ADMIN_ID } from "~/constants"; +import { db } from "~/db/sql"; +import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; +import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; +import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; +import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import invariant from "~/utils/invariant"; +import { logger } from "~/utils/logger"; + +const tournamentId = Number(process.argv[2]?.trim()); + +invariant( + tournamentId && !Number.isNaN(tournamentId), + "tournament id is required (argument 1)", +); + +const csvUrl = process.argv[3]?.trim(); + +invariant(z.string().url().parse(csvUrl), "csv url is required (argument 2)"); + +async function main() { + console.time("create-league-divisions"); + + const tournament = await tournamentFromDB({ + tournamentId, + user: { id: ADMIN_ID }, + }); + invariant(tournament.isLeagueSignup, "Tournament is not a league signup"); + + const csv = await loadCsv(); + + const teams = parseCsv(csv); + for (const team of teams) { + validateTeam(team, tournament); + } + validateDivs(teams); + + const grouped = Object.entries(Object.groupBy(teams, (t) => t.division)).sort( + (a, b) => { + const divAIndex = teams.findIndex((t) => t.division === a[0]); + const divBIndex = teams.findIndex((t) => t.division === b[0]); + + return divAIndex - divBIndex; + }, + ); + + for (const [, divsTeams] of grouped) { + divsTeams!.sort((a, b) => { + const teamAIndex = teams.findIndex((t) => t.id === a.id); + const teamBIndex = teams.findIndex((t) => t.id === b.id); + + return teamAIndex - teamBIndex; + }); + } + + const calendarEvent = await db + .selectFrom("CalendarEvent") + .selectAll() + .where("CalendarEvent.id", "=", tournament.ctx.eventId) + .executeTakeFirstOrThrow(); + + for (const [div, divsTeams] of grouped) { + logger.info(`Creating division ${div}...`); + + const createdEvent = await CalendarRepository.create({ + parentTournamentId: tournament.ctx.id, + authorId: tournament.ctx.author.id, + bracketProgression: tournament.ctx.settings.bracketProgression, + description: tournament.ctx.description, + deadlines: tournament.ctx.settings.deadlines, + discordInviteCode: + tournament.ctx.discordUrl?.replace("https://discord.gg/", "") ?? null, + mapPickingStyle: tournament.ctx.mapPickingStyle, + name: `${tournament.ctx.name} - Division ${div}`, + organizationId: tournament.ctx.organization?.id ?? null, + rules: tournament.ctx.rules, + startTimes: [dateToDatabaseTimestamp(tournament.ctx.startTime)], + tags: null, + tournamentToCopyId: tournament.ctx.id, + avatarImgId: calendarEvent.avatarImgId ?? undefined, + avatarFileName: undefined, + mapPoolMaps: + tournament.ctx.mapPickingStyle !== "TO" + ? tournament.ctx.tieBreakerMapPool + : tournament.ctx.toSetMapPool, + badges: [], + enableNoScreenToggle: tournament.ctx.settings.enableNoScreenToggle, + enableSubs: false, + isInvitational: true, + autonomousSubs: false, + isRanked: tournament.ctx.settings.isRanked, + minMembersPerTeam: tournament.ctx.settings.minMembersPerTeam, + regClosesAt: tournament.ctx.settings.regClosesAt, + requireInGameNames: tournament.ctx.settings.requireInGameNames, + bracketUrl: "https://sendou.ink", + isFullTournament: true, + autoValidateAvatar: true, + // these come from progression + swissGroupCount: undefined, + swissRoundCount: undefined, + teamsPerGroup: undefined, + thirdPlaceMatch: undefined, + }); + + for (const team of divsTeams!) { + await TournamentTeamRepository.copyFromAnotherTournament({ + destinationTournamentId: createdEvent.tournamentId!, + tournamentTeamId: team.id, + }); + } + + logger.info(`Created division ${div} (id: ${createdEvent.tournamentId})`); + } + + console.timeEnd("create-league-divisions"); +} + +async function loadCsv() { + const response = await fetch(csvUrl); + return response.text(); +} + +const csvSchema = z.array( + z.object({ + "Team id": z.coerce.number(), + Div: z.string(), + }), +); + +type ParsedTeam = ReturnType[number]; + +function parseCsv(csv: string) { + const lines = csv.split("\n"); + const headers = lines[0].split(","); + const rows = lines.slice(1).map((line) => { + const row = line.split(","); + return headers.reduce( + (acc, header, i) => { + acc[header] = row[i]; + return acc; + }, + {} as Record, + ); + }); + + const validated = csvSchema.parse(rows); + + return validated.map((row) => ({ + id: row["Team id"], + division: row.Div, + })); +} + +function validateTeam(team: ParsedTeam, tournament: Tournament) { + invariant( + tournament.ctx.teams.some((t) => t.id === team.id), + `Team with id ${team.id} not found in tournament`, + ); +} + +const MIN_TEAMS_COUNT_PER_DIV = 6; +function validateDivs(teams: ParsedTeam[]) { + const counts = teams.reduce( + (acc, team) => { + acc[team.division] = (acc[team.division] ?? 0) + 1; + return acc; + }, + {} as Record, + ); + + for (const [div, count] of Object.entries(counts)) { + invariant( + count >= MIN_TEAMS_COUNT_PER_DIV, + `Division ${div} has ${count} teams, expected at least ${MIN_TEAMS_COUNT_PER_DIV}`, + ); + } +} + +main();