Keep tournament showcase cards in sync with tournament page count

This commit is contained in:
Kalle
2026-08-09 18:29:42 +03:00
parent 2f9983a049
commit f3a744abbe
14 changed files with 183 additions and 76 deletions

View File

@@ -107,6 +107,7 @@ export const action = async (args: ActionFunctionArgs) => {
type: "participant",
userId,
});
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
await syncPickupChatMetadata({
teamId: team.id,

View File

@@ -72,6 +72,7 @@ export const action = async (args: ActionFunctionArgs) => {
type: "participant",
userId,
});
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
await syncPickupChatMetadata({
teamId: team.id,

View File

@@ -25,9 +25,9 @@ import {
concatUserSubmittedImagePrefix,
jsonArrayFrom,
jsonObjectFrom,
tournamentCheckedInTeams,
tournamentLogoWithDefault,
tournamentMembersCount,
tournamentTeamsCount,
} from "~/utils/kysely.server";
import { calendarEventPage, tournamentPage } from "~/utils/urls";
import {
@@ -140,13 +140,6 @@ const withOrganization = (eb: ExpressionBuilder<DB, "CalendarEvent">) =>
),
);
const withTeamsCount = (
eb: ExpressionBuilder<DB, "CalendarEventDate" | "Tournament">,
) =>
tournamentCheckedInTeams(eb).select(({ fn }) => [
fn.countAll<number>().as("count"),
]);
function findAllBetweenTwoTimestampsQuery({
startTime,
endTime,
@@ -175,7 +168,7 @@ function findAllBetweenTwoTimestampsQuery({
"normalizedStartsAt",
),
withOrganization(eb).as("organization"),
withTeamsCount(eb).as("teamsCount"),
tournamentTeamsCount(eb).as("teamsCount"),
tournamentMembersCount(eb).as("membersCount"),
tournamentLogoWithDefault(eb).as("logoUrl"),
jsonArrayFrom(

View File

@@ -78,13 +78,10 @@ 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;
@@ -98,13 +95,6 @@ export function addToCached({
}
participationInfoMap.set(userId, participation);
if (typeof newTeamCount === "number") {
updateCachedTournamentTeamCount({
tournamentId,
newTeamCount,
});
}
}
export function removeFromCached({
@@ -130,21 +120,25 @@ export function removeFromCached({
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;
}
});
/**
* Re-reads the team & participant counts of one tournament from the database into the cached
* showcase tournaments. No-op if the tournament is not part of the current cache.
*/
export async function refreshCachedTournamentCounts(tournamentId: number) {
if (!cache.has(SHOWCASE_TOURNAMENTS_CACHE_KEY)) return;
const tournaments = await cachedTournaments();
const cachedTournament = tournaments.upcoming.find(
(tournament) => tournament.id === tournamentId,
);
if (!cachedTournament) return;
const counts =
await TournamentRepository.findShowcaseCountsById(tournamentId);
if (!counts) return;
cachedTournament.teamsCount = counts.teamsCount;
cachedTournament.membersCount = counts.membersCount;
}
async function cachedParticipationInfo(

View File

@@ -56,6 +56,7 @@ export const action: ActionFunction = async ({ request, params }) => {
// no sources = regular check in
bracketIdx: bracket.sources ? data.bracketIdx : undefined,
});
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
if (!bracket.sources) {
await resolveNotifications({
@@ -85,6 +86,7 @@ export const action: ActionFunction = async ({ request, params }) => {
// no sources = regular check in
bracketIdx: !bracket.sources ? null : data.bracketIdx,
});
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
logger.info(
`Checked out: tournament team id: ${data.teamId} - user id: ${user.id} - tournament id: ${tournamentId} - bracket idx: ${data.bracketIdx}`,
);
@@ -112,12 +114,8 @@ export const action: ActionFunction = async ({ request, params }) => {
type: "participant",
userId,
});
ShowcaseTournaments.updateCachedTournamentTeamCount({
tournamentId,
newTeamCount: tournament.ctx.teams.length - 1,
});
}
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
break;
}

View File

@@ -186,12 +186,7 @@ export const upsertRegistrationAction = async (
});
}
if (!team) {
ShowcaseTournaments.updateCachedTournamentTeamCount({
tournamentId,
newTeamCount: tournament.ctx.teams.length + 1,
});
}
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
clearTournamentDataCache(tournamentId);

View File

@@ -1,6 +1,7 @@
import type { ActionFunction } from "react-router";
import type { PreparedMaps } from "~/db/tables-json";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import { notify } from "~/features/notifications/core/notify.server";
import {
calculateTournamentTierFromTeams,
@@ -176,6 +177,9 @@ export const action: ActionFunction = async ({ params, request }) => {
});
}
// starting drops the teams that did not check in and can change the tier
ShowcaseTournaments.clearCachedTournaments();
// update RunningTournaments
await tournamentFromDB({ tournamentId, user });
@@ -281,6 +285,7 @@ export const action: ActionFunction = async ({ params, request }) => {
await TournamentTeamRepository.checkIn(teamMemberOf.id, {
bracketIdx: data.bracketIdx,
});
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
logger.info(
`Checking in (bracket success): tournament team id: ${teamMemberOf.id} - user id: ${user.id} - tournament id: ${tournament.ctx.id} - bracket idx: ${data.bracketIdx}`,

View File

@@ -1,5 +1,6 @@
import type { ActionFunctionArgs } from "react-router";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import { notify } from "~/features/notifications/core/notify.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import { requireNotBannedByOrganization } from "~/features/tournament/tournament-utils.server";
@@ -195,6 +196,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
maxGroupSize: tournament.maxMembersPerTeam,
});
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
if (mergeResult.removedChatCode) {
ChatSystemMessage.removeRoom(mergeResult.removedChatCode);
}

View File

@@ -26,9 +26,9 @@ import {
concatUserSubmittedImagePrefix,
jsonArrayFrom,
jsonObjectFrom,
tournamentCheckedInTeams,
tournamentLogoWithDefault,
tournamentMembersCount,
tournamentTeamsCount,
tournamentUsername,
} from "~/utils/kysely.server";
import type { Unwrapped } from "~/utils/types";
@@ -800,11 +800,7 @@ export function findAllForShowcase() {
"CalendarEvent.organizationId",
"CalendarEventDate.startsAt",
"CalendarEvent.hidden",
tournamentCheckedInTeams(eb)
.select(({ fn }) => [
fn.count<number>("TournamentTeam.id").distinct().as("count"),
])
.as("teamsCount"),
tournamentTeamsCount(eb).as("teamsCount"),
tournamentMembersCount(eb).as("membersCount"),
tournamentLogoWithDefault(eb).as("logoUrl"),
jsonObjectFrom(
@@ -885,6 +881,22 @@ function databaseTimestampWeekAgo() {
return dateToDatabaseTimestamp(now);
}
/**
* Resolves the team & participant counts of one tournament exactly like {@link findAllForShowcase}
* does, meant for refreshing those counts of an already cached showcase tournament.
*/
export function findShowcaseCountsById(tournamentId: number) {
return db
.selectFrom("Tournament")
.select((eb) => [
tournamentTeamsCount(eb).as("teamsCount"),
tournamentMembersCount(eb).as("membersCount"),
])
.where("Tournament.id", "=", tournamentId)
.$narrowType<{ teamsCount: NotNull; membersCount: NotNull }>()
.executeTakeFirst();
}
export function findAllBetweenTwoTimestamps({
startTime,
endTime,

View File

@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, test } from "vitest";
import { actAs } from "~/db/seed/core/actAs";
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import * as TournamentRepository from "./TournamentRepository.server";
import * as TournamentTeamRepository from "./TournamentTeamRepository.server";
const MEMBERS_PER_TEAM = 4;
const TEAM_COUNT = 3;
let users: Array<{ id: number }>;
const createTournament = () =>
TournamentFactory.create({ authorId: users[0].id });
const createTeam = (
tournamentId: number,
{ nth, isCheckedIn }: { nth: number; isCheckedIn: boolean },
) =>
TournamentTeamFactory.create(
{
tournamentId,
memberUserIds: users
.slice(nth * MEMBERS_PER_TEAM, (nth + 1) * MEMBERS_PER_TEAM)
.map((user) => user.id),
},
{ isCheckedIn },
);
const showcaseCounts = async (tournamentId: number) => {
const counts =
await TournamentRepository.findShowcaseCountsById(tournamentId);
expect(counts).toBeDefined();
return counts!;
};
describe("TournamentRepository.findShowcaseCountsById", () => {
beforeEach(async () => {
users = await UserFactory.createMany(MEMBERS_PER_TEAM * TEAM_COUNT);
});
test("counts every registered team before a bracket has been started", async () => {
const { id: tournamentId } = await createTournament();
await createTeam(tournamentId, { nth: 0, isCheckedIn: true });
await createTeam(tournamentId, { nth: 1, isCheckedIn: true });
await createTeam(tournamentId, { nth: 2, isCheckedIn: false });
const counts = await showcaseCounts(tournamentId);
expect(counts.teamsCount).toBe(3);
expect(counts.membersCount).toBe(MEMBERS_PER_TEAM * 3);
});
test("counts only checked in teams after a bracket has been started", async () => {
const { id: tournamentId } = await createTournament();
await createTeam(tournamentId, { nth: 0, isCheckedIn: true });
await createTeam(tournamentId, { nth: 1, isCheckedIn: true });
await createTeam(tournamentId, { nth: 2, isCheckedIn: false });
await TournamentFactory.startBracket(tournamentId);
const counts = await showcaseCounts(tournamentId);
expect(counts.teamsCount).toBe(2);
expect(counts.membersCount).toBe(MEMBERS_PER_TEAM * 2);
});
test("counts a team having several check in rows once", async () => {
const { id: tournamentId } = await createTournament();
const team = await createTeam(tournamentId, { nth: 0, isCheckedIn: true });
await createTeam(tournamentId, { nth: 1, isCheckedIn: true });
await TournamentFactory.startBracket(tournamentId);
await actAs(team.ownerUserId, () =>
TournamentTeamRepository.checkIn(team.id, { bracketIdx: 1 }),
);
const counts = await showcaseCounts(tournamentId);
expect(counts.teamsCount).toBe(2);
expect(counts.membersCount).toBe(MEMBERS_PER_TEAM * 2);
});
});

View File

@@ -83,6 +83,7 @@ export const action: ActionFunction = async ({ params, url }) => {
type: "participant",
userId: user.id,
});
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
await syncPickupChatMetadata({
teamId: teamToJoin.id,

View File

@@ -137,8 +137,8 @@ export const action: ActionFunction = async ({ request, params }) => {
tournamentId,
type: "participant",
userId: user.id,
newTeamCount: tournament.ctx.teams.length + 1,
});
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
}
break;
}
@@ -168,6 +168,7 @@ export const action: ActionFunction = async ({ request, params }) => {
type: "participant",
userId: data.userId,
});
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
await syncPickupChatMetadata({
teamId: ownTeam.id,
@@ -206,6 +207,7 @@ export const action: ActionFunction = async ({ request, params }) => {
type: "participant",
userId: user.id,
});
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
await syncPickupChatMetadata({
teamId: teamMemberOf.id,
@@ -318,6 +320,7 @@ export const action: ActionFunction = async ({ request, params }) => {
type: "participant",
userId: data.userId,
});
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
await syncPickupChatMetadata({
teamId: ownTeam.id,
@@ -369,12 +372,8 @@ export const action: ActionFunction = async ({ request, params }) => {
type: "participant",
userId,
});
ShowcaseTournaments.updateCachedTournamentTeamCount({
tournamentId,
newTeamCount: tournament.ctx.teams.length - 1,
});
}
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
break;
}

View File

@@ -19,7 +19,6 @@ import {
} from "~/db/json-selections";
import { db } from "~/db/sql";
import type { DB, Tables } from "~/db/tables";
import { databaseTimestampNow } from "./dates";
import { IS_E2E_TEST_RUN } from "./e2e";
import { safeNumberParse } from "./number";
@@ -290,44 +289,60 @@ export function tournamentTeamCount(
.where("TournamentTeam.isPlaceholder", "=", 0);
}
/** Expression resolving to whether any of a tournament's brackets has been started. */
function tournamentHasStarted(eb: ExpressionBuilder<DB, "Tournament">) {
return eb.exists(
eb
.selectFrom("TournamentStage")
.select("TournamentStage.id")
.whereRef("TournamentStage.tournamentId", "=", "Tournament.id"),
);
}
/**
* Subquery resolving to a tournament's non-placeholder teams that are either checked in to the
* tournament itself (not to a specific bracket) or belong to a tournament that has not started yet.
* Correlates on `"Tournament"."id"` and `"CalendarEventDate"."startsAt"`. Has no select of its own,
* so extend it with the aggregate the caller needs, e.g. `.select(({ fn }) => fn.countAll().as("count"))`.
* Subquery resolving to the non-placeholder teams of a tournament that are still relevant to it:
* every registered team as long as no bracket has been started, only the checked in ones after
* that. Mirrors how the tournament page itself resolves its teams, so keep the two in sync.
* Correlates on `"Tournament"."id"`. Has no select of its own, so extend it with the aggregate the
* caller needs. A team can have several check in rows, so aggregate with `.distinct()`, e.g.
* `.select(({ fn }) => fn.count("TournamentTeam.id").distinct().as("count"))`.
*/
export function tournamentCheckedInTeams(
eb: ExpressionBuilder<DB, "CalendarEventDate" | "Tournament">,
) {
function tournamentCheckedInTeams(eb: ExpressionBuilder<DB, "Tournament">) {
return eb
.selectFrom("TournamentTeam")
.leftJoin("TournamentTeamCheckIn", (join) =>
join
.on("TournamentTeamCheckIn.bracketIdx", "is", null)
.onRef(
"TournamentTeamCheckIn.tournamentTeamId",
"=",
"TournamentTeam.id",
),
.leftJoin(
"TournamentTeamCheckIn",
"TournamentTeamCheckIn.tournamentTeamId",
"TournamentTeam.id",
)
.whereRef("TournamentTeam.tournamentId", "=", "Tournament.id")
.where("TournamentTeam.isPlaceholder", "=", 0)
.where((eb2) =>
eb2.or([
eb2("TournamentTeamCheckIn.checkedInAt", "is not", null),
eb2("CalendarEventDate.startsAt", ">", databaseTimestampNow()),
eb2.not(tournamentHasStarted(eb)),
]),
);
}
/**
* Subquery counting the teams of {@link tournamentCheckedInTeams}. Correlates on
* `"Tournament"."id"`. Alias it `.as("teamsCount")` when selecting it directly.
*/
export function tournamentTeamsCount(eb: ExpressionBuilder<DB, "Tournament">) {
return tournamentCheckedInTeams(eb).select(({ fn }) => [
fn.count<number>("TournamentTeam.id").distinct().as("count"),
]);
}
/**
* Expression resolving to a tournament's participant count: rostered players of the teams from
* {@link tournamentCheckedInTeams} while the tournament is still to come, players who actually got
* {@link tournamentCheckedInTeams} while the tournament is still ongoing, players who actually got
* a result once it has been finalized. Correlates on `"Tournament"."id"`. Alias it
* `.as("membersCount")` when selecting it directly.
*/
export function tournamentMembersCount(
eb: ExpressionBuilder<DB, "CalendarEventDate" | "Tournament">,
eb: ExpressionBuilder<DB, "Tournament">,
) {
return eb
.case()

View File

@@ -1087,6 +1087,11 @@ export function buildCases(fx: Fixtures): {
addStatic("TournamentRepository.findAllForShowcase", () =>
TournamentRepository.findAllForShowcase(),
);
add(
"TournamentRepository.findShowcaseCountsById",
fx.heavyTournamentId,
(tournamentId) => TournamentRepository.findShowcaseCountsById(tournamentId),
);
add(
"TournamentRepository.findAllBetweenTwoTimestamps",
fx.calendarWindow,