mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-14 07:06:14 -05:00
Migrate leagues to be standard many starting brackest tournaments (#3361)
This commit is contained in:
@@ -3,10 +3,12 @@ import * as React from "react";
|
||||
import {
|
||||
isRouteErrorResponse,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useRevalidator,
|
||||
useRouteError,
|
||||
} from "react-router";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import * as Redirect from "~/modules/redirects/core/Redirect";
|
||||
import { getSessionId } from "~/utils/session-id";
|
||||
import {
|
||||
ERROR_GIRL_IMAGE_PATH,
|
||||
@@ -121,12 +123,7 @@ export function Catcher() {
|
||||
</ErrorMain>
|
||||
);
|
||||
case 404:
|
||||
return (
|
||||
<ErrorMain>
|
||||
<h2>Error {error.status} - Page not found</h2>
|
||||
<GetHelp />
|
||||
</ErrorMain>
|
||||
);
|
||||
return <PageNotFound />;
|
||||
default:
|
||||
return (
|
||||
<ErrorMain>
|
||||
@@ -147,6 +144,31 @@ export function Catcher() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A client side navigation to an URL matching no route never reaches the server, so the
|
||||
* redirects (normally resolved by `redirectsMiddleware`) are checked here as well.
|
||||
*/
|
||||
function PageNotFound() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const redirectTo = Redirect.resolve(location);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (redirectTo) {
|
||||
navigate(redirectTo, { replace: true });
|
||||
}
|
||||
}, [redirectTo, navigate]);
|
||||
|
||||
if (redirectTo) return null;
|
||||
|
||||
return (
|
||||
<ErrorMain>
|
||||
<h2>Error 404 - Page not found</h2>
|
||||
<GetHelp />
|
||||
</ErrorMain>
|
||||
);
|
||||
}
|
||||
|
||||
/** Every branch of the error page, marked so tests can assert one is not shown. */
|
||||
function ErrorMain({ children }: { children: React.ReactNode }) {
|
||||
return <Main testId="error-page">{children}</Main>;
|
||||
|
||||
@@ -87,8 +87,9 @@ export const { create } = defineFactory({
|
||||
applyOptions: async (tournament, { tier }: Options) => {
|
||||
if (!tier) return;
|
||||
|
||||
await TournamentRepository.updateTournamentTier({
|
||||
await TournamentRepository.upsertDivisionTier({
|
||||
tournamentId: tournament.id,
|
||||
bracketIdx: 0,
|
||||
tier,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -155,6 +155,8 @@ export interface TournamentSettings {
|
||||
isTest?: boolean;
|
||||
isDraft?: boolean;
|
||||
requireSendouQParticipation?: boolean;
|
||||
/** Is this tournament a league? Leagues are played over many weeks, each starting bracket being a division. */
|
||||
isLeague?: boolean;
|
||||
}
|
||||
|
||||
export interface CastedMatchesInfo {
|
||||
|
||||
@@ -587,8 +587,6 @@ export interface Tournament {
|
||||
castTwitchAccounts: JSONColumnTypeNullable<string[]>;
|
||||
castedMatchesInfo: JSONColumnTypeNullable<CastedMatchesInfo>;
|
||||
rules: string | null;
|
||||
/** Related "parent tournament", the tournament that contains the original sign-ups (for leagues) */
|
||||
parentTournamentId: number | null;
|
||||
/** Is the tournament finalized meaning all the matches are played and TO has locked it making it read-only */
|
||||
isFinalized: Generated<DBBoolean>;
|
||||
/** Snapshot of teams and rosters when seeds were last saved. Used to detect NEW teams/players. */
|
||||
@@ -607,6 +605,19 @@ export interface SavedCalendarEvent {
|
||||
createdAt: Generated<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier of one division (= starting bracket) of a tournament, based on the skill of the teams that
|
||||
* checked in to it. Tournaments where every team plays the same bracket have one row (bracket idx 0)
|
||||
* matching `Tournament.tier`, tournaments with many starting brackets one row per division.
|
||||
*/
|
||||
export interface TournamentDivisionTier {
|
||||
tournamentId: number;
|
||||
/** Idx of the starting bracket in `Tournament.settings.bracketProgression`. */
|
||||
bracketIdx: number;
|
||||
/** Same scale as `Tournament.tier`. 1=X, 2=S+, 3=S, 4=A+, 5=A, 6=B+, 7=B, 8=C+, 9=C */
|
||||
tier: TournamentTierNumber;
|
||||
}
|
||||
|
||||
export interface TournamentBadgeOwner {
|
||||
badgeId: number;
|
||||
userId: number;
|
||||
@@ -709,6 +720,8 @@ export interface TournamentRound {
|
||||
number: number;
|
||||
stageId: number;
|
||||
maps: JSONColumnType<TournamentRoundMaps>;
|
||||
/** Datetime the round is played by default (leagues). Null = no default play time, the round is played whenever. */
|
||||
defaultPlayTime: number | null;
|
||||
}
|
||||
|
||||
/** A stage is an intermediate phase in a tournament. In essence a bracket. */
|
||||
@@ -1361,6 +1374,7 @@ export interface DB {
|
||||
/** VIEW over `AllTeamMember`, same as `TeamMember` but also includes rows where this is the member's secondary (i.e. non-main) team. Insert/update via `AllTeamMember`. */
|
||||
TeamMemberWithSecondary: TeamMember;
|
||||
Tournament: Tournament;
|
||||
TournamentDivisionTier: TournamentDivisionTier;
|
||||
TournamentStaff: TournamentStaff;
|
||||
TournamentGroup: TournamentGroup;
|
||||
TournamentLFGLike: TournamentLFGLike;
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function BracketTestLayout() {
|
||||
isOrganizer: () => false,
|
||||
streamingParticipantIds: [] as number[],
|
||||
streams: [] as unknown[],
|
||||
isLeagueDivision: false,
|
||||
isLeague: false,
|
||||
};
|
||||
|
||||
const mockBracket = {
|
||||
@@ -189,7 +189,6 @@ export default function BracketTestLayout() {
|
||||
tournament: mockTournament,
|
||||
bracketExpanded,
|
||||
setBracketExpanded,
|
||||
hasChildTournaments: false,
|
||||
preparedMaps: null,
|
||||
bracket: mockBracket,
|
||||
}}
|
||||
|
||||
@@ -439,7 +439,6 @@ type CreateArgs = Pick<
|
||||
avatarFileName?: string;
|
||||
avatarImgId?: number;
|
||||
autoValidateAvatar?: boolean;
|
||||
parentTournamentId?: number;
|
||||
};
|
||||
export async function insert(args: CreateArgs) {
|
||||
const copiedStaff = args.tournamentToCopyId
|
||||
@@ -486,7 +485,6 @@ export async function insert(args: CreateArgs) {
|
||||
.values({
|
||||
mapPickingStyle: args.mapPickingStyle,
|
||||
settings: JSON.stringify(settings),
|
||||
parentTournamentId: args.parentTournamentId,
|
||||
rules: args.rules,
|
||||
})
|
||||
.returning("id")
|
||||
@@ -524,7 +522,7 @@ export async function insert(args: CreateArgs) {
|
||||
bracketUrl: args.bracketUrl,
|
||||
avatarImgId: args.avatarImgId ?? avatarImgId,
|
||||
organizationId: args.organizationId,
|
||||
hidden: args.parentTournamentId || args.isTest || args.isDraft ? 1 : 0,
|
||||
hidden: args.isTest || args.isDraft ? 1 : 0,
|
||||
tournamentId,
|
||||
trophyId: args.trophyId ?? null,
|
||||
})
|
||||
@@ -603,14 +601,13 @@ export async function update(args: UpdateArgs) {
|
||||
: null;
|
||||
|
||||
if (tournamentId) {
|
||||
const { parentTournamentId, settings: existingSettings } = await trx
|
||||
const { settings: existingSettings } = await trx
|
||||
.selectFrom("Tournament")
|
||||
.select(["parentTournamentId", "settings"])
|
||||
.select(["settings"])
|
||||
.where("id", "=", tournamentId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const hidden =
|
||||
existingSettings.isTest || parentTournamentId || args.isDraft ? 1 : 0;
|
||||
const hidden = existingSettings.isTest || args.isDraft ? 1 : 0;
|
||||
await trx
|
||||
.updateTable("CalendarEvent")
|
||||
.set({ hidden })
|
||||
|
||||
@@ -30,7 +30,7 @@ export function getLiveTournamentStreams(): SidebarStream[] {
|
||||
const streams: SidebarStream[] = [];
|
||||
|
||||
for (const tournament of RunningTournaments.all) {
|
||||
if (tournament.isLeagueDivision) continue;
|
||||
if (tournament.isLeague) continue;
|
||||
if (tournament.streams.length === 0) continue;
|
||||
|
||||
streams.push({
|
||||
@@ -53,7 +53,7 @@ export function getLiveTournamentStreamerTwitchNames(): string[] {
|
||||
const names: string[] = [];
|
||||
|
||||
for (const tournament of RunningTournaments.all) {
|
||||
if (tournament.isLeagueDivision) continue;
|
||||
if (tournament.isLeague) continue;
|
||||
|
||||
for (const stream of tournament.streams) {
|
||||
names.push(stream.twitchUserName.toLowerCase());
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { redirect } from "react-router";
|
||||
import { MATCH_PROFILE_PAGE } from "~/utils/urls";
|
||||
|
||||
export const loader = () => {
|
||||
throw redirect(MATCH_PROFILE_PAGE);
|
||||
};
|
||||
|
||||
export default function MatchProfileRedirect() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from "react-router";
|
||||
|
||||
export const loader = () => {
|
||||
throw redirect("/plus/suggestions");
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
type ExpressionBuilder,
|
||||
type NotNull,
|
||||
type SqlBool,
|
||||
sql,
|
||||
type Transaction,
|
||||
} from "kysely";
|
||||
@@ -373,10 +374,30 @@ export async function findSeasonTournamentRunsByUserId({
|
||||
)
|
||||
.innerJoin("Tournament", "Tournament.id", "Skill.tournamentId")
|
||||
.innerJoin("CalendarEvent", "CalendarEvent.tournamentId", "Tournament.id")
|
||||
.innerJoin(
|
||||
"TournamentTeam",
|
||||
"TournamentTeam.id",
|
||||
"TournamentResult.tournamentTeamId",
|
||||
)
|
||||
.leftJoin("TournamentDivisionTier", (join) =>
|
||||
join
|
||||
.onRef(
|
||||
"TournamentDivisionTier.tournamentId",
|
||||
"=",
|
||||
"TournamentResult.tournamentId",
|
||||
)
|
||||
.on(
|
||||
sql<SqlBool>`"TournamentDivisionTier"."bracketIdx" = coalesce("TournamentTeam"."startingBracketIdx", 0)`,
|
||||
),
|
||||
)
|
||||
.select((eb) => [
|
||||
"TournamentResult.placement",
|
||||
"TournamentResult.participantCount as teamsCount",
|
||||
"Tournament.tier",
|
||||
sql<
|
||||
Tables["Tournament"]["tier"]
|
||||
>`coalesce("TournamentDivisionTier"."tier", "Tournament"."tier")`.as(
|
||||
"tier",
|
||||
),
|
||||
"CalendarEvent.name",
|
||||
tournamentLogoWithDefault(eb).as("logoUrl"),
|
||||
eb
|
||||
@@ -387,12 +408,20 @@ export async function findSeasonTournamentRunsByUserId({
|
||||
"in",
|
||||
eb
|
||||
.selectFrom("TournamentResult as TopEightResult")
|
||||
.innerJoin(
|
||||
"TournamentTeam as TopEightTeam",
|
||||
"TopEightTeam.id",
|
||||
"TopEightResult.tournamentTeamId",
|
||||
)
|
||||
.select("TopEightResult.userId")
|
||||
.whereRef("TopEightResult.tournamentId", "=", "Tournament.id")
|
||||
.where(
|
||||
"TopEightResult.placement",
|
||||
"<=",
|
||||
TOURNAMENT_FIELD_STRENGTH_PLACEMENT,
|
||||
)
|
||||
.where(
|
||||
sql<SqlBool>`coalesce("TopEightTeam"."startingBracketIdx", 0) = coalesce("TournamentTeam"."startingBracketIdx", 0)`,
|
||||
),
|
||||
)
|
||||
.as("TopEightLatestSkill"),
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { type LoaderFunction, redirect } from "react-router";
|
||||
import { SENDOUQ_PAGE } from "~/utils/urls";
|
||||
|
||||
// SendouQ's old URL was /play
|
||||
export const loader: LoaderFunction = () => {
|
||||
throw redirect(SENDOUQ_PAGE);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Insertable, sql, type Transaction } from "kysely";
|
||||
import { type Insertable, type SqlBool, sql, type Transaction } from "kysely";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, Tables } from "~/db/tables";
|
||||
import type { CustomTheme, UserMapModePreferences } from "~/db/tables-json";
|
||||
@@ -264,6 +264,7 @@ export async function findResultsById(teamId: number) {
|
||||
"TournamentResult.tournamentId",
|
||||
"TournamentResult.placement",
|
||||
"TournamentResult.participantCount",
|
||||
"TournamentTeam.startingBracketIdx",
|
||||
])
|
||||
.where("teamId", "=", teamId)
|
||||
.groupBy("TournamentResult.tournamentId"),
|
||||
@@ -280,6 +281,17 @@ export async function findResultsById(teamId: number) {
|
||||
"CalendarEvent.id",
|
||||
)
|
||||
.innerJoin("Tournament", "Tournament.id", "results.tournamentId")
|
||||
.leftJoin("TournamentDivisionTier", (join) =>
|
||||
join
|
||||
.onRef(
|
||||
"TournamentDivisionTier.tournamentId",
|
||||
"=",
|
||||
"results.tournamentId",
|
||||
)
|
||||
.on(
|
||||
sql<SqlBool>`"TournamentDivisionTier"."bracketIdx" = coalesce("results"."startingBracketIdx", 0)`,
|
||||
),
|
||||
)
|
||||
.select((eb) => [
|
||||
"results.placement",
|
||||
"results.tournamentId",
|
||||
@@ -287,7 +299,11 @@ export async function findResultsById(teamId: number) {
|
||||
"results.tournamentTeamId",
|
||||
"CalendarEvent.name as tournamentName",
|
||||
"CalendarEventDate.startsAt",
|
||||
"Tournament.tier",
|
||||
sql<
|
||||
Tables["Tournament"]["tier"]
|
||||
>`coalesce("TournamentDivisionTier"."tier", "Tournament"."tier")`.as(
|
||||
"tier",
|
||||
),
|
||||
tournamentLogoOrNull(eb).as("logoUrl"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from "react-router";
|
||||
|
||||
export const loader = () => {
|
||||
return redirect("/?search=open&type=teams");
|
||||
};
|
||||
@@ -50,12 +50,8 @@ export default function TournamentAdminBracketsPage() {
|
||||
<BracketProgressionEdit />
|
||||
</>
|
||||
) : null}
|
||||
{!tournament.isLeagueSignup ? (
|
||||
<>
|
||||
<Divider smallText>Bracket reset</Divider>
|
||||
<BracketReset />
|
||||
</>
|
||||
) : null}
|
||||
<Divider smallText>Bracket reset</Divider>
|
||||
<BracketReset />
|
||||
{showReopen ? (
|
||||
<>
|
||||
<Divider smallText>Reopen tournament (dev only)</Divider>
|
||||
|
||||
@@ -49,16 +49,10 @@ export default function TournamentAdminLayout() {
|
||||
tournament.ctx.isFinalized &&
|
||||
tournament.isAdmin(user),
|
||||
);
|
||||
const showEditBrackets =
|
||||
tournament.isAdmin(user) &&
|
||||
tournament.hasStarted &&
|
||||
!tournament.ctx.isFinalized;
|
||||
const showStaffTab = tournament.isAdmin(user);
|
||||
const showBracketsTab = tournament.ctx.isFinalized
|
||||
? showReopen
|
||||
: !tournament.isLeagueSignup || showEditBrackets;
|
||||
const showBracketsTab = tournament.ctx.isFinalized ? showReopen : true;
|
||||
const showStreamTab = !tournament.ctx.isFinalized;
|
||||
const showSeedsTab = !tournament.hasStarted && !tournament.isLeagueSignup;
|
||||
const showSeedsTab = !tournament.hasStarted;
|
||||
|
||||
if (!tournament.isOrganizer(user)) {
|
||||
return <Redirect to={tournamentPage(tournament.ctx.id)} />;
|
||||
@@ -84,24 +78,22 @@ export default function TournamentAdminLayout() {
|
||||
>
|
||||
Edit event info
|
||||
</LinkButton>
|
||||
{!tournament.isLeagueSignup ? (
|
||||
<FormWithConfirm
|
||||
dialogHeading={t("calendar:actions.delete.confirm", {
|
||||
name: tournament.ctx.name,
|
||||
})}
|
||||
action={calendarEventPage(tournament.ctx.eventId)}
|
||||
submitButtonTestId="delete-submit-button"
|
||||
<FormWithConfirm
|
||||
dialogHeading={t("calendar:actions.delete.confirm", {
|
||||
name: tournament.ctx.name,
|
||||
})}
|
||||
action={calendarEventPage(tournament.ctx.eventId)}
|
||||
submitButtonTestId="delete-submit-button"
|
||||
>
|
||||
<SendouButton
|
||||
className="ml-auto"
|
||||
size="small"
|
||||
variant="minimal-destructive"
|
||||
type="submit"
|
||||
>
|
||||
<SendouButton
|
||||
className="ml-auto"
|
||||
size="small"
|
||||
variant="minimal-destructive"
|
||||
type="submit"
|
||||
>
|
||||
{t("calendar:actions.delete")}
|
||||
</SendouButton>
|
||||
</FormWithConfirm>
|
||||
) : null}
|
||||
{t("calendar:actions.delete")}
|
||||
</SendouButton>
|
||||
</FormWithConfirm>
|
||||
</div>
|
||||
) : null}
|
||||
<SendouTabs
|
||||
|
||||
@@ -72,6 +72,7 @@ export async function findByTournamentId(
|
||||
"TournamentRound.groupId",
|
||||
"TournamentRound.number",
|
||||
"TournamentRound.maps",
|
||||
"TournamentRound.defaultPlayTime",
|
||||
])
|
||||
.where("TournamentStage.tournamentId", "=", tournamentId)
|
||||
.orderBy("TournamentRound.stageId", "asc")
|
||||
|
||||
@@ -98,7 +98,7 @@ export const action: ActionFunction = async ({ params, request }) => {
|
||||
type: bracket.type,
|
||||
seeding,
|
||||
settings: bracket.settings,
|
||||
independentRounds: tournament.isLeagueDivision,
|
||||
independentRounds: tournament.isLeague,
|
||||
abDivisions,
|
||||
maps,
|
||||
});
|
||||
@@ -141,7 +141,10 @@ export const action: ActionFunction = async ({ params, request }) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (data.bracketIdx === 0 && seeding.length >= MIN_TEAMS_FOR_TIERING) {
|
||||
const isDivision = Progression.startingBrackets(
|
||||
tournament.ctx.settings.bracketProgression,
|
||||
).includes(data.bracketIdx);
|
||||
if (isDivision && seeding.length >= MIN_TEAMS_FOR_TIERING) {
|
||||
const checkedInTeams = tournament.ctx.teams
|
||||
.filter((team) => seeding.includes(team.id))
|
||||
.map((team) => ({ avgOrdinal: team.avgSeedingSkillOrdinal }));
|
||||
@@ -152,8 +155,9 @@ export const action: ActionFunction = async ({ params, request }) => {
|
||||
);
|
||||
|
||||
if (tierNumber !== null) {
|
||||
await TournamentRepository.updateTournamentTier({
|
||||
await TournamentRepository.upsertDivisionTier({
|
||||
tournamentId: tournament.ctx.id,
|
||||
bracketIdx: data.bracketIdx,
|
||||
tier: tierNumber,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ const mockTournament = {
|
||||
brackets: [],
|
||||
bracketsMeta: [],
|
||||
bracketMetaByIdx: () => null,
|
||||
isLeagueDivision: false,
|
||||
isLeague: false,
|
||||
teamById: (id: number) =>
|
||||
mockTournament.ctx.teams.find((t) => t.id === id) ?? null,
|
||||
teamMemberOfByUser: () => null,
|
||||
|
||||
@@ -407,7 +407,7 @@ function MatchVods({ vods }: MatchVodsProps) {
|
||||
function MatchTimer({ match, bracket }: Pick<MatchProps, "match" | "bracket">) {
|
||||
const tournament = useTournament();
|
||||
|
||||
if (tournament.isLeagueDivision) return null;
|
||||
if (tournament.isLeague) return null;
|
||||
if (!match.startedAt) return null;
|
||||
|
||||
const isOver = Boolean(match.winnerSide);
|
||||
|
||||
@@ -30,7 +30,7 @@ export function RoundHeader({
|
||||
roundStartedAt?: number | null;
|
||||
matches?: Array<Unpacked<TournamentData["data"]["match"]>>;
|
||||
}) {
|
||||
const leagueRoundStartDate = useLeagueWeekStart(bracketIdx, roundId);
|
||||
const leagueRoundStartDate = useLeagueRoundStartDate(bracketIdx, roundId);
|
||||
|
||||
const countPrefix = maps?.type === "PLAY_ALL" ? "Play all " : "Bo";
|
||||
|
||||
@@ -141,10 +141,10 @@ function RoundTimer({
|
||||
return <div style={{ color: statusColor }}>{displayText}</div>;
|
||||
}
|
||||
|
||||
function useLeagueWeekStart(bracketIdx: number, roundId: number) {
|
||||
function useLeagueRoundStartDate(bracketIdx: number, roundId: number) {
|
||||
const tournament = useTournament();
|
||||
|
||||
if (bracketIdx !== 0 || !tournament.isLeagueDivision) return null;
|
||||
if (!tournament.isLeague) return null;
|
||||
|
||||
return resolveLeagueRoundStartDate(
|
||||
tournament,
|
||||
|
||||
@@ -388,7 +388,7 @@ export abstract class Bracket {
|
||||
...this.settings,
|
||||
hasAbDivisions: false,
|
||||
},
|
||||
independentRounds: this.tournament.isLeagueDivision,
|
||||
independentRounds: this.tournament.isLeague,
|
||||
abDivisions,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -447,6 +447,54 @@ describe("Adjusting team starting bracket", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("League divisions", () => {
|
||||
const leagueTournament = (isLeague = true) =>
|
||||
testTournament({
|
||||
ctx: {
|
||||
teams: [0, 0, 0, 2].map((startingBracketIdx, i) =>
|
||||
tournamentCtxTeam(i + 1, { startingBracketIdx }),
|
||||
),
|
||||
settings: {
|
||||
isLeague,
|
||||
bracketProgression: progressions.league,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
test("every starting bracket is a division", () => {
|
||||
expect(leagueTournament().leagueDivisions.map((div) => div.idx)).toEqual([
|
||||
0, 2,
|
||||
]);
|
||||
});
|
||||
|
||||
test("has no divisions when not a league", () => {
|
||||
expect(leagueTournament(false).leagueDivisions).toEqual([]);
|
||||
});
|
||||
|
||||
test("playoffs belong to the division they are sourced from", () => {
|
||||
expect(leagueTournament().leagueDivisionOfBracket(3)).toBe(2);
|
||||
});
|
||||
|
||||
test("brackets of a division exclude the other divisions'", () => {
|
||||
expect(
|
||||
leagueTournament()
|
||||
.visibleBracketsMetaOfDivision(2)
|
||||
.map((bracket) => bracket.name),
|
||||
).toEqual(["Division 2", "Division 2 Playoffs"]);
|
||||
});
|
||||
|
||||
test("every bracket is shown when no division is selected", () => {
|
||||
expect(leagueTournament().visibleBracketsMetaOfDivision(null)).toHaveLength(
|
||||
4,
|
||||
);
|
||||
});
|
||||
|
||||
test("teams of a division are the ones starting in it", () => {
|
||||
expect(leagueTournament().teamsCountOfBracket(0)).toBe(3);
|
||||
expect(leagueTournament().teamsCountOfBracket(2)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Resolving the team a user is a member of", () => {
|
||||
const USER_ID = 1;
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { sub } from "date-fns";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import type { TournamentStageSettings } from "~/db/tables-json";
|
||||
import {
|
||||
LEAGUES,
|
||||
TOURNAMENT,
|
||||
} from "~/features/tournament/tournament-constants";
|
||||
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
|
||||
import {
|
||||
modesIncluded,
|
||||
sortTeamsBySeeding,
|
||||
@@ -238,6 +235,83 @@ export class Tournament {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Divisions of a league. Every starting bracket is a division of its own, identified by its
|
||||
* bracket idx, the brackets it feeds into (its playoffs) belonging to that division as well.
|
||||
*/
|
||||
get leagueDivisions(): BracketMeta[] {
|
||||
if (!this.isLeague) return [];
|
||||
|
||||
return this.bracketsMeta.filter((bracket) => bracket.isStartingBracket);
|
||||
}
|
||||
|
||||
/** Division the given bracket belongs to, or null if the tournament has no divisions. */
|
||||
leagueDivisionOfBracket(bracketIdx: number): number | null {
|
||||
const division = this.leagueDivisions.find((division) =>
|
||||
this.bracketIdxsOfDivision(division.idx).includes(bracketIdx),
|
||||
);
|
||||
|
||||
return division?.idx ?? null;
|
||||
}
|
||||
|
||||
/** {@link bracketsMeta} limited to the brackets of one division, if a division is given. */
|
||||
bracketsMetaOfDivision(divisionIdx: number | null): BracketMeta[] {
|
||||
if (divisionIdx === null) return this.bracketsMeta;
|
||||
|
||||
const bracketIdxs = this.bracketIdxsOfDivision(divisionIdx);
|
||||
|
||||
return this.bracketsMeta.filter((bracket) =>
|
||||
bracketIdxs.includes(bracket.idx),
|
||||
);
|
||||
}
|
||||
|
||||
/** {@link visibleBracketsMeta} limited to the brackets of one division, if a division is given. */
|
||||
visibleBracketsMetaOfDivision(divisionIdx: number | null): BracketMeta[] {
|
||||
const visibleIdxs = new Set(
|
||||
this.visibleBracketsMeta.map((bracket) => bracket.idx),
|
||||
);
|
||||
|
||||
return this.bracketsMetaOfDivision(divisionIdx).filter((bracket) =>
|
||||
visibleIdxs.has(bracket.idx),
|
||||
);
|
||||
}
|
||||
|
||||
private bracketIdxsOfDivision(divisionIdx: number) {
|
||||
return Progression.bracketsReachableFrom(
|
||||
divisionIdx,
|
||||
this.ctx.settings.bracketProgression,
|
||||
);
|
||||
}
|
||||
|
||||
/** Teams that can play in the bracket: its participants plus the ones still pending check-in. */
|
||||
eligibleTeamsCountOfBracket(bracketIdx: number) {
|
||||
const bracket = this.bracketsMeta[bracketIdx];
|
||||
|
||||
if (bracket.sources) {
|
||||
return (
|
||||
(bracket.teamsPendingCheckIn ?? []).length +
|
||||
bracket.participantTournamentTeamIds.length
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.isMultiStartingBracket) {
|
||||
return this.ctx.teams.length;
|
||||
}
|
||||
|
||||
return this.ctx.teams.filter(
|
||||
(team) => (team.startingBracketIdx ?? 0) === bracketIdx,
|
||||
).length;
|
||||
}
|
||||
|
||||
/** Teams of the bracket: its participants, or every eligible team while it is a preview. */
|
||||
teamsCountOfBracket(bracketIdx: number) {
|
||||
const bracket = this.bracketsMeta[bracketIdx];
|
||||
|
||||
return bracket.preview
|
||||
? this.eligibleTeamsCountOfBracket(bracketIdx)
|
||||
: bracket.participantTournamentTeamIds.length;
|
||||
}
|
||||
|
||||
/** {@link bracketsMeta} in the shape it is shipped in, i.e. only what match data is needed for. */
|
||||
get bracketsDerivedMeta(): BracketDerivedMeta[] {
|
||||
if (!this._derivedMeta) {
|
||||
@@ -910,18 +984,11 @@ export class Tournament {
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this tournament a league sign-up? League sign-up tournament is a special case which just exists for registration.
|
||||
* It won't have brackets.
|
||||
* Is this tournament a league? A league is played over many weeks, each starting bracket
|
||||
* being a division that teams are placed in by the organizer.
|
||||
* */
|
||||
get isLeagueSignup() {
|
||||
return Object.values(LEAGUES)
|
||||
.flat()
|
||||
.some((entry) => entry.tournamentId === this.ctx.id);
|
||||
}
|
||||
|
||||
/** Is this tournament a league division? League division is a normal tournament that connects to a league sign-up tournament where teams are sourced from. */
|
||||
get isLeagueDivision() {
|
||||
return Boolean(this.ctx.parentTournamentId);
|
||||
get isLeague() {
|
||||
return this.ctx.settings.isLeague === true;
|
||||
}
|
||||
|
||||
/** Does this tournament have many brackets that act as the first bracket? In this format many bracket progressions advance independently from each other (so not all teams can meet). */
|
||||
|
||||
@@ -110,6 +110,8 @@ export interface RoundData {
|
||||
groupId: number;
|
||||
number: number;
|
||||
maps?: TournamentRoundMaps | null;
|
||||
/** Datetime the round is played by default (leagues). */
|
||||
defaultPlayTime?: number | null;
|
||||
}
|
||||
|
||||
export interface MatchResults {
|
||||
|
||||
@@ -125,8 +125,8 @@ async function updateSeriesTierHistory(tournament: Tournament) {
|
||||
}
|
||||
|
||||
function resolveFinalizationSeason(tournament: Tournament) {
|
||||
// league divisions might be running for many weeks
|
||||
const attributionDate = tournament.isLeagueDivision
|
||||
// leagues might be running for many weeks
|
||||
const attributionDate = tournament.isLeague
|
||||
? new Date()
|
||||
: tournament.ctx.startsAt;
|
||||
const season = Seasons.current(attributionDate);
|
||||
|
||||
@@ -6200,8 +6200,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
ctx: {
|
||||
id: 815,
|
||||
eventId: 2614,
|
||||
parentTournamentId: null,
|
||||
parentTournamentName: null,
|
||||
tier: null,
|
||||
tentativeTier: null,
|
||||
discordUrl: "https://discord.gg/F7RaNUR",
|
||||
|
||||
@@ -1685,8 +1685,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
],
|
||||
ctx: {
|
||||
id: 672,
|
||||
parentTournamentId: null,
|
||||
parentTournamentName: null,
|
||||
tier: null,
|
||||
tentativeTier: null,
|
||||
eventId: 2425,
|
||||
|
||||
@@ -306,8 +306,6 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||
castedMatchesInfo: null,
|
||||
mapPickingStyle: "TO",
|
||||
hasRules: true,
|
||||
parentTournamentId: null,
|
||||
parentTournamentName: null,
|
||||
name: "Zones Weekly 38",
|
||||
startsAt: 1734685200,
|
||||
isFinalized: 0,
|
||||
|
||||
@@ -1208,8 +1208,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
organization: null,
|
||||
tier: null,
|
||||
tentativeTier: null,
|
||||
parentTournamentId: null,
|
||||
parentTournamentName: null,
|
||||
tags: null,
|
||||
eventId: 1352,
|
||||
bracketProgressionOverrides: [],
|
||||
@@ -3831,8 +3829,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
organization: null,
|
||||
tier: null,
|
||||
tentativeTier: null,
|
||||
parentTournamentId: null,
|
||||
parentTournamentName: null,
|
||||
tags: null,
|
||||
eventId: 1286,
|
||||
bracketProgressionOverrides: [],
|
||||
@@ -6338,8 +6334,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
],
|
||||
ctx: {
|
||||
id: 11,
|
||||
parentTournamentId: null,
|
||||
parentTournamentName: null,
|
||||
organization: null,
|
||||
tier: null,
|
||||
tentativeTier: null,
|
||||
|
||||
@@ -62,8 +62,6 @@ export const testTournament = ({
|
||||
organization: null,
|
||||
tier: null,
|
||||
tentativeTier: null,
|
||||
parentTournamentId: null,
|
||||
parentTournamentName: null,
|
||||
hasRules: false,
|
||||
logoUrl: "/test.avif",
|
||||
discordUrl: null,
|
||||
@@ -278,6 +276,40 @@ export const progressions = {
|
||||
],
|
||||
},
|
||||
],
|
||||
league: [
|
||||
{
|
||||
...DEFAULT_PROGRESSION_ARGS,
|
||||
type: "round_robin",
|
||||
name: "Division 1",
|
||||
},
|
||||
{
|
||||
...DEFAULT_PROGRESSION_ARGS,
|
||||
type: "single_elimination",
|
||||
name: "Division 1 Playoffs",
|
||||
sources: [
|
||||
{
|
||||
bracketIdx: 0,
|
||||
placements: [1, 2],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...DEFAULT_PROGRESSION_ARGS,
|
||||
type: "round_robin",
|
||||
name: "Division 2",
|
||||
},
|
||||
{
|
||||
...DEFAULT_PROGRESSION_ARGS,
|
||||
type: "single_elimination",
|
||||
name: "Division 2 Playoffs",
|
||||
sources: [
|
||||
{
|
||||
bracketIdx: 2,
|
||||
placements: [1, 2],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
swissOneGroup: [
|
||||
{
|
||||
...DEFAULT_PROGRESSION_ARGS,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { type LoaderFunctionArgs, redirect } from "react-router";
|
||||
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
|
||||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { tournamentDivisionsPage } from "~/utils/urls";
|
||||
import type { Bracket } from "../core/Bracket";
|
||||
import type { Tournament } from "../core/Tournament";
|
||||
import {
|
||||
@@ -17,6 +18,9 @@ export type TournamentBracketsLoaderData = SerializeFrom<typeof loader>;
|
||||
* Match data of the one bracket the view renders, selected by the `idx` search param.
|
||||
* The other brackets are represented by the layout's bracket state alone. Of a swiss
|
||||
* bracket only the group the view renders, selected by the `group` search param.
|
||||
*
|
||||
* Of a league only the brackets of one division are shown, selected by the `division`
|
||||
* search param. Reaching the page without one lands on the divisions page instead.
|
||||
*/
|
||||
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
||||
const { tournament, user } = await tournamentFromParams(params, {
|
||||
@@ -25,7 +29,19 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
||||
|
||||
const searchParams = tournamentBracketsSearchParams.parse(request);
|
||||
|
||||
const bracketIdx = resolveBracketIdx(tournament, searchParams.idx);
|
||||
const divisionIdx = resolveDivisionIdx(tournament, searchParams);
|
||||
const hasDivisionToShow =
|
||||
divisionIdx !== null &&
|
||||
tournament.visibleBracketsMetaOfDivision(divisionIdx).length > 0;
|
||||
if (tournament.isLeague && !hasDivisionToShow) {
|
||||
throw redirect(tournamentDivisionsPage(tournament.ctx.id));
|
||||
}
|
||||
|
||||
const bracketIdx = resolveBracketIdx(
|
||||
tournament,
|
||||
searchParams.idx,
|
||||
divisionIdx,
|
||||
);
|
||||
const bracket = tournament.bracketByIdx(bracketIdx);
|
||||
const groupId = resolveGroupId(bracket, searchParams.group);
|
||||
|
||||
@@ -41,6 +57,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
||||
|
||||
return {
|
||||
bracketIdx,
|
||||
divisionIdx,
|
||||
groupId,
|
||||
bracket: bracket
|
||||
? serializeBracket(bracket, {
|
||||
@@ -60,13 +77,39 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The division to show the brackets of, of a league. Falls back to the division of the selected
|
||||
* bracket, so that a link to one bracket of a division works without naming the division.
|
||||
*/
|
||||
function resolveDivisionIdx(
|
||||
tournament: Tournament,
|
||||
searchParams: { division: number | null; idx: number | null },
|
||||
) {
|
||||
if (!tournament.isLeague) return null;
|
||||
|
||||
const isDivision = tournament.leagueDivisions.some(
|
||||
(division) => division.idx === searchParams.division,
|
||||
);
|
||||
if (isDivision) {
|
||||
return searchParams.division;
|
||||
}
|
||||
|
||||
return searchParams.idx !== null
|
||||
? tournament.leagueDivisionOfBracket(searchParams.idx)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bracket to show, always one of the brackets the view actually renders a tab for. Without
|
||||
* a valid `idx` the first bracket, unless it is over and followed by a bracket the tournament
|
||||
* actually continues in.
|
||||
*/
|
||||
function resolveBracketIdx(tournament: Tournament, idx: number | null) {
|
||||
const visibleBrackets = tournament.visibleBracketsMeta;
|
||||
function resolveBracketIdx(
|
||||
tournament: Tournament,
|
||||
idx: number | null,
|
||||
divisionIdx: number | null,
|
||||
) {
|
||||
const visibleBrackets = tournament.visibleBracketsMetaOfDivision(divisionIdx);
|
||||
const isVisible = (idx: number) =>
|
||||
visibleBrackets.some((bracket) => bracket.idx === idx);
|
||||
|
||||
@@ -74,15 +117,17 @@ function resolveBracketIdx(tournament: Tournament, idx: number | null) {
|
||||
return idx;
|
||||
}
|
||||
|
||||
const brackets = tournament.bracketsMeta;
|
||||
const defaultIdx =
|
||||
const brackets = tournament.bracketsMetaOfDivision(divisionIdx);
|
||||
const defaultBracket =
|
||||
brackets.length <= 1 ||
|
||||
brackets[1].isUnderground ||
|
||||
!brackets[0].everyMatchOver
|
||||
? 0
|
||||
: 1;
|
||||
? brackets[0]
|
||||
: brackets[1];
|
||||
|
||||
return isVisible(defaultIdx) ? defaultIdx : (visibleBrackets[0]?.idx ?? 0);
|
||||
return defaultBracket && isVisible(defaultBracket.idx)
|
||||
? defaultBracket.idx
|
||||
: (visibleBrackets[0]?.idx ?? 0);
|
||||
}
|
||||
|
||||
function resolveGroupId(bracket: Bracket | null, groupId: number | null) {
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import { notFoundIfNullish } from "~/utils/remix.server";
|
||||
import type { Unwrapped } from "../../../utils/types";
|
||||
import {
|
||||
tournamentFromDB,
|
||||
tournamentFromParams,
|
||||
} from "../core/Tournament.server";
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const { tournamentId, user } = await tournamentFromParams(params, {
|
||||
for: "view",
|
||||
});
|
||||
|
||||
const divisions = notFoundIfNullish(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);
|
||||
|
||||
if (!tournament.isLeagueSignup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
tournamentDivisionsCache.set(
|
||||
tournamentId,
|
||||
await TournamentRepository.findChildTournaments(tournamentId),
|
||||
);
|
||||
}
|
||||
|
||||
return tournamentDivisionsCache.get(tournamentId)!;
|
||||
}
|
||||
@@ -56,7 +56,7 @@ import * as AbDivisions from "../core/AbDivisions";
|
||||
import type { Bracket as BracketType } from "../core/Bracket";
|
||||
import * as PreparedMaps from "../core/PreparedMaps";
|
||||
import * as Progression from "../core/Progression";
|
||||
import type { BracketMeta, Tournament } from "../core/Tournament";
|
||||
import type { Tournament } from "../core/Tournament";
|
||||
import {
|
||||
loader,
|
||||
type TournamentBracketsLoaderData,
|
||||
@@ -141,8 +141,7 @@ function TournamentBracketsView() {
|
||||
} = useBracketSpoilerCensor();
|
||||
|
||||
const showTeamActionsRow =
|
||||
(!tournament.isLeagueDivision && Boolean(teamProgressStatus)) ||
|
||||
showAddSubsButton;
|
||||
(!tournament.isLeague && Boolean(teamProgressStatus)) || showAddSubsButton;
|
||||
const showSecondaryActionsRow =
|
||||
tournament.canFinalize(user) || censored || canToggle;
|
||||
|
||||
@@ -218,17 +217,13 @@ function TournamentBracketsView() {
|
||||
});
|
||||
};
|
||||
|
||||
if (tournament.isLeagueSignup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Outlet context={ctx} />
|
||||
{showTeamActionsRow ? (
|
||||
<div className="stack horizontal mb-4 sm justify-between items-center">
|
||||
{/** TournamentTeamActions more confusing than helpful for leagues, for example might say "Waiting for match..." when previous match was rescheduled */}
|
||||
{!tournament.isLeagueDivision ? (
|
||||
{!tournament.isLeague ? (
|
||||
<TournamentTeamActions status={teamProgressStatus} />
|
||||
) : null}
|
||||
{showAddSubsButton ? <AddSubsPopOver /> : null}
|
||||
@@ -257,7 +252,10 @@ function TournamentBracketsView() {
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<BracketTabs loadedBracketIdx={data.bracketIdx}>
|
||||
<BracketTabs
|
||||
loadedBracketIdx={data.bracketIdx}
|
||||
divisionIdx={data.divisionIdx}
|
||||
>
|
||||
{bracket ? (
|
||||
<BracketTabContent
|
||||
bracket={bracket}
|
||||
@@ -297,32 +295,6 @@ function useScrollToMatchOnLoad() {
|
||||
}, [scrollToMatchId]);
|
||||
}
|
||||
|
||||
function eligibleTeamCountForBracket(
|
||||
tournament: Tournament,
|
||||
bracket: BracketMeta,
|
||||
) {
|
||||
if (bracket.sources) {
|
||||
return (
|
||||
(bracket.teamsPendingCheckIn ?? []).length +
|
||||
bracket.participantTournamentTeamIds.length
|
||||
);
|
||||
}
|
||||
|
||||
if (!tournament.isMultiStartingBracket) {
|
||||
return tournament.ctx.teams.length;
|
||||
}
|
||||
|
||||
return tournament.ctx.teams.filter(
|
||||
(team) => (team.startingBracketIdx ?? 0) === bracket.idx,
|
||||
).length;
|
||||
}
|
||||
|
||||
function bracketTabTeamCount(tournament: Tournament, bracket: BracketMeta) {
|
||||
return bracket.preview
|
||||
? eligibleTeamCountForBracket(tournament, bracket)
|
||||
: bracket.participantTournamentTeamIds.length;
|
||||
}
|
||||
|
||||
function getAbDivisionsStartError(
|
||||
bracket: BracketType,
|
||||
tournament: Tournament,
|
||||
@@ -538,19 +510,22 @@ function SubsPopover({ children }: { children: React.ReactNode }) {
|
||||
/**
|
||||
* Bracket switcher. Only the bracket the loader shipped the match data of is rendered;
|
||||
* switching navigates so that the newly selected bracket's data gets loaded, the
|
||||
* previously loaded bracket staying up until it arrives.
|
||||
* previously loaded bracket staying up until it arrives. Of a league only the brackets
|
||||
* of the division the loader resolved can be switched between.
|
||||
*/
|
||||
function BracketTabs({
|
||||
loadedBracketIdx,
|
||||
divisionIdx,
|
||||
children,
|
||||
}: {
|
||||
loadedBracketIdx: number;
|
||||
divisionIdx: number | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const tournament = useTournament();
|
||||
const [, setIdxParam] = useSearchParam(tournamentBracketsSearchParams, "idx");
|
||||
|
||||
const visibleBrackets = tournament.visibleBracketsMeta;
|
||||
const visibleBrackets = tournament.visibleBracketsMetaOfDivision(divisionIdx);
|
||||
|
||||
const bracketNameForTab = (name: string) => name.replace("bracket", "");
|
||||
|
||||
@@ -564,7 +539,7 @@ function BracketTabs({
|
||||
<SendouTab
|
||||
key={bracket.name}
|
||||
id={String(bracket.idx)}
|
||||
number={bracketTabTeamCount(tournament, bracket)}
|
||||
number={tournament.teamsCountOfBracket(bracket.idx)}
|
||||
>
|
||||
{bracketNameForTab(bracket.name)}
|
||||
</SendouTab>
|
||||
@@ -726,10 +701,8 @@ function StartBracketAlert({
|
||||
}
|
||||
|
||||
const abDivisionsStartError = getAbDivisionsStartError(bracket, tournament);
|
||||
const totalTeamsAvailableForTheBracket = eligibleTeamCountForBracket(
|
||||
tournament,
|
||||
tournament.bracketsMeta[bracketIdx],
|
||||
);
|
||||
const totalTeamsAvailableForTheBracket =
|
||||
tournament.eligibleTeamsCountOfBracket(bracketIdx);
|
||||
|
||||
return (
|
||||
<div className="stack items-center mb-4">
|
||||
|
||||
@@ -1,56 +1,68 @@
|
||||
import clsx from "clsx";
|
||||
import { Users } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLoaderData } from "react-router";
|
||||
import { Link } from "react-router";
|
||||
import { Redirect } from "~/components/Redirect";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useTournament } from "~/features/tournament/tournament-context";
|
||||
import { tournamentBracketsPage } from "~/features/tournament-bracket/tournament-bracket-urls";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
|
||||
import { loader } from "../loaders/to.$id.divisions.server";
|
||||
import type { BracketMeta } from "../core/Tournament";
|
||||
import styles from "./to.$id.divisions.module.css";
|
||||
|
||||
export { loader };
|
||||
|
||||
export default function TournamentDivisionsPage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const tournament = useTournament();
|
||||
const user = useUser();
|
||||
|
||||
if (data.divisions.length === 0) {
|
||||
const ownTeam = tournament.teamMemberOfByUser(user);
|
||||
const ownDivisionIdx = ownTeam ? (ownTeam.startingBracketIdx ?? 0) : null;
|
||||
|
||||
if (!tournament.isLeague) {
|
||||
return (
|
||||
<div className="text-center text-lg font-semi-bold text-lighter">
|
||||
Divisions have not been released yet, check back later
|
||||
</div>
|
||||
<Redirect
|
||||
to={tournamentBracketsPage({ tournamentId: tournament.ctx.id })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.grid}>
|
||||
{data.divisions.map((div) => (
|
||||
<DivisionLink key={div.tournamentId} div={div} />
|
||||
{tournament.leagueDivisions.map((division) => (
|
||||
<DivisionLink
|
||||
key={division.idx}
|
||||
division={division}
|
||||
isParticipant={ownDivisionIdx === division.idx}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DivisionLink({
|
||||
div,
|
||||
division,
|
||||
isParticipant,
|
||||
}: {
|
||||
div: SerializeFrom<typeof loader>["divisions"][number];
|
||||
division: BracketMeta;
|
||||
isParticipant: boolean;
|
||||
}) {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { t } = useTranslation(["calendar"]);
|
||||
const shortName = div.name.split("-").at(-1);
|
||||
const tournament = useTournament();
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={tournamentBracketsPage({ tournamentId: div.tournamentId })}
|
||||
className={clsx(styles.link, {
|
||||
[styles.participant]: data.divsParticipantOf.includes(div.tournamentId),
|
||||
to={tournamentBracketsPage({
|
||||
tournamentId: tournament.ctx.id,
|
||||
divisionIdx: division.idx,
|
||||
})}
|
||||
className={clsx(styles.link, {
|
||||
[styles.participant]: isParticipant,
|
||||
})}
|
||||
data-testid="division-link"
|
||||
>
|
||||
{shortName}
|
||||
{division.name}
|
||||
<div className={styles.participantCounts}>
|
||||
<Users />{" "}
|
||||
{t("calendar:count.teams", {
|
||||
count: div.teamsCount,
|
||||
count: tournament.teamsCountOfBracket(division.idx),
|
||||
})}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -10,6 +10,7 @@ describe("tournamentBracketsSearchParams", () => {
|
||||
assertRoundTrips(tournamentBracketsSearchParams, {
|
||||
idx: [0, 3],
|
||||
group: [1, 173],
|
||||
division: [0, 24],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,5 +21,9 @@ describe("tournamentBracketsSearchParams", () => {
|
||||
["1.5"],
|
||||
]);
|
||||
assertDecodesToDefault(tournamentBracketsSearchParams, "group", [["abc"]]);
|
||||
assertDecodesToDefault(tournamentBracketsSearchParams, "division", [
|
||||
["-1"],
|
||||
["abc"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,4 +11,12 @@ export const tournamentBracketsSearchParams = SearchParams.define({
|
||||
group: SP.param(v.nullable(v.pipe(v.number(), v.integer())), {
|
||||
loader: true,
|
||||
}),
|
||||
/** Starting bracket idx of the league division whose brackets are shown. Leagues only. */
|
||||
division: SP.param(
|
||||
v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0))),
|
||||
{
|
||||
loader: true,
|
||||
resets: ["idx", "group"],
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
@@ -4,12 +4,15 @@ export const tournamentBracketsPage = ({
|
||||
tournamentId,
|
||||
bracketIdx,
|
||||
groupId,
|
||||
divisionIdx,
|
||||
}: {
|
||||
tournamentId: number;
|
||||
bracketIdx?: number | null;
|
||||
groupId?: number;
|
||||
divisionIdx?: number | null;
|
||||
}) =>
|
||||
tournamentBracketsSearchParams.href(`/to/${tournamentId}/brackets`, {
|
||||
idx: bracketIdx ?? null,
|
||||
group: groupId ?? null,
|
||||
division: divisionIdx ?? null,
|
||||
});
|
||||
|
||||
@@ -35,10 +35,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
if (tournament.isLeagueSignup && !tournament.registrationOpen) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
if (user) {
|
||||
await resolveNotifications({
|
||||
userIds: [user.id],
|
||||
|
||||
@@ -197,7 +197,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
url: tournamentMatchPage({ tournamentId, matchId }),
|
||||
imageUrl: tournament.ctx.logoUrl,
|
||||
participantUserIds: playerIds,
|
||||
expiresAfter: tournament.isLeagueDivision ? { days: 30 } : { hours: 2 },
|
||||
expiresAfter: tournament.isLeague ? { days: 30 } : { hours: 2 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
tournament.ctx.isFinalized && !isSiteStaff && !isTournamentStaff
|
||||
? true
|
||||
: !chatAccessible({
|
||||
expiresAfterDays: tournament.isLeagueDivision ? 30 : 7,
|
||||
expiresAfterDays: tournament.isLeague ? 30 : 7,
|
||||
comparedTo: tournament.ctx.startsAt,
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ export const TOURNAMENT_SERIES_LEADERBOARD_SIZE = 50;
|
||||
|
||||
export const MONTH_PARAM_FORMAT = "yyyy-MM";
|
||||
|
||||
/** Id of the "Leagues Under The Ink" (LUTI) organization. */
|
||||
export const LUTI_ORGANIZATION_ID = 19;
|
||||
|
||||
export const ESTABLISHED_ORG = {
|
||||
MONTHS_CONSIDERED: 6,
|
||||
GAIN_THRESHOLD: 150,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import * as TournamentRepository from "./TournamentRepository.server";
|
||||
|
||||
const users = UserFactory.pool();
|
||||
const authorId = () => users.id(1);
|
||||
|
||||
describe("TournamentRepository.upsertDivisionTier", () => {
|
||||
beforeEach(async () => {
|
||||
await users.create(1);
|
||||
});
|
||||
|
||||
const createTournament = async () => {
|
||||
const { id } = await TournamentFactory.create({ authorId: authorId() });
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
const tierOf = async (tournamentId: number) =>
|
||||
(await TournamentRepository.findById(tournamentId))?.tier;
|
||||
|
||||
test("gives the tournament the tier of its only division", async () => {
|
||||
const tournamentId = await createTournament();
|
||||
|
||||
await TournamentRepository.upsertDivisionTier({
|
||||
tournamentId,
|
||||
bracketIdx: 0,
|
||||
tier: 4,
|
||||
});
|
||||
|
||||
expect(await tierOf(tournamentId)).toBe(4);
|
||||
});
|
||||
|
||||
test("gives the tournament the best tier of its divisions", async () => {
|
||||
const tournamentId = await createTournament();
|
||||
|
||||
await TournamentRepository.upsertDivisionTier({
|
||||
tournamentId,
|
||||
bracketIdx: 0,
|
||||
tier: 3,
|
||||
});
|
||||
await TournamentRepository.upsertDivisionTier({
|
||||
tournamentId,
|
||||
bracketIdx: 2,
|
||||
tier: 7,
|
||||
});
|
||||
|
||||
expect(await tierOf(tournamentId)).toBe(3);
|
||||
});
|
||||
|
||||
test("keeps the best tier when a stronger division is tiered last", async () => {
|
||||
const tournamentId = await createTournament();
|
||||
|
||||
await TournamentRepository.upsertDivisionTier({
|
||||
tournamentId,
|
||||
bracketIdx: 0,
|
||||
tier: 7,
|
||||
});
|
||||
await TournamentRepository.upsertDivisionTier({
|
||||
tournamentId,
|
||||
bracketIdx: 2,
|
||||
tier: 3,
|
||||
});
|
||||
|
||||
expect(await tierOf(tournamentId)).toBe(3);
|
||||
});
|
||||
|
||||
// a replaced tier stops counting: the tournament would still be tier 3 if the row was kept
|
||||
test("replaces the tier of a division that is tiered again", async () => {
|
||||
const tournamentId = await createTournament();
|
||||
|
||||
await TournamentRepository.upsertDivisionTier({
|
||||
tournamentId,
|
||||
bracketIdx: 0,
|
||||
tier: 3,
|
||||
});
|
||||
await TournamentRepository.upsertDivisionTier({
|
||||
tournamentId,
|
||||
bracketIdx: 2,
|
||||
tier: 6,
|
||||
});
|
||||
await TournamentRepository.upsertDivisionTier({
|
||||
tournamentId,
|
||||
bracketIdx: 0,
|
||||
tier: 8,
|
||||
});
|
||||
|
||||
expect(await tierOf(tournamentId)).toBe(6);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
|
||||
import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory";
|
||||
import * as TrophyFactory from "~/db/seed/factories/TrophyFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import { db } from "~/db/sql";
|
||||
import type { TournamentSummary } from "../tournament-bracket/core/summarizer.server";
|
||||
import * as TournamentRepository from "./TournamentRepository.server";
|
||||
import * as TournamentTeamRepository from "./TournamentTeamRepository.server";
|
||||
|
||||
/** SQLite binds at most 32,766 parameters per statement and `PlayerResult` has
|
||||
* eight columns, so one multi-row insert fits this many rows at most. */
|
||||
@@ -244,6 +246,7 @@ describe("TournamentRepository.finalize", () => {
|
||||
|
||||
expect(second.matchesCount).toBe(8);
|
||||
});
|
||||
|
||||
test("finalizes a tournament with more player result deltas than fit in one insert statement", async () => {
|
||||
const { id: tournamentId } = await createTournament();
|
||||
const playerResultDeltas = playerResultDeltasForEveryPair(users.ids());
|
||||
@@ -266,4 +269,80 @@ describe("TournamentRepository.finalize", () => {
|
||||
|
||||
expect(inserted.count).toBe(playerResultDeltas.length);
|
||||
});
|
||||
|
||||
describe("trophy of a tournament with many divisions", () => {
|
||||
const TOP_DIVISION_TIER = 2;
|
||||
const LOW_DIVISION_TIER = 7;
|
||||
|
||||
const finalizeWithTrophyWonBy = async ({
|
||||
startingBracketIdx,
|
||||
divisionTiers = true,
|
||||
}: {
|
||||
startingBracketIdx: number;
|
||||
divisionTiers?: boolean;
|
||||
}) => {
|
||||
const { id: tournamentId } = await TournamentFactory.create(
|
||||
{ authorId: users.id(1) },
|
||||
{ tier: TOP_DIVISION_TIER },
|
||||
);
|
||||
const trophy = await TrophyFactory.create();
|
||||
const { id: tournamentTeamId } = await TournamentTeamFactory.create({
|
||||
tournamentId,
|
||||
memberUserIds: [users.id(1)],
|
||||
});
|
||||
|
||||
await TournamentTeamRepository.updateStartingBrackets([
|
||||
{ tournamentTeamId, startingBracketIdx },
|
||||
]);
|
||||
if (divisionTiers) {
|
||||
await TournamentRepository.upsertDivisionTier({
|
||||
tournamentId,
|
||||
bracketIdx: 1,
|
||||
tier: LOW_DIVISION_TIER,
|
||||
});
|
||||
}
|
||||
|
||||
await TournamentRepository.finalize({
|
||||
tournamentId,
|
||||
season: undefined,
|
||||
summary: {
|
||||
...emptySummary([]),
|
||||
tournamentResults: [
|
||||
{
|
||||
userId: users.id(1),
|
||||
placement: 1,
|
||||
participantCount: 1,
|
||||
tournamentTeamId,
|
||||
div: null,
|
||||
},
|
||||
],
|
||||
setResults: new Map([[users.id(1), ["W"]]]),
|
||||
},
|
||||
trophyReceiver: { trophyId: trophy.id, userIds: [users.id(1)] },
|
||||
});
|
||||
|
||||
const owner = await db
|
||||
.selectFrom("TrophyOwner")
|
||||
.select("tier")
|
||||
.where("tournamentId", "=", tournamentId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return owner.tier;
|
||||
};
|
||||
|
||||
test("records the tier of the division it was won in", async () => {
|
||||
expect(await finalizeWithTrophyWonBy({ startingBracketIdx: 1 })).toBe(
|
||||
LOW_DIVISION_TIER,
|
||||
);
|
||||
});
|
||||
|
||||
test("records the tournament's tier when the division has none", async () => {
|
||||
expect(
|
||||
await finalizeWithTrophyWonBy({
|
||||
startingBracketIdx: 1,
|
||||
divisionTiers: false,
|
||||
}),
|
||||
).toBe(TOP_DIVISION_TIER);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { sub } from "date-fns";
|
||||
import { type Insertable, type NotNull, sql, type Transaction } from "kysely";
|
||||
import {
|
||||
type Insertable,
|
||||
type NotNull,
|
||||
type SqlBool,
|
||||
sql,
|
||||
type Transaction,
|
||||
} from "kysely";
|
||||
import { ordinal } from "openskill";
|
||||
import * as R from "remeda";
|
||||
import { db } from "~/db/sql";
|
||||
@@ -58,16 +64,6 @@ export async function findById(id: number) {
|
||||
"Tournament.castedMatchesInfo",
|
||||
"Tournament.mapPickingStyle",
|
||||
sql<boolean>`"Tournament"."rules" is not null`.as("hasRules"),
|
||||
"Tournament.parentTournamentId",
|
||||
eb
|
||||
.selectFrom("CalendarEvent as ParentCalendarEvent")
|
||||
.select("ParentCalendarEvent.name")
|
||||
.whereRef(
|
||||
"ParentCalendarEvent.tournamentId",
|
||||
"=",
|
||||
"Tournament.parentTournamentId",
|
||||
)
|
||||
.as("parentTournamentName"),
|
||||
"Tournament.tier",
|
||||
"CalendarEvent.name",
|
||||
"CalendarEventDate.startsAt",
|
||||
@@ -703,67 +699,6 @@ export async function findSeedingSnapshotById(tournamentId: number) {
|
||||
return row?.seedingSnapshot ?? null;
|
||||
}
|
||||
|
||||
export async function hasChildTournaments(parentTournamentId: number) {
|
||||
const row = await db
|
||||
.selectFrom("Tournament")
|
||||
.select("Tournament.id")
|
||||
.where("Tournament.parentTournamentId", "=", parentTournamentId)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
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")
|
||||
.where("TournamentTeam.isPlaceholder", "=", 0)
|
||||
.as("teamsCount"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("TournamentTeam")
|
||||
.innerJoin(
|
||||
"TournamentTeamMember",
|
||||
"TournamentTeamMember.tournamentTeamId",
|
||||
"TournamentTeam.id",
|
||||
)
|
||||
.select(["TournamentTeamMember.userId"])
|
||||
.whereRef("TournamentTeam.tournamentId", "=", "Tournament.id")
|
||||
.where("TournamentTeam.isPlaceholder", "=", 0),
|
||||
).as("teamMembers"),
|
||||
])
|
||||
.where("Tournament.parentTournamentId", "=", parentTournamentId)
|
||||
.$narrowType<{ teamsCount: NotNull }>()
|
||||
.execute();
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
participantUserIds: new Set(row.teamMembers.map((member) => member.userId)),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Child division tournaments of a league sign-up, with their name and finalized status. */
|
||||
export function findChildTournamentsForDivCalc(parentTournamentId: number) {
|
||||
return db
|
||||
.selectFrom("Tournament")
|
||||
.innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId")
|
||||
.select([
|
||||
"Tournament.id as tournamentId",
|
||||
"CalendarEvent.name",
|
||||
"Tournament.isFinalized",
|
||||
])
|
||||
.where("Tournament.parentTournamentId", "=", parentTournamentId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user results of a finalized tournament as persisted at finalization time.
|
||||
* Empty for tournaments that have not been finalized.
|
||||
@@ -783,22 +718,58 @@ export function findResultsByTournamentId(tournamentId: number) {
|
||||
}
|
||||
|
||||
/**
|
||||
* User ids eligible for a LUTI division placement in the given tournament: they have a result, were
|
||||
* on a team that did not drop out, and played at least one match.
|
||||
* Participants of the latest finalized league of the given organization, along with the bracket
|
||||
* progression that tells what division (= starting bracket) each of them played in.
|
||||
*
|
||||
* Only participants eligible for a division placement are included: they have a result, were on a
|
||||
* team that did not drop out, and played at least one match. Null if the organization has no
|
||||
* finalized league.
|
||||
*/
|
||||
export function findLeagueDivParticipantUserIds(tournamentId: number) {
|
||||
return db
|
||||
export async function findLatestFinalizedLeagueParticipants(args: {
|
||||
organizationId: number;
|
||||
namePrefix: string;
|
||||
}) {
|
||||
const league = await db
|
||||
.selectFrom("Tournament")
|
||||
.innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId")
|
||||
.innerJoin(
|
||||
"CalendarEventDate",
|
||||
"CalendarEvent.id",
|
||||
"CalendarEventDate.eventId",
|
||||
)
|
||||
.select(["Tournament.id", "Tournament.settings"])
|
||||
.where("CalendarEvent.organizationId", "=", args.organizationId)
|
||||
.where("CalendarEvent.name", "like", `${args.namePrefix}%`)
|
||||
.where("Tournament.isFinalized", "=", 1)
|
||||
.where(
|
||||
sql<number>`json_extract("Tournament"."settings", '$.isLeague')`,
|
||||
"=",
|
||||
1,
|
||||
)
|
||||
.orderBy("CalendarEventDate.startsAt", "desc")
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!league) return null;
|
||||
|
||||
const participants = await db
|
||||
.selectFrom("TournamentResult")
|
||||
.innerJoin(
|
||||
"TournamentTeam",
|
||||
"TournamentTeam.id",
|
||||
"TournamentResult.tournamentTeamId",
|
||||
)
|
||||
.select("TournamentResult.userId")
|
||||
.select(["TournamentResult.userId", "TournamentTeam.startingBracketIdx"])
|
||||
.distinct()
|
||||
.where("TournamentResult.tournamentId", "=", tournamentId)
|
||||
.where("TournamentResult.tournamentId", "=", league.id)
|
||||
.where("TournamentTeam.droppedOut", "=", 0)
|
||||
.execute();
|
||||
|
||||
return {
|
||||
tournamentId: league.id,
|
||||
bracketProgression: league.settings.bracketProgression,
|
||||
participants,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findTOSetMapPoolById(tournamentId: number) {
|
||||
@@ -1591,11 +1562,12 @@ export function finalize({
|
||||
await trx.insertInto("TournamentBadgeOwner").values(badgeOwners).execute();
|
||||
|
||||
if (trophyReceiver && trophyReceiver.userIds.length > 0) {
|
||||
const tournamentRow = await trx
|
||||
.selectFrom("Tournament")
|
||||
.select("tier")
|
||||
.where("id", "=", tournamentId)
|
||||
.executeTakeFirst();
|
||||
const tier = await trophyTier(trx, {
|
||||
tournamentId,
|
||||
tournamentTeamId: summary.tournamentResults.find((result) =>
|
||||
trophyReceiver.userIds.includes(result.userId),
|
||||
)?.tournamentTeamId,
|
||||
});
|
||||
|
||||
await trx
|
||||
.insertInto("TrophyOwner")
|
||||
@@ -1604,7 +1576,7 @@ export function finalize({
|
||||
tournamentId,
|
||||
trophyId: trophyReceiver.trophyId,
|
||||
userId,
|
||||
tier: tournamentRow?.tier ?? null,
|
||||
tier,
|
||||
})),
|
||||
)
|
||||
.onConflict((oc) =>
|
||||
@@ -1781,18 +1753,43 @@ export function updateTeamSeeds({
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTournamentTier({
|
||||
/**
|
||||
* Records the tier of one division (= starting bracket), calculated from the teams that checked in
|
||||
* to it, and updates the tournament's own tier to the best tier of its divisions. Tournaments where
|
||||
* every team plays the same bracket have one division, making the two the same.
|
||||
*/
|
||||
export async function upsertDivisionTier({
|
||||
tournamentId,
|
||||
bracketIdx,
|
||||
tier,
|
||||
}: {
|
||||
tournamentId: number;
|
||||
bracketIdx: number;
|
||||
tier: TournamentTierNumber;
|
||||
}) {
|
||||
return db
|
||||
.updateTable("Tournament")
|
||||
.set({ tier })
|
||||
.where("id", "=", tournamentId)
|
||||
.execute();
|
||||
await db.transaction().execute(async (trx) => {
|
||||
await trx
|
||||
.insertInto("TournamentDivisionTier")
|
||||
.values({ tournamentId, bracketIdx, tier })
|
||||
.onConflict((oc) =>
|
||||
oc.columns(["tournamentId", "bracketIdx"]).doUpdateSet({ tier }),
|
||||
)
|
||||
.execute();
|
||||
|
||||
const best = await trx
|
||||
.selectFrom("TournamentDivisionTier")
|
||||
.select(({ fn }) =>
|
||||
fn.min<TournamentTierNumber>("TournamentDivisionTier.tier").as("tier"),
|
||||
)
|
||||
.where("TournamentDivisionTier.tournamentId", "=", tournamentId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await trx
|
||||
.updateTable("Tournament")
|
||||
.set({ tier: best.tier })
|
||||
.where("id", "=", tournamentId)
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
export async function findRunningTournamentIds() {
|
||||
@@ -1828,3 +1825,41 @@ export async function findRunningTournamentIds() {
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier the trophy was won at: the tier of the division the winning team played in, falling back to
|
||||
* the tournament's own tier when the team is not known or its division was never tiered.
|
||||
*/
|
||||
async function trophyTier(
|
||||
trx: Transaction<DB>,
|
||||
{
|
||||
tournamentId,
|
||||
tournamentTeamId,
|
||||
}: { tournamentId: number; tournamentTeamId?: number },
|
||||
) {
|
||||
const divisionTier = tournamentTeamId
|
||||
? await trx
|
||||
.selectFrom("TournamentDivisionTier")
|
||||
.innerJoin(
|
||||
"TournamentTeam",
|
||||
"TournamentTeam.tournamentId",
|
||||
"TournamentDivisionTier.tournamentId",
|
||||
)
|
||||
.select("TournamentDivisionTier.tier")
|
||||
.where("TournamentTeam.id", "=", tournamentTeamId)
|
||||
.where(
|
||||
sql<SqlBool>`"TournamentDivisionTier"."bracketIdx" = coalesce("TournamentTeam"."startingBracketIdx", 0)`,
|
||||
)
|
||||
.executeTakeFirst()
|
||||
: undefined;
|
||||
|
||||
if (divisionTier) return divisionTier.tier;
|
||||
|
||||
const tournament = await trx
|
||||
.selectFrom("Tournament")
|
||||
.select("tier")
|
||||
.where("id", "=", tournamentId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return tournament?.tier ?? null;
|
||||
}
|
||||
|
||||
@@ -505,107 +505,6 @@ async function resolveInGameName(
|
||||
return user.inGameName;
|
||||
}
|
||||
|
||||
export function copyFromAnotherTournament({
|
||||
tournamentTeamId,
|
||||
destinationTournamentId,
|
||||
seed,
|
||||
defaultCheckedIn = false,
|
||||
}: {
|
||||
tournamentTeamId: number;
|
||||
destinationTournamentId: number;
|
||||
seed?: number;
|
||||
defaultCheckedIn?: boolean;
|
||||
}) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const oldTeam = await trx
|
||||
.selectFrom("TournamentTeam")
|
||||
.select([
|
||||
"TournamentTeam.avatarImgId",
|
||||
"TournamentTeam.createdAt",
|
||||
"TournamentTeam.name",
|
||||
"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.role",
|
||||
"TournamentTeamMember.userId",
|
||||
"TournamentTeamMember.isSub",
|
||||
"TournamentTeamMember.isOrganizerAdded",
|
||||
|
||||
// -- 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: shortNanoid(),
|
||||
seed,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
if (defaultCheckedIn) {
|
||||
await trx
|
||||
.insertInto("TournamentTeamCheckIn")
|
||||
.values({
|
||||
checkedInAt: databaseTimestampNow(),
|
||||
tournamentTeamId: newTeam.id,
|
||||
bracketIdx: null,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
await trx
|
||||
.insertInto("TournamentTeamMember")
|
||||
.values(
|
||||
oldMembers.map((member) => ({
|
||||
...member,
|
||||
tournamentTeamId: newTeam.id,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await trx
|
||||
.insertInto("MapPoolMap")
|
||||
.values(
|
||||
oldMapPool.map((mapPoolMap) => ({
|
||||
...mapPoolMap,
|
||||
tournamentTeamId: newTeam.id,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
export function update({
|
||||
team,
|
||||
avatarImgId,
|
||||
|
||||
@@ -353,7 +353,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
"You cannot unregister after checking in",
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
!tournament.isLeagueSignup || tournament.registrationOpen,
|
||||
!tournament.isLeague || tournament.registrationOpen,
|
||||
"Unregistering from leagues is not possible after registration has closed",
|
||||
);
|
||||
|
||||
|
||||
@@ -21,19 +21,15 @@ import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-con
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
|
||||
import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect";
|
||||
import {
|
||||
tournamentDivisionsPage,
|
||||
tournamentInfoPage,
|
||||
tournamentRulesPage,
|
||||
} from "~/utils/urls";
|
||||
import { tournamentInfoPage, tournamentRulesPage } from "~/utils/urls";
|
||||
import { tournamentNameParts } from "../tournament-utils";
|
||||
import styles from "./TournamentNav.module.css";
|
||||
|
||||
type NavItemKey =
|
||||
| "register"
|
||||
| "brackets"
|
||||
| "teams"
|
||||
| "divisions"
|
||||
| "teams"
|
||||
| "streams"
|
||||
| "results"
|
||||
| "rules"
|
||||
@@ -52,10 +48,10 @@ interface NavItem {
|
||||
const PRIORITY_ORDER: NavItemKey[] = [
|
||||
"register",
|
||||
"brackets",
|
||||
"divisions",
|
||||
"teams",
|
||||
"results",
|
||||
"lfg",
|
||||
"divisions",
|
||||
"streams",
|
||||
"rules",
|
||||
"admin",
|
||||
@@ -64,18 +60,12 @@ const PRIORITY_ORDER: NavItemKey[] = [
|
||||
export function TournamentNav({
|
||||
tournament,
|
||||
streamsCount,
|
||||
hasChildTournaments,
|
||||
}: {
|
||||
tournament: Tournament;
|
||||
streamsCount: number;
|
||||
hasChildTournaments: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
const navItems = useNavItems({
|
||||
tournament,
|
||||
streamsCount,
|
||||
hasChildTournaments,
|
||||
});
|
||||
const navItems = useNavItems({ tournament, streamsCount });
|
||||
const { visibleCount, containerRef, measureRef } = useNavOverflow(
|
||||
navItems.length,
|
||||
);
|
||||
@@ -85,9 +75,7 @@ export function TournamentNav({
|
||||
|
||||
const { name, subtext } = tournamentNameParts(tournament);
|
||||
|
||||
const homeHref = tournament.isLeagueDivision
|
||||
? tournamentInfoPage(tournament.ctx.parentTournamentId!)
|
||||
: tournamentInfoPage(tournament.ctx.id);
|
||||
const homeHref = tournamentInfoPage(tournament.ctx.id);
|
||||
|
||||
return (
|
||||
<nav className={styles.nav} aria-label={t("tournament:nav.label")}>
|
||||
@@ -152,11 +140,9 @@ export function TournamentNav({
|
||||
function useNavItems({
|
||||
tournament,
|
||||
streamsCount,
|
||||
hasChildTournaments,
|
||||
}: {
|
||||
tournament: Tournament;
|
||||
streamsCount: number;
|
||||
hasChildTournaments: boolean;
|
||||
}): NavItem[] {
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
const user = useUser();
|
||||
@@ -180,8 +166,16 @@ function useNavItems({
|
||||
};
|
||||
}
|
||||
|
||||
const showBrackets = !tournament.isLeagueSignup;
|
||||
if (showBrackets) {
|
||||
// a league's brackets are reached through its divisions page, one division at a time
|
||||
if (tournament.isLeague) {
|
||||
items.divisions = {
|
||||
key: "divisions",
|
||||
label: t("tournament:nav.divisions"),
|
||||
to: "divisions",
|
||||
icon: <LayoutGrid />,
|
||||
testId: "divisions-tab",
|
||||
};
|
||||
} else {
|
||||
items.brackets = {
|
||||
key: "brackets",
|
||||
label: t("tournament:nav.brackets"),
|
||||
@@ -191,30 +185,16 @@ function useNavItems({
|
||||
};
|
||||
}
|
||||
|
||||
const showTeams = !(tournament.isLeagueSignup && hasChildTournaments);
|
||||
if (showTeams) {
|
||||
items.teams = {
|
||||
key: "teams",
|
||||
label: t("tournament:nav.teams", {
|
||||
count: tournament.ctx.teams.length,
|
||||
}),
|
||||
to: "teams",
|
||||
icon: <Users />,
|
||||
end: false,
|
||||
testId: "teams-tab",
|
||||
};
|
||||
}
|
||||
|
||||
if (tournament.isLeagueSignup || tournament.isLeagueDivision) {
|
||||
items.divisions = {
|
||||
key: "divisions",
|
||||
label: t("tournament:nav.divisions"),
|
||||
to: tournamentDivisionsPage(
|
||||
tournament.ctx.parentTournamentId ?? tournament.ctx.id,
|
||||
),
|
||||
icon: <LayoutGrid />,
|
||||
};
|
||||
}
|
||||
items.teams = {
|
||||
key: "teams",
|
||||
label: t("tournament:nav.teams", {
|
||||
count: tournament.ctx.teams.length,
|
||||
}),
|
||||
to: "teams",
|
||||
icon: <Users />,
|
||||
end: false,
|
||||
testId: "teams-tab",
|
||||
};
|
||||
|
||||
if (tournament.hasStarted && !tournament.everyBracketOver) {
|
||||
items.streams = {
|
||||
@@ -249,7 +229,6 @@ function useNavItems({
|
||||
const showLfg =
|
||||
!tournament.isInvitational &&
|
||||
!tournament.everyBracketOver &&
|
||||
!(tournament.isLeagueSignup && !tournament.registrationOpen) &&
|
||||
tournament.lfgEnabled;
|
||||
if (showLfg) {
|
||||
items.lfg = {
|
||||
|
||||
@@ -2,10 +2,7 @@ import { isAfter, subDays } from "date-fns";
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import {
|
||||
LEAGUES,
|
||||
TOURNAMENT,
|
||||
} from "~/features/tournament/tournament-constants";
|
||||
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
|
||||
import {
|
||||
bracketsMetaCached,
|
||||
requireTournamentVisible,
|
||||
@@ -23,7 +20,6 @@ export type TournamentLoaderData = {
|
||||
tournament: TournamentLayoutData;
|
||||
/** Count for the streams tab badge; the streams view loads the actual streams itself. */
|
||||
streamsCount: number;
|
||||
hasChildTournaments: boolean;
|
||||
friendCodes:
|
||||
| Awaited<
|
||||
ReturnType<typeof TournamentRepository.findFriendCodesByTournamentId>
|
||||
@@ -45,7 +41,8 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const tournament = await tournamentDataCached(tournamentId);
|
||||
requireTournamentVisible({ ctx: tournament.ctx, user });
|
||||
|
||||
const friendCodeVisibilityDays = tournament.ctx.parentTournamentId ? 120 : 30;
|
||||
// leagues run for many weeks, so their friend codes stay visible for longer
|
||||
const friendCodeVisibilityDays = tournament.ctx.settings.isLeague ? 120 : 30;
|
||||
const tournamentStartedRecently = isAfter(
|
||||
databaseTimestampToDate(tournament.ctx.startsAt),
|
||||
subDays(new Date(), friendCodeVisibilityDays),
|
||||
@@ -54,13 +51,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
tournamentStartedRecently &&
|
||||
hasPermission(tournament.ctx, "ORGANIZE", user);
|
||||
|
||||
const isLeagueSignup = Object.values(LEAGUES)
|
||||
.flat()
|
||||
.some((entry) => entry.tournamentId === tournamentId);
|
||||
const hasChildTournaments = isLeagueSignup
|
||||
? await TournamentRepository.hasChildTournaments(tournamentId)
|
||||
: false;
|
||||
|
||||
const showVods =
|
||||
tournament.ctx.isFinalized &&
|
||||
isAfter(
|
||||
@@ -74,7 +64,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
bracketsMeta: await bracketsMetaCached(tournamentId),
|
||||
},
|
||||
streamsCount: tournament.streams.length,
|
||||
hasChildTournaments,
|
||||
friendCodes: showFriendCodes
|
||||
? await TournamentRepository.findFriendCodesByTournamentId(tournamentId)
|
||||
: undefined,
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { redirect } from "react-router";
|
||||
import { notFoundIfNullish } 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 = notFoundIfNullish(maybeLatest);
|
||||
|
||||
return redirect(tournamentPage(latest.tournamentId));
|
||||
};
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { useTournament } from "~/features/tournament/tournament-context";
|
||||
import { tournamentJoinPage } from "~/features/tournament/tournament-urls";
|
||||
import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { LUTI_ORGANIZATION_ID } from "~/features/tournament-organization/tournament-organization-constants";
|
||||
import { FormField } from "~/form/FormField";
|
||||
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
|
||||
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
||||
@@ -221,7 +222,10 @@ function RegistrationForms({ readOnly = false }: { readOnly?: boolean }) {
|
||||
canUnregister={Boolean(ownTeam && !ownTeamCheckedIn)}
|
||||
/>
|
||||
) : null}
|
||||
{tournament.isLeagueSignup ? <GoogleFormsLink /> : null}
|
||||
{tournament.isLeague &&
|
||||
tournament.ctx.organization?.id === LUTI_ORGANIZATION_ID ? (
|
||||
<GoogleFormsLink />
|
||||
) : null}
|
||||
{ownTeam && hasFriendCodeSet ? (
|
||||
<>
|
||||
<FillRoster ownTeam={ownTeam} ownTeamCheckedIn={ownTeamCheckedIn} />
|
||||
@@ -300,13 +304,13 @@ function RegistrationProgress({
|
||||
status: completedIfTruthy(mapPool && mapPool.length > 0),
|
||||
}
|
||||
: null,
|
||||
!tournament.isLeagueSignup
|
||||
!tournament.isLeague
|
||||
? {
|
||||
name: t("tournament:pre.steps.check-in"),
|
||||
status: completedIfTruthy(checkedIn),
|
||||
}
|
||||
: null,
|
||||
tournament.isLeagueSignup
|
||||
tournament.isLeague
|
||||
? {
|
||||
name: "Google Sheet",
|
||||
status: "notice" as const,
|
||||
@@ -320,7 +324,7 @@ function RegistrationProgress({
|
||||
|
||||
const registrationClosesAtString =
|
||||
registrationClosesFormatter.format(
|
||||
tournament.isLeagueSignup
|
||||
tournament.isLeague
|
||||
? tournament.ctx.startsAt
|
||||
: tournament.registrationClosesAt,
|
||||
) ?? "";
|
||||
@@ -355,7 +359,7 @@ function RegistrationProgress({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!tournament.isLeagueSignup ? (
|
||||
{!tournament.isLeague ? (
|
||||
<CheckIn
|
||||
canCheckIn={
|
||||
steps.filter((step) => step.status === "incomplete").length === 1
|
||||
@@ -367,7 +371,7 @@ function RegistrationProgress({
|
||||
) : null}
|
||||
</section>
|
||||
<div className={styles.sectionWarning}>
|
||||
{regClosesBeforeStart || tournament.isLeagueSignup ? (
|
||||
{regClosesBeforeStart || tournament.isLeague ? (
|
||||
<span className="text-warning">
|
||||
Registration closes at {registrationClosesAtString}
|
||||
</span>
|
||||
@@ -501,7 +505,7 @@ function TeamInfo({
|
||||
1. {t("tournament:pre.info.header")}
|
||||
</h3>
|
||||
{canUnregister &&
|
||||
tournament.isLeagueSignup &&
|
||||
tournament.isLeague &&
|
||||
!tournament.registrationOpen ? (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import { useLoaderData } from "react-router";
|
||||
import { Pagination } from "~/components/Pagination";
|
||||
import { Redirect } from "~/components/Redirect";
|
||||
import { useTournament } from "~/features/tournament/tournament-context";
|
||||
import { useSearchParamPagination } from "~/hooks/useSearchParamPagination";
|
||||
import { tournamentDivisionsPage, tournamentTeamPage } from "~/utils/urls";
|
||||
import { tournamentTeamPage } from "~/utils/urls";
|
||||
import { TeamWithRoster } from "../components/TeamWithRoster";
|
||||
import type { TournamentTeamsLoaderData } from "../loaders/to.$id.teams.server";
|
||||
import { tournamentTeamsSearchParams } from "../tournament-search-params";
|
||||
import { getBracketProgressionLabel } from "../tournament-utils";
|
||||
import { useHasChildTournaments } from "./to.$id";
|
||||
|
||||
export { loader } from "../loaders/to.$id.teams.server";
|
||||
|
||||
export default function TournamentTeamsPage() {
|
||||
const tournament = useTournament();
|
||||
const hasChildTournaments = useHasChildTournaments();
|
||||
const data = useLoaderData<TournamentTeamsLoaderData>();
|
||||
const pagination = useSearchParamPagination({
|
||||
definition: tournamentTeamsSearchParams,
|
||||
@@ -22,10 +19,6 @@ export default function TournamentTeamsPage() {
|
||||
pagesCount: data.pagesCount,
|
||||
});
|
||||
|
||||
if (tournament.isLeagueSignup && hasChildTournaments) {
|
||||
return <Redirect to={tournamentDivisionsPage(tournament.ctx.id)} />;
|
||||
}
|
||||
|
||||
const seedInfoByTeamId = teamSeedInfo(tournament);
|
||||
|
||||
return (
|
||||
|
||||
@@ -111,11 +111,7 @@ export function TournamentLayout() {
|
||||
}
|
||||
const content = (
|
||||
<>
|
||||
<TournamentNav
|
||||
tournament={tournament}
|
||||
streamsCount={data.streamsCount}
|
||||
hasChildTournaments={data.hasChildTournaments}
|
||||
/>
|
||||
<TournamentNav tournament={tournament} streamsCount={data.streamsCount} />
|
||||
<TournamentProvider tournament={tournament}>
|
||||
<Outlet
|
||||
context={
|
||||
@@ -123,7 +119,6 @@ export function TournamentLayout() {
|
||||
tournament,
|
||||
bracketExpanded,
|
||||
setBracketExpanded,
|
||||
hasChildTournaments: data.hasChildTournaments,
|
||||
friendCodes: data.friendCodes,
|
||||
preparedMaps: data.preparedMaps,
|
||||
vods: data.vods ?? [],
|
||||
@@ -147,7 +142,6 @@ type TournamentContext = {
|
||||
tournament: Tournament;
|
||||
bracketExpanded: boolean;
|
||||
setBracketExpanded: (expanded: boolean) => void;
|
||||
hasChildTournaments: boolean;
|
||||
friendCode?: string;
|
||||
friendCodes?: TournamentLoaderData["friendCodes"];
|
||||
preparedMaps: TournamentLoaderData["preparedMaps"];
|
||||
@@ -161,10 +155,6 @@ export function useBracketExpanded() {
|
||||
return { bracketExpanded, setBracketExpanded };
|
||||
}
|
||||
|
||||
export function useHasChildTournaments() {
|
||||
return useOutletContext<TournamentContext>().hasChildTournaments;
|
||||
}
|
||||
|
||||
export function useTournamentFriendCodes() {
|
||||
return useOutletContext<TournamentContext>().friendCodes;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Config } from "~/config";
|
||||
import { TEAM } from "../team/team-constants";
|
||||
|
||||
export const TOURNAMENT = {
|
||||
@@ -71,81 +70,3 @@ export const TOURNAMENT_AUDIT_LOG_TYPES = [
|
||||
|
||||
export type TournamentAuditLogType =
|
||||
(typeof TOURNAMENT_AUDIT_LOG_TYPES)[number];
|
||||
|
||||
export const LEAGUES =
|
||||
process.env.NODE_ENV === "development" && !Config.prodMode
|
||||
? {
|
||||
LUTI: [
|
||||
{
|
||||
tournamentId: 6,
|
||||
weeks: [
|
||||
{
|
||||
weekNumber: 2,
|
||||
year: 2025,
|
||||
},
|
||||
{
|
||||
weekNumber: 3,
|
||||
year: 2025,
|
||||
},
|
||||
{
|
||||
weekNumber: 4,
|
||||
year: 2025,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
LUTI: [
|
||||
{
|
||||
tournamentId: 1066,
|
||||
weeks: [
|
||||
{
|
||||
weekNumber: 10,
|
||||
year: 2025,
|
||||
},
|
||||
{
|
||||
weekNumber: 11,
|
||||
year: 2025,
|
||||
},
|
||||
{
|
||||
weekNumber: 12,
|
||||
year: 2025,
|
||||
},
|
||||
{
|
||||
weekNumber: 13,
|
||||
year: 2025,
|
||||
},
|
||||
{
|
||||
weekNumber: 14,
|
||||
year: 2025,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tournamentId: 3192,
|
||||
weeks: [
|
||||
{
|
||||
weekNumber: 9,
|
||||
year: 2026,
|
||||
},
|
||||
{
|
||||
weekNumber: 10,
|
||||
year: 2026,
|
||||
},
|
||||
{
|
||||
weekNumber: 11,
|
||||
year: 2026,
|
||||
},
|
||||
{
|
||||
weekNumber: 12,
|
||||
year: 2026,
|
||||
},
|
||||
{
|
||||
weekNumber: 13,
|
||||
year: 2026,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -760,22 +760,7 @@ describe("splitTournamentName", () => {
|
||||
});
|
||||
|
||||
describe("tournamentNameParts", () => {
|
||||
test("uses the parent tournament name and division subtext for a league division", () => {
|
||||
const tournament = testTournament({
|
||||
ctx: {
|
||||
name: "LUTI: Season 17 - Division 1",
|
||||
parentTournamentId: 1,
|
||||
parentTournamentName: "LUTI: Season 17",
|
||||
},
|
||||
});
|
||||
|
||||
expect(tournamentNameParts(tournament)).toEqual({
|
||||
name: "LUTI: Season 17",
|
||||
subtext: "Division 1",
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to the organization series when not a league division", () => {
|
||||
test("splits the name by the organization series", () => {
|
||||
const tournament = testTournament({
|
||||
ctx: {
|
||||
name: "In The Zone 54",
|
||||
@@ -865,6 +850,38 @@ describe("bracketProgressionLabel", () => {
|
||||
).toEqual({ label: "SW → SE", hasUnderground: true });
|
||||
});
|
||||
|
||||
test("describes divisions leading to the same shape once", () => {
|
||||
const division = (idx: number) => [
|
||||
bracket({ type: "round_robin", name: `Division ${idx}` }),
|
||||
bracket({
|
||||
type: "single_elimination",
|
||||
name: `Division ${idx} Playoffs`,
|
||||
sources: [{ bracketIdx: idx * 2, placements: [1, 2] }],
|
||||
}),
|
||||
];
|
||||
|
||||
expect(
|
||||
bracketProgressionLabel([...division(0), ...division(1), ...division(2)]),
|
||||
).toEqual({ label: "RR → SE", hasUnderground: false });
|
||||
});
|
||||
|
||||
test("describes every starting bracket when they lead to different shapes", () => {
|
||||
expect(
|
||||
bracketProgressionLabel([
|
||||
bracket({ type: "round_robin" }),
|
||||
bracket({
|
||||
type: "single_elimination",
|
||||
sources: [{ bracketIdx: 0, placements: [1, 2] }],
|
||||
}),
|
||||
bracket({ type: "swiss" }),
|
||||
bracket({
|
||||
type: "double_elimination",
|
||||
sources: [{ bracketIdx: 2, placements: [1, 2] }],
|
||||
}),
|
||||
]),
|
||||
).toEqual({ label: "RR → SE → SW → DE", hasUnderground: false });
|
||||
});
|
||||
|
||||
test("returns empty label for empty progression", () => {
|
||||
expect(bracketProgressionLabel([])).toEqual({
|
||||
label: "",
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as R from "remeda";
|
||||
import type { CastedMatchesInfo } from "~/db/tables-json";
|
||||
import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import { weekNumberToDate } from "~/utils/dates";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { SHORT_NANOID_LENGTH } from "~/utils/id";
|
||||
import type { Tables } from "../../db/tables";
|
||||
import { MapPool } from "../map-list-generator/core/map-pool";
|
||||
@@ -14,7 +14,7 @@ import type { ParsedBracket } from "../tournament-bracket/core/Progression";
|
||||
import * as Progression from "../tournament-bracket/core/Progression";
|
||||
import type { Tournament as TournamentClass } from "../tournament-bracket/core/Tournament";
|
||||
import type { TournamentData } from "../tournament-bracket/core/Tournament.server";
|
||||
import { LEAGUES, TOURNAMENT } from "./tournament-constants";
|
||||
import { TOURNAMENT } from "./tournament-constants";
|
||||
|
||||
const mapPickingStyleToModeRecord = {
|
||||
AUTO_SZ: ["SZ"],
|
||||
@@ -181,37 +181,18 @@ export function tournamentInWeaponReportingWindow({
|
||||
return tournamentStartTime > windowStart;
|
||||
}
|
||||
|
||||
/** Datetime the league round is played by default, or null if the round has no default play time. */
|
||||
export function resolveLeagueRoundStartDate(
|
||||
tournament: TournamentClass,
|
||||
bracket: BracketClass | undefined,
|
||||
roundId: number,
|
||||
) {
|
||||
if (!tournament.isLeagueDivision) return null;
|
||||
|
||||
const league = Object.values(LEAGUES)
|
||||
.flat()
|
||||
.find(
|
||||
(league) => league.tournamentId === tournament.ctx.parentTournamentId,
|
||||
);
|
||||
if (!league) return null;
|
||||
if (!tournament.isLeague) return null;
|
||||
|
||||
const round = bracket?.data.round.find((r) => r.id === roundId);
|
||||
const onlyRelevantRounds = bracket?.data.round.filter(
|
||||
(r) => r.groupId === round?.groupId,
|
||||
);
|
||||
if (!round?.defaultPlayTime) return null;
|
||||
|
||||
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;
|
||||
return databaseTimestampToDate(round.defaultPlayTime);
|
||||
}
|
||||
|
||||
const EARLIEST_TIMEZONE_OFFSET_HOURS = 14;
|
||||
@@ -505,11 +486,8 @@ export function splitTournamentName(
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the display name and subtext for a tournament's identity.
|
||||
*
|
||||
* For a league division the parent tournament name is used as the base name and
|
||||
* the division name (e.g. `"Division 1"`) becomes the subtext. For all other
|
||||
* tournaments the split is based on the organization's tournament series.
|
||||
* Resolves the display name and subtext for a tournament's identity, based on
|
||||
* the organization's tournament series.
|
||||
*
|
||||
* @see {@link splitTournamentName}
|
||||
*/
|
||||
@@ -517,12 +495,6 @@ export function tournamentNameParts(tournament: TournamentClass): {
|
||||
name: string;
|
||||
subtext?: string;
|
||||
} {
|
||||
if (tournament.isLeagueDivision && tournament.ctx.parentTournamentName) {
|
||||
return splitTournamentName(tournament.ctx.name, [
|
||||
{ name: tournament.ctx.parentTournamentName },
|
||||
]);
|
||||
}
|
||||
|
||||
return splitTournamentName(
|
||||
tournament.ctx.name,
|
||||
tournament.ctx.organization?.series ?? [],
|
||||
@@ -552,6 +524,9 @@ const STAGE_TYPE_TO_SHORT_CODE: Record<
|
||||
* caller can render a `+ UG` suffix. Their type and where they branch off from is deliberately not
|
||||
* conveyed, keeping the label to one shape no matter how the underground brackets are set up.
|
||||
*
|
||||
* Starting brackets that lead to the same shape (a league's divisions) are described once, as they
|
||||
* are played in parallel rather than one after the other.
|
||||
*
|
||||
* @example
|
||||
* // [{type: "round_robin"}, {type: "single_elimination"}, ...underground SE brackets]
|
||||
* bracketProgressionLabel(progression) // { label: "RR → SE", hasUnderground: true }
|
||||
@@ -560,22 +535,47 @@ export function bracketProgressionLabel(progression: ParsedBracket[]): {
|
||||
label: string;
|
||||
hasUnderground: boolean;
|
||||
} {
|
||||
const mainCodes: string[] = [];
|
||||
let hasUnderground = false;
|
||||
return {
|
||||
label: labelOfBrackets(labeledBracketIdxs(progression), progression),
|
||||
hasUnderground: progression.some((_, idx) =>
|
||||
Progression.isUnderground(idx, progression),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
for (let i = 0; i < progression.length; i++) {
|
||||
if (Progression.isUnderground(i, progression)) {
|
||||
hasUnderground = true;
|
||||
continue;
|
||||
}
|
||||
/** Short code of every given bracket, arrow separated, consecutive duplicates collapsed. */
|
||||
function labelOfBrackets(bracketIdxs: number[], progression: ParsedBracket[]) {
|
||||
const codes: string[] = [];
|
||||
|
||||
const code = STAGE_TYPE_TO_SHORT_CODE[progression[i].type];
|
||||
if (mainCodes.at(-1) !== code) {
|
||||
mainCodes.push(code);
|
||||
for (const idx of bracketIdxs) {
|
||||
if (Progression.isUnderground(idx, progression)) continue;
|
||||
|
||||
const code = STAGE_TYPE_TO_SHORT_CODE[progression[idx].type];
|
||||
if (codes.at(-1) !== code) {
|
||||
codes.push(code);
|
||||
}
|
||||
}
|
||||
|
||||
return { label: mainCodes.join(" → "), hasUnderground };
|
||||
return codes.join(" → ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Brackets the label describes: every one of them, or the brackets of one starting bracket when
|
||||
* the tournament has many that all lead to the same shape.
|
||||
*/
|
||||
function labeledBracketIdxs(progression: ParsedBracket[]) {
|
||||
const everyBracketIdx = progression.map((_, idx) => idx);
|
||||
|
||||
const branches = Progression.startingBrackets(progression).map((idx) =>
|
||||
Progression.bracketsReachableFrom(idx, progression).sort((a, b) => a - b),
|
||||
);
|
||||
if (branches.length <= 1) return everyBracketIdx;
|
||||
|
||||
const labels = branches.map((branch) => labelOfBrackets(branch, progression));
|
||||
|
||||
return labels.every((label) => label === labels[0])
|
||||
? branches[0]
|
||||
: everyBracketIdx;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ExpressionBuilder, NotNull } from "kysely";
|
||||
import type { ExpressionBuilder, NotNull, SqlBool } from "kysely";
|
||||
import { sql } from "kysely";
|
||||
import * as R from "remeda";
|
||||
import { db } from "~/db/sql";
|
||||
@@ -681,6 +681,11 @@ const baseCalendarEventResultsQuery = (
|
||||
return query;
|
||||
};
|
||||
|
||||
/** Tier of the division the result was placed in, falling back to the tournament's own tier. */
|
||||
const RESULT_TIER = sql<
|
||||
Tables["Tournament"]["tier"]
|
||||
>`coalesce("TournamentDivisionTier"."tier", "Tournament"."tier")`;
|
||||
|
||||
const baseTournamentResultsQuery = (
|
||||
userId: number,
|
||||
filters: ResultsFilters,
|
||||
@@ -698,6 +703,17 @@ const baseTournamentResultsQuery = (
|
||||
"TournamentResult.tournamentId",
|
||||
)
|
||||
.innerJoin("Tournament", "Tournament.id", "TournamentResult.tournamentId")
|
||||
.leftJoin("TournamentDivisionTier", (join) =>
|
||||
join
|
||||
.onRef(
|
||||
"TournamentDivisionTier.tournamentId",
|
||||
"=",
|
||||
"TournamentResult.tournamentId",
|
||||
)
|
||||
.on(
|
||||
sql<SqlBool>`"TournamentDivisionTier"."bracketIdx" = coalesce("TournamentTeam"."startingBracketIdx", 0)`,
|
||||
),
|
||||
)
|
||||
.where("TournamentResult.userId", "=", userId);
|
||||
|
||||
if (!includesTournamentResults(filters)) {
|
||||
@@ -737,8 +753,8 @@ const baseTournamentResultsQuery = (
|
||||
|
||||
if (isTierFiltered(filters)) {
|
||||
query = query
|
||||
.where("Tournament.tier", ">=", filters.minTier ?? BEST_TIER_NUMBER)
|
||||
.where("Tournament.tier", "<=", filters.maxTier ?? WORST_TIER_NUMBER);
|
||||
.where(RESULT_TIER, ">=", filters.minTier ?? BEST_TIER_NUMBER)
|
||||
.where(RESULT_TIER, "<=", filters.maxTier ?? WORST_TIER_NUMBER);
|
||||
}
|
||||
|
||||
if (filters.maxPlacement) {
|
||||
@@ -843,7 +859,7 @@ export function findResultsByUserId(
|
||||
"TournamentTeam.id as teamId",
|
||||
"TournamentTeam.name as teamName",
|
||||
"TournamentResult.isHighlight",
|
||||
"Tournament.tier",
|
||||
RESULT_TIER.as("tier"),
|
||||
withMaxEventStartTime(eb),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
|
||||
@@ -2,7 +2,10 @@ import { describe, expect, test } from "vitest";
|
||||
import * as CalendarEventFactory from "~/db/seed/factories/CalendarEventFactory";
|
||||
import * as CalendarEventResultFactory from "~/db/seed/factories/CalendarEventResultFactory";
|
||||
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
|
||||
import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import * as UserRepository from "./UserRepository.server";
|
||||
|
||||
@@ -288,6 +291,89 @@ describe("UserRepository", () => {
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].eventName).toBe("Alpha Invitational");
|
||||
});
|
||||
|
||||
describe("of a tournament with many divisions", () => {
|
||||
const TOP_DIVISION_TIER = 2;
|
||||
const LOW_DIVISION_TIER = 7;
|
||||
|
||||
const seedDivisionedResults = async () => {
|
||||
const [topUser, lowUser, topMate, lowMate] =
|
||||
await UserFactory.createMany(4);
|
||||
|
||||
const { id: tournamentId } = await TournamentFactory.create(
|
||||
{
|
||||
name: "Divisioned Open",
|
||||
authorId: topUser.id,
|
||||
minMembersPerTeam: 1,
|
||||
bracketProgression: [
|
||||
{
|
||||
name: "Top Division",
|
||||
type: "single_elimination",
|
||||
requiresCheckIn: false,
|
||||
settings: { thirdPlaceMatch: false },
|
||||
},
|
||||
{
|
||||
name: "Low Division",
|
||||
type: "single_elimination",
|
||||
requiresCheckIn: false,
|
||||
settings: { thirdPlaceMatch: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const teams = [];
|
||||
for (const user of [topUser, topMate, lowUser, lowMate]) {
|
||||
teams.push(
|
||||
await TournamentTeamFactory.create(
|
||||
{ tournamentId, memberUserIds: [user.id] },
|
||||
{ isCheckedIn: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
await TournamentTeamRepository.updateStartingBrackets(
|
||||
teams.map((team, idx) => ({
|
||||
tournamentTeamId: team.id,
|
||||
startingBracketIdx: idx < 2 ? 0 : 1,
|
||||
})),
|
||||
);
|
||||
|
||||
await TournamentFactory.playOut(tournamentId, "all");
|
||||
|
||||
await TournamentRepository.upsertDivisionTier({
|
||||
tournamentId,
|
||||
bracketIdx: 0,
|
||||
tier: TOP_DIVISION_TIER,
|
||||
});
|
||||
await TournamentRepository.upsertDivisionTier({
|
||||
tournamentId,
|
||||
bracketIdx: 1,
|
||||
tier: LOW_DIVISION_TIER,
|
||||
});
|
||||
|
||||
return { topUserId: topUser.id, lowUserId: lowUser.id };
|
||||
};
|
||||
|
||||
test("reports the tier of the division the result is from", async () => {
|
||||
const { topUserId, lowUserId } = await seedDivisionedResults();
|
||||
|
||||
const [topResult] = await filteredResults(topUserId, {});
|
||||
const [lowResult] = await filteredResults(lowUserId, {});
|
||||
|
||||
expect(topResult.tier).toBe(TOP_DIVISION_TIER);
|
||||
expect(lowResult.tier).toBe(LOW_DIVISION_TIER);
|
||||
});
|
||||
|
||||
test("filters by the tier of the division the result is from", async () => {
|
||||
const { topUserId, lowUserId } = await seedDivisionedResults();
|
||||
|
||||
const topRange = { minTier: 1, maxTier: 3 } as const;
|
||||
|
||||
expect(await filteredResults(topUserId, topRange)).toHaveLength(1);
|
||||
expect(await filteredResults(lowUserId, topRange)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("userRoles", () => {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from "react-router";
|
||||
|
||||
export const loader = () => {
|
||||
return redirect("/?search=open&type=users");
|
||||
};
|
||||
72
app/modules/redirects/core/Redirect.test.ts
Normal file
72
app/modules/redirects/core/Redirect.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import * as Redirect from "./Redirect";
|
||||
|
||||
describe("Redirect.resolve", () => {
|
||||
test.each([
|
||||
{
|
||||
why: "exact match",
|
||||
pathname: "/luti",
|
||||
expected: "/to/3192",
|
||||
},
|
||||
{
|
||||
why: "trailing slash",
|
||||
pathname: "/luti/",
|
||||
expected: "/to/3192",
|
||||
},
|
||||
{
|
||||
why: "wildcard root",
|
||||
pathname: "/to/3325",
|
||||
expected: "/to/3192",
|
||||
},
|
||||
{
|
||||
why: "wildcard suffix preserved",
|
||||
pathname: "/to/3325/teams/58397",
|
||||
expected: "/to/3192/teams/58397",
|
||||
},
|
||||
{
|
||||
why: "last division of a season",
|
||||
pathname: "/to/1253/brackets",
|
||||
expected: "/to/1066/brackets",
|
||||
},
|
||||
{
|
||||
why: "page that lost its route",
|
||||
pathname: "/play",
|
||||
expected: "/q",
|
||||
},
|
||||
{
|
||||
why: "target with a query string of its own",
|
||||
pathname: "/t",
|
||||
expected: "/?search=open&type=teams",
|
||||
},
|
||||
{
|
||||
why: "no matching redirect",
|
||||
pathname: "/to/3192/teams/58397",
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
why: "id only a prefix of a redirected id",
|
||||
pathname: "/to/33250",
|
||||
expected: null,
|
||||
},
|
||||
])("$why", ({ pathname, expected }) => {
|
||||
expect(Redirect.resolve({ pathname })).toBe(expected);
|
||||
});
|
||||
|
||||
test("keeps the query string", () => {
|
||||
expect(
|
||||
Redirect.resolve({ pathname: "/to/3325/brackets", search: "?idx=1" }),
|
||||
).toBe("/to/3192/brackets?idx=1");
|
||||
});
|
||||
|
||||
test("merges the query string into a target that has one", () => {
|
||||
expect(Redirect.resolve({ pathname: "/u", search: "?foo=bar" })).toBe(
|
||||
"/?search=open&type=users&foo=bar",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolved target is not itself redirected", () => {
|
||||
const target = Redirect.resolve({ pathname: "/to/3325/teams/58397" });
|
||||
|
||||
expect(Redirect.resolve({ pathname: target! })).toBeNull();
|
||||
});
|
||||
});
|
||||
94
app/modules/redirects/core/Redirect.ts
Normal file
94
app/modules/redirects/core/Redirect.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
type RedirectRule = {
|
||||
/** Path to redirect from. A trailing `/*` matches the path and anything below it. */
|
||||
from: string;
|
||||
/**
|
||||
* Path to redirect to. Ends with `/*` if `from` does, the matched suffix being appended to it.
|
||||
* May contain a query string, in which case the original one is merged into it.
|
||||
*/
|
||||
to: string;
|
||||
};
|
||||
|
||||
const WILDCARD_SUFFIX = "/*";
|
||||
|
||||
const leagueDivisionRedirects = (args: {
|
||||
firstDivisionTournamentId: number;
|
||||
divisionsCount: number;
|
||||
seasonTournamentId: number;
|
||||
}): RedirectRule[] =>
|
||||
Array.from({ length: args.divisionsCount }, (_, idx) => ({
|
||||
from: `/to/${args.firstDivisionTournamentId + idx}/*`,
|
||||
to: `/to/${args.seasonTournamentId}/*`,
|
||||
}));
|
||||
|
||||
const REDIRECTS: RedirectRule[] = [
|
||||
// LUTI seasons used to be one tournament per division, now they are one tournament per season
|
||||
...leagueDivisionRedirects({
|
||||
firstDivisionTournamentId: 1241,
|
||||
divisionsCount: 13,
|
||||
seasonTournamentId: 1066,
|
||||
}),
|
||||
...leagueDivisionRedirects({
|
||||
firstDivisionTournamentId: 3325,
|
||||
divisionsCount: 13,
|
||||
seasonTournamentId: 3192,
|
||||
}),
|
||||
// update once per season
|
||||
{ from: "/luti", to: "/to/3192" },
|
||||
// pages that used to have a route of their own
|
||||
{ from: "/play", to: "/q" },
|
||||
{ from: "/q/settings", to: "/settings?tab=match-profile" },
|
||||
{ from: "/plus", to: "/plus/suggestions" },
|
||||
{ from: "/u", to: "/?search=open&type=users" },
|
||||
{ from: "/t", to: "/?search=open&type=teams" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolves where a location should redirect to, or null if it should be served as is.
|
||||
* The query string and the part of the path matched by a wildcard are preserved.
|
||||
*
|
||||
* @example
|
||||
* Redirect.resolve({ pathname: "/to/3325/teams/58397", search: "" }) // "/to/3192/teams/58397"
|
||||
*/
|
||||
export function resolve(location: {
|
||||
pathname: string;
|
||||
search?: string;
|
||||
}): string | null {
|
||||
const pathname = normalizedPathname(location.pathname);
|
||||
|
||||
for (const redirect of REDIRECTS) {
|
||||
const target = resolvedTarget(redirect, pathname);
|
||||
if (target) return withSearch(target, location.search);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function withSearch(target: string, search?: string) {
|
||||
if (!search || search === "?") return target;
|
||||
|
||||
return target.includes("?")
|
||||
? `${target}&${search.slice(1)}`
|
||||
: `${target}${search}`;
|
||||
}
|
||||
|
||||
function resolvedTarget(redirect: RedirectRule, pathname: string) {
|
||||
if (!redirect.from.endsWith(WILDCARD_SUFFIX)) {
|
||||
return redirect.from === pathname ? redirect.to : null;
|
||||
}
|
||||
|
||||
const prefix = redirect.from.slice(0, -WILDCARD_SUFFIX.length);
|
||||
if (pathname !== prefix && !pathname.startsWith(`${prefix}/`)) return null;
|
||||
|
||||
if (!redirect.to.endsWith(WILDCARD_SUFFIX)) return redirect.to;
|
||||
|
||||
return (
|
||||
redirect.to.slice(0, -WILDCARD_SUFFIX.length) +
|
||||
pathname.slice(prefix.length)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizedPathname(pathname: string) {
|
||||
return pathname.length > 1 && pathname.endsWith("/")
|
||||
? pathname.slice(0, -1)
|
||||
: pathname;
|
||||
}
|
||||
27
app/modules/redirects/redirects-middleware.server.ts
Normal file
27
app/modules/redirects/redirects-middleware.server.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { redirect } from "react-router";
|
||||
import * as Redirect from "./core/Redirect";
|
||||
|
||||
type MiddlewareArgs = {
|
||||
request: Request;
|
||||
url: URL;
|
||||
context: unknown;
|
||||
};
|
||||
|
||||
type MiddlewareFn = (
|
||||
args: MiddlewareArgs,
|
||||
next: () => Promise<Response>,
|
||||
) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* Redirects requests targeting a page that has moved. Runs for every kind of request
|
||||
* (documents, single fetch data requests and resource routes) so that no route needs to
|
||||
* know about the redirects.
|
||||
*
|
||||
* @see {@link Redirect.resolve}
|
||||
*/
|
||||
export const redirectsMiddleware: MiddlewareFn = ({ url }, next) => {
|
||||
const redirectTo = Redirect.resolve(url);
|
||||
if (redirectTo) throw redirect(redirectTo);
|
||||
|
||||
return next();
|
||||
};
|
||||
10
app/modules/redirects/routes/$.ts
Normal file
10
app/modules/redirects/routes/$.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { type LoaderFunctionArgs, redirect } from "react-router";
|
||||
import * as Redirect from "~/modules/redirects/core/Redirect";
|
||||
|
||||
/** Catches every URL matching no other route, so that pages that moved away can still redirect. */
|
||||
export const loader = ({ request }: LoaderFunctionArgs) => {
|
||||
const redirectTo = Redirect.resolve(new URL(request.url));
|
||||
if (redirectTo) return redirect(redirectTo);
|
||||
|
||||
throw new Response(null, { status: 404 });
|
||||
};
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
import { localePreloadUrls } from "./modules/i18n/locale-preload.server";
|
||||
import { useChangeLanguage } from "./modules/i18n/useChangeLanguage";
|
||||
import { isSupporter } from "./modules/permissions/utils";
|
||||
import { redirectsMiddleware } from "./modules/redirects/redirects-middleware.server";
|
||||
import { SearchParamsProvider } from "./modules/search-params/hooks";
|
||||
import { IS_E2E_TEST_RUN } from "./utils/e2e";
|
||||
import { allI18nNamespaces } from "./utils/i18n";
|
||||
@@ -78,6 +79,7 @@ import { requestContextMiddleware } from "./utils/request-context-middleware.ser
|
||||
import { APP_ICON_URL, pwaSplashScreenImageUrl } from "./utils/urls";
|
||||
|
||||
export const middleware: Route.MiddlewareFunction[] = [
|
||||
redirectsMiddleware,
|
||||
requestContextMiddleware,
|
||||
sessionIdMiddleware,
|
||||
userMiddleware,
|
||||
|
||||
@@ -71,8 +71,6 @@ export default [
|
||||
|
||||
route("/suspended", "features/ban/routes/suspended.tsx"),
|
||||
|
||||
route("/u", "features/user-search/routes/u.tsx"),
|
||||
|
||||
route("/search", "features/search/routes/search.ts"),
|
||||
|
||||
route("/u/:identifier", "features/user-page/routes/u.$identifier.tsx", [
|
||||
@@ -216,7 +214,6 @@ export default [
|
||||
"features/tournament-match/routes/to.$id.matches.$mid.tsx",
|
||||
),
|
||||
]),
|
||||
route("luti", "features/tournament/routes/luti.ts"),
|
||||
|
||||
route("/org/new", "features/tournament-organization/routes/org.new.tsx"),
|
||||
...prefix("/org/:slug", [
|
||||
@@ -233,7 +230,6 @@ export default [
|
||||
route("/contributions", "features/info/routes/contributions.tsx"),
|
||||
route("/support", "features/info/routes/support.tsx"),
|
||||
|
||||
route("/t", "features/team/routes/t.tsx"),
|
||||
route("/t/new", "features/team/routes/t.new.tsx"),
|
||||
route("/t/:customUrl", "features/team/routes/t.$customUrl.tsx", [
|
||||
index("features/team/routes/t.$customUrl.index.tsx"),
|
||||
@@ -280,10 +276,8 @@ export default [
|
||||
route("preparing", "features/sendouq/routes/q.preparing.tsx"),
|
||||
route("ready", "features/sendouq/routes/q.ready.tsx"),
|
||||
route("match/:id", "features/sendouq-match/routes/q.match.$id.tsx"),
|
||||
route("settings", "features/match-profile/routes/q.settings.tsx"),
|
||||
route("streams", "features/sendouq-streams/routes/q.streams.tsx"),
|
||||
]),
|
||||
route("/play", "features/sendouq/routes/play.ts"),
|
||||
|
||||
route("/friends-for-adding", "features/sendouq/routes/friends-for-adding.ts"),
|
||||
|
||||
@@ -332,7 +326,6 @@ export default [
|
||||
]),
|
||||
|
||||
route("/plus", "features/plus-suggestions/routes/plus.tsx", [
|
||||
index("features/plus-suggestions/routes/plus.index.ts"),
|
||||
route(
|
||||
"suggestions",
|
||||
"features/plus-suggestions/routes/plus.suggestions.tsx",
|
||||
@@ -463,4 +456,6 @@ export default [
|
||||
route("impersonate/stop", "features/auth/routes/auth.impersonate.stop.ts"),
|
||||
]),
|
||||
...devOnlyRoutes,
|
||||
|
||||
route("*", "modules/redirects/routes/$.ts"),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
import { parseLutiDivFromName } from "../features/scrims/scrims-utils";
|
||||
import * as TournamentRepository from "../features/tournament/TournamentRepository.server";
|
||||
import { LEAGUES } from "../features/tournament/tournament-constants";
|
||||
import { LUTI_ORGANIZATION_ID } from "../features/tournament-organization/tournament-organization-constants";
|
||||
import * as UserRepository from "../features/user-page/UserRepository.server";
|
||||
import { logger } from "../utils/logger";
|
||||
import { Routine } from "./routine.server";
|
||||
|
||||
/** Excludes the other leagues of the organization e.g. FLUTI */
|
||||
export const LUTI_NAME_PREFIX = "LUTI";
|
||||
|
||||
/**
|
||||
* Recomputes `User.div` (the user's division in the latest finished LUTI). Looks at the most recent
|
||||
* LUTI season whose division tournaments are all finalized and sets the division for every eligible
|
||||
* participant (on a team that did not drop out and played at least one match). Users not in that
|
||||
* season keep their previous division. Idempotent.
|
||||
* finalized LUTI season and sets the division for every eligible participant (on a team that did
|
||||
* not drop out and played at least one match). Users not in that season keep their previous
|
||||
* division. Idempotent.
|
||||
*/
|
||||
export const ComputeLutiDivsRoutine = new Routine({
|
||||
name: "ComputeLutiDivs",
|
||||
func: async () => {
|
||||
const children = await latestFinishedLutiDivisions();
|
||||
if (!children) return;
|
||||
const league =
|
||||
await TournamentRepository.findLatestFinalizedLeagueParticipants({
|
||||
organizationId: LUTI_ORGANIZATION_ID,
|
||||
namePrefix: LUTI_NAME_PREFIX,
|
||||
});
|
||||
if (!league) return;
|
||||
|
||||
const divByBracketIdx = new Map<number, string | null>();
|
||||
const divOfBracket = (bracketIdx: number) => {
|
||||
if (!divByBracketIdx.has(bracketIdx)) {
|
||||
const bracketName = league.bracketProgression[bracketIdx]?.name;
|
||||
const div = bracketName ? parseLutiDivFromName(bracketName) : null;
|
||||
if (!div) {
|
||||
logger.warn(
|
||||
`ComputeLutiDivs: could not parse division from bracket name "${bracketName}"`,
|
||||
);
|
||||
}
|
||||
divByBracketIdx.set(bracketIdx, div);
|
||||
}
|
||||
|
||||
return divByBracketIdx.get(bracketIdx)!;
|
||||
};
|
||||
|
||||
const updates: Array<{ userId: number; div: string }> = [];
|
||||
for (const child of children) {
|
||||
const div = parseLutiDivFromName(child.name);
|
||||
if (!div) {
|
||||
logger.warn(
|
||||
`ComputeLutiDivs: could not parse division from tournament name "${child.name}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for (const participant of league.participants) {
|
||||
const div = divOfBracket(participant.startingBracketIdx ?? 0);
|
||||
if (!div) continue;
|
||||
|
||||
const userIds =
|
||||
await TournamentRepository.findLeagueDivParticipantUserIds(
|
||||
child.tournamentId,
|
||||
);
|
||||
for (const { userId } of userIds) {
|
||||
updates.push({ userId, div });
|
||||
}
|
||||
updates.push({ userId: participant.userId, div });
|
||||
}
|
||||
|
||||
await UserRepository.updateManyDivs(updates);
|
||||
logger.info(`ComputeLutiDivs: updated div for ${updates.length} users`);
|
||||
logger.info(
|
||||
`ComputeLutiDivs: updated div for ${updates.length} users based on tournament ${league.tournamentId}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
async function latestFinishedLutiDivisions() {
|
||||
for (const league of [...(LEAGUES.LUTI ?? [])].reverse()) {
|
||||
const children = await TournamentRepository.findChildTournamentsForDivCalc(
|
||||
league.tournamentId,
|
||||
);
|
||||
if (children.length === 0) continue;
|
||||
if (children.every((child) => child.isFinalized === 1)) {
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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.
|
||||
607
migrations/20260815145515-leagues-as-normal-tournaments.ts
Normal file
607
migrations/20260815145515-leagues-as-normal-tournaments.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
import { type Kysely, sql, type Transaction } from "kysely";
|
||||
|
||||
/**
|
||||
* Turns leagues into normal tournaments.
|
||||
*
|
||||
* Before: a league was a "signup" tournament holding the registrations plus one child tournament
|
||||
* per division, linked via `Tournament.parentTournamentId`. After: one tournament per season with
|
||||
* `settings.isLeague` and two brackets per division (group stage + playoffs), teams pointing at
|
||||
* their division via `startingBracketIdx` and the weekly play dates stored per round.
|
||||
*/
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
await trx.schema
|
||||
.alterTable("TournamentRound")
|
||||
.addColumn("defaultPlayTime", "integer")
|
||||
.execute();
|
||||
|
||||
await createDivisionTierTable(trx);
|
||||
|
||||
// the divisions carry their own tier over, so they are done before the backfill below
|
||||
for (const season of SEASONS) {
|
||||
await migrateSeason(trx, season);
|
||||
}
|
||||
|
||||
await trx.schema
|
||||
.alterTable("Tournament")
|
||||
.dropColumn("parentTournamentId")
|
||||
.execute();
|
||||
|
||||
await backfillDivisionTiers(trx);
|
||||
});
|
||||
}
|
||||
|
||||
type Season = {
|
||||
/** Tournament that held the registrations. Becomes the tournament of the whole season. */
|
||||
signupTournamentId: number;
|
||||
year: number;
|
||||
/** ISO week each group stage round is played, in round order. */
|
||||
weekNumbers: number[];
|
||||
/** Division tournaments from the highest division to the lowest. */
|
||||
divisions: Array<{ tournamentId: number; label: string }>;
|
||||
};
|
||||
|
||||
const divisionsOfSeason = (firstTournamentId: number) =>
|
||||
[
|
||||
"Division X",
|
||||
"Division 1",
|
||||
"Division 2",
|
||||
"Division 3",
|
||||
"Division 4",
|
||||
"Division 5",
|
||||
"Division 6",
|
||||
"Division 7",
|
||||
"Division 8",
|
||||
"Division 9 Americas",
|
||||
"Division 9 World",
|
||||
"Division 10 Americas",
|
||||
"Division 10 World",
|
||||
].map((label, idx) => ({ tournamentId: firstTournamentId + idx, label }));
|
||||
|
||||
const SEASONS: Season[] = [
|
||||
{
|
||||
signupTournamentId: 1066,
|
||||
year: 2025,
|
||||
weekNumbers: [10, 11, 12, 13, 14],
|
||||
divisions: divisionsOfSeason(1241),
|
||||
},
|
||||
{
|
||||
signupTournamentId: 3192,
|
||||
year: 2026,
|
||||
weekNumbers: [9, 10, 11, 12, 13],
|
||||
divisions: divisionsOfSeason(3325),
|
||||
},
|
||||
];
|
||||
|
||||
async function migrateSeason(trx: Transaction<any>, season: Season) {
|
||||
const signup = await trx
|
||||
.selectFrom("Tournament")
|
||||
.select(["id", "settings", "castTwitchAccounts", "castedMatchesInfo"])
|
||||
.where("id", "=", season.signupTournamentId)
|
||||
.executeTakeFirst();
|
||||
|
||||
// databases without the production league data (dev, tests)
|
||||
if (!signup) return;
|
||||
|
||||
const divisions = [];
|
||||
for (const [idx, division] of season.divisions.entries()) {
|
||||
const row = await trx
|
||||
.selectFrom("Tournament")
|
||||
.select([
|
||||
"id",
|
||||
"settings",
|
||||
"tier",
|
||||
"castTwitchAccounts",
|
||||
"castedMatchesInfo",
|
||||
])
|
||||
.where("id", "=", division.tournamentId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) {
|
||||
throw new Error(
|
||||
`League division tournament ${division.tournamentId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
const progression = JSON.parse(row.settings).bracketProgression;
|
||||
if (progression.length !== 2) {
|
||||
throw new Error(
|
||||
`Expected 2 brackets in division tournament ${division.tournamentId}, got ${progression.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
divisions.push({
|
||||
...division,
|
||||
tier: row.tier as number | null,
|
||||
castTwitchAccounts: parseJson<string[]>(row.castTwitchAccounts),
|
||||
castedMatchesInfo: parseJson<CastedMatchesInfo>(row.castedMatchesInfo),
|
||||
groupStageIdx: idx * 2,
|
||||
playoffsIdx: idx * 2 + 1,
|
||||
groupStageBracket: progression[0],
|
||||
playoffsBracket: progression[1],
|
||||
});
|
||||
}
|
||||
|
||||
await trx
|
||||
.updateTable("Tournament")
|
||||
.set({
|
||||
settings: JSON.stringify({
|
||||
...JSON.parse(signup.settings),
|
||||
isLeague: true,
|
||||
bracketProgression: divisions.flatMap((division) => [
|
||||
{ ...division.groupStageBracket, name: division.label },
|
||||
{
|
||||
...division.playoffsBracket,
|
||||
name: playoffsName(division.label),
|
||||
sources: division.playoffsBracket.sources.map((source: any) => ({
|
||||
...source,
|
||||
bracketIdx: division.groupStageIdx,
|
||||
})),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
castTwitchAccounts: mergedCastTwitchAccounts(signup, divisions),
|
||||
castedMatchesInfo: mergedCastedMatchesInfo(signup, divisions),
|
||||
tier: bestTier(divisions),
|
||||
isFinalized: 1,
|
||||
})
|
||||
.where("id", "=", season.signupTournamentId)
|
||||
.execute();
|
||||
|
||||
// each division keeps the tier it was given as a tournament of its own
|
||||
const tieredDivisions = divisions.filter(
|
||||
(division) => division.tier !== null,
|
||||
);
|
||||
if (tieredDivisions.length > 0) {
|
||||
await trx
|
||||
.insertInto("TournamentDivisionTier")
|
||||
.values(
|
||||
tieredDivisions.map((division) => ({
|
||||
tournamentId: season.signupTournamentId,
|
||||
bracketIdx: division.groupStageIdx,
|
||||
tier: division.tier,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
// rosters come from the divisions, where they were kept up to date over the season
|
||||
await trx
|
||||
.deleteFrom("TournamentTeam")
|
||||
.where("tournamentId", "=", season.signupTournamentId)
|
||||
.execute();
|
||||
|
||||
for (const division of divisions) {
|
||||
const teamsOfDivision = (eb: any) =>
|
||||
eb
|
||||
.selectFrom("TournamentTeam")
|
||||
.select("TournamentTeam.id")
|
||||
.where("TournamentTeam.tournamentId", "=", division.tournamentId);
|
||||
|
||||
for (const [oldIdx, newIdx] of [
|
||||
[0, division.groupStageIdx],
|
||||
[1, division.playoffsIdx],
|
||||
]) {
|
||||
await trx
|
||||
.updateTable("TournamentTeamCheckIn")
|
||||
.set({ bracketIdx: newIdx })
|
||||
.where("bracketIdx", "=", oldIdx)
|
||||
.where("tournamentTeamId", "in", teamsOfDivision)
|
||||
.execute();
|
||||
}
|
||||
|
||||
const bracketIdxOfDivision = (idx: number) => {
|
||||
if (idx === 0) return division.groupStageIdx;
|
||||
if (idx === 1) return division.playoffsIdx;
|
||||
|
||||
// -1 = eliminated from the tournament
|
||||
return idx;
|
||||
};
|
||||
|
||||
const overrides = await trx
|
||||
.selectFrom("TournamentBracketProgressionOverride")
|
||||
.selectAll()
|
||||
.where("tournamentId", "=", division.tournamentId)
|
||||
.execute();
|
||||
for (const override of overrides) {
|
||||
await trx
|
||||
.updateTable("TournamentBracketProgressionOverride")
|
||||
.set({
|
||||
tournamentId: season.signupTournamentId,
|
||||
sourceBracketIdx: bracketIdxOfDivision(override.sourceBracketIdx),
|
||||
destinationBracketIdx: bracketIdxOfDivision(
|
||||
override.destinationBracketIdx,
|
||||
),
|
||||
})
|
||||
.where("tournamentTeamId", "=", override.tournamentTeamId)
|
||||
.where("sourceBracketIdx", "=", override.sourceBracketIdx)
|
||||
.execute();
|
||||
}
|
||||
|
||||
await trx
|
||||
.updateTable("TournamentTeam")
|
||||
.set({
|
||||
tournamentId: season.signupTournamentId,
|
||||
startingBracketIdx: division.groupStageIdx,
|
||||
})
|
||||
.where("tournamentId", "=", division.tournamentId)
|
||||
.execute();
|
||||
|
||||
// brackets resolve their stage by name, so these have to match the progression exactly
|
||||
for (const [bracket, name, bracketIdx] of [
|
||||
[division.groupStageBracket, division.label, division.groupStageIdx],
|
||||
[
|
||||
division.playoffsBracket,
|
||||
playoffsName(division.label),
|
||||
division.playoffsIdx,
|
||||
],
|
||||
] as const) {
|
||||
const stage = await trx
|
||||
.selectFrom("TournamentStage")
|
||||
.select("id")
|
||||
.where("tournamentId", "=", division.tournamentId)
|
||||
.where("name", "=", bracket.name)
|
||||
.executeTakeFirst();
|
||||
if (!stage) {
|
||||
throw new Error(
|
||||
`Stage "${bracket.name}" not found in division tournament ${division.tournamentId}`,
|
||||
);
|
||||
}
|
||||
|
||||
await trx
|
||||
.updateTable("TournamentStage")
|
||||
.set({
|
||||
tournamentId: season.signupTournamentId,
|
||||
name,
|
||||
number: bracketIdx + 1,
|
||||
})
|
||||
.where("id", "=", stage.id)
|
||||
.execute();
|
||||
|
||||
if (bracketIdx !== division.groupStageIdx) continue;
|
||||
|
||||
for (const [roundIdx, weekNumber] of season.weekNumbers.entries()) {
|
||||
await trx
|
||||
.updateTable("TournamentRound")
|
||||
.set({
|
||||
defaultPlayTime: weekNumberToTimestamp({
|
||||
week: weekNumber,
|
||||
year: season.year,
|
||||
}),
|
||||
})
|
||||
.where("stageId", "=", stage.id)
|
||||
.where("number", "=", roundIdx + 1)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
// a user who played in two divisions keeps the row of the higher one, which was
|
||||
// already moved over as divisions are handled from the highest to the lowest
|
||||
await trx
|
||||
.deleteFrom("TournamentResult")
|
||||
.where("tournamentId", "=", division.tournamentId)
|
||||
.where("userId", "in", (eb: any) =>
|
||||
eb
|
||||
.selectFrom("TournamentResult as Existing")
|
||||
.select("Existing.userId")
|
||||
.where("Existing.tournamentId", "=", season.signupTournamentId),
|
||||
)
|
||||
.execute();
|
||||
await trx
|
||||
.updateTable("TournamentResult")
|
||||
.set({ tournamentId: season.signupTournamentId, div: division.label })
|
||||
.where("tournamentId", "=", division.tournamentId)
|
||||
.execute();
|
||||
|
||||
await trx
|
||||
.deleteFrom("Skill")
|
||||
.where("tournamentId", "=", division.tournamentId)
|
||||
.where("userId", "in", (eb: any) =>
|
||||
eb
|
||||
.selectFrom("Skill as Existing")
|
||||
.select("Existing.userId")
|
||||
.where("Existing.tournamentId", "=", season.signupTournamentId)
|
||||
.where("Existing.userId", "is not", null),
|
||||
)
|
||||
.execute();
|
||||
await trx
|
||||
.deleteFrom("Skill")
|
||||
.where("tournamentId", "=", division.tournamentId)
|
||||
.where("userId", "is", null)
|
||||
.where("identifier", "in", (eb: any) =>
|
||||
eb
|
||||
.selectFrom("Skill as Existing")
|
||||
.select("Existing.identifier")
|
||||
.where("Existing.tournamentId", "=", season.signupTournamentId)
|
||||
.where("Existing.identifier", "is not", null),
|
||||
)
|
||||
.execute();
|
||||
await trx
|
||||
.updateTable("Skill")
|
||||
.set({ tournamentId: season.signupTournamentId })
|
||||
.where("tournamentId", "=", division.tournamentId)
|
||||
.execute();
|
||||
|
||||
await trx
|
||||
.deleteFrom("TournamentStaff")
|
||||
.where("tournamentId", "=", division.tournamentId)
|
||||
.where("userId", "in", (eb: any) =>
|
||||
eb
|
||||
.selectFrom("TournamentStaff as Existing")
|
||||
.select("Existing.userId")
|
||||
.where("Existing.tournamentId", "=", season.signupTournamentId),
|
||||
)
|
||||
.execute();
|
||||
await trx
|
||||
.updateTable("TournamentStaff")
|
||||
.set({ tournamentId: season.signupTournamentId })
|
||||
.where("tournamentId", "=", division.tournamentId)
|
||||
.execute();
|
||||
|
||||
await trx
|
||||
.deleteFrom("CalendarEvent")
|
||||
.where("tournamentId", "=", division.tournamentId)
|
||||
.execute();
|
||||
await trx
|
||||
.deleteFrom("Tournament")
|
||||
.where("id", "=", division.tournamentId)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
const playoffsName = (divisionLabel: string) => `${divisionLabel} Playoffs`;
|
||||
|
||||
type CastedMatchesInfo = {
|
||||
lockedMatches: Array<{ twitchAccount: string; matchId: number }>;
|
||||
castedMatches: Array<{ twitchAccount: string; matchId: number }>;
|
||||
castedMatchHistory?: Array<{
|
||||
twitchAccount: string;
|
||||
matchId: number;
|
||||
timestamp: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
function parseJson<T>(value: string | null): T | null {
|
||||
return value ? (JSON.parse(value) as T) : null;
|
||||
}
|
||||
|
||||
function mergedCastTwitchAccounts(
|
||||
signup: { castTwitchAccounts: string | null },
|
||||
divisions: Array<{ castTwitchAccounts: string[] | null }>,
|
||||
) {
|
||||
const accounts = new Set([
|
||||
...(parseJson<string[]>(signup.castTwitchAccounts) ?? []),
|
||||
...divisions.flatMap((division) => division.castTwitchAccounts ?? []),
|
||||
]);
|
||||
|
||||
return accounts.size > 0 ? JSON.stringify([...accounts]) : null;
|
||||
}
|
||||
|
||||
function mergedCastedMatchesInfo(
|
||||
signup: { castedMatchesInfo: string | null },
|
||||
divisions: Array<{ castedMatchesInfo: CastedMatchesInfo | null }>,
|
||||
) {
|
||||
const infos = [
|
||||
parseJson<CastedMatchesInfo>(signup.castedMatchesInfo),
|
||||
...divisions.map((division) => division.castedMatchesInfo),
|
||||
].filter((info) => info !== null);
|
||||
|
||||
if (infos.length === 0) return null;
|
||||
|
||||
const castedMatchHistory = infos.flatMap(
|
||||
(info) => info.castedMatchHistory ?? [],
|
||||
);
|
||||
|
||||
return JSON.stringify({
|
||||
castedMatches: infos.flatMap((info) => info.castedMatches),
|
||||
lockedMatches: infos.flatMap((info) => info.lockedMatches),
|
||||
castedMatchHistory:
|
||||
castedMatchHistory.length > 0 ? castedMatchHistory : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function bestTier(divisions: Array<{ tier: number | null }>) {
|
||||
const tiers = divisions
|
||||
.map((division) => division.tier)
|
||||
.filter((tier) => tier !== null);
|
||||
|
||||
return tiers.length > 0 ? Math.min(...tiers) : null;
|
||||
}
|
||||
|
||||
/** Unix timestamp of the Monday (UTC) starting the given ISO week. Same as `weekNumberToDate`. */
|
||||
function weekNumberToTimestamp({ week, year }: { week: number; year: number }) {
|
||||
const date = new Date(Date.UTC(year, 0, 4));
|
||||
date.setUTCDate(
|
||||
date.getUTCDate() - (date.getUTCDay() || 7) + 1 + 7 * (week - 1),
|
||||
);
|
||||
|
||||
return Math.floor(date.getTime() / 1000);
|
||||
}
|
||||
|
||||
function createDivisionTierTable(trx: Transaction<any>) {
|
||||
return (
|
||||
trx.schema
|
||||
.createTable("TournamentDivisionTier")
|
||||
.addColumn("tournamentId", "integer", (col) =>
|
||||
col.notNull().references("Tournament.id").onDelete("cascade"),
|
||||
)
|
||||
.addColumn("bracketIdx", "integer", (col) => col.notNull())
|
||||
.addColumn("tier", "integer", (col) => col.notNull())
|
||||
.addPrimaryKeyConstraint("tournament_division_tier_pk", [
|
||||
"tournamentId",
|
||||
"bracketIdx",
|
||||
])
|
||||
// every table in this schema is strict
|
||||
.modifyEnd(sql`strict`)
|
||||
.execute()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives every division (= starting bracket) a tier of its own, so that a result of a tournament
|
||||
* with many starting brackets stops showing the tier of its strongest division. Tournaments that
|
||||
* already got their divisions tiered, i.e. the leagues above, are left alone.
|
||||
*
|
||||
* Historical tiers can't be recomputed exactly: `SeedingSkill` only keeps current values and skill
|
||||
* inflates over time, so recomputing a finalized single division tournament lands on its stored tier
|
||||
* only about half of the time and is 1-3 tiers too good otherwise. Every division of one tournament
|
||||
* shares that drift, so they are recomputed as one batch and then shifted to make the first starting
|
||||
* bracket (the one the stored tier was calculated from) land exactly on it. Divisions of tournaments
|
||||
* with no tier are left out, having nothing to anchor to.
|
||||
*/
|
||||
async function backfillDivisionTiers(trx: Transaction<any>) {
|
||||
const alreadyTiered = new Set(
|
||||
(
|
||||
await trx
|
||||
.selectFrom("TournamentDivisionTier")
|
||||
.select("tournamentId")
|
||||
.distinct()
|
||||
.execute()
|
||||
).map((row: { tournamentId: number }) => row.tournamentId),
|
||||
);
|
||||
|
||||
const tournaments = await trx
|
||||
.selectFrom("Tournament")
|
||||
.select(["id", "settings", "tier"])
|
||||
.where("tier", "is not", null)
|
||||
.execute();
|
||||
|
||||
for (const tournament of tournaments) {
|
||||
if (alreadyTiered.has(tournament.id)) continue;
|
||||
|
||||
const settings = JSON.parse(tournament.settings);
|
||||
const startingBracketIdxs = (settings.bracketProgression as any[])
|
||||
.map((bracket, idx) => ({ bracket, idx }))
|
||||
.filter(({ bracket }) => !bracket.sources)
|
||||
.map(({ idx }) => idx);
|
||||
|
||||
// tournaments where every team plays the same bracket already have an accurate tier
|
||||
if (startingBracketIdxs.length < 2) continue;
|
||||
|
||||
const teams = await teamsWithSeedingSkill(trx, {
|
||||
tournamentId: tournament.id,
|
||||
isRanked: settings.isRanked === true,
|
||||
});
|
||||
|
||||
const tierByBracketIdx = new Map<number, number>();
|
||||
for (const bracketIdx of startingBracketIdxs) {
|
||||
const ofDivision = teams.filter(
|
||||
(team) => (team.startingBracketIdx ?? 0) === bracketIdx,
|
||||
);
|
||||
// teams that did not check in do not play the bracket, but tournaments predating
|
||||
// check-in data have none of them
|
||||
const checkedIn = ofDivision.filter((team) => team.checkedIn);
|
||||
|
||||
const tier = tierOfTeams(checkedIn.length > 0 ? checkedIn : ofDivision);
|
||||
if (tier !== null) {
|
||||
tierByBracketIdx.set(bracketIdx, tier);
|
||||
}
|
||||
}
|
||||
|
||||
const anchorTier = tierByBracketIdx.get(startingBracketIdxs[0]);
|
||||
if (anchorTier === undefined) continue;
|
||||
|
||||
const offset = tournament.tier - anchorTier;
|
||||
|
||||
await trx
|
||||
.insertInto("TournamentDivisionTier")
|
||||
.values(
|
||||
[...tierByBracketIdx].map(([bracketIdx, tier]) => ({
|
||||
tournamentId: tournament.id,
|
||||
bracketIdx,
|
||||
tier: clampTier(tier + offset),
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
function teamsWithSeedingSkill(
|
||||
trx: Transaction<any>,
|
||||
{ tournamentId, isRanked }: { tournamentId: number; isRanked: boolean },
|
||||
): Promise<
|
||||
Array<{
|
||||
startingBracketIdx: number | null;
|
||||
avgOrdinal: number | null;
|
||||
checkedIn: number;
|
||||
}>
|
||||
> {
|
||||
return trx
|
||||
.selectFrom("TournamentTeam")
|
||||
.select((eb) => [
|
||||
"TournamentTeam.startingBracketIdx",
|
||||
eb
|
||||
.selectFrom("TournamentTeamMember")
|
||||
.innerJoin("SeedingSkill", (join) =>
|
||||
join
|
||||
.onRef("SeedingSkill.userId", "=", "TournamentTeamMember.userId")
|
||||
.on("SeedingSkill.type", "=", isRanked ? "RANKED" : "UNRANKED"),
|
||||
)
|
||||
.select(({ fn }) => fn.avg("SeedingSkill.ordinal").as("v"))
|
||||
.whereRef(
|
||||
"TournamentTeamMember.tournamentTeamId",
|
||||
"=",
|
||||
"TournamentTeam.id",
|
||||
)
|
||||
.as("avgOrdinal"),
|
||||
eb
|
||||
.exists(
|
||||
eb
|
||||
.selectFrom("TournamentTeamCheckIn")
|
||||
.select("TournamentTeamCheckIn.tournamentTeamId")
|
||||
.whereRef(
|
||||
"TournamentTeamCheckIn.tournamentTeamId",
|
||||
"=",
|
||||
"TournamentTeam.id",
|
||||
)
|
||||
.where("TournamentTeamCheckIn.isCheckOut", "=", 0),
|
||||
)
|
||||
.as("checkedIn"),
|
||||
])
|
||||
.where("TournamentTeam.tournamentId", "=", tournamentId)
|
||||
.where("TournamentTeam.isPlaceholder", "=", 0)
|
||||
.execute() as any;
|
||||
}
|
||||
|
||||
// frozen copy of app/features/tournament/core/tiering.ts as of this migration
|
||||
const TIER_THRESHOLDS: Array<[tier: number, threshold: number]> = [
|
||||
[1, 32],
|
||||
[2, 29],
|
||||
[3, 26],
|
||||
[4, 24],
|
||||
[5, 21],
|
||||
[6, 15],
|
||||
[7, 10],
|
||||
[8, 5],
|
||||
];
|
||||
const WORST_TIER = 9;
|
||||
const TOP_TEAMS_COUNT = 8;
|
||||
const MIN_TEAMS_FOR_TIERING = 8;
|
||||
const NO_BONUS_ABOVE = 32;
|
||||
const MAX_BONUS_PER_10_TEAMS = 1.5;
|
||||
|
||||
function tierOfTeams(teams: Array<{ avgOrdinal: number | null }>) {
|
||||
if (teams.length < MIN_TEAMS_FOR_TIERING) return null;
|
||||
|
||||
const ordinals = teams
|
||||
.map((team) => team.avgOrdinal)
|
||||
.filter((ordinal) => ordinal !== null);
|
||||
if (ordinals.length === 0) return null;
|
||||
|
||||
const topOrdinals = ordinals.sort((a, b) => b - a).slice(0, TOP_TEAMS_COUNT);
|
||||
const rawScore =
|
||||
topOrdinals.reduce((sum, ordinal) => sum + ordinal, 0) / topOrdinals.length;
|
||||
|
||||
const scaleFactor = Math.max(0, (NO_BONUS_ABOVE - rawScore) / NO_BONUS_ABOVE);
|
||||
const teamsAboveMin = Math.max(0, teams.length - MIN_TEAMS_FOR_TIERING);
|
||||
const adjustedScore =
|
||||
rawScore + scaleFactor * MAX_BONUS_PER_10_TEAMS * (teamsAboveMin / 10);
|
||||
|
||||
for (const [tier, threshold] of TIER_THRESHOLDS) {
|
||||
if (adjustedScore >= threshold) return tier;
|
||||
}
|
||||
|
||||
return WORST_TIER;
|
||||
}
|
||||
|
||||
const clampTier = (tier: number) => Math.min(WORST_TIER, Math.max(1, tier));
|
||||
@@ -40,10 +40,12 @@ import * as TournamentMatchVodRepository from "~/features/tournament-bracket/Tou
|
||||
import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server";
|
||||
import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server";
|
||||
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
|
||||
import { LUTI_ORGANIZATION_ID } from "~/features/tournament-organization/tournament-organization-constants";
|
||||
import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
|
||||
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import * as VodRepository from "~/features/vods/VodRepository.server";
|
||||
import { LUTI_NAME_PREFIX } from "~/routines/computeLutiDivs";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import type { Fixtures } from "./fixtures";
|
||||
|
||||
@@ -1041,35 +1043,17 @@ export function buildCases(fx: Fixtures): {
|
||||
(tournamentId) =>
|
||||
TournamentRepository.findSeedingSnapshotById(tournamentId),
|
||||
);
|
||||
add(
|
||||
"TournamentRepository.hasChildTournaments",
|
||||
fx.parentTournamentId,
|
||||
(parentTournamentId) =>
|
||||
TournamentRepository.hasChildTournaments(parentTournamentId),
|
||||
);
|
||||
add(
|
||||
"TournamentRepository.findChildTournaments",
|
||||
fx.parentTournamentId,
|
||||
(parentTournamentId) =>
|
||||
TournamentRepository.findChildTournaments(parentTournamentId),
|
||||
);
|
||||
add(
|
||||
"TournamentRepository.findChildTournamentsForDivCalc",
|
||||
fx.parentTournamentId,
|
||||
(parentTournamentId) =>
|
||||
TournamentRepository.findChildTournamentsForDivCalc(parentTournamentId),
|
||||
);
|
||||
add(
|
||||
"TournamentRepository.findResultsByTournamentId",
|
||||
fx.heavyResultsTournamentId,
|
||||
(tournamentId) =>
|
||||
TournamentRepository.findResultsByTournamentId(tournamentId),
|
||||
);
|
||||
add(
|
||||
"TournamentRepository.findLeagueDivParticipantUserIds",
|
||||
fx.parentTournamentId,
|
||||
(parentTournamentId) =>
|
||||
TournamentRepository.findLeagueDivParticipantUserIds(parentTournamentId),
|
||||
addStatic("TournamentRepository.findLatestFinalizedLeagueParticipants", () =>
|
||||
TournamentRepository.findLatestFinalizedLeagueParticipants({
|
||||
organizationId: LUTI_ORGANIZATION_ID,
|
||||
namePrefix: LUTI_NAME_PREFIX,
|
||||
}),
|
||||
);
|
||||
add(
|
||||
"TournamentRepository.findTOSetMapPoolById",
|
||||
|
||||
@@ -39,7 +39,6 @@ export interface Fixtures {
|
||||
tournamentTeamPair: [number, number] | null;
|
||||
tournamentTeamInviteCode: string | null;
|
||||
recentTournamentIds: number[] | null;
|
||||
parentTournamentId: number | null;
|
||||
heavyTeam: { id: number; customUrl: string; memberUserId: number } | null;
|
||||
heavyCalendarEventId: number | null;
|
||||
resultsEventId: number | null;
|
||||
@@ -149,7 +148,6 @@ export async function resolveFixtures(): Promise<Fixtures> {
|
||||
tournamentTeamInviteCode:
|
||||
await resolveTournamentTeamInviteCode(heavyTournamentId),
|
||||
recentTournamentIds: await resolveRecentTournamentIds(),
|
||||
parentTournamentId: await resolveParentTournamentId(),
|
||||
heavyTeam: await resolveHeavyTeam(),
|
||||
heavyCalendarEventId: await resolveHeavyCalendarEventId(),
|
||||
resultsEventId: await resolveResultsEventId(),
|
||||
@@ -535,22 +533,6 @@ async function resolveRecentTournamentIds() {
|
||||
return rows.length > 0 ? rows.map((row) => row.tournamentId) : null;
|
||||
}
|
||||
|
||||
async function resolveParentTournamentId() {
|
||||
const row = await db
|
||||
.selectFrom("Tournament")
|
||||
.select(({ fn }) => [
|
||||
"parentTournamentId",
|
||||
fn.countAll<number>().as("count"),
|
||||
])
|
||||
.where("parentTournamentId", "is not", null)
|
||||
.groupBy("parentTournamentId")
|
||||
.orderBy("count", "desc")
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
|
||||
return row?.parentTournamentId ?? null;
|
||||
}
|
||||
|
||||
async function resolveHeavyTeam() {
|
||||
const row = await db
|
||||
.selectFrom("TeamMemberWithSecondary")
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
// for testing use the command `pnpm exec vite-node ./scripts/create-league-divisions.ts 6 'https://gist.githubusercontent.com/sendou-ink/38aa4d5d8426035ce178c09598ae627f/raw/17be9bb53a9f017c2097d0624f365d1c5a029f01/league.csv'`
|
||||
|
||||
import * as v from "valibot";
|
||||
import { db } from "~/db/sql";
|
||||
import { ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
|
||||
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.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(
|
||||
v.parse(v.pipe(v.string(), v.url()), csvUrl),
|
||||
"csv url is required (argument 2)",
|
||||
);
|
||||
|
||||
async function main() {
|
||||
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.insert({
|
||||
parentTournamentId: tournament.ctx.id,
|
||||
authorId: tournament.ctx.author.id,
|
||||
bracketProgression: tournament.ctx.settings.bracketProgression,
|
||||
description: tournament.ctx.description,
|
||||
discordInviteCode:
|
||||
tournament.ctx.discordUrl?.replace("https://discord.gg/", "") ?? null,
|
||||
mapPickingStyle: tournament.ctx.mapPickingStyle,
|
||||
name: `${tournament.ctx.name} - ${div.startsWith("Division") ? div : `Division ${div}`}`,
|
||||
organizationId: tournament.ctx.organization?.id ?? null,
|
||||
rules: tournament.ctx.rules,
|
||||
startTimes: [dateToDatabaseTimestamp(tournament.ctx.startsAt)],
|
||||
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,
|
||||
maxMembersPerTeam: tournament.ctx.settings.maxMembersPerTeam,
|
||||
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 [idx, team] of divsTeams!.entries()) {
|
||||
await TournamentTeamRepository.copyFromAnotherTournament({
|
||||
destinationTournamentId: createdEvent.tournamentId!,
|
||||
tournamentTeamId: team.id,
|
||||
seed: idx + 1,
|
||||
defaultCheckedIn: true,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`Created division ${div} (id: ${createdEvent.tournamentId})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCsv() {
|
||||
const response = await fetch(csvUrl);
|
||||
return response.text();
|
||||
}
|
||||
|
||||
const csvSchema = v.array(
|
||||
v.object({
|
||||
"Team id": v.pipe(v.unknown(), v.transform(Number), v.number()),
|
||||
Div: v.string(),
|
||||
}),
|
||||
);
|
||||
|
||||
type ParsedTeam = ReturnType<typeof parseCsv>[number];
|
||||
|
||||
function parseCsv(csv: string) {
|
||||
const lines = csv.trim().split("\n");
|
||||
const headers = splitCsvRow(lines[0]).map((h) => h.trim());
|
||||
const rows = lines.slice(1).map((line) => {
|
||||
const row = splitCsvRow(line);
|
||||
return headers.reduce(
|
||||
(acc, header, i) => {
|
||||
acc[header] = row[i];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
});
|
||||
|
||||
const validated = v.parse(csvSchema, 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function splitCsvRow(line: string) {
|
||||
const fields: string[] = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
|
||||
if (inQuotes) {
|
||||
if (char === '"') {
|
||||
if (line[i + 1] === '"') {
|
||||
current += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
} else if (char === '"') {
|
||||
inQuotes = true;
|
||||
} else if (char === ",") {
|
||||
fields.push(current);
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
fields.push(current);
|
||||
return fields;
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user