mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-27 05:38:09 -05:00
Keep tournament showcase cards in sync with tournament page count
This commit is contained in:
@@ -107,6 +107,7 @@ export const action = async (args: ActionFunctionArgs) => {
|
||||
type: "participant",
|
||||
userId,
|
||||
});
|
||||
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
|
||||
|
||||
await syncPickupChatMetadata({
|
||||
teamId: team.id,
|
||||
|
||||
@@ -72,6 +72,7 @@ export const action = async (args: ActionFunctionArgs) => {
|
||||
type: "participant",
|
||||
userId,
|
||||
});
|
||||
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
|
||||
|
||||
await syncPickupChatMetadata({
|
||||
teamId: team.id,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user