mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-09 12:46:10 -05:00
Est team count from series history
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
## General
|
||||
|
||||
- only rarely use comments, prefer descriptive variable and function names (leave existing comments as is).
|
||||
- if you encounter an existing TODO comment assume it is there for a reason and do not remove it
|
||||
- if you encounter an existing TODO or xxx comment assume it is there for a reason and do not remove it unless you specifically addressed what the comment is about
|
||||
- when a comment is needed, brevity is the key, less is more
|
||||
- task is not considered completely until `pnpm run checks` passes
|
||||
- normal file structure has constants at the top immediately followed by the main function body of the file. Helpers are used to structure the code and they are at the bottom of the file (main implementation first, at the top of the file)
|
||||
- note: any formatting issue (such as tabs vs. spaces) can be resolved by running the `pnpm run biome:fix` command
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as R from "remeda";
|
||||
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
|
||||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||
import * as SeriesTeamCount from "~/features/tournament-organization/core/SeriesTeamCount.server";
|
||||
import * as AvailabilityRepository from "../AvailabilityRepository.server";
|
||||
import { AVAILABILITY } from "../availability-constants";
|
||||
import type { BusyBlock } from "../availability-types";
|
||||
@@ -32,24 +33,27 @@ export async function busyBlocksByUserIds({
|
||||
}): Promise<Map<number, Array<BusyBlock>>> {
|
||||
if (userIds.length === 0) return new Map();
|
||||
|
||||
const [registrations, scrims, teamEvents] = await Promise.all([
|
||||
TournamentTeamRepository.findAllRegistrationsByUserIds({
|
||||
userIds,
|
||||
startsAt: startsAt - TournamentDuration.MAX_ESTIMATE_SECONDS,
|
||||
endsAt,
|
||||
excludeTournamentId,
|
||||
}),
|
||||
ScrimPostRepository.findAllAcceptedByUserIds({
|
||||
userIds,
|
||||
startsAt: startsAt - AVAILABILITY.SCRIM_COMMITMENT_SECONDS,
|
||||
endsAt,
|
||||
}),
|
||||
AvailabilityRepository.findAllTeamEventsByUserIds({
|
||||
userIds,
|
||||
startsAt,
|
||||
endsAt,
|
||||
}),
|
||||
]);
|
||||
// xxx: when promise.all not the play
|
||||
const [registrations, scrims, teamEvents, expectedTeamCount] =
|
||||
await Promise.all([
|
||||
TournamentTeamRepository.findAllRegistrationsByUserIds({
|
||||
userIds,
|
||||
startsAt: startsAt - TournamentDuration.MAX_ESTIMATE_SECONDS,
|
||||
endsAt,
|
||||
excludeTournamentId,
|
||||
}),
|
||||
ScrimPostRepository.findAllAcceptedByUserIds({
|
||||
userIds,
|
||||
startsAt: startsAt - AVAILABILITY.SCRIM_COMMITMENT_SECONDS,
|
||||
endsAt,
|
||||
}),
|
||||
AvailabilityRepository.findAllTeamEventsByUserIds({
|
||||
userIds,
|
||||
startsAt,
|
||||
endsAt,
|
||||
}),
|
||||
SeriesTeamCount.lookup(),
|
||||
]);
|
||||
|
||||
const blocks: Array<BusyBlock & { userId: number }> = [
|
||||
...registrations
|
||||
@@ -66,7 +70,7 @@ export async function busyBlocksByUserIds({
|
||||
bracketTypes: registration.settings.bracketProgression.map(
|
||||
(bracket) => bracket.type,
|
||||
),
|
||||
teamCount: registration.teamCount,
|
||||
teamCount: expectedTeamCount(registration),
|
||||
}),
|
||||
})),
|
||||
...scrims.map((scrim) => ({
|
||||
|
||||
@@ -6,7 +6,7 @@ import { AVAILABILITY } from "../availability-constants";
|
||||
import type { TimeRange } from "../availability-types";
|
||||
import * as Availability from "./Availability";
|
||||
import * as Commitments from "./Commitments.server";
|
||||
import * as TournamentDuration from "./TournamentDuration";
|
||||
import { estimatedEndsAt } from "./TournamentDuration.server";
|
||||
|
||||
export type RegistrationAvailability = Awaited<
|
||||
ReturnType<typeof registrationAvailability>
|
||||
@@ -14,7 +14,7 @@ export type RegistrationAvailability = Awaited<
|
||||
|
||||
/**
|
||||
* Availability of the given users for a tournament's estimated window
|
||||
* (start + {@link TournamentDuration.estimateSeconds}), for the registration
|
||||
* (start to {@link estimatedEndsAt}), for the registration
|
||||
* page's availability panel. The tournament's own registrations do not count
|
||||
* as being busy — the panel asks whether people can play this very event.
|
||||
*
|
||||
@@ -30,6 +30,8 @@ export async function registrationAvailability({
|
||||
}: {
|
||||
tournament: {
|
||||
id: number;
|
||||
name: string;
|
||||
organizationId: number | null;
|
||||
startsAt: number;
|
||||
minMembersPerTeam: number;
|
||||
bracketTypes: Array<Tables["TournamentStage"]["type"]>;
|
||||
@@ -56,13 +58,7 @@ export async function registrationAvailability({
|
||||
|
||||
const window: TimeRange = {
|
||||
startsAt: tournament.startsAt,
|
||||
endsAt:
|
||||
tournament.startsAt +
|
||||
TournamentDuration.estimateSeconds({
|
||||
minMembersPerTeam: tournament.minMembersPerTeam,
|
||||
bracketTypes: tournament.bracketTypes,
|
||||
teamCount: tournament.teamCount,
|
||||
}),
|
||||
endsAt: await estimatedEndsAt(tournament),
|
||||
};
|
||||
|
||||
const [weeks, busyByUserId] = await Promise.all([
|
||||
|
||||
32
app/features/availability/core/TournamentDuration.server.ts
Normal file
32
app/features/availability/core/TournamentDuration.server.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Tables } from "~/db/tables";
|
||||
import * as SeriesTeamCount from "~/features/tournament-organization/core/SeriesTeamCount.server";
|
||||
import * as TournamentDuration from "./TournamentDuration";
|
||||
|
||||
interface EstimatedTournament {
|
||||
name: string;
|
||||
organizationId: number | null;
|
||||
startsAt: number;
|
||||
minMembersPerTeam: number;
|
||||
bracketTypes: Array<Tables["TournamentStage"]["type"]>;
|
||||
/** Teams registered so far. */
|
||||
teamCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a tournament is estimated to end: its start plus
|
||||
* {@link TournamentDuration.estimateSeconds}, sized by the count the event is
|
||||
* expected to draw rather than the one registered so far. Every surface showing
|
||||
* or blocking out a tournament's window goes through this so the two agree.
|
||||
*/
|
||||
export async function estimatedEndsAt(tournament: EstimatedTournament) {
|
||||
const expectedTeamCount = await SeriesTeamCount.lookup();
|
||||
|
||||
return (
|
||||
tournament.startsAt +
|
||||
TournamentDuration.estimateSeconds({
|
||||
minMembersPerTeam: tournament.minMembersPerTeam,
|
||||
bracketTypes: tournament.bracketTypes,
|
||||
teamCount: expectedTeamCount(tournament),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -14,10 +14,13 @@ export const MAX_ESTIMATE_SECONDS = LARGE_FOUR_VS_FOUR_HOURS * HOUR_SECONDS;
|
||||
|
||||
/**
|
||||
* Estimated length of a tournament in seconds, used to block its players'
|
||||
* availability from the event's start. The actual length is not in the data
|
||||
* model, so this is a constant table measured from the production database
|
||||
* (August 2026): 3222 finalized tournaments, duration = scheduled start → last
|
||||
* reported game result, leagues and test tournaments excluded. Hours:
|
||||
* availability from the event's start. Only for a tournament played in one
|
||||
* sitting, the numbers being measured over whole events.
|
||||
*
|
||||
* The actual length is not in the data model, so this is a constant table
|
||||
* measured from the production database (August 2026): 3222 finalized
|
||||
* tournaments, duration = scheduled start → last reported game result, leagues
|
||||
* and test tournaments excluded. Hours:
|
||||
*
|
||||
* | case | n | p25 | med | p75 | p90 |
|
||||
* | --------------------------- | ---- | --- | --- | --- | --- |
|
||||
@@ -37,13 +40,14 @@ export const MAX_ESTIMATE_SECONDS = LARGE_FOUR_VS_FOUR_HOURS * HOUR_SECONDS;
|
||||
* - Team count raises duration (4v4 medians: <8 teams 2.2, 8–15 3.1, 16–31
|
||||
* 3.7, 32–63 3.7, 64+ 4.2) but at estimate time the registered count is
|
||||
* only a lower bound of the final count, so it only ever raises the
|
||||
* estimate above the size default, never lowers it.
|
||||
* estimate above the size default, never lowers it. Callers pass what the
|
||||
* event is *expected* to draw, see `SeriesTeamCount.lookup`.
|
||||
* - SZ-only vs multi-mode map pools made no meaningful difference (medians
|
||||
* 3.4 vs 3.2), so modes are not a dimension.
|
||||
*
|
||||
* The estimates sit at ≈p75 of their case: slightly generous, because a block
|
||||
* that runs a bit long beats showing a player free while they are still
|
||||
* playing.
|
||||
* playing. 84.7% of 4v4 tournaments end within their window.
|
||||
*/
|
||||
export function estimateSeconds({
|
||||
minMembersPerTeam,
|
||||
@@ -52,6 +56,7 @@ export function estimateSeconds({
|
||||
}: {
|
||||
minMembersPerTeam: number;
|
||||
bracketTypes: Array<Tables["TournamentStage"]["type"]>;
|
||||
/** Teams the tournament is expected to draw, not necessarily the registered count. */
|
||||
teamCount: number;
|
||||
}) {
|
||||
const isSingleEliminationOnly =
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isFuture } from "date-fns";
|
||||
import { type ExpressionBuilder, sql } from "kysely";
|
||||
import { type ExpressionBuilder, type NotNull, sql } from "kysely";
|
||||
import * as R from "remeda";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, Tables, TablesInsertable } from "~/db/tables";
|
||||
@@ -373,6 +373,61 @@ export async function findEventsByMonth({
|
||||
return events.map(mapEvent);
|
||||
}
|
||||
|
||||
/** Every tournament series of every organization. */
|
||||
export function findAllSeries() {
|
||||
return db
|
||||
.selectFrom("TournamentOrganizationSeries")
|
||||
.select([
|
||||
"TournamentOrganizationSeries.organizationId",
|
||||
"TournamentOrganizationSeries.substringMatches",
|
||||
"TournamentOrganizationSeries.tierHistory",
|
||||
])
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* How many teams each organization's already started tournaments drew within the
|
||||
* given window, oldest first. Counts what the tournament's own page shows:
|
||||
* placeholder teams excluded, dropped out ones included.
|
||||
*/
|
||||
export function findAllOrganizedTournamentTeamCounts({
|
||||
startedAfter,
|
||||
}: {
|
||||
startedAfter: number;
|
||||
}) {
|
||||
return db
|
||||
.selectFrom("CalendarEvent")
|
||||
.innerJoin(
|
||||
"CalendarEventDate",
|
||||
"CalendarEventDate.eventId",
|
||||
"CalendarEvent.id",
|
||||
)
|
||||
.select((eb) => [
|
||||
"CalendarEvent.name",
|
||||
"CalendarEvent.organizationId",
|
||||
eb.fn.min("CalendarEventDate.startsAt").as("startsAt"),
|
||||
eb
|
||||
.selectFrom("TournamentTeam")
|
||||
.select(({ fn }) => fn.countAll<number>().as("count"))
|
||||
.whereRef(
|
||||
"TournamentTeam.tournamentId",
|
||||
"=",
|
||||
"CalendarEvent.tournamentId",
|
||||
)
|
||||
.where("TournamentTeam.isPlaceholder", "=", 0)
|
||||
.as("teamCount"),
|
||||
])
|
||||
.$narrowType<{ organizationId: NotNull; teamCount: NotNull }>()
|
||||
.where("CalendarEvent.organizationId", "is not", null)
|
||||
.where("CalendarEvent.tournamentId", "is not", null)
|
||||
.where("CalendarEvent.hidden", "=", 0)
|
||||
.where("CalendarEventDate.startsAt", ">=", startedAfter)
|
||||
.where("CalendarEventDate.startsAt", "<=", databaseTimestampNow())
|
||||
.groupBy("CalendarEvent.id")
|
||||
.orderBy("startsAt", "asc")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function findAllUnfinalizedEvents(organizationId: number) {
|
||||
return db
|
||||
.selectFrom("Tournament")
|
||||
@@ -885,13 +940,6 @@ export function deleteById(organizationId: number) {
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function findAllSeriesWithTierHistory() {
|
||||
return db
|
||||
.selectFrom("TournamentOrganizationSeries")
|
||||
.select(["organizationId", "substringMatches", "tierHistory"])
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function updateSeriesTierHistory({
|
||||
organizationId,
|
||||
eventName,
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { subDays } from "date-fns";
|
||||
import * as R from "remeda";
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
|
||||
import * as TournamentOrganizationFactory from "~/db/seed/factories/TournamentOrganizationFactory";
|
||||
import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import { cache } from "~/utils/cache.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import * as SeriesTeamCount from "./SeriesTeamCount.server";
|
||||
|
||||
const users = UserFactory.pool();
|
||||
const authorId = () => users.id(1);
|
||||
|
||||
const SERIES_NAME = "Swim or Sink";
|
||||
|
||||
describe("SeriesTeamCount.lookup", () => {
|
||||
beforeEach(async () => {
|
||||
// the counts are cached for the process, but every test seeds its own
|
||||
cache.clear();
|
||||
await users.create(6);
|
||||
});
|
||||
|
||||
test("raises the registered count to the median of the series' recent editions", async () => {
|
||||
const organizationId = await createOrganization();
|
||||
await createEdition({ organizationId, startedDaysAgo: 21, teamCount: 2 });
|
||||
await createEdition({ organizationId, startedDaysAgo: 14, teamCount: 6 });
|
||||
await createEdition({ organizationId, startedDaysAgo: 7, teamCount: 4 });
|
||||
|
||||
const expectedTeamCount = await SeriesTeamCount.lookup();
|
||||
|
||||
expect(
|
||||
expectedTeamCount({
|
||||
organizationId,
|
||||
name: `${SERIES_NAME} 4`,
|
||||
teamCount: 1,
|
||||
}),
|
||||
).toBe(4);
|
||||
});
|
||||
|
||||
test("keeps the registered count when it is already above the series median", async () => {
|
||||
const organizationId = await createOrganization();
|
||||
await createEdition({ organizationId, startedDaysAgo: 7, teamCount: 2 });
|
||||
|
||||
const expectedTeamCount = await SeriesTeamCount.lookup();
|
||||
|
||||
expect(
|
||||
expectedTeamCount({
|
||||
organizationId,
|
||||
name: `${SERIES_NAME} 2`,
|
||||
teamCount: 5,
|
||||
}),
|
||||
).toBe(5);
|
||||
});
|
||||
|
||||
test("counts only the latest editions of the series", async () => {
|
||||
const organizationId = await createOrganization();
|
||||
await createEdition({ organizationId, startedDaysAgo: 28, teamCount: 6 });
|
||||
await createEdition({ organizationId, startedDaysAgo: 21, teamCount: 6 });
|
||||
await createEdition({ organizationId, startedDaysAgo: 14, teamCount: 1 });
|
||||
await createEdition({ organizationId, startedDaysAgo: 7, teamCount: 1 });
|
||||
|
||||
const expectedTeamCount = await SeriesTeamCount.lookup();
|
||||
|
||||
expect(
|
||||
expectedTeamCount({
|
||||
organizationId,
|
||||
name: `${SERIES_NAME} 5`,
|
||||
teamCount: 0,
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
test("ignores editions that have not started yet", async () => {
|
||||
const organizationId = await createOrganization();
|
||||
await createEdition({ organizationId, startedDaysAgo: 7, teamCount: 2 });
|
||||
await createEdition({ organizationId, startedDaysAgo: -7, teamCount: 6 });
|
||||
|
||||
const expectedTeamCount = await SeriesTeamCount.lookup();
|
||||
|
||||
expect(
|
||||
expectedTeamCount({
|
||||
organizationId,
|
||||
name: `${SERIES_NAME} 3`,
|
||||
teamCount: 0,
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
test("ignores the organization's tournaments outside the series", async () => {
|
||||
const organizationId = await createOrganization();
|
||||
await createEdition({ organizationId, startedDaysAgo: 7, teamCount: 2 });
|
||||
await createEdition({
|
||||
organizationId,
|
||||
name: "One off invitational",
|
||||
startedDaysAgo: 5,
|
||||
teamCount: 6,
|
||||
});
|
||||
|
||||
const expectedTeamCount = await SeriesTeamCount.lookup();
|
||||
|
||||
expect(
|
||||
expectedTeamCount({
|
||||
organizationId,
|
||||
name: `${SERIES_NAME} 3`,
|
||||
teamCount: 0,
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
test("returns the registered count for a tournament of no organization", async () => {
|
||||
const organizationId = await createOrganization();
|
||||
await createEdition({ organizationId, startedDaysAgo: 7, teamCount: 6 });
|
||||
|
||||
const expectedTeamCount = await SeriesTeamCount.lookup();
|
||||
|
||||
expect(
|
||||
expectedTeamCount({
|
||||
organizationId: null,
|
||||
name: `${SERIES_NAME} 2`,
|
||||
teamCount: 1,
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
test("returns the registered count when the series has no edition yet", async () => {
|
||||
const organizationId = await createOrganization();
|
||||
|
||||
const expectedTeamCount = await SeriesTeamCount.lookup();
|
||||
|
||||
expect(
|
||||
expectedTeamCount({
|
||||
organizationId,
|
||||
name: `${SERIES_NAME} 1`,
|
||||
teamCount: 1,
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
async function createOrganization() {
|
||||
const organization = await TournamentOrganizationFactory.create(
|
||||
{ ownerId: authorId() },
|
||||
{
|
||||
series: [
|
||||
{ name: SERIES_NAME, description: null, showLeaderboard: false },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
return organization.id;
|
||||
}
|
||||
|
||||
async function createEdition({
|
||||
organizationId,
|
||||
name = SERIES_NAME,
|
||||
startedDaysAgo,
|
||||
teamCount,
|
||||
}: {
|
||||
organizationId: number;
|
||||
name?: string;
|
||||
startedDaysAgo: number;
|
||||
teamCount: number;
|
||||
}) {
|
||||
const tournament = await TournamentFactory.create({
|
||||
authorId: authorId(),
|
||||
name,
|
||||
organizationId,
|
||||
startTimes: [dateToDatabaseTimestamp(subDays(new Date(), startedDaysAgo))],
|
||||
});
|
||||
|
||||
for (const idx of R.range(0, teamCount)) {
|
||||
await TournamentTeamFactory.create({
|
||||
tournamentId: tournament.id,
|
||||
memberUserIds: [users.id(idx + 1)],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { cachified } from "@epic-web/cachified";
|
||||
import { subDays } from "date-fns";
|
||||
import * as R from "remeda";
|
||||
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server";
|
||||
|
||||
const CACHE_KEY = "series-team-counts";
|
||||
/** How old an edition can be and still say something about the next one. */
|
||||
const LOOKBACK_DAYS = 90;
|
||||
/** How many of a series' latest editions the typical count is taken from. */
|
||||
const EDITIONS_CONSIDERED = 3;
|
||||
|
||||
interface Tournament {
|
||||
organizationId: number | null;
|
||||
name: string;
|
||||
/** Teams registered so far. */
|
||||
teamCount: number;
|
||||
}
|
||||
|
||||
interface SeriesTeamCounts {
|
||||
substringMatches: Array<string>;
|
||||
teamCounts: Array<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the team count a tournament is *expected* to draw: its registered
|
||||
* count raised to the median of the last {@link EDITIONS_CONSIDERED} editions of
|
||||
* its series, never lowered.
|
||||
*/
|
||||
export async function lookup() {
|
||||
const seriesByOrganizationId = await cachedSeriesTeamCounts();
|
||||
|
||||
return (tournament: Tournament) => {
|
||||
if (!tournament.organizationId) return tournament.teamCount;
|
||||
|
||||
const series = seriesByOrganizationId.get(tournament.organizationId);
|
||||
if (!series) return tournament.teamCount;
|
||||
|
||||
const nameLower = tournament.name.toLowerCase();
|
||||
const match = series.find((candidate) =>
|
||||
candidate.substringMatches.some((substring) =>
|
||||
nameLower.includes(substring.toLowerCase()),
|
||||
),
|
||||
);
|
||||
if (!match) return tournament.teamCount;
|
||||
|
||||
return Math.max(
|
||||
tournament.teamCount,
|
||||
R.median(match.teamCounts) ?? tournament.teamCount,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function cachedSeriesTeamCounts() {
|
||||
return cachified({
|
||||
key: CACHE_KEY,
|
||||
cache,
|
||||
ttl: ttl(IN_MILLISECONDS.TWO_HOURS),
|
||||
getFreshValue: seriesTeamCounts,
|
||||
});
|
||||
}
|
||||
|
||||
async function seriesTeamCounts() {
|
||||
const [series, tournaments] = await Promise.all([
|
||||
TournamentOrganizationRepository.findAllSeries(),
|
||||
TournamentOrganizationRepository.findAllOrganizedTournamentTeamCounts({
|
||||
startedAfter: dateToDatabaseTimestamp(subDays(new Date(), LOOKBACK_DAYS)),
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = new Map<number, Array<SeriesTeamCounts>>();
|
||||
for (const row of series) {
|
||||
const teamCounts = tournaments
|
||||
.filter(
|
||||
(tournament) =>
|
||||
tournament.organizationId === row.organizationId &&
|
||||
row.substringMatches.some((substring) =>
|
||||
tournament.name.toLowerCase().includes(substring.toLowerCase()),
|
||||
),
|
||||
)
|
||||
.slice(-EDITIONS_CONSIDERED)
|
||||
.map((tournament) => tournament.teamCount);
|
||||
|
||||
if (teamCounts.length === 0) continue;
|
||||
|
||||
const existing = result.get(row.organizationId) ?? [];
|
||||
existing.push({ substringMatches: row.substringMatches, teamCounts });
|
||||
result.set(row.organizationId, existing);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -8,8 +8,7 @@ interface SeriesMatch {
|
||||
}
|
||||
|
||||
async function loadCache(): Promise<Map<number, SeriesMatch[]>> {
|
||||
const rows =
|
||||
await TournamentOrganizationRepository.findAllSeriesWithTierHistory();
|
||||
const rows = await TournamentOrganizationRepository.findAllSeries();
|
||||
|
||||
const result = new Map<number, SeriesMatch[]>();
|
||||
for (const row of rows) {
|
||||
|
||||
@@ -1029,6 +1029,7 @@ export function findAllRegistrationsByUserIds({
|
||||
.select((eb) => [
|
||||
"TournamentTeamMember.userId",
|
||||
"CalendarEvent.name",
|
||||
"CalendarEvent.organizationId",
|
||||
"CalendarEventDate.startsAt",
|
||||
"Tournament.settings",
|
||||
eb
|
||||
|
||||
@@ -93,6 +93,19 @@
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.estimatedEnd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
font-weight: var(--weight-normal);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ActionButton } from "~/components/ActionButton";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { LinkButton } from "~/components/elements/Button";
|
||||
import { DiscordIcon } from "~/components/icons/Discord";
|
||||
import { LocaleTime } from "~/components/LocaleTime";
|
||||
import { ShareUrlButton } from "~/components/ShareUrlButton";
|
||||
import TimePopover from "~/components/TimePopover";
|
||||
import { UserLink } from "~/components/UserLink";
|
||||
@@ -18,7 +19,14 @@ import { saveTournamentSchema } from "../tournament-schemas";
|
||||
import { tournamentNameParts } from "../tournament-utils";
|
||||
import styles from "./TournamentHeader.module.css";
|
||||
|
||||
export function TournamentHeader({ tournament }: { tournament: Tournament }) {
|
||||
export function TournamentHeader({
|
||||
tournament,
|
||||
estimatedEndsAt,
|
||||
}: {
|
||||
tournament: Tournament;
|
||||
/** `null` when the tournament has no estimate. */
|
||||
estimatedEndsAt: number | null;
|
||||
}) {
|
||||
const { name, subtext } = tournamentNameParts(tournament);
|
||||
|
||||
const startTimes = R.uniqueBy(
|
||||
@@ -53,18 +61,31 @@ export function TournamentHeader({ tournament }: { tournament: Tournament }) {
|
||||
</div>
|
||||
<div className={styles.dates}>
|
||||
{startTimes.map((date) => (
|
||||
<TimePopover
|
||||
key={date.getTime()}
|
||||
date={date}
|
||||
options={{
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: date.getFullYear() !== currentYear ? "numeric" : undefined,
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}}
|
||||
/>
|
||||
<div key={date.getTime()} className={styles.date}>
|
||||
<TimePopover
|
||||
date={date}
|
||||
options={{
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year:
|
||||
date.getFullYear() !== currentYear ? "numeric" : undefined,
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}}
|
||||
/>
|
||||
{estimatedEndsAt ? (
|
||||
<span className={styles.estimatedEnd}>
|
||||
~
|
||||
<LocaleTime
|
||||
date={estimatedEndsAt}
|
||||
options={{ hour: "numeric", minute: "numeric" }}
|
||||
data-testid="estimated-end"
|
||||
inline
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { estimatedEndsAt } from "~/features/availability/core/TournamentDuration.server";
|
||||
import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
|
||||
import { tournamentFromParams } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const { tournamentId, user } = await tournamentFromParams(params, {
|
||||
for: "view",
|
||||
});
|
||||
const { tournament, tournamentId, user } = await tournamentFromParams(
|
||||
params,
|
||||
{
|
||||
for: "view",
|
||||
},
|
||||
);
|
||||
|
||||
const description =
|
||||
await TournamentRepository.findDescriptionById(tournamentId);
|
||||
const [description, endsAt] = await Promise.all([
|
||||
TournamentRepository.findDescriptionById(tournamentId),
|
||||
estimatedEnd(tournament),
|
||||
]);
|
||||
|
||||
if (!user) {
|
||||
return { isSaved: false, description };
|
||||
return { isSaved: false, description, endsAt };
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -21,5 +29,24 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
tournamentId,
|
||||
}),
|
||||
description,
|
||||
endsAt,
|
||||
};
|
||||
};
|
||||
|
||||
function estimatedEnd(tournament: Tournament) {
|
||||
const isMultiSession = tournament.ctx.settings.bracketProgression.some(
|
||||
(bracket) => bracket.startTime,
|
||||
);
|
||||
if (isMultiSession) return null;
|
||||
|
||||
return estimatedEndsAt({
|
||||
name: tournament.ctx.name,
|
||||
organizationId: tournament.ctx.organization?.id ?? null,
|
||||
startsAt: dateToDatabaseTimestamp(tournament.ctx.startsAt),
|
||||
minMembersPerTeam: tournament.minMembersPerTeam,
|
||||
bracketTypes: tournament.ctx.settings.bracketProgression.map(
|
||||
(bracket) => bracket.type,
|
||||
),
|
||||
teamCount: tournament.ctx.teams.length,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,6 +75,8 @@ function rosterAvailability({
|
||||
return RegistrationAvailability.registrationAvailability({
|
||||
tournament: {
|
||||
id: tournament.ctx.id,
|
||||
name: tournament.ctx.name,
|
||||
organizationId: tournament.ctx.organization?.id ?? null,
|
||||
startsAt,
|
||||
minMembersPerTeam: tournament.minMembersPerTeam,
|
||||
bracketTypes: tournament.ctx.settings.bracketProgression.map(
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function TournamentInfoPage() {
|
||||
|
||||
return (
|
||||
<div className={clsx("stack lg", containerClassName("normal"))}>
|
||||
<TournamentHeader tournament={tournament} />
|
||||
<TournamentHeader tournament={tournament} estimatedEndsAt={data.endsAt} />
|
||||
<div className="stack md">
|
||||
<FactCardGrid facts={facts} />
|
||||
<TournamentHeaderActions
|
||||
|
||||
@@ -10,7 +10,7 @@ declare global {
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: trick to only create one
|
||||
export const cache = (global.__lruCache = global.__lruCache
|
||||
? global.__lruCache
|
||||
: new LRUCache<string, CacheEntry<unknown>>({ max: 5000 }));
|
||||
: new LRUCache<string, CacheEntry<unknown>>({ max: 6000 }));
|
||||
|
||||
export const ttl = (ms: number) => (ServerConfig.disableCache ? 0 : ms);
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export class TournamentPage {
|
||||
this.nav = new TournamentNav(page);
|
||||
this.locators = {
|
||||
registerCta: page.getByTestId("register-cta"),
|
||||
estimatedEnd: page.getByTestId("estimated-end"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { addHours, addMinutes } from "date-fns";
|
||||
import { ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
import * as Availability from "~/features/availability/core/Availability";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import {
|
||||
databaseTimestampToDate,
|
||||
dateToDatabaseTimestamp,
|
||||
} from "~/utils/dates";
|
||||
import {
|
||||
expect,
|
||||
impersonate,
|
||||
@@ -21,6 +24,7 @@ import { TournamentTeamsPage } from "./pages/tournament/tournament-teams-page";
|
||||
const TEAM_NAME = "Chimera";
|
||||
const ROSTER_SIZE = 4;
|
||||
const SEEDED_TEAM_COUNT = 8;
|
||||
const HOUR_SECONDS = 60 * 60;
|
||||
|
||||
/** Views of a tournament whose loaders each ship some of its teams' data. */
|
||||
const TOURNAMENT_TEAM_VIEWS = ["teams", "results", "brackets", "admin/seeds"];
|
||||
@@ -77,6 +81,26 @@ test.describe("Tournament", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows the estimated end time next to the start time", async ({
|
||||
page,
|
||||
factories,
|
||||
}) => {
|
||||
const startsAt = dateToDatabaseTimestamp(addHours(new Date(), 2));
|
||||
const tournament = await factories.TournamentFactory.create({
|
||||
authorId: ADMIN_ID,
|
||||
startTimes: [startsAt],
|
||||
});
|
||||
|
||||
const tournamentPage = new TournamentPage(page);
|
||||
await tournamentPage.goto(tournament.id);
|
||||
|
||||
// a lone single elimination bracket is the estimator's two hour case
|
||||
await expect(tournamentPage.locators.estimatedEnd).toHaveAttribute(
|
||||
"datetime",
|
||||
databaseTimestampToDate(startsAt + 2 * HOUR_SECONDS).toISOString(),
|
||||
);
|
||||
});
|
||||
|
||||
test("quick adds all of the team's players at once", async ({
|
||||
page,
|
||||
factories,
|
||||
|
||||
@@ -479,4 +479,4 @@
|
||||
"tier.confirmed": "{{tierName}}-tier tournament",
|
||||
"spoilerFree.showResults": "Show results",
|
||||
"spoilerFree.hideResults": "Hide results"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { subDays } from "date-fns";
|
||||
import * as AdminRepository from "~/features/admin/AdminRepository.server";
|
||||
import * as ExternalStreamRepository from "~/features/admin/ExternalStreamRepository.server";
|
||||
import * as ApiRepository from "~/features/api/ApiRepository.server";
|
||||
@@ -1028,9 +1029,15 @@ export function buildCases(fx: Fixtures): {
|
||||
org.memberUserId,
|
||||
),
|
||||
);
|
||||
addStatic("TournamentOrganizationRepository.findAllSeries", () =>
|
||||
TournamentOrganizationRepository.findAllSeries(),
|
||||
);
|
||||
addStatic(
|
||||
"TournamentOrganizationRepository.findAllSeriesWithTierHistory",
|
||||
() => TournamentOrganizationRepository.findAllSeriesWithTierHistory(),
|
||||
"TournamentOrganizationRepository.findAllOrganizedTournamentTeamCounts",
|
||||
() =>
|
||||
TournamentOrganizationRepository.findAllOrganizedTournamentTeamCounts({
|
||||
startedAfter: dateToDatabaseTimestamp(subDays(new Date(), 90)),
|
||||
}),
|
||||
);
|
||||
|
||||
// SavedCalendarEventRepository
|
||||
|
||||
Reference in New Issue
Block a user