From 038c2b7f16b50cfafb27a07a68496ccb1f3302af Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:58:00 +0300 Subject: [PATCH] Est team count from series history --- AGENTS.md | 3 +- .../availability/core/Commitments.server.ts | 42 +++-- .../core/RegistrationAvailability.server.ts | 14 +- .../core/TournamentDuration.server.ts | 32 ++++ .../availability/core/TournamentDuration.ts | 17 +- ...TournamentOrganizationRepository.server.ts | 64 ++++++- .../core/SeriesTeamCount.server.test.ts | 178 ++++++++++++++++++ .../core/SeriesTeamCount.server.ts | 93 +++++++++ .../core/tentativeTiers.server.ts | 3 +- .../TournamentTeamRepository.server.ts | 1 + .../components/TournamentHeader.module.css | 13 ++ .../components/TournamentHeader.tsx | 47 +++-- .../tournament/loaders/to.$id.info.server.ts | 39 +++- .../loaders/to.$id.register.server.ts | 2 + .../tournament/routes/to.$id.info.tsx | 2 +- app/utils/cache.server.ts | 2 +- e2e/pages/tournament/tournament-page.ts | 1 + e2e/tournament.spec.ts | 26 ++- locales/en/common.json | 2 +- scripts/benchmark-db/cases.ts | 11 +- 20 files changed, 522 insertions(+), 70 deletions(-) create mode 100644 app/features/availability/core/TournamentDuration.server.ts create mode 100644 app/features/tournament-organization/core/SeriesTeamCount.server.test.ts create mode 100644 app/features/tournament-organization/core/SeriesTeamCount.server.ts diff --git a/AGENTS.md b/AGENTS.md index da9851cbe..7a97414fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/app/features/availability/core/Commitments.server.ts b/app/features/availability/core/Commitments.server.ts index 188162932..e0d4ec704 100644 --- a/app/features/availability/core/Commitments.server.ts +++ b/app/features/availability/core/Commitments.server.ts @@ -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>> { 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 = [ ...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) => ({ diff --git a/app/features/availability/core/RegistrationAvailability.server.ts b/app/features/availability/core/RegistrationAvailability.server.ts index 08a33e38b..173c1975f 100644 --- a/app/features/availability/core/RegistrationAvailability.server.ts +++ b/app/features/availability/core/RegistrationAvailability.server.ts @@ -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 @@ -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; @@ -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([ diff --git a/app/features/availability/core/TournamentDuration.server.ts b/app/features/availability/core/TournamentDuration.server.ts new file mode 100644 index 000000000..bd672f7dd --- /dev/null +++ b/app/features/availability/core/TournamentDuration.server.ts @@ -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; + /** 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), + }) + ); +} diff --git a/app/features/availability/core/TournamentDuration.ts b/app/features/availability/core/TournamentDuration.ts index 3dd5a777c..ea3171282 100644 --- a/app/features/availability/core/TournamentDuration.ts +++ b/app/features/availability/core/TournamentDuration.ts @@ -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; + /** Teams the tournament is expected to draw, not necessarily the registered count. */ teamCount: number; }) { const isSingleEliminationOnly = diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts index 2a67ae5b6..2fbcde250 100644 --- a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts +++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts @@ -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().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, diff --git a/app/features/tournament-organization/core/SeriesTeamCount.server.test.ts b/app/features/tournament-organization/core/SeriesTeamCount.server.test.ts new file mode 100644 index 000000000..b1ffc1d84 --- /dev/null +++ b/app/features/tournament-organization/core/SeriesTeamCount.server.test.ts @@ -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)], + }); + } +} diff --git a/app/features/tournament-organization/core/SeriesTeamCount.server.ts b/app/features/tournament-organization/core/SeriesTeamCount.server.ts new file mode 100644 index 000000000..dccb26015 --- /dev/null +++ b/app/features/tournament-organization/core/SeriesTeamCount.server.ts @@ -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; + teamCounts: Array; +} + +/** + * 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>(); + 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; +} diff --git a/app/features/tournament-organization/core/tentativeTiers.server.ts b/app/features/tournament-organization/core/tentativeTiers.server.ts index 8bb0c7fe7..423118808 100644 --- a/app/features/tournament-organization/core/tentativeTiers.server.ts +++ b/app/features/tournament-organization/core/tentativeTiers.server.ts @@ -8,8 +8,7 @@ interface SeriesMatch { } async function loadCache(): Promise> { - const rows = - await TournamentOrganizationRepository.findAllSeriesWithTierHistory(); + const rows = await TournamentOrganizationRepository.findAllSeries(); const result = new Map(); for (const row of rows) { diff --git a/app/features/tournament/TournamentTeamRepository.server.ts b/app/features/tournament/TournamentTeamRepository.server.ts index a88f71fb6..b6babdb0f 100644 --- a/app/features/tournament/TournamentTeamRepository.server.ts +++ b/app/features/tournament/TournamentTeamRepository.server.ts @@ -1029,6 +1029,7 @@ export function findAllRegistrationsByUserIds({ .select((eb) => [ "TournamentTeamMember.userId", "CalendarEvent.name", + "CalendarEvent.organizationId", "CalendarEventDate.startsAt", "Tournament.settings", eb diff --git a/app/features/tournament/components/TournamentHeader.module.css b/app/features/tournament/components/TournamentHeader.module.css index 7b9372ca6..eba77b65f 100644 --- a/app/features/tournament/components/TournamentHeader.module.css +++ b/app/features/tournament/components/TournamentHeader.module.css @@ -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); diff --git a/app/features/tournament/components/TournamentHeader.tsx b/app/features/tournament/components/TournamentHeader.tsx index 19f3dbb2c..5c727ac25 100644 --- a/app/features/tournament/components/TournamentHeader.tsx +++ b/app/features/tournament/components/TournamentHeader.tsx @@ -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 }) {
{startTimes.map((date) => ( - +
+ + {estimatedEndsAt ? ( + + ~ + + + ) : null} +
))}
diff --git a/app/features/tournament/loaders/to.$id.info.server.ts b/app/features/tournament/loaders/to.$id.info.server.ts index b224d820d..0f028e7b6 100644 --- a/app/features/tournament/loaders/to.$id.info.server.ts +++ b/app/features/tournament/loaders/to.$id.info.server.ts @@ -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, + }); +} diff --git a/app/features/tournament/loaders/to.$id.register.server.ts b/app/features/tournament/loaders/to.$id.register.server.ts index c28fcc35b..0170b561d 100644 --- a/app/features/tournament/loaders/to.$id.register.server.ts +++ b/app/features/tournament/loaders/to.$id.register.server.ts @@ -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( diff --git a/app/features/tournament/routes/to.$id.info.tsx b/app/features/tournament/routes/to.$id.info.tsx index f76220154..0e604744e 100644 --- a/app/features/tournament/routes/to.$id.info.tsx +++ b/app/features/tournament/routes/to.$id.info.tsx @@ -58,7 +58,7 @@ export default function TournamentInfoPage() { return (
- +
>({ max: 5000 })); + : new LRUCache>({ max: 6000 })); export const ttl = (ms: number) => (ServerConfig.disableCache ? 0 : ms); diff --git a/e2e/pages/tournament/tournament-page.ts b/e2e/pages/tournament/tournament-page.ts index bc5272d66..45e8fd4a9 100644 --- a/e2e/pages/tournament/tournament-page.ts +++ b/e2e/pages/tournament/tournament-page.ts @@ -14,6 +14,7 @@ export class TournamentPage { this.nav = new TournamentNav(page); this.locators = { registerCta: page.getByTestId("register-cta"), + estimatedEnd: page.getByTestId("estimated-end"), }; } diff --git a/e2e/tournament.spec.ts b/e2e/tournament.spec.ts index 7dd1b0d54..4882b8bce 100644 --- a/e2e/tournament.spec.ts +++ b/e2e/tournament.spec.ts @@ -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, diff --git a/locales/en/common.json b/locales/en/common.json index 1ff781ac8..86b9250c2 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -479,4 +479,4 @@ "tier.confirmed": "{{tierName}}-tier tournament", "spoilerFree.showResults": "Show results", "spoilerFree.hideResults": "Hide results" -} \ No newline at end of file +} diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index 23462c694..badcaec60 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -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