Est team count from series history

This commit is contained in:
Kalle
2026-08-29 09:58:00 +03:00
parent f0b41b87c9
commit 038c2b7f16
20 changed files with 522 additions and 70 deletions

View File

@@ -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

View File

@@ -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) => ({

View File

@@ -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([

View 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),
})
);
}

View File

@@ -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, 815 3.1, 1631
* 3.7, 3263 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 =

View File

@@ -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,

View File

@@ -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)],
});
}
}

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -1029,6 +1029,7 @@ export function findAllRegistrationsByUserIds({
.select((eb) => [
"TournamentTeamMember.userId",
"CalendarEvent.name",
"CalendarEvent.organizationId",
"CalendarEventDate.startsAt",
"Tournament.settings",
eb

View File

@@ -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);

View File

@@ -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>

View File

@@ -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,
});
}

View File

@@ -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(

View File

@@ -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

View File

@@ -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);

View File

@@ -14,6 +14,7 @@ export class TournamentPage {
this.nav = new TournamentNav(page);
this.locators = {
registerCta: page.getByTestId("register-cta"),
estimatedEnd: page.getByTestId("estimated-end"),
};
}

View File

@@ -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,

View File

@@ -479,4 +479,4 @@
"tier.confirmed": "{{tierName}}-tier tournament",
"spoilerFree.showResults": "Show results",
"spoilerFree.hideResults": "Hide results"
}
}

View File

@@ -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