sendou.ink/app/features/front-page/core/ShowcaseTournaments.server.ts
Kalle 49e35fd4c2
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run
Fix A/B round robin only tournament placements on front page
Closes #3009
2026-04-26 20:01:58 +03:00

437 lines
12 KiB
TypeScript

import cachified from "@epic-web/cachified";
import * as R from "remeda";
import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import {
getBracketProgressionLabel,
tournamentIsRanked,
} from "~/features/tournament/tournament-utils";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server";
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
} from "~/utils/dates";
import type { CommonUser } from "~/utils/kysely.server";
import { tournamentPage } from "~/utils/urls";
interface ShowcaseTournamentCollection {
participatingFor: ShowcaseCalendarEvent[];
organizingFor: ShowcaseCalendarEvent[];
showcase: ShowcaseCalendarEvent[];
results: ShowcaseCalendarEvent[];
}
interface ParticipationInfo {
participants: Set<ShowcaseCalendarEvent["id"]>;
organizers: Set<ShowcaseCalendarEvent["id"]>;
}
export async function upcomingTournaments(): Promise<ShowcaseCalendarEvent[]> {
const tournaments = await cachedTournaments();
return tournaments.upcoming;
}
export async function categorizedTournamentsByUserId(
userId: number | null,
): Promise<ShowcaseTournamentCollection> {
const tournaments = await cachedTournaments();
const participation = await cachedParticipationInfo(
userId,
tournaments.upcoming,
);
return {
organizingFor: tournaments.upcoming.filter((tournament) =>
participation.organizers.has(tournament.id),
),
participatingFor: tournaments.upcoming.filter(
(tournament) =>
!tournament.hidden && participation.participants.has(tournament.id),
),
showcase: resolveShowcaseTournaments(
tournaments.upcoming.filter(
(tournament) =>
!tournament.hidden &&
!participation.organizers.has(tournament.id) &&
!participation.participants.has(tournament.id),
),
),
results: tournaments.results,
};
}
let participationInfoMap: Map<CommonUser["id"], ParticipationInfo> | null =
null;
const emptyParticipationInfo = (): ParticipationInfo => ({
participants: new Set(),
organizers: new Set(),
});
export function clearParticipationInfoMap() {
participationInfoMap = null;
}
export function addToCached({
userId,
tournamentId,
type,
newTeamCount,
}: {
userId: number;
tournamentId: number;
type: "participant" | "organizer";
/** If a new team joined, the new total team count for the tournament including the new one */
newTeamCount?: number;
}) {
if (!participationInfoMap) return;
const participation =
participationInfoMap.get(userId) ?? emptyParticipationInfo();
if (type === "participant") {
participation.participants.add(tournamentId);
} else if (type === "organizer") {
participation.organizers.add(tournamentId);
}
participationInfoMap.set(userId, participation);
if (typeof newTeamCount === "number") {
updateCachedTournamentTeamCount({
tournamentId,
newTeamCount,
});
}
}
export function removeFromCached({
userId,
tournamentId,
type,
}: {
userId: number;
tournamentId: number;
type: "participant" | "organizer";
}) {
if (!participationInfoMap) return;
const participation = participationInfoMap.get(userId);
if (!participation) return;
if (type === "participant") {
participation.participants.delete(tournamentId);
} else if (type === "organizer") {
participation.organizers.delete(tournamentId);
}
participationInfoMap.set(userId, participation);
}
export function updateCachedTournamentTeamCount({
tournamentId,
newTeamCount,
}: {
tournamentId: number;
newTeamCount: number;
}) {
cachedTournaments().then((tournaments) => {
const tournament = tournaments.upcoming.find(
(tournament) => tournament.id === tournamentId,
);
if (tournament) {
tournament.teamsCount = newTeamCount;
}
});
}
async function cachedParticipationInfo(
userId: number | null,
tournaments: ShowcaseCalendarEvent[],
): Promise<ParticipationInfo> {
if (!userId) {
return emptyParticipationInfo();
}
if (participationInfoMap) {
return participationInfoMap.get(userId) ?? emptyParticipationInfo();
}
const participation = await tournamentsToParticipationInfoMap(tournaments);
participationInfoMap = participation;
return participation.get(userId) ?? emptyParticipationInfo();
}
const SHOWCASE_TOURNAMENTS_CACHE_KEY = "front-tournaments-list";
export const clearCachedTournaments = () =>
cache.delete(SHOWCASE_TOURNAMENTS_CACHE_KEY);
async function cachedTournaments() {
return cachified({
key: SHOWCASE_TOURNAMENTS_CACHE_KEY,
cache,
ttl: ttl(IN_MILLISECONDS.TWO_HOURS),
async getFreshValue() {
const tournaments = await TournamentRepository.forShowcase();
const mapped = tournaments.map(mapTournamentFromDB);
return deleteExtraResults(mapped);
},
});
}
function deleteExtraResults(tournaments: ShowcaseCalendarEvent[]) {
const threeDaysAgo = databaseTimestampThreeDaysAgo();
const nonResults = tournaments.filter(
(tournament) =>
tournament.firstPlacers.length === 0 &&
!tournament.isFinalized &&
tournament.startTime > threeDaysAgo,
);
const rankedResults = tournaments
.filter(
(tournament) => tournament.firstPlacers.length > 0 && tournament.isRanked,
)
.sort((a, b) => showcaseScore(b) - showcaseScore(a));
const nonRankedResults = tournaments
.filter(
(tournament) =>
tournament.firstPlacers.length > 0 && !tournament.isRanked,
)
.sort((a, b) => showcaseScore(b) - showcaseScore(a));
const rankedResultsToKeep = rankedResults.slice(0, 4);
// min 2, max 6 non ranked results
const nonRankedResultsToKeep = nonRankedResults.slice(
0,
6 - rankedResultsToKeep.length,
);
return {
results: [...rankedResultsToKeep, ...nonRankedResultsToKeep].sort(
(a, b) => b.startTime - a.startTime,
),
upcoming: nonResults,
};
}
function resolveShowcaseTournaments(
tournaments: ShowcaseCalendarEvent[],
): ShowcaseCalendarEvent[] {
const happeningDuringNextWeek = tournaments.filter(
(tournament) =>
tournament.startTime > databaseTimestampSixHoursAgo() &&
tournament.startTime < databaseTimestampWeekFromNow(),
);
const sorted = happeningDuringNextWeek.sort(
(a, b) => b.teamsCount - a.teamsCount,
);
const ranked = sorted.filter((tournament) => tournament.isRanked).slice(0, 3);
// min 3, max 6 non ranked
const nonRanked = sorted
.filter((tournament) => !tournament.isRanked)
.slice(0, 6 - ranked.length);
return [...ranked, ...nonRanked].sort((a, b) => a.startTime - b.startTime);
}
async function tournamentsToParticipationInfoMap(
tournaments: ShowcaseCalendarEvent[],
): Promise<Map<CommonUser["id"], ParticipationInfo>> {
const tournamentIds = tournaments.map((tournament) => tournament.id);
const tournamentsWithUsers =
await TournamentRepository.relatedUsersByTournamentIds(tournamentIds);
const result: Map<CommonUser["id"], ParticipationInfo> = new Map();
const addToMap = (
userId: number,
tournamentId: number,
type: "participant" | "organizer",
) => {
const participation = result.get(userId) ?? emptyParticipationInfo();
if (type === "participant") {
participation.participants.add(tournamentId);
} else if (type === "organizer") {
participation.organizers.add(tournamentId);
}
result.set(userId, participation);
};
for (const tournament of tournamentsWithUsers) {
for (const { userId } of tournament.teamMembers) {
addToMap(userId, tournament.id, "participant");
}
for (const { userId } of tournament.staff) {
addToMap(userId, tournament.id, "organizer");
}
for (const { userId } of tournament.organizationMembers) {
addToMap(userId, tournament.id, "organizer");
}
addToMap(tournament.authorId, tournament.id, "organizer");
}
return result;
}
const MEMBERS_TO_SHOW = 5;
function mapTournamentFromDB(
tournament: TournamentRepository.ForShowcase,
): ShowcaseCalendarEvent {
const firstPlacers = resolveFirstPlacers(tournament);
const tentativeTier =
tournament.tier === null &&
tournament.organizationId !== null &&
!tournament.firstPlacers.length
? getTentativeTier(tournament.organizationId, tournament.name)
: null;
return {
type: "showcase",
url: tournamentPage(tournament.id),
id: tournament.id,
authorId: tournament.authorId,
name: tournament.name,
startTime: tournament.startTime,
teamsCount: tournament.teamsCount,
logoUrl: tournament.logoUrl,
organization: tournament.organization
? {
name: tournament.organization.name,
slug: tournament.organization.slug,
}
: null,
isRanked: tournamentIsRanked({
isSetAsRanked: tournament.settings.isRanked,
startTime: databaseTimestampToDate(tournament.startTime),
minMembersPerTeam: tournament.settings.minMembersPerTeam ?? 4,
isTest: tournament.settings.isTest ?? false,
}),
tier: tournament.tier ?? null,
tentativeTier,
hidden: Boolean(tournament.hidden),
isFinalized: Boolean(tournament.isFinalized),
minMembersPerTeam: tournament.settings.minMembersPerTeam ?? 4,
modes: null,
hasVods: (tournament.vodCount ?? 0) > 0,
firstPlacers,
};
}
type FirstPlacerRow = TournamentRepository.ForShowcase["firstPlacers"][number];
function resolveFirstPlacers(
tournament: TournamentRepository.ForShowcase,
): ShowcaseCalendarEvent["firstPlacers"] {
if (tournament.firstPlacers.length === 0) {
return [];
}
if (
Progression.hasAbDivisionsFinals(tournament.settings.bracketProgression)
) {
const byDiv = R.groupBy(tournament.firstPlacers, (p) => p.div ?? "");
return Object.values(byDiv)
.map((rows) => buildFirstPlacerEntry(rows, { withMembers: false }))
.sort((a, b) => (a.div ?? "").localeCompare(b.div ?? ""));
}
const winnerRows = winnersOfHighestDivision(tournament);
return [buildFirstPlacerEntry(winnerRows, { withMembers: true })];
}
function winnersOfHighestDivision(
tournament: TournamentRepository.ForShowcase,
): FirstPlacerRow[] {
if (tournament.firstPlacers.every((p) => p.div === null)) {
return tournament.firstPlacers;
}
const highestDivName = getBracketProgressionLabel(
0,
tournament.settings.bracketProgression,
);
const highestDivWinners = tournament.firstPlacers.filter(
(p) => p.div === highestDivName,
);
return highestDivWinners.length > 0
? highestDivWinners
: tournament.firstPlacers;
}
function buildFirstPlacerEntry(
rows: FirstPlacerRow[],
{ withMembers }: { withMembers: boolean },
): ShowcaseCalendarEvent["firstPlacers"][number] {
const first = rows[0];
const members = withMembers
? rows.slice(0, MEMBERS_TO_SHOW).map((row) => ({
customUrl: row.customUrl,
discordAvatar: row.discordAvatar,
discordId: row.discordId,
id: row.id,
username: row.username,
country: row.country,
}))
: [];
return {
teamName: first.teamName,
logoUrl: first.teamLogoUrl ?? first.pickupAvatarUrl,
div: first.div,
members,
notShownMembersCount:
withMembers && rows.length > MEMBERS_TO_SHOW
? rows.length - MEMBERS_TO_SHOW
: 0,
};
}
function databaseTimestampWeekFromNow() {
const now = new Date();
now.setDate(now.getDate() + 7);
return dateToDatabaseTimestamp(now);
}
function databaseTimestampThreeDaysAgo() {
const now = new Date();
now.setDate(now.getDate() - 3);
return dateToDatabaseTimestamp(now);
}
function databaseTimestampSixHoursAgo() {
const now = new Date();
now.setHours(now.getHours() - 6);
return dateToDatabaseTimestamp(now);
}
const TIER_BONUS_PER_STEP = 5;
function showcaseScore(tournament: ShowcaseCalendarEvent): number {
const tierBonus =
typeof tournament.tier === "number"
? (10 - tournament.tier) * TIER_BONUS_PER_STEP
: 0;
return tournament.teamsCount + tierBonus;
}