mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
League support (#2030)
* Initial * Create league divs script works * Progress * Progress * Prevent round from starting * Finalized? * Tweaks * linter
This commit is contained in:
parent
9a671457a2
commit
86b50ced56
|
|
@ -30,3 +30,5 @@ VITE_SKALOP_WS_URL=ws://localhost:5900
|
||||||
|
|
||||||
// trunc, full or none (default: none)
|
// trunc, full or none (default: none)
|
||||||
SQL_LOG=trunc
|
SQL_LOG=trunc
|
||||||
|
|
||||||
|
VITE_SHOW_LUTI_NAV_ITEM=false
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Link } from "@remix-run/react";
|
import { Link } from "@remix-run/react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { useUser } from "~/features/auth/core/user";
|
||||||
import { LOG_OUT_URL, navIconUrl, userPage } from "~/utils/urls";
|
import { LOG_OUT_URL, navIconUrl, userPage } from "~/utils/urls";
|
||||||
import { Avatar } from "../Avatar";
|
import { Avatar } from "../Avatar";
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
104
app/components/layout/nav-items.ts
Normal file
104
app/components/layout/nav-items.ts
Normal file
|
|
@ -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);
|
||||||
|
|
@ -98,6 +98,10 @@ const calendarEventWithToToolsDepths = () => calendarEventWithToTools("DEPTHS");
|
||||||
const calendarEventWithToToolsTeamsDepths = () =>
|
const calendarEventWithToToolsTeamsDepths = () =>
|
||||||
calendarEventWithToToolsTeams("DEPTHS");
|
calendarEventWithToToolsTeams("DEPTHS");
|
||||||
|
|
||||||
|
const calendarEventWithToToolsLUTI = () => calendarEventWithToTools("LUTI");
|
||||||
|
const calendarEventWithToToolsTeamsLUTI = () =>
|
||||||
|
calendarEventWithToToolsTeams("LUTI");
|
||||||
|
|
||||||
const basicSeeds = (variation?: SeedVariation | null) => [
|
const basicSeeds = (variation?: SeedVariation | null) => [
|
||||||
adminUser,
|
adminUser,
|
||||||
makeAdminPatron,
|
makeAdminPatron,
|
||||||
|
|
@ -145,6 +149,8 @@ const basicSeeds = (variation?: SeedVariation | null) => [
|
||||||
calendarEventWithToToolsToSetMapPool,
|
calendarEventWithToToolsToSetMapPool,
|
||||||
calendarEventWithToToolsDepths,
|
calendarEventWithToToolsDepths,
|
||||||
calendarEventWithToToolsTeamsDepths,
|
calendarEventWithToToolsTeamsDepths,
|
||||||
|
calendarEventWithToToolsLUTI,
|
||||||
|
calendarEventWithToToolsTeamsLUTI,
|
||||||
tournamentSubs,
|
tournamentSubs,
|
||||||
adminBuilds,
|
adminBuilds,
|
||||||
manySplattershotBuilds,
|
manySplattershotBuilds,
|
||||||
|
|
@ -218,6 +224,12 @@ function wipeDB() {
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const table of tablesToDelete) {
|
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();
|
sql.prepare(`delete from "${table}"`).run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -861,7 +873,7 @@ async function calendarEventResults() {
|
||||||
|
|
||||||
const TO_TOOLS_CALENDAR_EVENT_ID = 201;
|
const TO_TOOLS_CALENDAR_EVENT_ID = 201;
|
||||||
function calendarEventWithToTools(
|
function calendarEventWithToTools(
|
||||||
event: "PICNIC" | "ITZ" | "PP" | "SOS" | "DEPTHS" = "PICNIC",
|
event: "PICNIC" | "ITZ" | "PP" | "SOS" | "DEPTHS" | "LUTI" = "PICNIC",
|
||||||
registrationOpen = false,
|
registrationOpen = false,
|
||||||
) {
|
) {
|
||||||
const tournamentId = {
|
const tournamentId = {
|
||||||
|
|
@ -870,6 +882,7 @@ function calendarEventWithToTools(
|
||||||
PP: 3,
|
PP: 3,
|
||||||
SOS: 4,
|
SOS: 4,
|
||||||
DEPTHS: 5,
|
DEPTHS: 5,
|
||||||
|
LUTI: 6,
|
||||||
}[event];
|
}[event];
|
||||||
const eventId = {
|
const eventId = {
|
||||||
PICNIC: TO_TOOLS_CALENDAR_EVENT_ID + 0,
|
PICNIC: TO_TOOLS_CALENDAR_EVENT_ID + 0,
|
||||||
|
|
@ -877,6 +890,7 @@ function calendarEventWithToTools(
|
||||||
PP: TO_TOOLS_CALENDAR_EVENT_ID + 2,
|
PP: TO_TOOLS_CALENDAR_EVENT_ID + 2,
|
||||||
SOS: TO_TOOLS_CALENDAR_EVENT_ID + 3,
|
SOS: TO_TOOLS_CALENDAR_EVENT_ID + 3,
|
||||||
DEPTHS: TO_TOOLS_CALENDAR_EVENT_ID + 4,
|
DEPTHS: TO_TOOLS_CALENDAR_EVENT_ID + 4,
|
||||||
|
LUTI: TO_TOOLS_CALENDAR_EVENT_ID + 5,
|
||||||
}[event];
|
}[event];
|
||||||
const name = {
|
const name = {
|
||||||
PICNIC: "PICNIC #2",
|
PICNIC: "PICNIC #2",
|
||||||
|
|
@ -884,6 +898,7 @@ function calendarEventWithToTools(
|
||||||
PP: "Paddling Pool 253",
|
PP: "Paddling Pool 253",
|
||||||
SOS: "Swim or Sink 101",
|
SOS: "Swim or Sink 101",
|
||||||
DEPTHS: "The Depths 5",
|
DEPTHS: "The Depths 5",
|
||||||
|
LUTI: "Leagues Under The Ink Season 15",
|
||||||
}[event];
|
}[event];
|
||||||
|
|
||||||
const settings: Tables["Tournament"]["settings"] =
|
const settings: Tables["Tournament"]["settings"] =
|
||||||
|
|
@ -987,16 +1002,34 @@ function calendarEventWithToTools(
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: {
|
: event === "LUTI"
|
||||||
bracketProgression: [
|
? {
|
||||||
{
|
bracketProgression: [
|
||||||
type: "double_elimination",
|
{
|
||||||
name: "Main bracket",
|
type: "round_robin",
|
||||||
requiresCheckIn: false,
|
name: "Groups stage",
|
||||||
settings: {},
|
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
|
sql
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|
@ -1016,7 +1049,11 @@ function calendarEventWithToTools(
|
||||||
id: tournamentId,
|
id: tournamentId,
|
||||||
settings: JSON.stringify(settings),
|
settings: JSON.stringify(settings),
|
||||||
mapPickingStyle:
|
mapPickingStyle:
|
||||||
event === "SOS" ? "TO" : event === "ITZ" ? "AUTO_SZ" : "AUTO_ALL",
|
event === "SOS" || event === "LUTI"
|
||||||
|
? "TO"
|
||||||
|
: event === "ITZ"
|
||||||
|
? "AUTO_SZ"
|
||||||
|
: "AUTO_ALL",
|
||||||
});
|
});
|
||||||
|
|
||||||
sql
|
sql
|
||||||
|
|
@ -1156,7 +1193,7 @@ const availablePairs = rankedModesShort
|
||||||
)
|
)
|
||||||
.filter((pair) => !tiebreakerPicks.has(pair));
|
.filter((pair) => !tiebreakerPicks.has(pair));
|
||||||
function calendarEventWithToToolsTeams(
|
function calendarEventWithToToolsTeams(
|
||||||
event: "PICNIC" | "ITZ" | "PP" | "SOS" | "DEPTHS" = "PICNIC",
|
event: "PICNIC" | "ITZ" | "PP" | "SOS" | "DEPTHS" | "LUTI" = "PICNIC",
|
||||||
isSmall = false,
|
isSmall = false,
|
||||||
) {
|
) {
|
||||||
const userIds = userIdsInAscendingOrderById();
|
const userIds = userIdsInAscendingOrderById();
|
||||||
|
|
@ -1170,6 +1207,7 @@ function calendarEventWithToToolsTeams(
|
||||||
PP: 3,
|
PP: 3,
|
||||||
SOS: 4,
|
SOS: 4,
|
||||||
DEPTHS: 5,
|
DEPTHS: 5,
|
||||||
|
LUTI: 6,
|
||||||
}[event];
|
}[event];
|
||||||
|
|
||||||
const teamIdAddition = {
|
const teamIdAddition = {
|
||||||
|
|
@ -1178,6 +1216,7 @@ function calendarEventWithToToolsTeams(
|
||||||
PP: 200,
|
PP: 200,
|
||||||
SOS: 300,
|
SOS: 300,
|
||||||
DEPTHS: 400,
|
DEPTHS: 400,
|
||||||
|
LUTI: 500,
|
||||||
}[event];
|
}[event];
|
||||||
|
|
||||||
for (let id = 1; id <= (isSmall ? 4 : 16); id++) {
|
for (let id = 1; id <= (isSmall ? 4 : 16); id++) {
|
||||||
|
|
@ -1212,8 +1251,8 @@ function calendarEventWithToToolsTeams(
|
||||||
inviteCode: nanoid(INVITE_CODE_LENGTH),
|
inviteCode: nanoid(INVITE_CODE_LENGTH),
|
||||||
});
|
});
|
||||||
|
|
||||||
// in PICNIC & PP Chimera is not checked in
|
// in PICNIC & PP Chimera is not checked in + in LUTI no check-ins at all
|
||||||
if (teamId !== 1 && teamId !== 201) {
|
if (teamId !== 1 && teamId !== 201 && event !== "LUTI") {
|
||||||
sql
|
sql
|
||||||
.prepare(
|
.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());
|
const shuffledPairs = shuffle(availablePairs.slice());
|
||||||
|
|
||||||
let SZ = 0;
|
let SZ = 0;
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export const db = new Kysely<DB>({
|
||||||
dialect: new SqliteDialect({
|
dialect: new SqliteDialect({
|
||||||
database: sql,
|
database: sql,
|
||||||
}),
|
}),
|
||||||
log: LOG_LEVEL === "trunc" || LOG_LEVEL === "full" ? logQuery : undefined,
|
log: LOG_LEVEL === "trunc" || LOG_LEVEL === "full" ? logQuery : logError,
|
||||||
plugins: [new ParseJSONResultsPlugin()],
|
plugins: [new ParseJSONResultsPlugin()],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -54,6 +54,23 @@ function logQuery(event: LogEvent) {
|
||||||
);
|
);
|
||||||
// biome-ignore lint/suspicious/noConsoleLog: dev only
|
// biome-ignore lint/suspicious/noConsoleLog: dev only
|
||||||
console.log(formatSql(event.query.sql, event.query.parameters));
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,7 @@ export interface CalendarEvent {
|
||||||
name: string;
|
name: string;
|
||||||
participantCount: number | null;
|
participantCount: number | null;
|
||||||
tags: string | null;
|
tags: string | null;
|
||||||
|
hidden: Generated<number>;
|
||||||
tournamentId: number | null;
|
tournamentId: number | null;
|
||||||
organizationId: number | null;
|
organizationId: number | null;
|
||||||
avatarImgId: number | null;
|
avatarImgId: number | null;
|
||||||
|
|
@ -455,6 +456,8 @@ export interface Tournament {
|
||||||
string | null
|
string | null
|
||||||
>;
|
>;
|
||||||
rules: string | null;
|
rules: string | null;
|
||||||
|
/** Related "parent tournament", the tournament that contains the original sign-ups (for leagues) */
|
||||||
|
parentTournamentId: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PreparedMaps {
|
export interface PreparedMaps {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import { UserSearch } from "~/components/UserSearch";
|
||||||
import { useUser } from "~/features/auth/core/user";
|
import { useUser } from "~/features/auth/core/user";
|
||||||
import { FRIEND_CODE_REGEXP_PATTERN } from "~/features/sendouq/q-constants";
|
import { FRIEND_CODE_REGEXP_PATTERN } from "~/features/sendouq/q-constants";
|
||||||
import { isAdmin, isMod } from "~/permissions";
|
import { isAdmin, isMod } from "~/permissions";
|
||||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
|
||||||
import { makeTitle } from "~/utils/strings";
|
import { makeTitle } from "~/utils/strings";
|
||||||
import { SEED_URL, STOP_IMPERSONATING_URL, impersonateUrl } from "~/utils/urls";
|
import { SEED_URL, STOP_IMPERSONATING_URL, impersonateUrl } from "~/utils/urls";
|
||||||
|
|
||||||
|
|
@ -27,10 +26,6 @@ export const meta: MetaFunction = () => {
|
||||||
return [{ title: makeTitle("Admin page") }];
|
return [{ title: makeTitle("Admin page") }];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handle: SendouRouteHandle = {
|
|
||||||
navItemName: "admin",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const user = useUser();
|
const user = useUser();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,7 @@ export async function findAllBetweenTwoTimestamps({
|
||||||
"<=",
|
"<=",
|
||||||
dateToDatabaseTimestamp(endTime),
|
dateToDatabaseTimestamp(endTime),
|
||||||
)
|
)
|
||||||
|
.where("CalendarEvent.hidden", "=", 0)
|
||||||
.orderBy("CalendarEventDate.startTime", "asc");
|
.orderBy("CalendarEventDate.startTime", "asc");
|
||||||
|
|
||||||
for (const tag of tagsToFilterBy) {
|
for (const tag of tagsToFilterBy) {
|
||||||
|
|
@ -362,6 +363,7 @@ export async function eventsToReport(authorId: number) {
|
||||||
fn.max("CalendarEventDate.startTime").as("startTime"),
|
fn.max("CalendarEventDate.startTime").as("startTime"),
|
||||||
])
|
])
|
||||||
.where("CalendarEvent.authorId", "=", authorId)
|
.where("CalendarEvent.authorId", "=", authorId)
|
||||||
|
.where("CalendarEvent.hidden", "=", 0)
|
||||||
.where("startTime", ">=", dateToDatabaseTimestamp(oneMonthAgo))
|
.where("startTime", ">=", dateToDatabaseTimestamp(oneMonthAgo))
|
||||||
.where("startTime", "<=", dateToDatabaseTimestamp(new Date()))
|
.where("startTime", "<=", dateToDatabaseTimestamp(new Date()))
|
||||||
.where("CalendarEvent.participantCount", "is", null)
|
.where("CalendarEvent.participantCount", "is", null)
|
||||||
|
|
@ -382,6 +384,7 @@ export async function findRecentMapPoolsByAuthorId(authorId: number) {
|
||||||
withMapPool(eb),
|
withMapPool(eb),
|
||||||
])
|
])
|
||||||
.where("CalendarEvent.authorId", "=", authorId)
|
.where("CalendarEvent.authorId", "=", authorId)
|
||||||
|
.where("CalendarEvent.hidden", "=", 0)
|
||||||
.orderBy("CalendarEvent.id", "desc")
|
.orderBy("CalendarEvent.id", "desc")
|
||||||
.groupBy("CalendarEvent.id")
|
.groupBy("CalendarEvent.id")
|
||||||
.limit(5)
|
.limit(5)
|
||||||
|
|
@ -484,6 +487,7 @@ type CreateArgs = Pick<
|
||||||
avatarFileName?: string;
|
avatarFileName?: string;
|
||||||
avatarImgId?: number;
|
avatarImgId?: number;
|
||||||
autoValidateAvatar?: boolean;
|
autoValidateAvatar?: boolean;
|
||||||
|
parentTournamentId?: number;
|
||||||
};
|
};
|
||||||
export async function create(args: CreateArgs) {
|
export async function create(args: CreateArgs) {
|
||||||
const copiedStaff = args.tournamentToCopyId
|
const copiedStaff = args.tournamentToCopyId
|
||||||
|
|
@ -527,6 +531,7 @@ export async function create(args: CreateArgs) {
|
||||||
.values({
|
.values({
|
||||||
mapPickingStyle: args.mapPickingStyle,
|
mapPickingStyle: args.mapPickingStyle,
|
||||||
settings: JSON.stringify(settings),
|
settings: JSON.stringify(settings),
|
||||||
|
parentTournamentId: args.parentTournamentId,
|
||||||
rules: args.rules,
|
rules: args.rules,
|
||||||
})
|
})
|
||||||
.returning("id")
|
.returning("id")
|
||||||
|
|
@ -568,6 +573,7 @@ export async function create(args: CreateArgs) {
|
||||||
bracketUrl: args.bracketUrl,
|
bracketUrl: args.bracketUrl,
|
||||||
avatarImgId: args.avatarImgId ?? avatarImgId,
|
avatarImgId: args.avatarImgId ?? avatarImgId,
|
||||||
organizationId: args.organizationId,
|
organizationId: args.organizationId,
|
||||||
|
hidden: args.parentTournamentId ? 1 : 0,
|
||||||
tournamentId,
|
tournamentId,
|
||||||
})
|
})
|
||||||
.returning("id")
|
.returning("id")
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import { KeyIcon } from "~/components/icons/Key";
|
||||||
import { LogOutIcon } from "~/components/icons/LogOut";
|
import { LogOutIcon } from "~/components/icons/LogOut";
|
||||||
import { SearchIcon } from "~/components/icons/Search";
|
import { SearchIcon } from "~/components/icons/Search";
|
||||||
import { UsersIcon } from "~/components/icons/Users";
|
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 { useUser } from "~/features/auth/core/user";
|
||||||
import type * as Changelog from "~/features/front-page/core/Changelog.server";
|
import type * as Changelog from "~/features/front-page/core/Changelog.server";
|
||||||
import {
|
import {
|
||||||
|
|
@ -34,6 +34,7 @@ import {
|
||||||
BLANK_IMAGE_URL,
|
BLANK_IMAGE_URL,
|
||||||
CALENDAR_TOURNAMENTS_PAGE,
|
CALENDAR_TOURNAMENTS_PAGE,
|
||||||
LOG_OUT_URL,
|
LOG_OUT_URL,
|
||||||
|
LUTI_PAGE,
|
||||||
SENDOUQ_PAGE,
|
SENDOUQ_PAGE,
|
||||||
leaderboardsPage,
|
leaderboardsPage,
|
||||||
navIconUrl,
|
navIconUrl,
|
||||||
|
|
@ -55,6 +56,7 @@ export const handle: SendouRouteHandle = {
|
||||||
export default function FrontPage() {
|
export default function FrontPage() {
|
||||||
return (
|
return (
|
||||||
<Main className="front-page__container">
|
<Main className="front-page__container">
|
||||||
|
<LeagueBanner />
|
||||||
<DesktopSideNav />
|
<DesktopSideNav />
|
||||||
<SeasonBanner />
|
<SeasonBanner />
|
||||||
<TournamentCards />
|
<TournamentCards />
|
||||||
|
|
@ -158,6 +160,19 @@ function SeasonBanner() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LeagueBanner() {
|
||||||
|
const showBannerFor = import.meta.env.VITE_SHOW_BANNER_FOR_SEASON;
|
||||||
|
if (!showBannerFor) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to={LUTI_PAGE} className="front__luti-banner">
|
||||||
|
<Image path={navIconUrl("luti")} size={24} alt="" />
|
||||||
|
Registration now open for Leagues Under The Ink (LUTI) Season{" "}
|
||||||
|
{showBannerFor}!
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TournamentCards() {
|
function TournamentCards() {
|
||||||
const { t } = useTranslation(["front"]);
|
const { t } = useTranslation(["front"]);
|
||||||
const data = useLoaderData<typeof loader>();
|
const data = useLoaderData<typeof loader>();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import type { TournamentRoundMaps } from "~/db/tables";
|
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 { useAutoRerender } from "~/hooks/useAutoRerender";
|
||||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||||
import { useDeadline } from "./useDeadline";
|
import { useDeadline } from "./useDeadline";
|
||||||
|
|
@ -17,6 +19,8 @@ export function RoundHeader({
|
||||||
showInfos?: boolean;
|
showInfos?: boolean;
|
||||||
maps?: TournamentRoundMaps | null;
|
maps?: TournamentRoundMaps | null;
|
||||||
}) {
|
}) {
|
||||||
|
const leagueRoundStartDate = useLeagueWeekStart(roundId);
|
||||||
|
|
||||||
const hasDeadline = ![
|
const hasDeadline = ![
|
||||||
"WB Finals",
|
"WB Finals",
|
||||||
"Grand Finals",
|
"Grand Finals",
|
||||||
|
|
@ -36,7 +40,7 @@ export function RoundHeader({
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="elim-bracket__round-header">{name}</div>
|
<div className="elim-bracket__round-header">{name}</div>
|
||||||
{showInfos && bestOf ? (
|
{showInfos && bestOf && !leagueRoundStartDate ? (
|
||||||
<div className="elim-bracket__round-header__infos">
|
<div className="elim-bracket__round-header__infos">
|
||||||
<div>
|
<div>
|
||||||
{countPrefix}
|
{countPrefix}
|
||||||
|
|
@ -45,6 +49,16 @@ export function RoundHeader({
|
||||||
</div>
|
</div>
|
||||||
{hasDeadline ? <Deadline roundId={roundId} bestOf={bestOf} /> : null}
|
{hasDeadline ? <Deadline roundId={roundId} bestOf={bestOf} /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
) : leagueRoundStartDate ? (
|
||||||
|
<div className="elim-bracket__round-header__infos">
|
||||||
|
<div>
|
||||||
|
{leagueRoundStartDate.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}{" "}
|
||||||
|
→
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="elim-bracket__round-header__infos invisible">
|
<div className="elim-bracket__round-header__infos invisible">
|
||||||
Hidden
|
Hidden
|
||||||
|
|
@ -75,3 +89,9 @@ function Deadline({ roundId, bestOf }: { roundId: number; bestOf: number }) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useLeagueWeekStart(roundId: number) {
|
||||||
|
const tournament = useTournament();
|
||||||
|
|
||||||
|
return resolveLeagueRoundStartDate(tournament, roundId);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { SubmitButton } from "~/components/SubmitButton";
|
||||||
import { EditIcon } from "~/components/icons/Edit";
|
import { EditIcon } from "~/components/icons/Edit";
|
||||||
import { useUser } from "~/features/auth/core/user";
|
import { useUser } from "~/features/auth/core/user";
|
||||||
import { useTournament } from "~/features/tournament/routes/to.$id";
|
import { useTournament } from "~/features/tournament/routes/to.$id";
|
||||||
|
import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-utils";
|
||||||
import invariant from "~/utils/invariant";
|
import invariant from "~/utils/invariant";
|
||||||
import * as PickBan from "../core/PickBan";
|
import * as PickBan from "../core/PickBan";
|
||||||
import type { TournamentDataTeam } from "../core/Tournament.server";
|
import type { TournamentDataTeam } from "../core/Tournament.server";
|
||||||
|
|
@ -223,6 +224,7 @@ function ReportScoreButtons({
|
||||||
matchLocked: boolean;
|
matchLocked: boolean;
|
||||||
newScore: [number, number];
|
newScore: [number, number];
|
||||||
}) {
|
}) {
|
||||||
|
const data = useLoaderData<TournamentMatchLoaderData>();
|
||||||
const user = useUser();
|
const user = useUser();
|
||||||
const tournament = useTournament();
|
const tournament = useTournament();
|
||||||
const confirmCheckId = React.useId();
|
const confirmCheckId = React.useId();
|
||||||
|
|
@ -230,6 +232,18 @@ function ReportScoreButtons({
|
||||||
const [endConfirmation, setEndConfirmation] = React.useState(false);
|
const [endConfirmation, setEndConfirmation] = React.useState(false);
|
||||||
const [pointConfirmation, setPointConfirmation] = React.useState(false);
|
const [pointConfirmation, setPointConfirmation] = React.useState(false);
|
||||||
|
|
||||||
|
const leagueRoundStartDate = resolveLeagueRoundStartDate(
|
||||||
|
tournament,
|
||||||
|
data.match.roundId,
|
||||||
|
);
|
||||||
|
if (leagueRoundStartDate && leagueRoundStartDate > new Date()) {
|
||||||
|
return (
|
||||||
|
<p className="tournament-bracket__during-match-actions__amount-warning-paragraph">
|
||||||
|
League round has not started yet
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (matchLocked) {
|
if (matchLocked) {
|
||||||
return (
|
return (
|
||||||
<p className="tournament-bracket__during-match-actions__amount-warning-paragraph">
|
<p className="tournament-bracket__during-match-actions__amount-warning-paragraph">
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { PickIcon } from "~/components/icons/Pick";
|
||||||
import { useUser } from "~/features/auth/core/user";
|
import { useUser } from "~/features/auth/core/user";
|
||||||
import { Chat, useChat } from "~/features/chat/components/Chat";
|
import { Chat, useChat } from "~/features/chat/components/Chat";
|
||||||
import { useTournament } from "~/features/tournament/routes/to.$id";
|
import { useTournament } from "~/features/tournament/routes/to.$id";
|
||||||
|
import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-utils";
|
||||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||||
import { useSearchParamState } from "~/hooks/useSearchParamState";
|
import { useSearchParamState } from "~/hooks/useSearchParamState";
|
||||||
import type { StageId } from "~/modules/in-game-lists";
|
import type { StageId } from "~/modules/in-game-lists";
|
||||||
|
|
@ -311,6 +312,14 @@ function FancyStageBanner({
|
||||||
return null;
|
return null;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const waitingForLeagueRoundToStart = (() => {
|
||||||
|
const date = resolveLeagueRoundStartDate(tournament, data.match.roundId);
|
||||||
|
|
||||||
|
if (!date) return false;
|
||||||
|
|
||||||
|
return date > new Date();
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{inBanPhase ? (
|
{inBanPhase ? (
|
||||||
|
|
@ -337,6 +346,22 @@ function FancyStageBanner({
|
||||||
<div>Please wait for staff to unlock</div>
|
<div>Please wait for staff to unlock</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : waitingForLeagueRoundToStart ? (
|
||||||
|
<div className="tournament-bracket__locked-banner">
|
||||||
|
<div className="stack sm items-center">
|
||||||
|
<div className="text-lg text-center font-bold">
|
||||||
|
Waiting for league round to start
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Round playable from{" "}
|
||||||
|
{resolveLeagueRoundStartDate(
|
||||||
|
tournament,
|
||||||
|
data.match.roundId,
|
||||||
|
)!.toLocaleDateString()}{" "}
|
||||||
|
onwards
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : waitingForActiveRosterSelectionFor ? (
|
) : waitingForActiveRosterSelectionFor ? (
|
||||||
<div className="tournament-bracket__locked-banner">
|
<div className="tournament-bracket__locked-banner">
|
||||||
<div className="stack sm items-center">
|
<div className="stack sm items-center">
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import type {
|
||||||
import { TOURNAMENT } from "~/features/tournament";
|
import { TOURNAMENT } from "~/features/tournament";
|
||||||
import type * as Progression from "~/features/tournament-bracket/core/Progression";
|
import type * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||||
import * as Standings from "~/features/tournament/core/Standings";
|
import * as Standings from "~/features/tournament/core/Standings";
|
||||||
|
import { LEAGUES } from "~/features/tournament/tournament-constants";
|
||||||
import { tournamentIsRanked } from "~/features/tournament/tournament-utils";
|
import { tournamentIsRanked } from "~/features/tournament/tournament-utils";
|
||||||
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
|
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
|
||||||
import type { Match, Stage } from "~/modules/brackets-model";
|
import type { Match, Stage } from "~/modules/brackets-model";
|
||||||
|
|
@ -588,7 +589,9 @@ export class Tournament {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
groupCount: Math.ceil(participantsCount / teamsPerGroup),
|
groupCount: Math.ceil(participantsCount / teamsPerGroup),
|
||||||
seedOrdering: ["groups.seed_optimized"],
|
seedOrdering: [
|
||||||
|
this.isLeagueDivision ? "natural" : "groups.seed_optimized",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "swiss": {
|
case "swiss": {
|
||||||
|
|
@ -903,6 +906,16 @@ export class Tournament {
|
||||||
return this.ctx.settings.autonomousSubs ?? true;
|
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) {
|
matchNameById(matchId: number) {
|
||||||
let bracketName: string | undefined;
|
let bracketName: string | undefined;
|
||||||
let roundName: string | undefined;
|
let roundName: string | undefined;
|
||||||
|
|
|
||||||
|
|
@ -7154,6 +7154,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||||
ctx: {
|
ctx: {
|
||||||
id: 815,
|
id: 815,
|
||||||
eventId: 2614,
|
eventId: 2614,
|
||||||
|
parentTournamentId: null,
|
||||||
discordUrl: "https://discord.gg/F7RaNUR",
|
discordUrl: "https://discord.gg/F7RaNUR",
|
||||||
tags: "LOW",
|
tags: "LOW",
|
||||||
settings: {
|
settings: {
|
||||||
|
|
|
||||||
|
|
@ -1999,6 +1999,7 @@ export const SWIM_OR_SINK_167 = (
|
||||||
},
|
},
|
||||||
ctx: {
|
ctx: {
|
||||||
id: 672,
|
id: 672,
|
||||||
|
parentTournamentId: null,
|
||||||
eventId: 2425,
|
eventId: 2425,
|
||||||
discordUrl: "https://discord.gg/F7RaNUR",
|
discordUrl: "https://discord.gg/F7RaNUR",
|
||||||
tags: null,
|
tags: null,
|
||||||
|
|
|
||||||
|
|
@ -1979,6 +1979,7 @@ export const PADDLING_POOL_257 = () =>
|
||||||
ctx: {
|
ctx: {
|
||||||
id: 27,
|
id: 27,
|
||||||
organization: null,
|
organization: null,
|
||||||
|
parentTournamentId: null,
|
||||||
tags: null,
|
tags: null,
|
||||||
eventId: 1352,
|
eventId: 1352,
|
||||||
bracketProgressionOverrides: [],
|
bracketProgressionOverrides: [],
|
||||||
|
|
@ -7916,6 +7917,7 @@ export const PADDLING_POOL_255 = () =>
|
||||||
ctx: {
|
ctx: {
|
||||||
id: 18,
|
id: 18,
|
||||||
organization: null,
|
organization: null,
|
||||||
|
parentTournamentId: null,
|
||||||
tags: null,
|
tags: null,
|
||||||
eventId: 1286,
|
eventId: 1286,
|
||||||
bracketProgressionOverrides: [],
|
bracketProgressionOverrides: [],
|
||||||
|
|
@ -14188,6 +14190,7 @@ export const IN_THE_ZONE_32 = ({
|
||||||
},
|
},
|
||||||
ctx: {
|
ctx: {
|
||||||
id: 11,
|
id: 11,
|
||||||
|
parentTournamentId: null,
|
||||||
organization: null,
|
organization: null,
|
||||||
tags: null,
|
tags: null,
|
||||||
eventId: 1134,
|
eventId: 1134,
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ export const testTournament = ({
|
||||||
tags: null,
|
tags: null,
|
||||||
description: null,
|
description: null,
|
||||||
organization: null,
|
organization: null,
|
||||||
|
parentTournamentId: null,
|
||||||
rules: null,
|
rules: null,
|
||||||
logoUrl: null,
|
logoUrl: null,
|
||||||
logoSrc: "/test.png",
|
logoSrc: "/test.png",
|
||||||
|
|
|
||||||
|
|
@ -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<Unwrapped<typeof TournamentRepository.findChildTournaments>>
|
||||||
|
>();
|
||||||
|
|
||||||
|
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)!;
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ const stm = sql.prepare(/* sql */ `
|
||||||
"TournamentMatch"."bestOf",
|
"TournamentMatch"."bestOf",
|
||||||
"TournamentMatch"."chatCode",
|
"TournamentMatch"."chatCode",
|
||||||
"Tournament"."mapPickingStyle",
|
"Tournament"."mapPickingStyle",
|
||||||
|
"TournamentRound"."id" as "roundId",
|
||||||
"TournamentRound"."maps" as "roundMaps",
|
"TournamentRound"."maps" as "roundMaps",
|
||||||
json_group_array(
|
json_group_array(
|
||||||
json_object(
|
json_object(
|
||||||
|
|
@ -60,6 +61,7 @@ export const findMatchById = (id: number) => {
|
||||||
"id" | "groupId" | "opponentOne" | "opponentTwo" | "bestOf" | "chatCode"
|
"id" | "groupId" | "opponentOne" | "opponentTwo" | "bestOf" | "chatCode"
|
||||||
> &
|
> &
|
||||||
Pick<Tournament, "mapPickingStyle"> & { players: string }) & {
|
Pick<Tournament, "mapPickingStyle"> & { players: string }) & {
|
||||||
|
roundId: number;
|
||||||
roundMaps: string | null;
|
roundMaps: string | null;
|
||||||
})
|
})
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
@ -73,6 +75,7 @@ export const findMatchById = (id: number) => {
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
bestOf: (roundMaps?.count ?? row.bestOf) as 3 | 5 | 7,
|
bestOf: (roundMaps?.count ?? row.bestOf) as 3 | 5 | 7,
|
||||||
|
roundId: row.roundId,
|
||||||
roundMaps,
|
roundMaps,
|
||||||
opponentOne: JSON.parse(row.opponentOne) as Match["opponent1"],
|
opponentOne: JSON.parse(row.opponentOne) as Match["opponent1"],
|
||||||
opponentTwo: JSON.parse(row.opponentTwo) as Match["opponent2"],
|
opponentTwo: JSON.parse(row.opponentTwo) as Match["opponent2"],
|
||||||
|
|
|
||||||
|
|
@ -430,6 +430,10 @@ export default function TournamentBracketsPage() {
|
||||||
).length;
|
).length;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (tournament.isLeagueSignup) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{visibility !== "hidden" && !tournament.everyBracketOver ? (
|
{visibility !== "hidden" && !tournament.everyBracketOver ? (
|
||||||
|
|
|
||||||
55
app/features/tournament-bracket/routes/to.$id.divisions.tsx
Normal file
55
app/features/tournament-bracket/routes/to.$id.divisions.tsx
Normal file
|
|
@ -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<typeof loader>();
|
||||||
|
|
||||||
|
if (data.divisions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center text-lg font-semi-bold text-lighter">
|
||||||
|
Divisions have not been released yet, check back later
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack horizontal md flex-wrap">
|
||||||
|
{data.divisions.map((div) => (
|
||||||
|
<DivisionLink key={div.tournamentId} div={div} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DivisionLink({
|
||||||
|
div,
|
||||||
|
}: { div: SerializeFrom<typeof loader>["divisions"][number] }) {
|
||||||
|
const data = useLoaderData<typeof loader>();
|
||||||
|
const { t } = useTranslation(["calendar"]);
|
||||||
|
const shortName = div.name.split("-").at(-1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={tournamentBracketsPage({ tournamentId: div.tournamentId })}
|
||||||
|
className={clsx("tournament__div__link", {
|
||||||
|
tournament__div__link__participant: data.divsParticipantOf.includes(
|
||||||
|
div.tournamentId,
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{shortName}
|
||||||
|
<div className="tournament__div__participant-counts">
|
||||||
|
<UsersIcon />{" "}
|
||||||
|
{t("calendar:count.teams", {
|
||||||
|
count: div.teamsCount,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -46,6 +46,7 @@ export async function findById(id: number) {
|
||||||
"Tournament.castedMatchesInfo",
|
"Tournament.castedMatchesInfo",
|
||||||
"Tournament.mapPickingStyle",
|
"Tournament.mapPickingStyle",
|
||||||
"Tournament.rules",
|
"Tournament.rules",
|
||||||
|
"Tournament.parentTournamentId",
|
||||||
"CalendarEvent.name",
|
"CalendarEvent.name",
|
||||||
"CalendarEvent.description",
|
"CalendarEvent.description",
|
||||||
"CalendarEventDate.startTime",
|
"CalendarEventDate.startTime",
|
||||||
|
|
@ -321,6 +322,40 @@ function nullifyingAvg(values: number[]) {
|
||||||
return values.reduce((acc, cur) => acc + cur, 0) / values.length;
|
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<number>().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) {
|
export async function findTOSetMapPoolById(tournamentId: number) {
|
||||||
return (
|
return (
|
||||||
await db
|
await db
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { INVITE_CODE_LENGTH } from "~/constants";
|
||||||
import { db } from "~/db/sql";
|
import { db } from "~/db/sql";
|
||||||
import type { DB, Tables } from "~/db/tables";
|
import type { DB, Tables } from "~/db/tables";
|
||||||
import { databaseTimestampNow } from "~/utils/dates";
|
import { databaseTimestampNow } from "~/utils/dates";
|
||||||
|
import invariant from "~/utils/invariant";
|
||||||
|
|
||||||
export function setActiveRoster({
|
export function setActiveRoster({
|
||||||
teamId,
|
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({
|
export function update({
|
||||||
team,
|
team,
|
||||||
avatarFileName,
|
avatarFileName,
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,10 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||||
case "UNREGISTER": {
|
case "UNREGISTER": {
|
||||||
validate(ownTeam, "You are not registered to this tournament");
|
validate(ownTeam, "You are not registered to this tournament");
|
||||||
validate(!ownTeamCheckedIn, "You cannot unregister after checking in");
|
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);
|
deleteTeam(ownTeam.id);
|
||||||
|
|
||||||
|
|
|
||||||
12
app/features/tournament/routes/luti.tsx
Normal file
12
app/features/tournament/routes/luti.tsx
Normal file
|
|
@ -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));
|
||||||
|
};
|
||||||
|
|
@ -23,6 +23,7 @@ import invariant from "~/utils/invariant";
|
||||||
import { assertUnreachable } from "~/utils/types";
|
import { assertUnreachable } from "~/utils/types";
|
||||||
import {
|
import {
|
||||||
calendarEventPage,
|
calendarEventPage,
|
||||||
|
teamPage,
|
||||||
tournamentEditPage,
|
tournamentEditPage,
|
||||||
tournamentPage,
|
tournamentPage,
|
||||||
} from "~/utils/urls";
|
} from "~/utils/urls";
|
||||||
|
|
@ -62,22 +63,24 @@ export default function TournamentAdminPage() {
|
||||||
>
|
>
|
||||||
Edit event info
|
Edit event info
|
||||||
</LinkButton>
|
</LinkButton>
|
||||||
<FormWithConfirm
|
{!tournament.isLeagueSignup ? (
|
||||||
dialogHeading={t("calendar:actions.delete.confirm", {
|
<FormWithConfirm
|
||||||
name: tournament.ctx.name,
|
dialogHeading={t("calendar:actions.delete.confirm", {
|
||||||
})}
|
name: tournament.ctx.name,
|
||||||
action={calendarEventPage(tournament.ctx.eventId)}
|
})}
|
||||||
submitButtonTestId="delete-submit-button"
|
action={calendarEventPage(tournament.ctx.eventId)}
|
||||||
>
|
submitButtonTestId="delete-submit-button"
|
||||||
<Button
|
|
||||||
className="ml-auto"
|
|
||||||
size="tiny"
|
|
||||||
variant="minimal-destructive"
|
|
||||||
type="submit"
|
|
||||||
>
|
>
|
||||||
{t("calendar:actions.delete")}
|
<Button
|
||||||
</Button>
|
className="ml-auto"
|
||||||
</FormWithConfirm>
|
size="tiny"
|
||||||
|
variant="minimal-destructive"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{t("calendar:actions.delete")}
|
||||||
|
</Button>
|
||||||
|
</FormWithConfirm>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{tournament.isAdmin(user) &&
|
{tournament.isAdmin(user) &&
|
||||||
|
|
@ -111,8 +114,12 @@ export default function TournamentAdminPage() {
|
||||||
<CastTwitchAccounts />
|
<CastTwitchAccounts />
|
||||||
<Divider smallText>Participant list download</Divider>
|
<Divider smallText>Participant list download</Divider>
|
||||||
<DownloadParticipants />
|
<DownloadParticipants />
|
||||||
<Divider smallText>Bracket reset</Divider>
|
{!tournament.isLeagueSignup ? (
|
||||||
<BracketReset />
|
<>
|
||||||
|
<Divider smallText>Bracket reset</Divider>
|
||||||
|
<BracketReset />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -609,6 +616,35 @@ function DownloadParticipants() {
|
||||||
.join("\n");
|
.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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="stack horizontal sm flex-wrap">
|
<div className="stack horizontal sm flex-wrap">
|
||||||
|
|
@ -656,6 +692,19 @@ function DownloadParticipants() {
|
||||||
>
|
>
|
||||||
Simple list in seeded order
|
Simple list in seeded order
|
||||||
</Button>
|
</Button>
|
||||||
|
{tournament.isLeagueSignup ? (
|
||||||
|
<Button
|
||||||
|
size="tiny"
|
||||||
|
onClick={() =>
|
||||||
|
handleDownload({
|
||||||
|
filename: "league-format.csv",
|
||||||
|
content: leagueFormat(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
League format
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -119,20 +119,22 @@ export default function TournamentRegisterPage() {
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="tournament__by mt-2">
|
{!tournament.isLeagueSignup ? (
|
||||||
<div className="stack horizontal xs items-center">
|
<div className="tournament__by mt-2">
|
||||||
<ClockIcon className="tournament__info__icon" />{" "}
|
<div className="stack horizontal xs items-center">
|
||||||
{isMounted
|
<ClockIcon className="tournament__info__icon" />{" "}
|
||||||
? tournament.ctx.startTime.toLocaleString(i18n.language, {
|
{isMounted
|
||||||
timeZoneName: "short",
|
? tournament.ctx.startTime.toLocaleString(i18n.language, {
|
||||||
minute: startsAtEvenHour ? undefined : "numeric",
|
timeZoneName: "short",
|
||||||
hour: "numeric",
|
minute: startsAtEvenHour ? undefined : "numeric",
|
||||||
day: "numeric",
|
hour: "numeric",
|
||||||
month: "long",
|
day: "numeric",
|
||||||
})
|
month: "long",
|
||||||
: null}
|
})
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
<div className="stack horizontal sm mt-1">
|
<div className="stack horizontal sm mt-1">
|
||||||
{tournament.ranked ? (
|
{tournament.ranked ? (
|
||||||
<div className="tournament__badge tournament__badge__ranked">
|
<div className="tournament__badge tournament__badge__ranked">
|
||||||
|
|
@ -411,10 +413,12 @@ function RegistrationProgress({
|
||||||
completed: mapPool && mapPool.length > 0,
|
completed: mapPool && mapPool.length > 0,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
{
|
!tournament.isLeagueSignup
|
||||||
name: t("tournament:pre.steps.check-in"),
|
? {
|
||||||
completed: checkedIn,
|
name: t("tournament:pre.steps.check-in"),
|
||||||
},
|
completed: checkedIn,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const regClosesBeforeStart =
|
const regClosesBeforeStart =
|
||||||
|
|
@ -422,7 +426,10 @@ function RegistrationProgress({
|
||||||
tournament.ctx.startTime.getTime();
|
tournament.ctx.startTime.getTime();
|
||||||
|
|
||||||
const registrationClosesAtString = isMounted
|
const registrationClosesAtString = isMounted
|
||||||
? tournament.registrationClosesAt.toLocaleTimeString(i18n.language, {
|
? (tournament.isLeagueSignup
|
||||||
|
? tournament.ctx.startTime
|
||||||
|
: tournament.registrationClosesAt
|
||||||
|
).toLocaleTimeString(i18n.language, {
|
||||||
minute: "numeric",
|
minute: "numeric",
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
|
|
@ -456,22 +463,24 @@ function RegistrationProgress({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<CheckIn
|
{!tournament.isLeagueSignup ? (
|
||||||
canCheckIn={steps.filter((step) => !step.completed).length === 1}
|
<CheckIn
|
||||||
status={
|
canCheckIn={steps.filter((step) => !step.completed).length === 1}
|
||||||
tournament.regularCheckInIsOpen
|
status={
|
||||||
? "OPEN"
|
tournament.regularCheckInIsOpen
|
||||||
: tournament.regularCheckInHasEnded
|
? "OPEN"
|
||||||
? "OVER"
|
: tournament.regularCheckInHasEnded
|
||||||
: "UPCOMING"
|
? "OVER"
|
||||||
}
|
: "UPCOMING"
|
||||||
startDate={tournament.regularCheckInStartsAt}
|
}
|
||||||
endDate={tournament.regularCheckInEndsAt}
|
startDate={tournament.regularCheckInStartsAt}
|
||||||
checkedIn={checkedIn}
|
endDate={tournament.regularCheckInEndsAt}
|
||||||
/>
|
checkedIn={checkedIn}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
<div className="tournament__section__warning">
|
<div className="tournament__section__warning">
|
||||||
{regClosesBeforeStart ? (
|
{regClosesBeforeStart || tournament.isLeagueSignup ? (
|
||||||
<span className="text-warning">
|
<span className="text-warning">
|
||||||
Registration closes at {registrationClosesAtString}
|
Registration closes at {registrationClosesAtString}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -672,6 +681,17 @@ function TeamInfo({
|
||||||
</Button>
|
</Button>
|
||||||
</FormWithConfirm>
|
</FormWithConfirm>
|
||||||
) : null}
|
) : null}
|
||||||
|
{canUnregister &&
|
||||||
|
tournament.isLeagueSignup &&
|
||||||
|
!tournament.registrationOpen ? (
|
||||||
|
<Popover
|
||||||
|
triggerClassName="minimal-destructive tiny build__small-text"
|
||||||
|
buttonChildren={t("tournament:pre.info.unregister")}
|
||||||
|
>
|
||||||
|
Unregistration from a league after the registration has ended is
|
||||||
|
handled by the organizers
|
||||||
|
</Popover>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<section className="tournament__section">
|
<section className="tournament__section">
|
||||||
<Form method="post" className="stack md items-center" ref={ref}>
|
<Form method="post" className="stack md items-center" ref={ref}>
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,10 @@ import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||||
import { makeTitle } from "~/utils/strings";
|
import { makeTitle } from "~/utils/strings";
|
||||||
import { assertUnreachable } from "~/utils/types";
|
import { assertUnreachable } from "~/utils/types";
|
||||||
import {
|
import {
|
||||||
|
tournamentDivisionsPage,
|
||||||
tournamentOrganizationPage,
|
tournamentOrganizationPage,
|
||||||
tournamentPage,
|
tournamentPage,
|
||||||
|
tournamentRegisterPage,
|
||||||
userSubmittedImage,
|
userSubmittedImage,
|
||||||
} from "~/utils/urls";
|
} from "~/utils/urls";
|
||||||
import { streamsByTournamentId } from "../core/streams.server";
|
import { streamsByTournamentId } from "../core/streams.server";
|
||||||
|
|
@ -245,12 +247,37 @@ export function TournamentLayout() {
|
||||||
return (
|
return (
|
||||||
<Main bigger>
|
<Main bigger>
|
||||||
<SubNav>
|
<SubNav>
|
||||||
<SubNavLink to="register" data-testid="register-tab" prefetch="intent">
|
<SubNavLink
|
||||||
{tournament.hasStarted ? "Info" : t("tournament:tabs.register")}
|
to={tournamentRegisterPage(
|
||||||
</SubNavLink>
|
tournament.isLeagueDivision
|
||||||
<SubNavLink to="brackets" data-testid="brackets-tab" prefetch="render">
|
? tournament.ctx.parentTournamentId!
|
||||||
{t("tournament:tabs.brackets")}
|
: tournament.ctx.id,
|
||||||
|
)}
|
||||||
|
data-testid="register-tab"
|
||||||
|
prefetch="intent"
|
||||||
|
>
|
||||||
|
{tournament.hasStarted || tournament.isLeagueDivision
|
||||||
|
? "Info"
|
||||||
|
: t("tournament:tabs.register")}
|
||||||
</SubNavLink>
|
</SubNavLink>
|
||||||
|
{!tournament.isLeagueSignup ? (
|
||||||
|
<SubNavLink
|
||||||
|
to="brackets"
|
||||||
|
data-testid="brackets-tab"
|
||||||
|
prefetch="render"
|
||||||
|
>
|
||||||
|
{t("tournament:tabs.brackets")}
|
||||||
|
</SubNavLink>
|
||||||
|
) : null}
|
||||||
|
{tournament.isLeagueSignup || tournament.isLeagueDivision ? (
|
||||||
|
<SubNavLink
|
||||||
|
to={tournamentDivisionsPage(
|
||||||
|
tournament.ctx.parentTournamentId ?? tournament.ctx.id,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Divisions
|
||||||
|
</SubNavLink>
|
||||||
|
) : null}
|
||||||
<SubNavLink
|
<SubNavLink
|
||||||
to="teams"
|
to="teams"
|
||||||
end={false}
|
end={false}
|
||||||
|
|
@ -276,9 +303,11 @@ export function TournamentLayout() {
|
||||||
{t("tournament:tabs.results")}
|
{t("tournament:tabs.results")}
|
||||||
</SubNavLink>
|
</SubNavLink>
|
||||||
) : null}
|
) : null}
|
||||||
{tournament.isOrganizer(user) && !tournament.hasStarted && (
|
{tournament.isOrganizer(user) &&
|
||||||
<SubNavLink to="seeds">{t("tournament:tabs.seeds")}</SubNavLink>
|
!tournament.hasStarted &&
|
||||||
)}
|
!tournament.isLeagueSignup && (
|
||||||
|
<SubNavLink to="seeds">{t("tournament:tabs.seeds")}</SubNavLink>
|
||||||
|
)}
|
||||||
{tournament.isOrganizer(user) && !tournament.everyBracketOver && (
|
{tournament.isOrganizer(user) && !tournament.everyBracketOver && (
|
||||||
<SubNavLink to="admin" data-testid="admin-tab">
|
<SubNavLink to="admin" data-testid="admin-tab">
|
||||||
{t("tournament:tabs.admin")}
|
{t("tournament:tabs.admin")}
|
||||||
|
|
|
||||||
|
|
@ -14,3 +14,28 @@ export const TOURNAMENT = {
|
||||||
SWISS_DEFAULT_GROUP_COUNT: 1,
|
SWISS_DEFAULT_GROUP_COUNT: 1,
|
||||||
SWISS_DEFAULT_ROUND_COUNT: 5,
|
SWISS_DEFAULT_ROUND_COUNT: 5,
|
||||||
} as const;
|
} 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,16 @@ import type { Params } from "@remix-run/react";
|
||||||
import type { Tournament } from "~/db/types";
|
import type { Tournament } from "~/db/types";
|
||||||
import type { ModeShort, StageId } from "~/modules/in-game-lists";
|
import type { ModeShort, StageId } from "~/modules/in-game-lists";
|
||||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||||
|
import { weekNumberToDate } from "~/utils/dates";
|
||||||
import invariant from "~/utils/invariant";
|
import invariant from "~/utils/invariant";
|
||||||
import { tournamentLogoUrl } from "~/utils/urls";
|
import { tournamentLogoUrl } from "~/utils/urls";
|
||||||
import { MapPool } from "../map-list-generator/core/map-pool";
|
import { MapPool } from "../map-list-generator/core/map-pool";
|
||||||
import { currentSeason } from "../mmr/season";
|
import { currentSeason } from "../mmr/season";
|
||||||
import { BANNED_MAPS } from "../sendouq-settings/banned-maps";
|
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 { TournamentData } from "../tournament-bracket/core/Tournament.server";
|
||||||
import type { PlayedSet } from "./core/sets.server";
|
import type { PlayedSet } from "./core/sets.server";
|
||||||
import { TOURNAMENT } from "./tournament-constants";
|
import { LEAGUES, TOURNAMENT } from "./tournament-constants";
|
||||||
|
|
||||||
export function tournamentIdFromParams(params: Params<string>) {
|
export function tournamentIdFromParams(params: Params<string>) {
|
||||||
const result = Number(params.id);
|
const result = Number(params.id);
|
||||||
|
|
@ -269,3 +271,39 @@ export function tournamentIsRanked({
|
||||||
|
|
||||||
return isSetAsRanked ?? true;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -651,3 +651,39 @@
|
||||||
background-color: var(--theme-transparent);
|
background-color: var(--theme-transparent);
|
||||||
border-radius: var(--rounded);
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,10 @@ export default [
|
||||||
route("subs/new", "features/tournament-subs/routes/to.$id.subs.new.tsx"),
|
route("subs/new", "features/tournament-subs/routes/to.$id.subs.new.tsx"),
|
||||||
|
|
||||||
route("brackets", "features/tournament-bracket/routes/to.$id.brackets.tsx"),
|
route("brackets", "features/tournament-bracket/routes/to.$id.brackets.tsx"),
|
||||||
|
route(
|
||||||
|
"divisions",
|
||||||
|
"features/tournament-bracket/routes/to.$id.divisions.tsx",
|
||||||
|
),
|
||||||
route(
|
route(
|
||||||
"brackets/subscribe",
|
"brackets/subscribe",
|
||||||
"features/tournament-bracket/routes/to.$id.brackets.subscribe.tsx",
|
"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",
|
"features/tournament-bracket/routes/to.$id.matches.$mid.subscribe.tsx",
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
route("luti", "features/tournament/routes/luti.tsx"),
|
||||||
|
|
||||||
...prefix("/org/:slug", [
|
...prefix("/org/:slug", [
|
||||||
index("features/tournament-organization/routes/org.$slug.tsx"),
|
index("features/tournament-organization/routes/org.$slug.tsx"),
|
||||||
|
|
|
||||||
|
|
@ -347,3 +347,14 @@
|
||||||
gap: var(--s-1);
|
gap: var(--s-1);
|
||||||
padding: var(--s-0-5) var(--s-1-5);
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import type { Params, UIMatch } from "@remix-run/react";
|
||||||
import type { Namespace, TFunction } from "i18next";
|
import type { Namespace, TFunction } from "i18next";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { z } from "zod";
|
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 { s3UploadHandler } from "~/features/img-upload";
|
||||||
import invariant from "./invariant";
|
import invariant from "./invariant";
|
||||||
|
|
||||||
|
|
@ -247,7 +247,7 @@ export type SendouRouteHandle = {
|
||||||
t: TFunction<"common", undefined>;
|
t: TFunction<"common", undefined>;
|
||||||
}) => Breadcrumb | Array<Breadcrumb> | undefined;
|
}) => Breadcrumb | Array<Breadcrumb> | 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"];
|
navItemName?: (typeof navItems)[number]["name"];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import slugify from "slugify";
|
import slugify from "slugify";
|
||||||
import type navItems from "~/components/layout/nav-items.json";
|
|
||||||
import type { Preference } from "~/db/tables";
|
import type { Preference } from "~/db/tables";
|
||||||
import type {
|
import type {
|
||||||
Art,
|
Art,
|
||||||
|
|
@ -135,6 +134,7 @@ export const TIERS_PAGE = "/tiers";
|
||||||
export const SUSPENDED_PAGE = "/suspended";
|
export const SUSPENDED_PAGE = "/suspended";
|
||||||
export const LFG_PAGE = "/lfg";
|
export const LFG_PAGE = "/lfg";
|
||||||
export const SETTINGS_PAGE = "/settings";
|
export const SETTINGS_PAGE = "/settings";
|
||||||
|
export const LUTI_PAGE = "/luti";
|
||||||
|
|
||||||
export const BLANK_IMAGE_URL = "/static-assets/img/blank.gif";
|
export const BLANK_IMAGE_URL = "/static-assets/img/blank.gif";
|
||||||
export const COMMON_PREVIEW_IMAGE =
|
export const COMMON_PREVIEW_IMAGE =
|
||||||
|
|
@ -314,6 +314,8 @@ export const tournamentBracketsPage = ({
|
||||||
query.size > 0 ? `?${query.toString()}` : ""
|
query.size > 0 ? `?${query.toString()}` : ""
|
||||||
}`;
|
}`;
|
||||||
};
|
};
|
||||||
|
export const tournamentDivisionsPage = (tournamentId: number) =>
|
||||||
|
`/to/${tournamentId}/divisions`;
|
||||||
export const tournamentResultsPage = (tournamentId: number) =>
|
export const tournamentResultsPage = (tournamentId: number) =>
|
||||||
`/to/${tournamentId}/results`;
|
`/to/${tournamentId}/results`;
|
||||||
export const tournamentBracketsSubscribePage = (tournamentId: number) =>
|
export const tournamentBracketsSubscribePage = (tournamentId: number) =>
|
||||||
|
|
@ -420,7 +422,7 @@ export const badgeUrl = ({
|
||||||
export const articlePreviewUrl = (slug: string) =>
|
export const articlePreviewUrl = (slug: string) =>
|
||||||
`/static-assets/img/article-previews/${slug}.png`;
|
`/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}`;
|
`/static-assets/img/layout/${navItem}`;
|
||||||
export const gearImageUrl = (gearType: GearType, gearSplId: number) =>
|
export const gearImageUrl = (gearType: GearType, gearSplId: number) =>
|
||||||
`/static-assets/img/gear/${gearType.toLowerCase()}/${gearSplId}`;
|
`/static-assets/img/gear/${gearType.toLowerCase()}/${gearSplId}`;
|
||||||
|
|
|
||||||
14
docs/tournament-leagues.md
Normal file
14
docs/tournament-leagues.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
"pages.sendouq": "SendouQ",
|
"pages.sendouq": "SendouQ",
|
||||||
"pages.lfg": "LFG",
|
"pages.lfg": "LFG",
|
||||||
"pages.settings": "Settings",
|
"pages.settings": "Settings",
|
||||||
|
"pages.luti": "LUTI",
|
||||||
|
|
||||||
"header.profile": "Profile",
|
"header.profile": "Profile",
|
||||||
"header.logout": "Log out",
|
"header.logout": "Log out",
|
||||||
|
|
|
||||||
11
migrations/078-parent-tournament-id.js
Normal file
11
migrations/078-parent-tournament-id.js
Normal file
|
|
@ -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();
|
||||||
|
})();
|
||||||
|
}
|
||||||
BIN
public/static-assets/img/layout/luti.avif
Normal file
BIN
public/static-assets/img/layout/luti.avif
Normal file
Binary file not shown.
BIN
public/static-assets/img/layout/luti.png
Normal file
BIN
public/static-assets/img/layout/luti.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
184
scripts/create-league-divisions.ts
Normal file
184
scripts/create-league-divisions.ts
Normal file
|
|
@ -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<typeof parseCsv>[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<string, string>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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<string, number>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
Loading…
Reference in New Issue
Block a user