mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
380 lines
10 KiB
TypeScript
380 lines
10 KiB
TypeScript
import cachified from "@epic-web/cachified";
|
|
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 { 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 frontPageTournamentsByUserId(
|
|
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 nonResults = tournaments.filter(
|
|
(tournament) => !tournament.firstPlacer,
|
|
);
|
|
|
|
const rankedResults = tournaments
|
|
.filter((tournament) => tournament.firstPlacer && tournament.isRanked)
|
|
.sort((a, b) => b.teamsCount - a.teamsCount);
|
|
const nonRankedResults = tournaments
|
|
.filter((tournament) => tournament.firstPlacer && !tournament.isRanked)
|
|
.sort((a, b) => b.teamsCount - a.teamsCount);
|
|
|
|
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 highestDivWinners = resolveHighestDivisionWinners(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),
|
|
modes: null,
|
|
firstPlacer:
|
|
highestDivWinners.length > 0
|
|
? {
|
|
teamName: highestDivWinners[0].teamName,
|
|
logoUrl:
|
|
highestDivWinners[0].teamLogoUrl ??
|
|
highestDivWinners[0].pickupAvatarUrl,
|
|
div: highestDivWinners[0].div,
|
|
members: highestDivWinners
|
|
.slice(0, MEMBERS_TO_SHOW)
|
|
.map((firstPlacer) => ({
|
|
customUrl: firstPlacer.customUrl,
|
|
discordAvatar: firstPlacer.discordAvatar,
|
|
discordId: firstPlacer.discordId,
|
|
id: firstPlacer.id,
|
|
username: firstPlacer.username,
|
|
country: firstPlacer.country,
|
|
})),
|
|
notShownMembersCount:
|
|
highestDivWinners.length > MEMBERS_TO_SHOW
|
|
? highestDivWinners.length - MEMBERS_TO_SHOW
|
|
: 0,
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
function resolveHighestDivisionWinners(
|
|
tournament: TournamentRepository.ForShowcase,
|
|
) {
|
|
if (tournament.firstPlacers.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// not a "many starting brackets" tournament
|
|
if (tournament.firstPlacers.every((p) => p.div === null)) {
|
|
return tournament.firstPlacers;
|
|
}
|
|
|
|
const highestDivName = getBracketProgressionLabel(
|
|
0,
|
|
tournament.settings.bracketProgression,
|
|
);
|
|
|
|
// Filter to only include winners from the highest division
|
|
const highestDivWinners = tournament.firstPlacers.filter(
|
|
(p) => p.div === highestDivName,
|
|
);
|
|
|
|
return highestDivWinners.length > 0
|
|
? highestDivWinners
|
|
: tournament.firstPlacers;
|
|
}
|
|
|
|
function databaseTimestampWeekFromNow() {
|
|
const now = new Date();
|
|
|
|
now.setDate(now.getDate() + 7);
|
|
|
|
return dateToDatabaseTimestamp(now);
|
|
}
|
|
|
|
function databaseTimestampSixHoursAgo() {
|
|
const now = new Date();
|
|
|
|
now.setHours(now.getHours() - 6);
|
|
|
|
return dateToDatabaseTimestamp(now);
|
|
}
|