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; organizers: Set; } export async function upcomingTournaments(): Promise { const tournaments = await cachedTournaments(); return tournaments.upcoming; } export async function categorizedTournamentsByUserId( userId: number | null, ): Promise { 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 | 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 { 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> { const tournamentIds = tournaments.map((tournament) => tournament.id); const tournamentsWithUsers = await TournamentRepository.relatedUsersByTournamentIds(tournamentIds); const result: Map = 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; }