{bracketLabel ? `${bracketLabel} ` : null}#{seed}
diff --git a/app/features/tournament/components/TournamentNav.tsx b/app/features/tournament/components/TournamentNav.tsx
index 83f2297cc..9a31d919e 100644
--- a/app/features/tournament/components/TournamentNav.tsx
+++ b/app/features/tournament/components/TournamentNav.tsx
@@ -63,13 +63,19 @@ const PRIORITY_ORDER: NavItemKey[] = [
export function TournamentNav({
tournament,
+ streamsCount,
hasChildTournaments,
}: {
tournament: Tournament;
+ streamsCount: number;
hasChildTournaments: boolean;
}) {
const { t } = useTranslation(["tournament"]);
- const navItems = useNavItems({ tournament, hasChildTournaments });
+ const navItems = useNavItems({
+ tournament,
+ streamsCount,
+ hasChildTournaments,
+ });
const { visibleCount, containerRef, measureRef } = useNavOverflow(
navItems.length,
);
@@ -145,9 +151,11 @@ export function TournamentNav({
function useNavItems({
tournament,
+ streamsCount,
hasChildTournaments,
}: {
tournament: Tournament;
+ streamsCount: number;
hasChildTournaments: boolean;
}): NavItem[] {
const { t } = useTranslation(["tournament"]);
@@ -212,7 +220,7 @@ function useNavItems({
items.streams = {
key: "streams",
label: t("tournament:nav.streams", {
- count: tournament.streams.length,
+ count: streamsCount,
}),
to: "streams",
icon:
,
diff --git a/app/features/tournament/components/TournamentStream.tsx b/app/features/tournament/components/TournamentStream.tsx
index d43f2477f..357c36099 100644
--- a/app/features/tournament/components/TournamentStream.tsx
+++ b/app/features/tournament/components/TournamentStream.tsx
@@ -15,10 +15,6 @@ export function TournamentStream({
withThumbnail?: boolean;
}) {
const tournament = useTournament();
- const team = tournament.ctx.teams.find((team) =>
- team.members.some((m) => m.userId === stream.userId),
- );
- const user = team?.members.find((m) => m.userId === stream.userId);
return (
) : null}
- {user && team ? (
+ {stream.user ? (
-
{user.username}
+
{stream.user.username}
- {team.name}
+ {stream.teamName}
) : (
diff --git a/app/features/tournament/core/Standings.ts b/app/features/tournament/core/Standings.ts
index 9207ae067..013ccedbb 100644
--- a/app/features/tournament/core/Standings.ts
+++ b/app/features/tournament/core/Standings.ts
@@ -154,6 +154,74 @@ export function matchesPlayed({
});
}
+type PersistedResultRow = {
+ tournamentTeamId: number;
+ userId: number;
+ placement: number;
+ div: string | null;
+};
+
+/**
+ * Standings of a finalized tournament reconstructed from the per-user results persisted at
+ * finalization, instead of recomputing them from bracket match data. Returns null when the
+ * persisted rows cannot back the standings (no rows, a team no longer in the tournament
+ * context, or a division label the progression no longer produces) so the caller can fall
+ * back to {@link tournamentStandings}.
+ */
+export function standingsFromPersistedResults({
+ tournament,
+ results,
+}: {
+ tournament: Tournament;
+ results: PersistedResultRow[];
+}): TournamentStandingsResult | null {
+ if (results.length === 0) return null;
+
+ const standings: Array
= [];
+
+ for (const rows of Object.values(
+ R.groupBy(results, (row) => row.tournamentTeamId),
+ )) {
+ const team = tournament.teamById(rows[0].tournamentTeamId);
+ if (!team) return null;
+
+ standings.push({
+ team: { ...team, memberUserIds: rows.map((row) => row.userId) },
+ placement: rows[0].placement,
+ div: rows[0].div,
+ });
+ }
+
+ const sorted = R.sortBy(
+ standings,
+ (standing) => standing.placement,
+ (standing) => standing.team.seed ?? Number.POSITIVE_INFINITY,
+ );
+
+ if (sorted.every((standing) => standing.div === null)) {
+ return { type: "single", standings: sorted };
+ }
+
+ const progression = tournament.ctx.settings.bracketProgression;
+ const divs = Progression.hasAbDivisionsFinals(progression)
+ ? ["A", "B"]
+ : Progression.startingBrackets(progression).map((bracketIdx) =>
+ getBracketProgressionLabel(bracketIdx, progression),
+ );
+ const hasUnknownDiv = sorted.some(
+ (standing) => standing.div === null || !divs.includes(standing.div),
+ );
+ if (hasUnknownDiv) return null;
+
+ return {
+ type: "multi",
+ standings: divs.map((div) => ({
+ div,
+ standings: sorted.filter((standing) => standing.div === div),
+ })),
+ };
+}
+
/**
* Computes the standings for a given tournament by aggregating results from relevant brackets.
*
diff --git a/app/features/tournament/core/layout-payload.ts b/app/features/tournament/core/layout-payload.ts
new file mode 100644
index 000000000..a6f371599
--- /dev/null
+++ b/app/features/tournament/core/layout-payload.ts
@@ -0,0 +1,68 @@
+import type { TournamentLoaderData } from "../loaders/to.$id.server";
+
+/**
+ * Team keys that {@link serializeTournamentLoaderData} drops when null and
+ * {@link parseTournamentLoaderData} restores. Null for most teams, so dropping
+ * them trims the layout payload of big tournaments by a few gzipped kilobytes.
+ */
+const NULL_COMPACTED_TEAM_KEYS = [
+ "seed",
+ "inviteCode",
+ "logoUrl",
+ "activeRosterUserIds",
+ "startingBracketIdx",
+ "abDivision",
+ "avgSeedingSkillOrdinal",
+ "ownerUserId",
+] as const;
+
+type LayoutTeam = TournamentLoaderData["tournament"]["ctx"]["teams"][number];
+
+/**
+ * Serializes the tournament layout loader data, omitting null-valued team keys.
+ * Counterpart of {@link parseTournamentLoaderData}, the only way the payload
+ * should be read.
+ */
+export function serializeTournamentLoaderData(
+ data: TournamentLoaderData,
+): string {
+ // JSON.stringify so that we skip expensive rr7 data serialization (hot path loader)
+ return JSON.stringify({
+ ...data,
+ tournament: {
+ ...data.tournament,
+ ctx: {
+ ...data.tournament.ctx,
+ teams: data.tournament.ctx.teams.map(compactTeamNulls),
+ },
+ },
+ });
+}
+
+/**
+ * Parses the tournament layout loader data serialized by
+ * {@link serializeTournamentLoaderData}, restoring the null team keys it omitted.
+ */
+export function parseTournamentLoaderData(raw: string): TournamentLoaderData {
+ const data = JSON.parse(raw) as TournamentLoaderData;
+
+ for (const team of data.tournament.ctx.teams) {
+ const teamRecord = team as Record;
+ for (const key of NULL_COMPACTED_TEAM_KEYS) {
+ teamRecord[key] ??= null;
+ }
+ }
+
+ return data;
+}
+
+function compactTeamNulls(team: LayoutTeam) {
+ const compacted: Record = { ...team };
+ for (const key of NULL_COMPACTED_TEAM_KEYS) {
+ if (compacted[key] === null) {
+ delete compacted[key];
+ }
+ }
+
+ return compacted as unknown as LayoutTeam;
+}
diff --git a/app/features/tournament/loaders/to.$id.info.server.ts b/app/features/tournament/loaders/to.$id.info.server.ts
index 28ea524e3..15e615c17 100644
--- a/app/features/tournament/loaders/to.$id.info.server.ts
+++ b/app/features/tournament/loaders/to.$id.info.server.ts
@@ -2,6 +2,10 @@ import type { LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
+import {
+ requireTournamentVisible,
+ tournamentDataCached,
+} from "~/features/tournament-bracket/core/Tournament.server";
import { parseParams } from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
@@ -12,6 +16,9 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
schema: idObject,
});
+ const { ctx } = await tournamentDataCached({ tournamentId });
+ requireTournamentVisible({ ctx, user });
+
const description =
await TournamentRepository.findDescriptionById(tournamentId);
diff --git a/app/features/tournament/loaders/to.$id.register.server.ts b/app/features/tournament/loaders/to.$id.register.server.ts
index aa4327e1e..83fd34876 100644
--- a/app/features/tournament/loaders/to.$id.register.server.ts
+++ b/app/features/tournament/loaders/to.$id.register.server.ts
@@ -3,7 +3,11 @@ import { getUser } from "~/features/auth/core/user.server";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server";
-import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server";
+import {
+ requireTournamentVisible,
+ tournamentFromDBCached,
+ tournamentTeamsFullCached,
+} from "~/features/tournament-bracket/core/Tournament.server";
import { parseParams } from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
@@ -17,10 +21,13 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
});
const tournament = await tournamentFromDBCached({ tournamentId, user });
- const ownTeam = tournament.ownedTeamByUser(user);
+ requireTournamentVisible({ ctx: tournament.ctx, user });
- if (!ownTeam) {
+ const teamMemberOf = tournament.teamMemberOfByUser(user);
+
+ if (!teamMemberOf) {
return {
+ ownTeam: null,
mapPool: null,
friendPlayers: null,
teams: await TeamRepository.findAllMemberOfByUserId(user.id),
@@ -31,8 +38,14 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
};
}
+ const ownTeam =
+ (await tournamentTeamsFullCached({ tournamentId, user })).find(
+ (team) => team.id === teamMemberOf.id,
+ ) ?? null;
+
return {
- mapPool: ownTeam.mapPool,
+ ownTeam,
+ mapPool: ownTeam?.mapPool ?? null,
friendPlayers: await SQGroupRepository.findFriendsAndTeammates(user.id),
teams: await TeamRepository.findAllMemberOfByUserId(user.id),
isSaved: false,
diff --git a/app/features/tournament/loaders/to.$id.results.server.ts b/app/features/tournament/loaders/to.$id.results.server.ts
new file mode 100644
index 000000000..0b92f82c6
--- /dev/null
+++ b/app/features/tournament/loaders/to.$id.results.server.ts
@@ -0,0 +1,76 @@
+import type { LoaderFunctionArgs } from "react-router";
+import { getUser } from "~/features/auth/core/user.server";
+import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
+import type { Standing } from "~/features/tournament-bracket/core/Bracket";
+import {
+ requireTournamentVisible,
+ tournamentSharedCached,
+ tournamentTeamsFullCached,
+} from "~/features/tournament-bracket/core/Tournament.server";
+import type { SerializeFrom } from "~/utils/remix";
+import { parseParams } from "~/utils/remix.server";
+import { idObject } from "~/utils/zod";
+import * as Standings from "../core/Standings";
+
+export type TournamentResultsLoaderData = SerializeFrom;
+
+export const loader = async ({ params }: LoaderFunctionArgs) => {
+ const { id: tournamentId } = parseParams({ params, schema: idObject });
+
+ const tournament = await tournamentSharedCached(tournamentId);
+ requireTournamentVisible({ ctx: tournament.ctx, user: getUser() });
+
+ const teams = await tournamentTeamsFullCached({ tournamentId });
+
+ const rosterByTeamId = new Map(
+ teams.map((team) => [
+ team.id,
+ team.members.map((member) => ({
+ userId: member.userId,
+ username: member.username,
+ country: member.country,
+ })),
+ ]),
+ );
+
+ const toRow = (standings: Standing[]) => (standing: Standing) => ({
+ placement: standing.placement,
+ spr: Standings.calculateSPR({ standings, teamId: standing.team.id }),
+ team: {
+ id: standing.team.id,
+ name: standing.team.name,
+ seed: standing.team.seed,
+ logoUrl: standing.team.logoUrl,
+ },
+ roster: (rosterByTeamId.get(standing.team.id) ?? []).filter((member) =>
+ standing.team.memberUserIds.includes(member.userId),
+ ),
+ matches: Standings.matchesPlayed({ tournament, teamId: standing.team.id }),
+ });
+
+ const persistedStandings = tournament.ctx.isFinalized
+ ? Standings.standingsFromPersistedResults({
+ tournament,
+ results:
+ await TournamentRepository.findResultsByTournamentId(tournamentId),
+ })
+ : null;
+ const result =
+ persistedStandings ?? Standings.tournamentStandings(tournament);
+
+ return {
+ standings:
+ result.type === "single"
+ ? {
+ type: "single" as const,
+ standings: result.standings.map(toRow(result.standings)),
+ }
+ : {
+ type: "multi" as const,
+ standings: result.standings.map(({ div, standings }) => ({
+ div,
+ standings: standings.map(toRow(standings)),
+ })),
+ },
+ };
+};
diff --git a/app/features/tournament/loaders/to.$id.rules.server.ts b/app/features/tournament/loaders/to.$id.rules.server.ts
index e003b71c4..a4e361511 100644
--- a/app/features/tournament/loaders/to.$id.rules.server.ts
+++ b/app/features/tournament/loaders/to.$id.rules.server.ts
@@ -1,5 +1,10 @@
import type { LoaderFunctionArgs } from "react-router";
+import { getUser } from "~/features/auth/core/user.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
+import {
+ requireTournamentVisible,
+ tournamentDataCached,
+} from "~/features/tournament-bracket/core/Tournament.server";
import { parseParams } from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
@@ -9,6 +14,9 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
schema: idObject,
});
+ const { ctx } = await tournamentDataCached({ tournamentId });
+ requireTournamentVisible({ ctx, user: getUser() });
+
return {
rules: await TournamentRepository.findRulesById(tournamentId),
};
diff --git a/app/features/tournament/loaders/to.$id.server.ts b/app/features/tournament/loaders/to.$id.server.ts
index d3a64e405..807785096 100644
--- a/app/features/tournament/loaders/to.$id.server.ts
+++ b/app/features/tournament/loaders/to.$id.server.ts
@@ -6,15 +6,22 @@ import {
LEAGUES,
TOURNAMENT,
} from "~/features/tournament/tournament-constants";
-import { tournamentDataCached } from "~/features/tournament-bracket/core/Tournament.server";
+import { isTournamentOrganizer } from "~/features/tournament-bracket/core/Tournament";
+import {
+ bracketsMetaCached,
+ requireTournamentVisible,
+ type TournamentLayoutData,
+ tournamentDataCached,
+} from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentMatchVodRepository from "~/features/tournament-bracket/TournamentMatchVodRepository.server";
import { databaseTimestampToDate } from "~/utils/dates";
import { parseParams } from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
+import { serializeTournamentLoaderData } from "../core/layout-payload";
export type TournamentLoaderData = {
- tournament: Awaited>;
- streamingParticipants: number[];
+ tournament: TournamentLayoutData;
+ /** Count for the streams tab badge; the streams view loads the actual streams itself. */
streamsCount: number;
hasChildTournaments: boolean;
friendCodes:
@@ -36,6 +43,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
});
const tournament = await tournamentDataCached({ tournamentId, user });
+ requireTournamentVisible({ ctx: tournament.ctx, user });
const friendCodeVisibilityDays = tournament.ctx.parentTournamentId ? 120 : 30;
const tournamentStartedRecently = isAfter(
@@ -51,18 +59,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
tournament.ctx.organization?.members.some(
(m) => m.userId === user?.id && m.role === "ADMIN",
);
- const isTournamentOrganizer =
- isTournamentAdmin ||
- tournament.ctx.staff.some(
- (s) => s.role === "ORGANIZER" && s.id === user?.id,
- ) ||
- tournament.ctx.organization?.members.some(
- (m) => m.userId === user?.id && m.role === "ORGANIZER",
- );
- if (tournament.ctx.settings.isDraft && !isTournamentOrganizer) {
- throw new Response(null, { status: 404 });
- }
-
const showFriendCodes = tournamentStartedRecently && isTournamentAdmin;
const isLeagueSignup = Object.values(LEAGUES)
@@ -79,15 +75,19 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
subDays(new Date(), TOURNAMENT.VOD_VISIBILITY_DAYS),
);
- // skip expensive rr7 data serialization (hot path loader)
- return JSON.stringify({
- tournament,
+ return serializeTournamentLoaderData({
+ tournament: {
+ ctx: tournament.ctx,
+ bracketsMeta: await bracketsMetaCached(tournamentId),
+ },
+ streamsCount: tournament.streams.length,
hasChildTournaments,
friendCodes: showFriendCodes
? await TournamentRepository.findFriendCodesByTournamentId(tournamentId)
: undefined,
preparedMaps:
- isTournamentOrganizer && !tournament.ctx.isFinalized
+ isTournamentOrganizer({ ctx: tournament.ctx, user }) &&
+ !tournament.ctx.isFinalized
? await TournamentRepository.findPreparedMapsById(tournamentId)
: undefined,
vods: showVods
diff --git a/app/features/tournament/loaders/to.$id.streams.server.ts b/app/features/tournament/loaders/to.$id.streams.server.ts
new file mode 100644
index 000000000..3c4e73a1a
--- /dev/null
+++ b/app/features/tournament/loaders/to.$id.streams.server.ts
@@ -0,0 +1,21 @@
+import type { LoaderFunctionArgs } from "react-router";
+import { getUser } from "~/features/auth/core/user.server";
+import {
+ fetchTournamentStreams,
+ requireTournamentVisible,
+ tournamentSharedCached,
+} from "~/features/tournament-bracket/core/Tournament.server";
+import type { SerializeFrom } from "~/utils/remix";
+import { parseParams } from "~/utils/remix.server";
+import { idObject } from "~/utils/zod";
+
+export type TournamentStreamsLoaderData = SerializeFrom;
+
+export const loader = async ({ params }: LoaderFunctionArgs) => {
+ const { id: tournamentId } = parseParams({ params, schema: idObject });
+
+ const tournament = await tournamentSharedCached(tournamentId);
+ requireTournamentVisible({ ctx: tournament.ctx, user: getUser() });
+
+ return { streams: await fetchTournamentStreams(tournamentId) };
+};
diff --git a/app/features/tournament/loaders/to.$id.teams.$tid.server.ts b/app/features/tournament/loaders/to.$id.teams.$tid.server.ts
index 19074f6d4..f1a218d20 100644
--- a/app/features/tournament/loaders/to.$id.teams.$tid.server.ts
+++ b/app/features/tournament/loaders/to.$id.teams.$tid.server.ts
@@ -1,23 +1,38 @@
import type { LoaderFunctionArgs } from "react-router";
-import { tournamentDataCached } from "~/features/tournament-bracket/core/Tournament.server";
+import { getUser } from "~/features/auth/core/user.server";
+import {
+ requireTournamentVisible,
+ tournamentDataCached,
+ tournamentSharedCached,
+ tournamentTeamsFullCached,
+} from "~/features/tournament-bracket/core/Tournament.server";
import { tournamentTeamPageParamsSchema } from "~/features/tournament-bracket/tournament-bracket-schemas.server";
import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server";
import invariant from "~/utils/invariant";
+import type { SerializeFrom } from "~/utils/remix";
import { parseParams } from "~/utils/remix.server";
+import * as Standings from "../core/Standings";
import {
type AllRoundsItem,
tournamentTeamSets,
winCounts,
} from "../core/sets.server";
+export type TournamentTeamLoaderData = SerializeFrom;
+
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id: tournamentId, tid: tournamentTeamId } = parseParams({
params,
schema: tournamentTeamPageParamsSchema,
});
+ const user = getUser();
const tournament = await tournamentDataCached({ tournamentId });
- const team = tournament?.ctx.teams.find((t) => t.id === tournamentTeamId);
+ requireTournamentVisible({ ctx: tournament.ctx, user });
+
+ const team = (await tournamentTeamsFullCached({ tournamentId, user })).find(
+ (t) => t.id === tournamentTeamId,
+ );
const tournamentHasStarted = (tournament?.data.stage.length ?? 0) > 0;
if (!team || (tournamentHasStarted && team.checkIns.length === 0)) {
throw new Response(null, { status: 404 });
@@ -42,9 +57,47 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
const sets = tournamentTeamSets({ sets: setHistory, allRounds });
+ const fullTournament = await tournamentSharedCached(tournamentId);
+ const standingsResult = Standings.tournamentStandings(fullTournament);
+ const overallStandings = Standings.flattenStandings(standingsResult);
+ const undergroundBracketIdx = fullTournament.bracketsMeta.find(
+ (bracket) => bracket.isUnderground,
+ )?.idx;
+
return {
tournamentTeamId,
- sets,
+ team,
+ activePlayers:
+ sets.length > 0
+ ? fullTournament.participatedPlayerUserIdsByTeamId(tournamentTeamId)
+ : undefined,
+ tournamentName: tournament.ctx.name,
+ sets: sets.map((set) => ({
+ ...set,
+ // the layout ships no bracket match data, so the names can't be derived in the view
+ matchContextNames: fullTournament.matchContextNamesById(
+ set.tournamentMatchId,
+ ),
+ })),
winCounts: winCounts(sets),
+ division:
+ standingsResult.type === "multi"
+ ? (standingsResult.standings.find((div) =>
+ div.standings.some(
+ (standing) => standing.team.id === tournamentTeamId,
+ ),
+ )?.div ?? null)
+ : null,
+ placement: overallStandings.find(
+ (standing) => standing.team.id === tournamentTeamId,
+ )?.placement,
+ undergroundPlacement:
+ typeof undergroundBracketIdx === "number"
+ ? fullTournament
+ .bracketByIdx(undergroundBracketIdx)
+ ?.standings.find(
+ (standing) => standing.team.id === tournamentTeamId,
+ )?.placement
+ : undefined,
};
};
diff --git a/app/features/tournament/loaders/to.$id.teams.server.ts b/app/features/tournament/loaders/to.$id.teams.server.ts
new file mode 100644
index 000000000..0ed05ba9b
--- /dev/null
+++ b/app/features/tournament/loaders/to.$id.teams.server.ts
@@ -0,0 +1,43 @@
+import type { LoaderFunctionArgs } from "react-router";
+import { getUser } from "~/features/auth/core/user.server";
+import {
+ requireTournamentVisible,
+ tournamentSharedCached,
+ tournamentTeamsFullInSeedOrder,
+} from "~/features/tournament-bracket/core/Tournament.server";
+import type { SerializeFrom } from "~/utils/remix";
+import { paginate, parseParams } from "~/utils/remix.server";
+import { idObject } from "~/utils/zod";
+import { tournamentTeamsSearchParams } from "../tournament-search-params";
+
+export type TournamentTeamsLoaderData = SerializeFrom;
+
+/** How many rosters are rendered (and shipped) per page of the teams tab. */
+const TEAMS_PAGE_SIZE = 50;
+
+export const loader = async ({ request, params, url }: LoaderFunctionArgs) => {
+ const user = getUser();
+ const { id: tournamentId } = parseParams({ params, schema: idObject });
+ const { page } = tournamentTeamsSearchParams.parse(request);
+
+ const tournament = await tournamentSharedCached(tournamentId);
+ requireTournamentVisible({ ctx: tournament.ctx, user });
+
+ const teams = await tournamentTeamsFullInSeedOrder({ tournament, user });
+
+ const { currentPage, pagesCount } = paginate({
+ url,
+ page,
+ pageSize: TEAMS_PAGE_SIZE,
+ totalCount: teams.length,
+ });
+
+ return {
+ teams: teams.slice(
+ (currentPage - 1) * TEAMS_PAGE_SIZE,
+ currentPage * TEAMS_PAGE_SIZE,
+ ),
+ currentPage,
+ pagesCount,
+ };
+};
diff --git a/app/features/tournament/routes/to.$id.index.ts b/app/features/tournament/routes/to.$id.index.ts
index 61e10fd2d..695db5699 100644
--- a/app/features/tournament/routes/to.$id.index.ts
+++ b/app/features/tournament/routes/to.$id.index.ts
@@ -1,5 +1,9 @@
import { type LoaderFunctionArgs, redirect } from "react-router";
-import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server";
+import { getUser } from "~/features/auth/core/user.server";
+import {
+ requireTournamentVisible,
+ tournamentFromDBCached,
+} from "~/features/tournament-bracket/core/Tournament.server";
import { parseParams } from "~/utils/remix.server";
import {
tournamentBracketsPage,
@@ -18,6 +22,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
tournamentId,
user: undefined,
});
+ requireTournamentVisible({ ctx: tournament.ctx, user: getUser() });
if (!tournament.hasStarted) {
return redirect(tournamentInfoPage(tournamentId));
diff --git a/app/features/tournament/routes/to.$id.info.tsx b/app/features/tournament/routes/to.$id.info.tsx
index 636bf3818..385aab5fa 100644
--- a/app/features/tournament/routes/to.$id.info.tsx
+++ b/app/features/tournament/routes/to.$id.info.tsx
@@ -7,7 +7,6 @@ import { containerClassName } from "~/components/Main";
import { Markdown } from "~/components/Markdown";
import { TierPill } from "~/components/TierPill";
import * as Seasons from "~/features/mmr/core/Seasons";
-import type { TournamentData } from "~/features/tournament-bracket/core/Tournament.server";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { removeMarkdown } from "~/utils/strings";
@@ -19,6 +18,7 @@ import {
TournamentHeader,
TournamentHeaderActions,
} from "../components/TournamentHeader";
+import { parseTournamentLoaderData } from "../core/layout-payload";
import { loader } from "../loaders/to.$id.info.server";
import { bracketProgressionLabel } from "../tournament-utils";
import { useTournament } from "./to.$id";
@@ -27,8 +27,10 @@ import styles from "./to.$id.info.module.css";
export { action, loader };
export const meta: MetaFunction = (args) => {
- const tournamentData = JSON.parse(args.matches[1].loaderData as any)
- ?.tournament as TournamentData | undefined;
+ const rawLayoutData = args.matches[1].loaderData as string | undefined;
+ const tournamentData = rawLayoutData
+ ? parseTournamentLoaderData(rawLayoutData).tournament
+ : undefined;
if (!tournamentData) return [];
return metaTags({
diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx
index d4e40d955..a5fc64eac 100644
--- a/app/features/tournament/routes/to.$id.register.tsx
+++ b/app/features/tournament/routes/to.$id.register.tsx
@@ -17,7 +17,7 @@ import { Config } from "~/config";
import { useUser } from "~/features/auth/core/user";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { ModeMapPoolPicker } from "~/features/settings/components/ModeMapPoolPicker";
-import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
+import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server";
import { FormField } from "~/form/FormField";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
@@ -159,7 +159,9 @@ function RegistrationForms({ readOnly = false }: { readOnly?: boolean }) {
return ;
}
- const ownTeam = tournament.ownedTeamByUser(user);
+ const ownTeam = tournament.ownedTeamByUser(user)
+ ? (data?.ownTeam ?? null)
+ : null;
const ownTeamCheckedIn = Boolean(ownTeam && ownTeam.checkIns.length > 0);
const hasFriendCodeSet = Boolean(user?.friendCode);
@@ -219,10 +221,10 @@ function RegistrationForms({ readOnly = false }: { readOnly?: boolean }) {
}
function ReadOnlyRegistrationForms() {
- const user = useUser();
+ const data = useLoaderData();
const tournament = useTournament();
- const team = tournament.teamMemberOfByUser(user);
+ const team = data?.ownTeam;
if (!team) return null;
const checkedIn = team.checkIns.length > 0;
@@ -453,7 +455,7 @@ function TeamInfo({
canUnregister,
readOnly = false,
}: {
- ownTeam?: TournamentDataTeam | null;
+ ownTeam?: TournamentTeamFull | null;
canUnregister: boolean;
readOnly?: boolean;
}) {
@@ -638,7 +640,7 @@ function FillRoster({
ownTeamCheckedIn,
readOnly = false,
}: {
- ownTeam: TournamentDataTeam;
+ ownTeam: TournamentTeamFull;
ownTeamCheckedIn: boolean;
readOnly?: boolean;
}) {
@@ -673,8 +675,8 @@ function FillRoster({
const playersAvailableToDirectlyAdd = (() => {
if (readOnly) return [];
return (data?.friendPlayers?.friends ?? []).filter((user) => {
- const isNotInTeam = tournament.ctx.teams.every((team) =>
- team.members.every((member) => member.userId !== user.id),
+ const isNotInTeam = tournament.ctx.teams.every(
+ (team) => !team.memberUserIds.includes(user.id),
);
const hasInGameNameIfNeeded =
@@ -855,7 +857,7 @@ function DirectlyAddPlayerSelect({
);
}
-function DeleteMember({ members }: { members: TournamentDataTeam["members"] }) {
+function DeleteMember({ members }: { members: TournamentTeamFull["members"] }) {
const { t } = useTranslation(["tournament", "common"]);
const id = React.useId();
const fetcher = useFetcher();
@@ -903,7 +905,7 @@ function CounterPickMapPoolPicker({
mapPool,
}: {
readOnly?: boolean;
- mapPool?: NonNullable;
+ mapPool?: NonNullable;
}) {
const { t } = useTranslation(["common", "game-misc", "tournament"]);
const tournament = useTournament();
diff --git a/app/features/tournament/routes/to.$id.results.tsx b/app/features/tournament/routes/to.$id.results.tsx
index d662dffc9..e5cd17319 100644
--- a/app/features/tournament/routes/to.$id.results.tsx
+++ b/app/features/tournament/routes/to.$id.results.tsx
@@ -3,7 +3,7 @@ import { differenceInDays } from "date-fns";
import { ShieldMinus } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
-import { Link } from "react-router";
+import { Link, useLoaderData } from "react-router";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import {
@@ -16,20 +16,30 @@ import { Flag } from "~/components/Flag";
import { InfoPopover } from "~/components/InfoPopover";
import { Placement } from "~/components/Placement";
import { Table } from "~/components/Table";
-import type { Standing } from "~/features/tournament-bracket/core/Bracket";
import { useSpoilerFree } from "~/hooks/useSpoilerFree";
import {
SPR_INFO_URL,
tournamentMatchPage,
tournamentTeamPage,
} from "~/utils/urls";
-import * as Standings from "../core/Standings";
+import type { TournamentResultsLoaderData } from "../loaders/to.$id.results.server";
import styles from "../tournament.module.css";
import { TOURNAMENT } from "../tournament-constants";
import { useTournament } from "./to.$id";
+export { loader } from "../loaders/to.$id.results.server";
+
+type ResultsStanding =
+ TournamentResultsLoaderData["standings"]["standings"] extends Array
+ ? T extends { standings: Array }
+ ? U
+ : T
+ : never;
+
export default function TournamentResultsPage() {
const { t } = useTranslation(["common"]);
+ const { standings: standingsResult } =
+ useLoaderData();
const tournament = useTournament();
const { isCensored, reveal } = useSpoilerFree();
@@ -53,8 +63,6 @@ export default function TournamentResultsPage() {
);
}
- const standingsResult = Standings.tournamentStandings(tournament);
-
if (standingsResult.type === "single") {
if (standingsResult.standings.length === 0) {
return (
@@ -95,7 +103,7 @@ export default function TournamentResultsPage() {
);
}
-function ResultsTable({ standings }: { standings: Standing[] }) {
+function ResultsTable({ standings }: { standings: ResultsStanding[] }) {
const tournament = useTournament();
let lastRenderedPlacement = 0;
@@ -141,12 +149,7 @@ function ResultsTable({ standings }: { standings: Standing[] }) {
rowDarkerBg = !rowDarkerBg;
}
- const teamLogoSrc = tournament.tournamentTeamLogoSrc(standing.team);
-
- const spr = Standings.calculateSPR({
- standings,
- teamId: standing.team.id,
- });
+ const teamLogoSrc = standing.team.logoUrl;
return (
- {standing.team.members.map((player) => (
+ {standing.roster.map((player) => (
{standing.team.seed}
{tournament.ctx.isFinalized ? (
- {spr > 0 ? "+" : ""}
- {spr}
+ {standing.spr > 0 ? "+" : ""}
+ {standing.spr}
) : null}
-
+
);
@@ -209,14 +212,11 @@ function ResultsTable({ standings }: { standings: Standing[] }) {
);
}
-function MatchHistoryRow({ teamId }: { teamId: number }) {
- const tournament = useTournament();
-
- const teamMatches = Standings.matchesPlayed({
- tournament,
- teamId,
- });
-
+function MatchHistoryRow({
+ matches: teamMatches,
+}: {
+ matches: ResultsStanding["matches"];
+}) {
return (
{teamMatches.map((match, i) => {
diff --git a/app/features/tournament/routes/to.$id.streams.tsx b/app/features/tournament/routes/to.$id.streams.tsx
index 458522fc6..8cec7d009 100644
--- a/app/features/tournament/routes/to.$id.streams.tsx
+++ b/app/features/tournament/routes/to.$id.streams.tsx
@@ -1,19 +1,24 @@
import { useTranslation } from "react-i18next";
+import { useLoaderData } from "react-router";
import { Redirect } from "~/components/Redirect";
import { tournamentRegisterPage } from "~/utils/urls";
import { TournamentStream } from "../components/TournamentStream";
+import type { TournamentStreamsLoaderData } from "../loaders/to.$id.streams.server";
import styles from "../tournament.module.css";
import { useTournament } from "./to.$id";
+export { loader } from "../loaders/to.$id.streams.server";
+
export default function TournamentStreamsPage() {
const { t } = useTranslation(["tournament"]);
const tournament = useTournament();
+ const { streams } = useLoaderData
();
if (!tournament.hasStarted || tournament.everyBracketOver) {
return ;
}
- if (tournament.streams.length === 0) {
+ if (streams.length === 0) {
return (
{t("tournament:streams.none")}
@@ -24,7 +29,7 @@ export default function TournamentStreamsPage() {
// TODO: link to user page, later tournament team page?
return (
- {tournament.streams.map((stream) => (
+ {streams.map((stream) => (
))}
diff --git a/app/features/tournament/routes/to.$id.teams.$tid.tsx b/app/features/tournament/routes/to.$id.teams.$tid.tsx
index 537593a23..3ef98b707 100644
--- a/app/features/tournament/routes/to.$id.teams.$tid.tsx
+++ b/app/features/tournament/routes/to.$id.teams.$tid.tsx
@@ -7,10 +7,7 @@ import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { ModeImage, StageImage } from "~/components/Image";
import { Placement } from "~/components/Placement";
-import type {
- TournamentData,
- TournamentDataTeam,
-} from "~/features/tournament-bracket/core/Tournament.server";
+import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server";
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator/types";
import { metaTags } from "~/utils/remix";
import {
@@ -20,30 +17,26 @@ import {
userPage,
} from "~/utils/urls";
import { TeamWithRoster } from "../components/TeamWithRoster";
-import * as Standings from "../core/Standings";
-import type { PlayedSet } from "../core/sets.server";
-import { loader } from "../loaders/to.$id.teams.$tid.server";
+import {
+ loader,
+ type TournamentTeamLoaderData,
+} from "../loaders/to.$id.teams.$tid.server";
import styles from "../tournament.module.css";
import { useTournament } from "./to.$id";
export { loader };
export const meta: MetaFunction
= (args) => {
- const tournamentData = JSON.parse(args.matches[1].loaderData as any)
- ?.tournament as TournamentData;
- if (!args.loaderData || !tournamentData) return [];
+ if (!args.loaderData) return [];
- const team = tournamentData.ctx.teams.find(
- (t) => t.id === args.loaderData!.tournamentTeamId,
- )!;
- const teamLogoUrl = team.team?.logoUrl ?? team.pickupAvatarUrl;
+ const { team, tournamentName } = args.loaderData;
return metaTags({
- title: `${team.name} @ ${tournamentData.ctx.name}`,
- description: `${team.name} roster (${team.members.map((m) => m.username).join(", ")}) and sets in ${tournamentData.ctx.name}.`,
- image: teamLogoUrl
+ title: `${team.name} @ ${tournamentName}`,
+ description: `${team.name} roster (${team.members.map((m) => m.username).join(", ")}) and sets in ${tournamentName}.`,
+ image: team.logoUrl
? {
- url: teamLogoUrl,
+ url: team.logoUrl,
dimensions: { width: 124, height: 124 },
}
: undefined,
@@ -57,7 +50,7 @@ export default function TournamentTeamPage() {
const teamIndex = tournament.ctx.teams.findIndex(
(t) => t.id === data.tournamentTeamId,
);
- const team = tournament.teamById(data.tournamentTeamId)!;
+ const team = data.team;
return (
@@ -65,13 +58,7 @@ export default function TournamentTeamPage() {
0
- ? tournament
- .participatedPlayersByTeamId(team.id)
- .map((p) => p.userId)
- : undefined
- }
+ activePlayers={data.activePlayers}
/>
{team.team && !team.team.deletedAt ? (
();
- const tournament = useTournament();
- const standingsResult = Standings.tournamentStandings(tournament);
- const overallStandings = Standings.flattenStandings(standingsResult);
- const placement = overallStandings.find(
- (s) => s.team.id === data.tournamentTeamId,
- )?.placement;
-
- const undergroundBracket = tournament.brackets.find((b) => b.isUnderground);
- const undergroundPlacement = undergroundBracket?.standings.find(
- (s) => s.team.id === data.tournamentTeamId,
- )?.placement;
+ const { placement, undergroundPlacement, division } = data;
return (
@@ -171,21 +148,19 @@ function StatSquares({
{t("tournament:team.placement.footer")}
) : null}
- {standingsResult.type === "multi" ? (
-
- {
- standingsResult.standings.find((s) =>
- s.standings.some((s) => s.team.id === data.tournamentTeamId),
- )?.div
- }
-
- ) : null}
+ {division ? {division}
: null}
);
}
-function SetInfo({ set, team }: { set: PlayedSet; team: TournamentDataTeam }) {
+function SetInfo({
+ set,
+ team,
+}: {
+ set: TournamentTeamLoaderData["sets"][number];
+ team: TournamentTeamFull;
+}) {
const { t } = useTranslation(["tournament"]);
const tournament = useTournament();
@@ -220,7 +195,7 @@ function SetInfo({ set, team }: { set: PlayedSet; team: TournamentDataTeam }) {
};
const { bracketName, roundNameWithoutMatchIdentifier } =
- tournament.matchContextNamesById(set.tournamentMatchId);
+ set.matchContextNames;
return (
diff --git a/app/features/tournament/routes/to.$id.teams.tsx b/app/features/tournament/routes/to.$id.teams.tsx
index e83cc2445..7b987d79f 100644
--- a/app/features/tournament/routes/to.$id.teams.tsx
+++ b/app/features/tournament/routes/to.$id.teams.tsx
@@ -1,39 +1,36 @@
+import { useLoaderData } from "react-router";
+import { Pagination } from "~/components/Pagination";
import { Redirect } from "~/components/Redirect";
+import { useSearchParamPagination } from "~/hooks/useSearchParamPagination";
import { tournamentDivisionsPage, 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, useTournament } from "./to.$id";
+export { loader } from "../loaders/to.$id.teams.server";
+
export default function TournamentTeamsPage() {
const tournament = useTournament();
const hasChildTournaments = useHasChildTournaments();
+ const data = useLoaderData
();
+ const pagination = useSearchParamPagination({
+ definition: tournamentTeamsSearchParams,
+ currentPage: data.currentPage,
+ pagesCount: data.pagesCount,
+ });
if (tournament.isLeagueSignup && hasChildTournaments) {
return ;
}
- const perBracketSeedCounters = new Map();
- const teamSeedInfo = tournament.ctx.teams.map((team, globalIndex) => {
- if (!tournament.isMultiStartingBracket) {
- return { seed: globalIndex + 1, bracketLabel: undefined };
- }
-
- const bracketIdx = team.startingBracketIdx ?? 0;
- const currentSeed = (perBracketSeedCounters.get(bracketIdx) ?? 0) + 1;
- perBracketSeedCounters.set(bracketIdx, currentSeed);
-
- const bracketLabel = getBracketProgressionLabel(
- bracketIdx,
- tournament.ctx.settings.bracketProgression,
- );
-
- return { seed: currentSeed, bracketLabel };
- });
+ const seedInfoByTeamId = teamSeedInfo(tournament);
return (
- {tournament.ctx.teams.map((team, i) => {
- const { seed, bracketLabel } = teamSeedInfo[i];
+ {data.teams.map((team) => {
+ const { seed, bracketLabel } = seedInfoByTeamId.get(team.id) ?? {};
return (
);
})}
+ {data.pagesCount > 1 ?
: null}
);
}
+
+function teamSeedInfo(tournament: ReturnType) {
+ const perBracketSeedCounters = new Map();
+
+ return new Map(
+ tournament.ctx.teams.map((team, globalIndex) => {
+ if (!tournament.isMultiStartingBracket) {
+ return [
+ team.id,
+ {
+ seed: globalIndex + 1,
+ bracketLabel: undefined as string | undefined,
+ },
+ ] as const;
+ }
+
+ const bracketIdx = team.startingBracketIdx ?? 0;
+ const currentSeed = (perBracketSeedCounters.get(bracketIdx) ?? 0) + 1;
+ perBracketSeedCounters.set(bracketIdx, currentSeed);
+
+ return [
+ team.id,
+ {
+ seed: currentSeed,
+ bracketLabel: getBracketProgressionLabel(
+ bracketIdx,
+ tournament.ctx.settings.bracketProgression,
+ ),
+ },
+ ] as const;
+ }),
+ );
+}
diff --git a/app/features/tournament/routes/to.$id.tsx b/app/features/tournament/routes/to.$id.tsx
index 50f0873e3..1c5631e08 100644
--- a/app/features/tournament/routes/to.$id.tsx
+++ b/app/features/tournament/routes/to.$id.tsx
@@ -8,6 +8,7 @@ import {
} from "react-router";
import { containerClassName, Main } from "~/components/Main";
import { Placeholder } from "~/components/Placeholder";
+import { isMatchResultsScopedRevalidation } from "~/features/chat/revalidation-scope";
import { useChatContext } from "~/features/chat/useChatContext";
import { Tournament } from "~/features/tournament-bracket/core/Tournament";
import { useHydrated } from "~/hooks/useHydrated";
@@ -15,12 +16,14 @@ import type { SendouRouteHandle } from "~/utils/remix.server";
import { tournamentPage } from "~/utils/urls";
import { isRevalidation, metaTags } from "../../../utils/remix";
import { TournamentNav } from "../components/TournamentNav";
+import { parseTournamentLoaderData } from "../core/layout-payload";
import { loader, type TournamentLoaderData } from "../loaders/to.$id.server";
export { loader };
export const shouldRevalidate: ShouldRevalidateFunction = (args) => {
+ if (isMatchResultsScopedRevalidation(args)) return false;
if (isRevalidation(args)) return args.defaultShouldRevalidate;
if (args.formMethod === "POST") return args.defaultShouldRevalidate;
if (args.currentParams.id !== args.nextParams.id) {
@@ -35,7 +38,7 @@ export const meta: MetaFunction = (args) => {
if (!rawData) return [];
- const data = JSON.parse(rawData) as TournamentLoaderData;
+ const data = parseTournamentLoaderData(rawData);
return metaTags({
title: data.tournament.ctx.name,
@@ -55,7 +58,7 @@ export const handle: SendouRouteHandle = {
if (!rawData) return [];
- const data = JSON.parse(rawData) as TournamentLoaderData;
+ const data = parseTournamentLoaderData(rawData);
return [
{
@@ -70,6 +73,24 @@ export const handle: SendouRouteHandle = {
const TournamentContext = React.createContext(null!);
+/**
+ * Overrides the tournament of the subtree, used by the views that load bracket match data
+ * of their own on top of what the layout ships.
+ */
+export function TournamentOverrideProvider({
+ tournament,
+ children,
+}: {
+ tournament: Tournament;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
export default function TournamentLayoutShell() {
const isHydrated = useHydrated();
@@ -89,7 +110,7 @@ export default function TournamentLayoutShell() {
export function TournamentLayout() {
const rawData = useLoaderData();
const data = React.useMemo(
- () => JSON.parse(rawData) as TournamentLoaderData,
+ () => parseTournamentLoaderData(rawData),
[rawData],
);
const tournament = React.useMemo(
@@ -111,6 +132,7 @@ export function TournamentLayout() {
<>
@@ -152,7 +174,7 @@ type TournamentContext = {
};
export function useTournament() {
- return useOutletContext().tournament;
+ return React.useContext(TournamentContext);
}
export function useBracketExpanded() {
diff --git a/app/features/tournament/tournament-search-params.test.ts b/app/features/tournament/tournament-search-params.test.ts
index 8d76108ca..3073745d4 100644
--- a/app/features/tournament/tournament-search-params.test.ts
+++ b/app/features/tournament/tournament-search-params.test.ts
@@ -6,6 +6,7 @@ import {
import {
tournamentJoinSearchParams,
tournamentSearchSearchParams,
+ tournamentTeamsSearchParams,
} from "./tournament-search-params";
describe("tournamentSearchSearchParams", () => {
@@ -40,3 +41,18 @@ describe("tournamentJoinSearchParams", () => {
});
});
});
+
+describe("tournamentTeamsSearchParams", () => {
+ it("round-trips", () => {
+ assertRoundTrips(tournamentTeamsSearchParams, {
+ page: [1, 2, 17],
+ });
+ });
+
+ it("decodes garbage to defaults", () => {
+ assertDecodesToDefault(tournamentTeamsSearchParams, "page", [
+ ["0"],
+ ["abc"],
+ ]);
+ });
+});
diff --git a/app/features/tournament/tournament-search-params.ts b/app/features/tournament/tournament-search-params.ts
index 81fdea819..78eba804e 100644
--- a/app/features/tournament/tournament-search-params.ts
+++ b/app/features/tournament/tournament-search-params.ts
@@ -31,3 +31,7 @@ export const tournamentSearchSearchParams = SearchParams.define({
export const tournamentJoinSearchParams = SearchParams.define({
code: SP.param(z.string().nullable(), { loader: true }),
});
+
+export const tournamentTeamsSearchParams = SearchParams.define({
+ page: SP.page(),
+});
diff --git a/app/features/tournament/tournament-utils.test.ts b/app/features/tournament/tournament-utils.test.ts
index 2e4e15fc6..f6dd39ea7 100644
--- a/app/features/tournament/tournament-utils.test.ts
+++ b/app/features/tournament/tournament-utils.test.ts
@@ -28,7 +28,7 @@ const createTeam = (
): TeamForOrdering => ({
id,
seed: options.seed ?? null,
- members: { length: options.members ?? 4 },
+ memberUserIds: { length: options.members ?? 4 },
avgSeedingSkillOrdinal:
options.avgSeedingSkillOrdinal === undefined
? 100
diff --git a/app/features/tournament/tournament-utils.ts b/app/features/tournament/tournament-utils.ts
index 76afff929..1d207b471 100644
--- a/app/features/tournament/tournament-utils.ts
+++ b/app/features/tournament/tournament-utils.ts
@@ -13,6 +13,7 @@ import { assertUnreachable } from "../../utils/types";
import { MapPool } from "../map-list-generator/core/map-pool";
import { BANNED_MAPS } from "../match-profile/banned-maps";
import * as Seasons from "../mmr/core/Seasons";
+import type { Bracket as BracketClass } from "../tournament-bracket/core/Bracket";
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";
@@ -186,6 +187,7 @@ export function tournamentInWeaponReportingWindow({
export function resolveLeagueRoundStartDate(
tournament: TournamentClass,
+ bracket: BracketClass | undefined,
roundId: number,
) {
if (!tournament.isLeagueDivision) return null;
@@ -197,10 +199,6 @@ export function resolveLeagueRoundStartDate(
);
if (!league) return null;
- const bracket = tournament.brackets.find((b) =>
- b.data.round.some((r) => r.id === roundId),
- );
-
const round = bracket?.data.round.find((r) => r.id === roundId);
const onlyRelevantRounds = bracket?.data.round.filter(
(r) => r.groupId === round?.groupId,
@@ -226,7 +224,10 @@ export function isLeagueRoundLocked(
tournament: TournamentClass,
roundId: number,
) {
- const date = resolveLeagueRoundStartDate(tournament, roundId);
+ const bracket = tournament.brackets.find((b) =>
+ b.data.round.some((r) => r.id === roundId),
+ );
+ const date = resolveLeagueRoundStartDate(tournament, bracket, roundId);
if (!date) return false;
@@ -269,7 +270,7 @@ export function validateCanJoinTeam({
maxTeamSize,
}: {
inviteCode?: string | null;
- teamToJoin?: { members: { userId: number }[] };
+ teamToJoin?: { memberUserIds: number[] };
userId?: number;
maxTeamSize: number;
}) {
@@ -285,10 +286,10 @@ export function validateCanJoinTeam({
if (!teamToJoin) {
return "NO_TEAM_MATCHING_CODE";
}
- if (teamToJoin.members.some((member) => member.userId === userId)) {
+ if (teamToJoin.memberUserIds.includes(userId)) {
return "ALREADY_JOINED";
}
- if (teamToJoin.members.length >= maxTeamSize) {
+ if (teamToJoin.memberUserIds.length >= maxTeamSize) {
return "TEAM_FULL";
}
@@ -308,7 +309,7 @@ export function normalizedTeamCount({
export type TeamForOrdering = {
id: number;
seed: number | null;
- members: { length: number };
+ memberUserIds: { length: number };
avgSeedingSkillOrdinal: number | null;
createdAt: number;
startingBracketIdx: number | null;
@@ -327,8 +328,8 @@ export function compareTeamsForOrdering(
return a.seed - b.seed;
}
- const aIsFull = a.members.length >= minMembersPerTeam;
- const bIsFull = b.members.length >= minMembersPerTeam;
+ const aIsFull = a.memberUserIds.length >= minMembersPerTeam;
+ const bIsFull = b.memberUserIds.length >= minMembersPerTeam;
if (aIsFull && !bIsFull) {
return -1;
diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts
index 460730980..c20821d53 100644
--- a/app/features/user-page/UserRepository.server.ts
+++ b/app/features/user-page/UserRepository.server.ts
@@ -48,6 +48,19 @@ export async function findCountriesByUserIds(userIds: number[]) {
return new Map(rows.map((row) => [row.id, row.country]));
}
+/** Plus tiers of the given users keyed by user id, users without a tier absent. */
+export async function findPlusTiersByUserIds(userIds: number[]) {
+ if (userIds.length === 0) return new Map();
+
+ const rows = await db
+ .selectFrom("PlusTier")
+ .select(["PlusTier.userId", "PlusTier.tier"])
+ .where("PlusTier.userId", "in", userIds)
+ .execute();
+
+ return new Map(rows.map((row) => [row.userId, row.tier]));
+}
+
export async function findBuildFieldsByIdentifier(identifier: string) {
const row = await userByIdentifierQuery(identifier)
.select(({ eb }) => [
diff --git a/app/root.tsx b/app/root.tsx
index 241d51cc2..0e2edad8a 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -43,6 +43,7 @@ import { Layout, NPROGRESS_ANCHOR_ID } from "./components/layout";
import { getUser } from "./features/auth/core/user.server";
import { userMiddleware } from "./features/auth/core/user-middleware.server";
import { ChatProvider } from "./features/chat/ChatProvider";
+import { isMatchResultsScopedRevalidation } from "./features/chat/revalidation-scope";
import { getSidenavSession } from "./features/layout/core/sidenav-session.server";
import { sessionIdMiddleware } from "./features/session-id/session-id-middleware.server";
import {
@@ -91,6 +92,7 @@ import "nprogress/nprogress.css";
NProgress.configure({ parent: `#${NPROGRESS_ANCHOR_ID}` });
export const shouldRevalidate: ShouldRevalidateFunction = (args) => {
+ if (isMatchResultsScopedRevalidation(args)) return false;
if (isRevalidation(args)) return true;
if (args.formData?.get("revalidateRoot") === "true") return true;
diff --git a/app/routines/notifyCheckInStart.ts b/app/routines/notifyCheckInStart.ts
index 6164e7935..fa0fed15a 100644
--- a/app/routines/notifyCheckInStart.ts
+++ b/app/routines/notifyCheckInStart.ts
@@ -37,7 +37,7 @@ export const NotifyCheckInStartRoutine = new Routine({
pictureUrl: tournament.ctx.logoUrl,
},
userIds: tournament.ctx.teams
- .flatMap((team) => team.members.map((member) => member.userId))
+ .flatMap((team) => team.memberUserIds)
.concat(tournament.ctx.staff.map((staff) => staff.id)),
});
}
diff --git a/app/routines/syncLiveStreams.test.ts b/app/routines/syncLiveStreams.test.ts
index e585da8ad..ad15e7a79 100644
--- a/app/routines/syncLiveStreams.test.ts
+++ b/app/routines/syncLiveStreams.test.ts
@@ -1,11 +1,13 @@
import { add } from "date-fns";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+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 { db } from "~/db/sql";
+import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server";
-import {
- testTournament,
- tournamentCtxTeam,
-} from "~/features/tournament-bracket/core/tests/test-utils";
+import { testTournament } from "~/features/tournament-bracket/core/tests/test-utils";
+import { withUserId } from "~/utils/Test";
import { SyncLiveStreamsRoutine } from "./syncLiveStreams";
const { mockGetStreams } = vi.hoisted(() => ({
@@ -20,9 +22,17 @@ vi.mock("~/modules/twitch/utils.server", () => ({
hasTwitchEnvVars: () => true,
}));
-vi.mock("~/features/user-page/UserRepository.server", () => ({
- findIdsByTwitchUsernames: () => [],
-}));
+vi.mock(
+ "~/features/user-page/UserRepository.server",
+ async (importOriginal) => ({
+ ...(await importOriginal<
+ typeof import("~/features/user-page/UserRepository.server")
+ >()),
+ findIdsByTwitchUsernames: () => [],
+ }),
+);
+
+const users = UserFactory.pool();
function findAllTournamentStreamers() {
return db.selectFrom("TournamentStreamer").selectAll().execute();
@@ -32,11 +42,43 @@ function findAllLiveStreams() {
return db.selectFrom("LiveStream").selectAll().execute();
}
-function addRunningTournament(
- ctx?: Partial[0]["ctx"]>,
-) {
- const tournament = testTournament({ ctx });
- RunningTournaments.add(tournament);
+/**
+ * A tournament the sync routine considers live: its teams are in the database,
+ * which is where the routine looks up their members' Twitch accounts, and the
+ * tournament itself is in the running tournaments registry.
+ */
+async function addRunningTournament({
+ memberUserIds = [],
+ castTwitchAccounts = [],
+ droppedOut = false,
+}: {
+ memberUserIds?: number[];
+ castTwitchAccounts?: string[];
+ droppedOut?: boolean;
+} = {}) {
+ const tournament = await TournamentFactory.create({ authorId: users.id(1) });
+
+ if (memberUserIds.length > 0) {
+ const team = await TournamentTeamFactory.create({
+ tournamentId: tournament.id,
+ memberUserIds,
+ });
+
+ if (droppedOut) {
+ await withUserId(users.id(1), () =>
+ TournamentTeamRepository.dropOut({
+ tournamentTeamId: team.id,
+ previewBracketIdxs: [],
+ }),
+ );
+ }
+ }
+
+ RunningTournaments.clear();
+ RunningTournaments.add(
+ testTournament({ ctx: { id: tournament.id, castTwitchAccounts } }),
+ );
+
return tournament;
}
@@ -51,6 +93,7 @@ describe("syncLiveStreams tournament streamers", () => {
timeOffset += 31;
RunningTournaments.clear();
mockGetStreams.mockReset();
+ await users.create(1);
});
afterEach(() => {
@@ -87,40 +130,18 @@ describe("syncLiveStreams tournament streamers", () => {
{ twitchUserName: "player_one", viewerCount: 100, thumbnailUrl: "" },
]);
- addRunningTournament({
- teams: [
- tournamentCtxTeam(1, {
- members: [
- {
- userId: 10,
- username: "Player One",
- discordId: "10",
- discordAvatar: null,
- customUrl: null,
- inGameName: null,
- country: null,
- twitch: "player_one",
- plusTier: null,
- role: "OWNER",
- isSub: 0,
- createdAt: 0,
- streamTwitch: null,
- streamViewerCount: null,
- streamThumbnailUrl: null,
- customAvatarUrl: null,
- },
- ],
- }),
- ],
+ const player = await UserFactory.create({ twitch: "player_one" });
+ const tournament = await addRunningTournament({
+ memberUserIds: [player.id],
});
await SyncLiveStreamsRoutine.run();
const rows = await findAllTournamentStreamers();
expect(rows).toHaveLength(1);
- expect(rows[0].userId).toBe(10);
+ expect(rows[0].userId).toBe(player.id);
expect(rows[0].twitchAccount).toBe("player_one");
- expect(rows[0].tournamentId).toBe(1);
+ expect(rows[0].tournamentId).toBe(tournament.id);
});
test("skips dropped-out teams", async () => {
@@ -128,32 +149,10 @@ describe("syncLiveStreams tournament streamers", () => {
{ twitchUserName: "dropped_player", viewerCount: 50, thumbnailUrl: "" },
]);
- addRunningTournament({
- teams: [
- tournamentCtxTeam(1, {
- droppedOut: 1,
- members: [
- {
- userId: 20,
- username: "Dropped",
- discordId: "20",
- discordAvatar: null,
- customUrl: null,
- inGameName: null,
- country: null,
- twitch: "dropped_player",
- plusTier: null,
- role: "OWNER",
- isSub: 0,
- createdAt: 0,
- streamTwitch: null,
- streamViewerCount: null,
- streamThumbnailUrl: null,
- customAvatarUrl: null,
- },
- ],
- }),
- ],
+ const player = await UserFactory.create({ twitch: "dropped_player" });
+ await addRunningTournament({
+ memberUserIds: [player.id],
+ droppedOut: true,
});
await SyncLiveStreamsRoutine.run();
@@ -167,9 +166,7 @@ describe("syncLiveStreams tournament streamers", () => {
{ twitchUserName: "caster_account", viewerCount: 200, thumbnailUrl: "" },
]);
- addRunningTournament({
- castTwitchAccounts: ["caster_account"],
- });
+ await addRunningTournament({ castTwitchAccounts: ["caster_account"] });
await SyncLiveStreamsRoutine.run();
@@ -184,32 +181,8 @@ describe("syncLiveStreams tournament streamers", () => {
{ twitchUserName: "unrelated_stream", viewerCount: 10, thumbnailUrl: "" },
]);
- addRunningTournament({
- teams: [
- tournamentCtxTeam(1, {
- members: [
- {
- userId: 30,
- username: "No Match",
- discordId: "30",
- discordAvatar: null,
- customUrl: null,
- inGameName: null,
- country: null,
- twitch: "different_account",
- plusTier: null,
- role: "OWNER",
- isSub: 0,
- createdAt: 0,
- streamTwitch: null,
- streamViewerCount: null,
- streamThumbnailUrl: null,
- customAvatarUrl: null,
- },
- ],
- }),
- ],
- });
+ const player = await UserFactory.create({ twitch: "different_account" });
+ await addRunningTournament({ memberUserIds: [player.id] });
await SyncLiveStreamsRoutine.run();
@@ -222,72 +195,21 @@ describe("syncLiveStreams tournament streamers", () => {
{ twitchUserName: "streamer_a", viewerCount: 100, thumbnailUrl: "" },
]);
- addRunningTournament({
- teams: [
- tournamentCtxTeam(1, {
- members: [
- {
- userId: 40,
- username: "Streamer A",
- discordId: "40",
- discordAvatar: null,
- customUrl: null,
- inGameName: null,
- country: null,
- twitch: "streamer_a",
- plusTier: null,
- role: "OWNER",
- isSub: 0,
- createdAt: 0,
- streamTwitch: null,
- streamViewerCount: null,
- streamThumbnailUrl: null,
- customAvatarUrl: null,
- },
- ],
- }),
- ],
- });
+ const playerA = await UserFactory.create({ twitch: "streamer_a" });
+ await addRunningTournament({ memberUserIds: [playerA.id] });
await SyncLiveStreamsRoutine.run();
const rowsAfterFirst = await findAllTournamentStreamers();
expect(rowsAfterFirst).toHaveLength(1);
- // add a different tournament — if throttle works, nothing new is inserted
- RunningTournaments.clear();
-
+ // a different tournament — if throttle works, nothing new is inserted
mockGetStreams.mockResolvedValue([
{ twitchUserName: "streamer_b", viewerCount: 50, thumbnailUrl: "" },
]);
- addRunningTournament({
- id: 2,
- teams: [
- tournamentCtxTeam(2, {
- members: [
- {
- userId: 50,
- username: "Streamer B",
- discordId: "50",
- discordAvatar: null,
- customUrl: null,
- inGameName: null,
- country: null,
- twitch: "streamer_b",
- plusTier: null,
- role: "OWNER",
- isSub: 0,
- createdAt: 0,
- streamTwitch: null,
- streamViewerCount: null,
- streamThumbnailUrl: null,
- customAvatarUrl: null,
- },
- ],
- }),
- ],
- });
+ const playerB = await UserFactory.create({ twitch: "streamer_b" });
+ await addRunningTournament({ memberUserIds: [playerB.id] });
// call again without advancing time — should be throttled
await SyncLiveStreamsRoutine.run();
diff --git a/app/routines/syncLiveStreams.ts b/app/routines/syncLiveStreams.ts
index 6ed8d4012..2f15959bd 100644
--- a/app/routines/syncLiveStreams.ts
+++ b/app/routines/syncLiveStreams.ts
@@ -1,5 +1,6 @@
import { differenceInMinutes } from "date-fns";
import * as LiveStreamRepository from "~/features/live-streams/LiveStreamRepository.server";
+import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { getStreams } from "~/modules/twitch";
@@ -73,30 +74,27 @@ async function syncTournamentStreamers(
typeof LiveStreamRepository.insertTournamentStreamers
>[0] = [];
+ const participants = await TournamentRepository.findParticipantTwitchAccounts(
+ tournaments.map((tournament) => tournament.ctx.id),
+ );
+
+ for (const participant of participants) {
+ if (!streamsByTwitchName.has(participant.twitch.toLowerCase())) continue;
+
+ rows.push({
+ userId: participant.userId,
+ tournamentId: participant.tournamentId,
+ twitchAccount: participant.twitch,
+ });
+ }
+
for (const tournament of tournaments) {
- const tournamentId = tournament.ctx.id;
-
- for (const team of tournament.ctx.teams) {
- if (team.droppedOut) continue;
-
- for (const member of team.members) {
- if (!member.twitch) continue;
- if (!streamsByTwitchName.has(member.twitch.toLowerCase())) continue;
-
- rows.push({
- userId: member.userId,
- tournamentId,
- twitchAccount: member.twitch,
- });
- }
- }
-
for (const twitchAccount of tournament.ctx.castTwitchAccounts ?? []) {
if (!streamsByTwitchName.has(twitchAccount.toLowerCase())) continue;
rows.push({
userId: null,
- tournamentId,
+ tournamentId: tournament.ctx.id,
twitchAccount,
});
}
diff --git a/e2e/tournament.spec.ts b/e2e/tournament.spec.ts
index 5c02e763c..e8146df4b 100644
--- a/e2e/tournament.spec.ts
+++ b/e2e/tournament.spec.ts
@@ -1,7 +1,13 @@
import { addHours, addMinutes } from "date-fns";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { dateToDatabaseTimestamp } from "~/utils/dates";
-import { expect, impersonate, isNotVisible, test } from "./helpers/playwright";
+import {
+ expect,
+ impersonate,
+ isNotVisible,
+ navigate,
+ test,
+} from "./helpers/playwright";
import { TournamentBracketsPage } from "./pages/tournament/tournament-brackets-page";
import { TournamentPage } from "./pages/tournament/tournament-page";
import { TournamentRegisterPage } from "./pages/tournament/tournament-register-page";
@@ -12,6 +18,9 @@ const TEAM_NAME = "Chimera";
const ROSTER_SIZE = 4;
const SEEDED_TEAM_COUNT = 8;
+/** Views of a tournament whose loaders each ship some of its teams' data. */
+const TOURNAMENT_TEAM_VIEWS = ["teams", "results", "brackets", "admin/seeds"];
+
test.describe("Tournament", () => {
test("registers for tournament", async ({ page, factories }) => {
const [captain, ...friends] =
@@ -131,6 +140,42 @@ test.describe("Tournament", () => {
teamNameForSeed(1),
);
});
+
+ test("hides a draft tournament from non-organizers, including its loaders", async ({
+ page,
+ factories,
+ }) => {
+ const tournament = await factories.TournamentFactory.create({
+ authorId: ADMIN_ID,
+ isDraft: true,
+ startTimes: [dateToDatabaseTimestamp(addHours(new Date(), 2))],
+ });
+ const captain = await factories.UserFactory.create();
+ const team = await factories.TournamentTeamFactory.create({
+ tournamentId: tournament.id,
+ team: pickUpTeam(TEAM_NAME),
+ memberUserIds: [captain.id],
+ });
+
+ // the draft's own pages never render, so flush the factory writes elsewhere
+ await navigate({ page, url: "/" });
+
+ for (const view of [...TOURNAMENT_TEAM_VIEWS, `teams/${team.id}`]) {
+ const url = `/to/${tournament.id}/${view}`;
+
+ const pageResponse = await page.request.fetch(url);
+ expect(pageResponse.status(), `${url} page`).toBe(404);
+
+ // each view's loader is also reachable on its own via single fetch
+ const dataResponse = await page.request.fetch(`${url}.data`);
+ expect(await dataResponse.text(), `${url}.data`).not.toContain(TEAM_NAME);
+ }
+
+ const scopedToTeamsLoader = await page.request.fetch(
+ `/to/${tournament.id}/teams.data?_routes=features/tournament/routes/to.$id.teams`,
+ );
+ expect(await scopedToTeamsLoader.text()).not.toContain(TEAM_NAME);
+ });
});
function pickUpTeam(name: string) {
diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts
index d8c52070b..7bab47b1b 100644
--- a/scripts/benchmark-db/cases.ts
+++ b/scripts/benchmark-db/cases.ts
@@ -34,6 +34,7 @@ import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalend
import * as TournamentAuditLogRepository from "~/features/tournament/TournamentAuditLogRepository.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
+import * as BracketRepository from "~/features/tournament-bracket/BracketRepository.server";
import * as TournamentMatchVodRepository from "~/features/tournament-bracket/TournamentMatchVodRepository.server";
import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server";
import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server";
@@ -657,6 +658,13 @@ export function buildCases(fx: Fixtures): {
XRankPlacementRepository.findPeaksByUserId(xrank.userId, "both"),
);
+ // BracketRepository
+ add(
+ "BracketRepository.findByTournamentId",
+ fx.heaviestBracketTournamentId,
+ (tournamentId) => BracketRepository.findByTournamentId(tournamentId),
+ );
+
// TournamentMatchVodRepository
add(
"TournamentMatchVodRepository.findVodsByTournamentId",
@@ -876,6 +884,24 @@ export function buildCases(fx: Fixtures): {
add("TournamentRepository.findById", fx.heavyTournamentId, (tournamentId) =>
TournamentRepository.findById(tournamentId),
);
+ add(
+ "TournamentRepository.findStreamsByTournamentId",
+ fx.heavyTournamentId,
+ (tournamentId) =>
+ TournamentRepository.findStreamsByTournamentId(tournamentId),
+ );
+ add(
+ "TournamentRepository.findTeamsFullByTournamentId",
+ fx.heavyTournamentId,
+ (tournamentId) =>
+ TournamentRepository.findTeamsFullByTournamentId(tournamentId),
+ );
+ add(
+ "TournamentRepository.findParticipatedUserIdsById",
+ fx.heavyTournamentId,
+ (tournamentId) =>
+ TournamentRepository.findParticipatedUserIdsById(tournamentId),
+ );
add(
"TournamentRepository.findRulesById",
fx.heavyTournamentId,
@@ -910,6 +936,12 @@ export function buildCases(fx: Fixtures): {
(parentTournamentId) =>
TournamentRepository.findChildTournamentsForDivCalc(parentTournamentId),
);
+ add(
+ "TournamentRepository.findResultsByTournamentId",
+ fx.heavyResultsTournamentId,
+ (tournamentId) =>
+ TournamentRepository.findResultsByTournamentId(tournamentId),
+ );
add(
"TournamentRepository.findLeagueDivParticipantUserIds",
fx.parentTournamentId,
@@ -932,6 +964,12 @@ export function buildCases(fx: Fixtures): {
(tournamentIds) =>
TournamentRepository.findRelatedUsersByTournamentIds(tournamentIds),
);
+ add(
+ "TournamentRepository.findParticipantTwitchAccounts",
+ fx.recentTournamentIds,
+ (tournamentIds) =>
+ TournamentRepository.findParticipantTwitchAccounts(tournamentIds),
+ );
addStatic("TournamentRepository.findAllForShowcase", () =>
TournamentRepository.findAllForShowcase(),
);
@@ -976,6 +1014,11 @@ export function buildCases(fx: Fixtures): {
(teamIds) =>
TournamentTeamRepository.findRecentlyPlayedMapsByIds({ teamIds }),
);
+ add(
+ "TournamentTeamRepository.findMapPoolsByTeamIds",
+ fx.tournamentTeamPair,
+ (teamIds) => TournamentTeamRepository.findMapPoolsByTeamIds(teamIds),
+ );
// TrophyRepository
addStatic("TrophyRepository.all", () => TrophyRepository.all());
@@ -1011,6 +1054,9 @@ export function buildCases(fx: Fixtures): {
add("UserRepository.findCountriesByUserIds", fx.skillBatch, (skillBatch) =>
UserRepository.findCountriesByUserIds(skillBatch.userIds),
);
+ add("UserRepository.findPlusTiersByUserIds", fx.skillBatch, (skillBatch) =>
+ UserRepository.findPlusTiersByUserIds(skillBatch.userIds),
+ );
add("UserRepository.findBuildFieldsByIdentifier", fx.heavyUser, (user) =>
UserRepository.findBuildFieldsByIdentifier(user.identifier),
);
diff --git a/scripts/benchmark-db/fixtures.ts b/scripts/benchmark-db/fixtures.ts
index 300f0e959..bbabe34a1 100644
--- a/scripts/benchmark-db/fixtures.ts
+++ b/scripts/benchmark-db/fixtures.ts
@@ -28,6 +28,10 @@ export interface Fixtures {
heavyGroupIds: [number, number] | null;
heavyStageModeCombo: { stageId: StageId; mode: ModeShort } | null;
heavyTournamentId: number | null;
+ /** Tournament with the most matches. Unlike `heavyTournamentId` (most teams) this one is guaranteed to have brackets. */
+ heaviestBracketTournamentId: number | null;
+ /** Finalized tournament with the most persisted results. */
+ heavyResultsTournamentId: number | null;
heavyTournamentMatchId: number | null;
tournamentMatchGameResultId: number | null;
heavyTournamentTeamId: number | null;
@@ -121,6 +125,8 @@ export async function resolveFixtures(): Promise {
heavyGroupIds: await resolveHeavyGroupIds(heavyGroupMatchId),
heavyStageModeCombo: await resolveHeavyStageModeCombo(),
heavyTournamentId,
+ heaviestBracketTournamentId: await resolveHeaviestBracketTournamentId(),
+ heavyResultsTournamentId: await resolveHeavyResultsTournamentId(),
heavyTournamentMatchId: await resolveHeavyTournamentMatchId(),
tournamentMatchGameResultId: await resolveTournamentMatchGameResultId(),
heavyTournamentTeamId:
@@ -389,6 +395,38 @@ async function resolveHeavyTournamentId() {
return row?.tournamentId ?? null;
}
+async function resolveHeaviestBracketTournamentId() {
+ const row = await db
+ .selectFrom("TournamentMatch")
+ .innerJoin(
+ "TournamentStage",
+ "TournamentStage.id",
+ "TournamentMatch.stageId",
+ )
+ .select(({ fn }) => [
+ "TournamentStage.tournamentId",
+ fn.countAll().as("count"),
+ ])
+ .groupBy("TournamentStage.tournamentId")
+ .orderBy("count", "desc")
+ .limit(1)
+ .executeTakeFirst();
+
+ return row?.tournamentId ?? null;
+}
+
+async function resolveHeavyResultsTournamentId() {
+ const row = await db
+ .selectFrom("TournamentResult")
+ .select(({ fn }) => ["tournamentId", fn.countAll().as("count")])
+ .groupBy("tournamentId")
+ .orderBy("count", "desc")
+ .limit(1)
+ .executeTakeFirst();
+
+ return row?.tournamentId ?? null;
+}
+
async function resolveHeavyTournamentMatchId() {
const row = await db
.selectFrom("TournamentMatchGameResult")