Tournaments league mode (#3411)

This commit is contained in:
Kalle
2026-09-24 17:52:01 +03:00
committed by GitHub
parent 2353a064ad
commit 263dd557fb
165 changed files with 6164 additions and 271 deletions

View File

@@ -130,7 +130,10 @@ export function FormWithConfirm({
</SendouDialog>
{children
? React.cloneElement(children, {
onClick: openDialog,
onClick: () => {
children.props.onClick?.();
openDialog();
},
type: "button",
})
: null}

View File

@@ -35,7 +35,7 @@ describe("SendouDatePicker", () => {
await screen.getByLabelText("When").fill(input);
expect(onChange).toHaveBeenLastCalledWith(expected);
expect(onChange).toHaveBeenLastCalledWith(expected, { isBadInput: false });
});
test("clearing the input reports null", async () => {
@@ -51,6 +51,6 @@ describe("SendouDatePicker", () => {
await screen.getByLabelText("When").fill("");
expect(onChange).toHaveBeenLastCalledWith(null);
expect(onChange).toHaveBeenLastCalledWith(null, { isBadInput: false });
});
});

View File

@@ -11,7 +11,8 @@ const DATETIME_INPUT_FORMAT = "yyyy-MM-dd'T'HH:mm";
interface SendouDatePickerProps {
label: string;
value: Date | null;
onChange: (value: Date | null) => void;
/** `isBadInput` = the input holds a partial or impossible date, e.g. 31.9. */
onChange: (value: Date | null, meta: { isBadInput: boolean }) => void;
granularity?: "day" | "minute";
bottomText?: string;
errorText?: string;
@@ -53,7 +54,9 @@ export function SendouDatePicker({
type={granularity === "day" ? "date" : "datetime-local"}
value={inputValue}
onChange={(event) =>
onChange(parseInputValue(event.target.value, granularity))
onChange(parseInputValue(event.target.value, granularity), {
isBadInput: event.target.validity.badInput,
})
}
onBlur={() => onBlur?.()}
disabled={isDisabled}

View File

@@ -1,4 +1,11 @@
import { BarChart3, Key, ScrollText, Tally5, Users } from "lucide-react";
import {
BarChart3,
CalendarClock,
Key,
ScrollText,
Tally5,
Users,
} from "lucide-react";
import type * as React from "react";
import { useTranslation } from "react-i18next";
import { useSearchParam } from "~/modules/search-params/hooks";
@@ -13,10 +20,13 @@ interface MatchTabsProps {
tabs: Array<MatchTabsKey>;
/** tabs showing a warning-colored alert icon */
alertTabs?: Array<MatchTabsKey>;
/** the tab opened without one in the URL; the first one otherwise */
defaultTab?: MatchTabsKey;
}
export const TAB_KEYS = {
ROSTERS: "rosters",
SCHEDULE: "schedule",
ACTION: "action",
RESULT: "result",
STATS: "stats",
@@ -25,6 +35,7 @@ export const TAB_KEYS = {
const TAB_ICONS: Record<MatchTabsKey, React.ReactNode> = {
rosters: <Users />,
schedule: <CalendarClock />,
action: <Tally5 />,
result: <ScrollText />,
stats: <BarChart3 />,
@@ -33,17 +44,26 @@ const TAB_ICONS: Record<MatchTabsKey, React.ReactNode> = {
const TAB_TRANSLATION_KEYS = {
rosters: "q:match.tabs.rosters",
schedule: "q:match.tabs.schedule",
action: "q:match.tabs.action",
result: "q:match.tabs.result",
stats: "q:match.tabs.stats",
admin: "common:pages.admin",
} as const;
export function MatchTabs({ children, tabs, alertTabs }: MatchTabsProps) {
export function MatchTabs({
children,
tabs,
alertTabs,
defaultTab,
}: MatchTabsProps) {
const { t } = useTranslation(["q", "common"]);
const [tabParam, setTab] = useSearchParam(matchPageSearchParams, "tab");
const currentTab = tabs.find((tab) => tabParam === tab) ?? tabs.at(0);
const currentTab =
tabs.find((tab) => tabParam === tab) ??
tabs.find((tab) => tab === defaultTab) ??
tabs.at(0);
invariant(currentTab);
return (

View File

@@ -8,7 +8,7 @@ import { matchPageSearchParams } from "./match-page-search-params";
describe("matchPageSearchParams", () => {
test("round-trips", () => {
assertRoundTrips(matchPageSearchParams, {
tab: [null, "rosters", "action", "result", "stats", "admin"],
tab: [null, "rosters", "schedule", "action", "result", "stats", "admin"],
});
});

View File

@@ -4,6 +4,7 @@ import { SP } from "~/modules/search-params/search-params";
const MATCH_PAGE_TABS = [
"rosters",
"schedule",
"action",
"result",
"stats",

View File

@@ -42,6 +42,46 @@ type SeededSchedule = {
const EMPTY_WEEK: WeekSchedule = [[], [], [], [], [], [], []];
/** N-ZAP's league teammates: evenings in common with one of them out on Wednesday, so the set's board shows both full and one-short windows. */
const LEAGUE_TEAMMATE_WEEKS: WeekSchedule[] = [
[
[["18:00", "23:00"]],
[["18:00", "23:00"]],
[["18:00", "23:00"]],
[["18:00", "23:00"]],
[["18:00", "23:00"]],
[["14:00", "23:00"]],
[["14:00", "22:00"]],
],
[
[["19:00", "22:00"]],
[["19:00", "23:00"]],
[],
[["18:00", "22:00"]],
[["19:00", "23:00"]],
[["12:00", "23:00"]],
[["12:00", "20:00"]],
],
[
[["17:00", "22:00"]],
[["18:00", "22:00"]],
[["18:00", "22:00"]],
[["18:00", "22:00"]],
[],
[["14:00", "22:00"]],
[["14:00", "22:00"]],
],
[
[["18:00", "22:00"]],
[["18:00", "23:00"]],
[["19:00", "22:00"]],
[["18:00", "23:00"]],
[["18:00", "23:00"]],
[["10:00", "23:00"]],
[],
],
];
const EVENINGS: WeekSchedule = [
[["18:00", "22:00"]],
[["18:00", "22:00"]],
@@ -194,6 +234,12 @@ export async function seedAvailability({
weekly: EVENINGS,
fillsNextWeek: true,
},
...tournaments.luti.nzapTeammateIds.map((userId, index) => ({
userId,
timezone: "Europe/Helsinki",
weekly: LEAGUE_TEAMMATE_WEEKS[index % LEAGUE_TEAMMATE_WEEKS.length],
fillsNextWeek: true,
})),
];
// the friends the admin could ask to sub are free when the tournament runs

View File

@@ -48,9 +48,17 @@ export async function seedMisc({
await seedNotifications(users, tournaments);
await seedUserReports(users, sendouq);
const showcaseStreamerIds = users.showcaseIds.slice(0, STREAM_COUNT);
await LiveStreamFactory.replaceAll([
{ userId: users.nzapId, twitch: "nzap_stream" },
...users.showcaseIds.slice(0, STREAM_COUNT).map((userId) => ({ userId })),
...showcaseStreamerIds.map((userId) => ({ userId })),
// the league sets live right now have a member of theirs on, unless they already are
...tournaments.luti.streamerUserIds
.filter(
(userId) =>
userId !== users.nzapId && !showcaseStreamerIds.includes(userId),
)
.map((userId) => ({ userId })),
]);
await SplatoonRotationFactory.replaceAll();
@@ -204,6 +212,14 @@ async function seedNotifications(
meta: { tournamentId, tournamentName },
pictureUrl: `${Config.staticAssetsUrl}/img/tournament-logos/pn.avif`,
},
{
type: "TO_LEAGUE_TIMES_PROPOSED",
meta: {
tournamentId: tournaments.luti.id,
matchId: tournaments.luti.nzapMatchId,
opponentTeamName: tournaments.luti.nzapOpponentTeamName,
},
},
];
for (const [i, notification] of notifications.entries()) {

View File

@@ -34,5 +34,24 @@ export async function seedOrganizations(
},
);
return [{ id: created.id, name: "sendou.ink", seriesNames: ["PICNIC"] }];
const luti = await TournamentOrganizationFactory.create(
{ name: "Leagues Under The Ink", ownerId: users.orgAdminId },
{
description: "The long-running Splatoon league, one season at a time",
series: [
{
name: "LUTI",
description: "Seasons of Leagues Under The Ink",
showLeaderboard: false,
},
],
members: [{ userId: users.adminId, role: "ADMIN" }],
isEstablished: true,
},
);
return [
{ id: created.id, name: "sendou.ink", seriesNames: ["PICNIC"] },
{ id: luti.id, name: "Leagues Under The Ink", seriesNames: ["LUTI"] },
];
}

View File

@@ -1,19 +1,27 @@
import { sub } from "date-fns";
import { addDays, addHours, addWeeks, sub, subWeeks } from "date-fns";
import type { TeamPickSettings, TournamentSettings } from "~/db/tables-json";
import * as Availability from "~/features/availability/core/Availability";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { BANNED_MAPS } from "~/features/match-profile/banned-maps";
import * as TeamPick from "~/features/tournament/core/TeamPick";
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import * as LeagueScheduling from "~/features/tournament-match/core/LeagueScheduling";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
} from "~/utils/dates";
import { faker, unique } from "../core/faker";
import * as showcaseNames from "../core/showcaseNames";
import * as ImageFactory from "../factories/ImageFactory";
import * as SavedCalendarEventFactory from "../factories/SavedCalendarEventFactory";
import * as TournamentFactory from "../factories/TournamentFactory";
import * as TournamentLFGTeamFactory from "../factories/TournamentLFGTeamFactory";
import * as TournamentMatchScheduleFactory from "../factories/TournamentMatchScheduleFactory";
import * as TournamentStaffFactory from "../factories/TournamentStaffFactory";
import * as TournamentStreamerFactory from "../factories/TournamentStreamerFactory";
import * as TournamentTeamFactory from "../factories/TournamentTeamFactory";
import type { SeededBadges } from "./badges";
@@ -49,6 +57,22 @@ const TOURNAMENT_NAME_STEMS = [
{ name: "Leagues Under The Ink", avatarFileName: "luti.png" },
];
/** The season in progress; the players' divisions come from the one before it. */
const LUTI_SEASON_IN_PROGRESS = 18;
/** Prod runs 13 divisions of 12–48 teams, the seed keeps three worth of every scheduling state. */
const LUTI_DIVISIONS = [
{ name: "Division X", teamCount: 6, tier: 1 as TournamentTierNumber },
{ name: "Division 1", teamCount: 12, tier: 3 as TournamentTierNumber },
{ name: "Division 2", teamCount: 12, tier: 6 as TournamentTierNumber },
];
const LUTI_TEAMS_PER_GROUP = 6;
/** The round being played this week; the ones before it are done, the ones after not open. */
const LUTI_CURRENT_ROUND = 3;
const LUTI_BEST_OF = 9;
const LUTI_MODE_CYCLE: ModeShort[] = ["SZ", "TC", "RM", "CB"];
const LUTI_MIN_MEMBERS = 4;
const LUTI_MAX_MEMBERS = 8;
const HISTORICAL_COUNT = 5;
/** Showcase users seeded into every played tournament, so their results paginate. */
const CORE_PLAYER_COUNT = 8;
@@ -149,6 +173,17 @@ export type SeededTournaments = {
};
/** Teams N-ZAP played on in the tournaments that were played to the end. */
nzapTeamIds: number[];
/** The league in progress, with the pieces other seeds hang their data on. */
luti: {
id: number;
name: string;
/** N-ZAP's set of the current round, the one with the other team's candidates on the board. */
nzapMatchId: number;
nzapOpponentTeamName: string;
nzapTeammateIds: number[];
/** Members streaming a set that is live right now. */
streamerUserIds: number[];
};
};
export async function seedTournaments({
@@ -165,6 +200,8 @@ export async function seedTournaments({
trophies: SeededTrophies;
}): Promise<SeededTournaments> {
const rosters = rosterBuilder(users, teams);
// editions of a series share one logo image, an image row not being allowed the url of another
const seriesLogoImgIds = new Map<string, number>();
const inTheZone = await seedInTheZone({
users,
@@ -176,6 +213,12 @@ export async function seedTournaments({
await seedPaddlingPool({ users, organizations, rosters });
await seedLowInk({ users, organizations, rosters });
await seedSwimOrSink({ users, organizations, rosters });
const luti = await seedLuti({
users,
organizations,
rosters,
seriesLogoImgIds,
});
const nzapTeamIds = await seedHistoricalTournaments({
users,
@@ -183,9 +226,10 @@ export async function seedTournaments({
badges,
rosters,
trophies,
seriesLogoImgIds,
});
return { regOpen: inTheZone, nzapTeamIds };
return { regOpen: inTheZone, nzapTeamIds, luti };
}
type Ctx = {
@@ -352,14 +396,337 @@ async function seedSwimOrSink({ users, rosters }: Ctx) {
});
}
/**
* #5 LUTI in progress, mirroring prod: an org's league of three divisions, each a round robin feeding
* playoffs, five weekly rounds on Bo9 TO map lists. Rounds 1–2 are played, round 3 is this week's with
* every scheduling state on show (N-ZAP has the other team's candidates waiting, the admin organizes
* and plays a set scheduled for tomorrow), rounds 4–5 are not open yet.
*/
async function seedLuti({
users,
organizations,
rosters,
seriesLogoImgIds,
}: Ctx & { seriesLogoImgIds: Map<string, number> }) {
const now = new Date();
const thisMonday = databaseTimestampToDate(
Availability.weekStartsAt(now, "UTC"),
);
const roundMonday = (roundNumber: number) =>
addWeeks(thisMonday, roundNumber - LUTI_CURRENT_ROUND);
const startsAt = addHours(roundMonday(1), 8);
const name = `LUTI: Season ${LUTI_SEASON_IN_PROGRESS}`;
const tournament = await TournamentFactory.create(
{
name,
authorId: users.adminId,
organizationId: organizations.find(
(organization) => organization.name === "Leagues Under The Ink",
)?.id,
avatarImgId: await seriesLogoImgId(
seriesLogoImgIds,
TOURNAMENT_NAME_STEMS[2],
users.adminId,
),
startTimes: [dateToDatabaseTimestamp(startsAt)],
regClosesAt: dateToDatabaseTimestamp(subWeeks(startsAt, 1)),
mapPickingStyle: "TO",
mapPoolMaps: toSetMapPool(),
bracketProgression: lutiProgression(),
minMembersPerTeam: LUTI_MIN_MEMBERS,
maxMembersPerTeam: LUTI_MAX_MEMBERS,
isRanked: false,
},
{
isLeague: true,
tiers: Object.fromEntries(
LUTI_DIVISIONS.map((division, index) => [index * 2, division.tier]),
),
},
);
const teamCount = LUTI_DIVISIONS.reduce(
(sum, division) => sum + division.teamCount,
0,
);
const nzapTeamIdx = LUTI_DIVISIONS[0].teamCount;
const teamRosters = rosters.take({
teamCount,
teamSize: LUTI_MAX_MEMBERS,
// the admin's team plays Division X, N-ZAP's opens Division 1
pinned: [
{ teamIdx: 0, userId: users.adminId },
{ teamIdx: nzapTeamIdx, userId: users.nzapId },
],
});
const divisionOfTeamIdx = (teamIdx: number) => {
let firstIdxOfDivision = 0;
for (const [index, division] of LUTI_DIVISIONS.entries()) {
if (teamIdx < firstIdxOfDivision + division.teamCount) return index;
firstIdxOfDivision += division.teamCount;
}
throw new Error(`No division for team ${teamIdx}`);
};
const teams: Awaited<ReturnType<typeof TournamentTeamFactory.create>>[] = [];
for (const [i, roster] of teamRosters.entries()) {
const memberUserIds = roster.memberUserIds.slice(
0,
LUTI_MIN_MEMBERS + (i % (LUTI_MAX_MEMBERS - LUTI_MIN_MEMBERS + 1)),
);
teams.push(
await TournamentTeamFactory.create(
{
tournamentId: tournament.id,
team: fakeTeamProfile(roster),
memberUserIds,
registeredAt: sub(startsAt, { days: 10 + (i % 5) }),
hasAvatar: roster.teamId === null && i % 4 === 0,
},
{ isCheckedIn: true, startingBracketIdx: divisionOfTeamIdx(i) * 2 },
),
);
}
await TournamentStaffFactory.create({
tournamentId: tournament.id,
userId: users.showcaseIds[96],
role: "STREAMER",
});
await TournamentStreamerFactory.create({
tournamentId: tournament.id,
twitchAccount: "luti_cast",
});
for (const [index] of LUTI_DIVISIONS.entries()) {
await TournamentFactory.startBracket(tournament.id, {
bracketIdx: index * 2,
maps: lutiRoundMaps,
isPlayableAt: (roundNumber) =>
LeagueScheduling.playableAtFromDate(roundMonday(roundNumber)),
});
}
const started = await tournamentFromDB(tournament.id);
const setsOf = (divisionIndex: number, roundNumber: number) => {
const bracket = started.bracketByIdx(divisionIndex * 2);
if (!bracket) throw new Error(`Division ${divisionIndex} not started`);
const roundIds = bracket.data.round
.filter((round) => round.number === roundNumber)
.map((round) => round.id);
return bracket.data.match.filter((match) =>
roundIds.includes(match.roundId),
);
};
const teamIdsOf = (match: {
opponent1: { id: number | null } | null;
opponent2: { id: number | null } | null;
}) =>
[match.opponent1?.id, match.opponent2?.id].filter(
(id): id is number => typeof id === "number",
);
const memberIdsOf = (teamId: number) =>
teams.find((team) => team.id === teamId)?.memberUserIds ?? [];
const nameOf = (teamId: number) => started.teamById(teamId)?.name ?? "";
// Division 2 has two round 2 stragglers, the rest of rounds 1–2 got played on their week
const stragglerIds = setsOf(2, 2)
.slice(0, 2)
.map((match) => match.id);
for (const roundNumber of [1, 2]) {
const played = await TournamentFactory.playMatches(tournament.id, {
roundNumbers: [roundNumber],
matchIds: LUTI_DIVISIONS.flatMap((_, divisionIndex) =>
setsOf(divisionIndex, roundNumber)
.map((match) => match.id)
.filter((id) => !stragglerIds.includes(id)),
),
});
for (const [i, match] of played.entries()) {
await TournamentFactory.backdateMatch({
matchId: match.id,
playedAt: addHours(
addDays(roundMonday(roundNumber), 1 + (i % 5)),
17 + (i % 4),
),
});
}
}
const at = (daysFromMonday: number, hour: number) =>
dateToDatabaseTimestamp(
addHours(addDays(thisMonday, daysFromMonday), hour),
);
const nowAt = dateToDatabaseTimestamp(now);
const streamerUserIds: number[] = [];
// Division X: every set scheduled, one of them live right now
const [xLive, xAdmin, xLater] = setsOf(0, LUTI_CURRENT_ROUND).toSorted(
(a, b) =>
Number(teamIdsOf(a).includes(teams[0].id)) -
Number(teamIdsOf(b).includes(teams[0].id)),
);
await TournamentMatchScheduleFactory.schedule({
matchId: xLive.id,
scheduledAt: nowAt - 10 * 60,
});
streamerUserIds.push(memberIdsOf(teamIdsOf(xLive)[0])[1]);
await TournamentMatchScheduleFactory.schedule({
matchId: xAdmin.id,
scheduledAt: dateToDatabaseTimestamp(
new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate() + 1,
19,
),
),
),
});
await TournamentMatchScheduleFactory.schedule({
matchId: xLater.id,
scheduledAt: at(5, 20),
});
// Division 1: N-ZAP's set has the other team's candidates waiting, the rest spread over every state
const nzapTeamId = teams[nzapTeamIdx].id;
const [nzapSet, ...otherSets] = setsOf(1, LUTI_CURRENT_ROUND).toSorted(
(a, b) =>
Number(teamIdsOf(b).includes(nzapTeamId)) -
Number(teamIdsOf(a).includes(nzapTeamId)),
);
const nzapOpponentId = teamIdsOf(nzapSet).find((id) => id !== nzapTeamId)!;
await TournamentMatchScheduleFactory.propose({
matchId: nzapSet.id,
tournamentTeamId: nzapOpponentId,
authorId: memberIdsOf(nzapOpponentId)[0],
proposedAts: [at(1, 20), at(3, 19)],
createdAt: sub(now, { days: 1 }),
});
const [playedSet, scheduledSet, organizerSet, liveSet, castSet] = otherSets;
if (playedSet) {
await TournamentFactory.playMatches(tournament.id, {
matchIds: [playedSet.id],
});
await TournamentFactory.backdateMatch({
matchId: playedSet.id,
playedAt: sub(now, { days: 1, hours: 2 }),
});
}
if (scheduledSet) {
await TournamentMatchScheduleFactory.schedule({
matchId: scheduledSet.id,
scheduledAt: at(4, 19),
});
}
if (organizerSet) {
await TournamentMatchScheduleFactory.schedule({
matchId: organizerSet.id,
scheduledAt: at(5, 18),
byOrganizer: true,
});
}
if (liveSet) {
await TournamentMatchScheduleFactory.schedule({
matchId: liveSet.id,
scheduledAt: nowAt - 5 * 60,
});
streamerUserIds.push(memberIdsOf(teamIdsOf(liveSet)[1])[0]);
}
if (castSet) {
await TournamentMatchScheduleFactory.schedule({
matchId: castSet.id,
scheduledAt: nowAt + 26 * 60 * 60,
});
await TournamentFactory.castMatch({
tournamentId: tournament.id,
matchId: castSet.id,
twitchAccount: "luti_cast",
});
}
// Division 2: half unscheduled and quiet, one board with both teams' candidates, the rest scheduled
const [bothProposed, scheduledA, scheduledB] = setsOf(2, LUTI_CURRENT_ROUND);
for (const [index, teamId] of teamIdsOf(bothProposed).entries()) {
await TournamentMatchScheduleFactory.propose({
matchId: bothProposed.id,
tournamentTeamId: teamId,
authorId: memberIdsOf(teamId)[0],
proposedAts: [at(2 + index, 20), at(4 + index, 19)],
createdAt: sub(now, { hours: 20 - index * 6 }),
});
}
await TournamentMatchScheduleFactory.schedule({
matchId: scheduledA.id,
scheduledAt: at(3, 20),
});
await TournamentMatchScheduleFactory.schedule({
matchId: scheduledB.id,
scheduledAt: at(6, 17),
});
return {
id: tournament.id,
name,
nzapMatchId: nzapSet.id,
nzapOpponentTeamName: nameOf(nzapOpponentId),
nzapTeammateIds: teams[nzapTeamIdx].memberUserIds.filter(
(userId) => userId !== users.nzapId,
),
streamerUserIds,
};
}
/** Every division is a round robin whose top two go on to its playoffs. */
function lutiProgression(): Progression {
return LUTI_DIVISIONS.flatMap((division, index) => [
{
type: "round_robin" as const,
name: division.name,
requiresCheckIn: false,
settings: { teamsPerGroup: LUTI_TEAMS_PER_GROUP },
},
{
type: "single_elimination" as const,
name: `${division.name} Playoffs`,
requiresCheckIn: false,
settings: {},
sources: [{ bracketIdx: index * 2, placements: [1, 2] }],
},
]);
}
/** Bo9 TO map lists cycling the modes, a different list per round. */
function lutiRoundMaps(round: { number: number }): TournamentFactory.RoundMaps {
return {
count: LUTI_BEST_OF,
type: "BEST_OF",
list: Array.from({ length: LUTI_BEST_OF }, (_, i) => {
const mode =
LUTI_MODE_CYCLE[(round.number - 1 + i) % LUTI_MODE_CYCLE.length];
const stages = legalStages(mode);
return { mode, stageId: stages[(round.number * 3 + i) % stages.length] };
}),
};
}
async function seedHistoricalTournaments({
users,
badges,
rosters,
trophies,
}: Ctx & { badges: SeededBadges; trophies: SeededTrophies }) {
seriesLogoImgIds,
}: Ctx & {
badges: SeededBadges;
trophies: SeededTrophies;
seriesLogoImgIds: Map<string, number>;
}) {
const nzapTeamIds: number[] = [];
const seriesLogoImgIds = new Map<string, number>();
for (let i = 0; i < HISTORICAL_COUNT; i++) {
const progression = faker.helpers.weightedArrayElement([

View File

@@ -1,3 +1,4 @@
import { addMinutes } from "date-fns";
import { sql } from "kysely";
import * as R from "remeda";
import { db } from "~/db/sql";
@@ -19,6 +20,7 @@ import { resolveMatchMapList } from "~/features/tournament-match/core/mapList.se
import { reportScore } from "~/features/tournament-match/core/reportScore.server";
import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server";
import { invariant } from "~/utils/invariant";
import { backdate } from "../core/backdate";
import { defineFactory } from "../core/defineFactory";
import { eventDefaults } from "./CalendarEventFactory";
import * as TournamentTeamFactory from "./TournamentTeamFactory";
@@ -44,8 +46,11 @@ const ROUND_MAPS = {
})),
} satisfies RoundMaps;
/** How long a backdated game is assumed to take. */
const MINUTES_PER_GAME = 8;
/** The maps every round of a factory-started bracket is played on. */
export type RoundMaps = Omit<Engine.RoundMapsInput, "roundId">;
export type RoundMaps = Omit<Engine.RoundMapsInput, "roundId" | "isPlayableAt">;
/** The wrapping calendar event is not the caller's to choose, so it is not an argument. */
type InsertArgs = Omit<
@@ -56,10 +61,27 @@ type InsertArgs = Omit<
type Options = {
/** Confirmed tier, as starting the first bracket computes one. */
tier?: TournamentTierNumber;
/** Confirmed tier per starting bracket, for a league whose divisions are tiered apart. */
tiers?: Record<number, TournamentTierNumber>;
/** Marks the tournament a league. Leagues have no creation UI, the flag is set straight in the db. */
isLeague?: boolean;
};
/** The maps of each round the factory starts, or one list every round shares; `isPlayableAt` is when a league round opens. */
type StartBracketArgs = {
bracketIdx?: number;
maps?: RoundMaps | ((round: { number: number }) => RoundMaps);
isPlayableAt?: (roundNumber: number) => number | null;
/** Leagues: the bracket is played in real time, its sets are not scheduled. */
isRealtime?: boolean;
};
/** Which of the playable matches to play; every one of them by default. */
type PlayMatchesFilter = {
roundNumbers?: number[];
matchIds?: number[];
};
/** Bracket idx(s) in the progression, or `"all"` = every bracket then finalize. */
type PlayedBrackets = number | number[] | "all";
@@ -80,7 +102,7 @@ export const { create } = defineFactory({
return { id: tournamentId, eventId };
},
applyOptions: async (tournament, { tier, isLeague }: Options) => {
applyOptions: async (tournament, { tier, tiers, isLeague }: Options) => {
if (isLeague) {
await db
.updateTable("Tournament")
@@ -91,13 +113,14 @@ export const { create } = defineFactory({
.execute();
}
if (!tier) return;
await TournamentRepository.upsertDivisionTier({
tournamentId: tournament.id,
bracketIdx: 0,
tier,
});
const tierByBracketIdx = { ...(tier ? { 0: tier } : {}), ...tiers };
for (const [bracketIdx, divisionTier] of Object.entries(tierByBracketIdx)) {
await TournamentRepository.upsertDivisionTier({
tournamentId: tournament.id,
bracketIdx: Number(bracketIdx),
tier: divisionTier,
});
}
},
});
@@ -181,7 +204,9 @@ export async function startBracket(
{
bracketIdx = 0,
maps = ROUND_MAPS,
}: { bracketIdx?: number; maps?: RoundMaps } = {},
isPlayableAt,
isRealtime = false,
}: StartBracketArgs = {},
) {
const tournament = await tournamentFromDB(tournamentId);
@@ -195,6 +220,8 @@ export async function startBracket(
type: bracket.type,
seeding,
settings: bracket.settings,
independentRounds: tournament.isLeague && !isRealtime,
isRealtime,
};
await BracketRepository.insertBracket({
@@ -202,7 +229,12 @@ export async function startBracket(
name: bracket.name,
bracket: Engine.create({
...createInput,
maps: roundMapsFor(Engine.create(createInput), bracket.type, maps),
maps: roundMapsFor({
bracket: Engine.create(createInput),
type: bracket.type,
maps,
isPlayableAt,
}),
}),
isLeague: tournament.isLeague,
});
@@ -251,6 +283,7 @@ interface PlayedMatch {
id: number;
/** Index of the bracket the match belongs to in the progression. */
bracketIdx: number;
roundNumber: number;
/** Number of the bracket group the match belongs to, e.g. its round robin pool. */
groupNumber: number;
winnerTeamId: number;
@@ -263,10 +296,11 @@ interface PlayedMatch {
*/
export async function playMatches(
tournamentId: number,
filter: PlayMatchesFilter = {},
): Promise<PlayedMatch[]> {
const tournament = await tournamentFromDB(tournamentId);
const played = playableMatches(tournament);
const played = playableMatches(tournament, filter);
for (const match of played) {
await setActiveRosters(tournamentId, match);
await playOutMatch(tournamentId, match);
@@ -336,7 +370,7 @@ async function generateNextSwissRound(
await BracketRepository.insertRoundMatches({
stageId,
round: round.value,
isLeague: tournament.isLeague,
hasScheduling: bracket.hasScheduling,
});
generated = true;
}
@@ -363,22 +397,33 @@ async function persistSeeds(
});
}
function roundMapsFor(
bracket: Engine.BracketData,
type: Engine.StageType,
maps: RoundMaps,
): Engine.RoundMapsInput[] {
function roundMapsFor({
bracket,
type,
maps,
isPlayableAt,
}: {
bracket: Engine.BracketData;
type: Engine.StageType;
maps: NonNullable<StartBracketArgs["maps"]>;
isPlayableAt: StartBracketArgs["isPlayableAt"];
}): Engine.RoundMapsInput[] {
// round robin and swiss share one map list per round number across their groups
const rounds =
type === "round_robin" || type === "swiss"
? R.uniqueBy(bracket.round, (round) => round.number)
: bracket.round;
return rounds.map((round) => ({ roundId: round.id, ...maps }));
return rounds.map((round) => ({
roundId: round.id,
...(typeof maps === "function" ? maps(round) : maps),
isPlayableAt: isPlayableAt?.(round.number) ?? null,
}));
}
function playableMatches(
tournament: Awaited<ReturnType<typeof tournamentFromDB>>,
filter: PlayMatchesFilter = {},
): PlayedMatch[] {
return tournament.brackets.flatMap((bracket, bracketIdx) => {
if (bracket.preview) return [];
@@ -386,15 +431,25 @@ function playableMatches(
const groupNumbers = new Map(
bracket.data.group.map((group) => [group.id, group.number]),
);
const roundNumbers = new Map(
bracket.data.round.map((round) => [round.id, round.number]),
);
return bracket.data.match
.filter((match) => bracket.matchStatus(match.id) === "STARTED")
.filter((match) => !filter.matchIds || filter.matchIds.includes(match.id))
.filter(
(match) =>
!filter.roundNumbers ||
filter.roundNumbers.includes(roundNumbers.get(match.roundId)!),
)
.flatMap((match) =>
match.opponent1?.id && match.opponent2?.id
? [
{
id: match.id,
bracketIdx,
roundNumber: roundNumbers.get(match.roundId)!,
groupNumber: groupNumbers.get(match.groupId)!,
winnerTeamId: match.opponent1.id,
loserTeamId: match.opponent2.id,
@@ -405,6 +460,24 @@ function playableMatches(
});
}
/** Moves a played match into the past: its start and every reported game, a few minutes apart. */
export async function backdateMatch({
matchId,
playedAt,
}: {
matchId: number;
playedAt: Date;
}) {
await backdate("TournamentMatch", matchId, { startedAt: playedAt });
const results = await TournamentMatchRepository.findResultsByMatchId(matchId);
for (const [index, result] of results.entries()) {
await backdate("TournamentMatchGameResult", result.id, {
createdAt: addMinutes(playedAt, (index + 1) * MINUTES_PER_GAME),
});
}
}
async function setActiveRosters(tournamentId: number, match: PlayedMatch) {
const tournament = await tournamentFromDB(tournamentId);

View File

@@ -0,0 +1,48 @@
import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server";
import { backdate } from "../core/backdate";
/** Puts a team's candidate times on a league set's board, as its member does on the match page. */
export async function propose({
matchId,
tournamentTeamId,
authorId,
proposedAts,
createdAt,
}: {
matchId: number;
tournamentTeamId: number;
authorId: number;
proposedAts: Array<number>;
/** When the candidates were put up, for a board that should look older than now. */
createdAt?: Date;
}) {
const rows = await TournamentMatchRepository.insertScheduleProposals({
matchId,
tournamentTeamId,
authorId,
proposedAts,
});
for (const row of rows) {
await backdate("TournamentMatchScheduleProposal", row.id, { createdAt });
}
return rows;
}
/** Agrees the league set's time, as a team's pick or the organizer's decision does. */
export function schedule({
matchId,
scheduledAt,
byOrganizer = false,
}: {
matchId: number;
scheduledAt: number;
byOrganizer?: boolean;
}) {
return TournamentMatchRepository.scheduleMatch({
matchId,
scheduledAt,
setByOrganizer: byOrganizer,
});
}

View File

@@ -0,0 +1,31 @@
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { defineFactory } from "../core/defineFactory";
type InsertArgs = {
tournamentId: number;
userId: number;
role: Parameters<
typeof TournamentRepository.setStaff
>[0]["staff"][number]["role"];
};
/** Adds one staff member on top of the tournament's existing staff, as the admin staff page saves them. */
export const { create } = defineFactory({
defaults: () => ({ role: "ORGANIZER" as const }),
insert: async (args: InsertArgs) => {
const tournament = await TournamentRepository.findById(args.tournamentId);
await TournamentRepository.setStaff({
tournamentId: args.tournamentId,
staff: [
...(tournament?.staff ?? []).map((staffer) => ({
userId: staffer.id,
role: staffer.role,
})),
{ userId: args.userId, role: args.role },
],
});
return { tournamentId: args.tournamentId, userId: args.userId };
},
});

View File

@@ -28,6 +28,8 @@ type Options = {
isCheckedIn?: boolean;
/** Is the team looking for more players on the tournament's LFG page? */
isLooking?: boolean;
/** The division (starting bracket) the organizer placed the team in, as the seeds page does. */
startingBracketIdx?: number;
};
/**
@@ -84,7 +86,16 @@ export const { create } = defineFactory({
return { id: team.id, ownerUserId, memberUserIds };
},
applyOptions: async (team, { isCheckedIn, isLooking }: Options) => {
applyOptions: async (
team,
{ isCheckedIn, isLooking, startingBracketIdx }: Options,
) => {
if (typeof startingBracketIdx === "number") {
await TournamentTeamRepository.updateStartingBrackets([
{ tournamentTeamId: team.id, startingBracketIdx },
]);
}
if (isCheckedIn) {
await actAs(team.ownerUserId, () =>
TournamentTeamRepository.checkIn(team.id),

View File

@@ -141,6 +141,8 @@ export interface PreparedMaps {
TournamentRoundMaps & {
roundId: number;
section: TournamentRoundSection | null;
/** Leagues: when the round's sets are playable from, so sibling divisions start with the same times. */
isPlayableAt?: number | null;
}
>;
eliminationTeamCount?: number;

View File

@@ -704,6 +704,21 @@ export interface TournamentMatch {
startedAt: number | null;
/** The side that won the set. `null` while the match has no winner. */
winnerSide: Side | null;
/** Leagues: the time the teams (or the organizer) agreed the set is played at. */
scheduledAt: number | null;
/** Leagues: the organizer set {@link TournamentMatch.scheduledAt}, closing the candidate board for the teams. */
scheduleSetByOrganizer: Generated<DBBoolean>;
}
/** Leagues: a candidate time one team put on the set's scheduling board. Only exists while open, accepting or rejecting deletes the match's proposals. */
export interface TournamentMatchScheduleProposal {
id: GeneratedAlways<number>;
matchId: number;
tournamentTeamId: number;
authorId: number;
/** The candidate time. */
proposedAt: number;
createdAt: Generated<number>;
}
/** Represents one decision, pick or ban, during tournaments pick/ban (counterpick, ban 2) phase. */
@@ -760,8 +775,8 @@ export interface TournamentRound {
/** Part of the elimination group the round belongs to. `null` in round robin and swiss. */
section: TournamentRoundSection | null;
maps: JSONColumnType<TournamentRoundMaps>;
/** Datetime the round is played by default (leagues). Null = no default play time, the round is played whenever. */
defaultPlayTime: number | null;
/** Leagues: the round's sets are playable from this time on. Null = playable whenever. */
isPlayableAt: number | null;
}
/** A stage is an intermediate phase in a tournament. In essence a bracket. */
@@ -1439,6 +1454,7 @@ export interface DB {
TournamentGroup: TournamentGroup;
TournamentLFGLike: TournamentLFGLike;
TournamentMatch: TournamentMatch;
TournamentMatchScheduleProposal: TournamentMatchScheduleProposal;
TournamentMatchPickBanEvent: TournamentMatchPickBanEvent;
TournamentMatchGameResult: TournamentMatchGameResult;
TournamentMatchGameResultParticipant: TournamentMatchGameResultParticipant;

View File

@@ -72,7 +72,8 @@
.nameBlock {
display: flex;
flex-direction: column;
min-width: 0;
flex-shrink: 0;
max-width: 50%;
}
.name {
@@ -98,8 +99,15 @@
}
.ranges {
display: flex;
flex-wrap: wrap;
column-gap: 0.25em;
min-width: 0;
color: var(--color-text-high);
font-variant-numeric: tabular-nums;
}
.range {
white-space: nowrap;
}

View File

@@ -301,7 +301,14 @@ function RangesText({ ranges }: { ranges: Array<TimeRange> }) {
const rangeText = useRangeText();
return (
<span className={styles.ranges}>{ranges.map(rangeText).join(" · ")}</span>
<span className={styles.ranges}>
{ranges.map((range, index) => (
<span key={index} className={styles.range}>
{index > 0 ? "· " : null}
{rangeText(range)}
</span>
))}
</span>
);
}

View File

@@ -3,6 +3,7 @@ import * as ScrimPostFactory from "~/db/seed/factories/ScrimPostFactory";
import * as TeamEventFactory from "~/db/seed/factories/TeamEventFactory";
import * as TeamFactory from "~/db/seed/factories/TeamFactory";
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
import * as TournamentMatchScheduleFactory from "~/db/seed/factories/TournamentMatchScheduleFactory";
import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { db } from "~/db/sql";
@@ -29,6 +30,15 @@ const WINDOW = {
endsAt: WEEK_STARTS_AT + 7 * DAY,
};
const ROUND_ROBIN: TournamentSettings["bracketProgression"] = [
{
name: "Groups",
type: "round_robin",
requiresCheckIn: false,
settings: {},
},
];
const DOUBLE_ELIMINATION: TournamentSettings["bracketProgression"] = [
{
name: "Bracket",
@@ -225,6 +235,50 @@ describe("Commitments.busyBlocksByUserIds", () => {
expect(await blocksOf(memberId())).toBeUndefined();
});
test("a league set agreed to be played blocks both rosters for an hour", async () => {
const league = await TournamentFactory.create(
{
authorId: organizerId(),
startTimes: [WEEK_STARTS_AT - 7 * DAY],
bracketProgression: ROUND_ROBIN,
minMembersPerTeam: 1,
},
{ isLeague: true },
);
for (const userId of [memberId(), opponentId()]) {
await TournamentTeamFactory.create(
{ tournamentId: league.id, memberUserIds: [userId] },
{ isCheckedIn: true },
);
}
const [match] = await TournamentFactory.startBracket(league.id);
await TournamentMatchScheduleFactory.schedule({
matchId: match.id,
scheduledAt: WEEK_STARTS_AT + 2 * DAY,
});
for (const userId of [memberId(), opponentId()]) {
expect(await blocksOf(userId)).toEqual([
{
type: "tournament",
name: expect.any(String),
startsAt: WEEK_STARTS_AT + 2 * DAY,
endsAt: WEEK_STARTS_AT + 2 * DAY + HOUR,
},
]);
}
expect(
(
await Commitments.busyBlocksByUserIds({
userIds: [memberId()],
...WINDOW,
excludeTournamentId: league.id,
})
).get(memberId()),
).toBeUndefined();
});
test("a dropped-out team's registration is not a block", async () => {
const tournament = await TournamentFactory.create({
authorId: organizerId(),

View File

@@ -1,6 +1,8 @@
import * as R from "remeda";
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import * as LeagueScheduling from "~/features/tournament-match/core/LeagueScheduling";
import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server";
import * as SeriesTeamCount from "~/features/tournament-organization/core/SeriesTeamCount.server";
import * as AvailabilityRepository from "../AvailabilityRepository.server";
import { AVAILABILITY } from "../availability-constants";
@@ -13,7 +15,8 @@ import { estimatedEndsAtWith } from "./TournamentDuration.server";
* Busy blocks of the users within the window, keyed by user id, sorted by start (effective
* availability = reported − busy). From tournament registrations (start + estimated duration,
* {@link TournamentDuration.estimateSeconds}), accepted scrims (start + assumed length) and team
* events (actual span). Leagues are not blocks, their matches are scheduled separately.
* events (actual span). A league registration is not a block, its sets are: each one agreed to be
* played blocks {@link LeagueScheduling.busyBlock}.
* `excludeTournamentId` leaves one tournament out, for "busy elsewhere" views of that tournament.
* Busy blocks are part of the schedule, so callers pass only ids
* {@link AvailabilityRepository.findScheduleVisibleUserIds} handed back.
@@ -48,6 +51,12 @@ export async function busyBlocksByUserIds({
startsAt,
endsAt,
});
const leagueSets = await TournamentMatchRepository.findScheduledByUserIds({
userIds,
startsAt:
startsAt - LeagueScheduling.LEAGUE_SCHEDULING.SET_DURATION_SECONDS,
endsAt,
});
const expectedTeamCount = await SeriesTeamCount.lookup();
const blocks: Array<BusyBlock & { userId: number }> = [
@@ -83,6 +92,14 @@ export async function busyBlocksByUserIds({
startsAt: event.startsAt,
endsAt: event.endsAt,
})),
...leagueSets
.filter((set) => set.tournamentId !== excludeTournamentId)
.map((set) => ({
userId: set.userId,
type: "tournament" as const,
name: set.name,
...LeagueScheduling.busyBlock(set.scheduledAt),
})),
].filter((block) => Availability.overlaps(block, { startsAt, endsAt }));
return new Map(

View File

@@ -7,6 +7,8 @@ import * as Commitments from "./Commitments.server";
* schedule with the viewer. Every read of other users' schedules goes through here so the
* visibility rule is applied in one place; a user left out looks like one who never filled the
* week in. `excludeTournamentId` keeps that tournament's own registrations from counting as busy.
* `bypassVisibility` reads every user's schedule: for rosters whose membership itself implies
* sharing (a league set's own tournament team).
*/
export async function findByUserIds({
userIds,
@@ -14,16 +16,19 @@ export async function findByUserIds({
startsAt,
endsAt,
excludeTournamentId,
bypassVisibility = false,
}: TimeRange & {
userIds: Array<number>;
viewerId: number;
excludeTournamentId?: number;
bypassVisibility?: boolean;
}) {
const visibleUserIds =
await AvailabilityRepository.findScheduleVisibleUserIds({
userIds,
viewerId,
});
const visibleUserIds = bypassVisibility
? userIds
: await AvailabilityRepository.findScheduleVisibleUserIds({
userIds,
viewerId,
});
const [reportedWeeks, busyByUserId] = await Promise.all([
AvailabilityRepository.findAllWeeksByUserIds({

View File

@@ -237,3 +237,34 @@ describe("findAvatarImgIds", () => {
expect(await CalendarRepository.findAvatarImgIds({})).toEqual([]);
});
});
describe("findAllBetweenTwoTimestamps", () => {
const authorId = () => users.id(1);
beforeEach(async () => {
await users.create(1);
});
test("tags only leagues with the virtual LEAGUE tag, ahead of their own tags", async () => {
await TournamentFactory.create(
{ authorId: authorId(), name: "League", tags: ["SPECIAL"] },
{ isLeague: true },
);
await TournamentFactory.create({
authorId: authorId(),
name: "Tournament",
tags: ["SPECIAL"],
});
const days = await CalendarRepository.findAllBetweenTwoTimestamps({
startTime: sub(new Date(), { hours: 1 }),
endTime: new Date(Date.now() + 60 * 60 * 1000),
});
const tagsOf = (name: string) =>
days.flatMap((day) => day.events).find((event) => event.name === name)
?.tags;
expect(tagsOf("League")).toEqual(["LEAGUE", "SPECIAL"]);
expect(tagsOf("Tournament")).toEqual(["SPECIAL"]);
});
});

View File

@@ -41,7 +41,7 @@ import {
normalizedTeamCount,
tournamentIsRanked,
} from "../tournament/tournament-utils";
import type { CalendarEvent } from "./calendar-types";
import type { CalendarEvent, CalendarEventTag } from "./calendar-types";
import { calendarEventSorter } from "./calendar-utils";
const RECENT_TOURNAMENTS_SHOWN = 10;
@@ -210,7 +210,10 @@ function findAllBetweenTwoTimestampsMapped(
}> {
const mapped: Array<CalendarEvent & { startsAt: number }> = rows.map(
(row) => {
const tags = row.tags ?? [];
// a virtual tag: leagues are told apart by their setting, not by anything the organizer picks
const tags: Array<CalendarEventTag> = row.tournamentSettings?.isLeague
? ["LEAGUE", ...(row.tags ?? [])]
: (row.tags ?? []);
const isPastEvent =
databaseTimestampToDate(row.startsAt) < sub(new Date(), { days: 1 });
@@ -544,6 +547,7 @@ type CreateArgs = Pick<
requireSendouQParticipation?: boolean;
isRanked?: boolean;
isTest?: boolean;
isLeague?: boolean;
isDraft?: boolean;
isInvitational?: boolean;
enableNoScreenToggle?: boolean;
@@ -578,6 +582,7 @@ export async function insert(args: CreateArgs) {
thirdPlaceMatch: args.thirdPlaceMatch,
isRanked: args.isRanked,
isTest: args.isTest,
isLeague: args.isLeague,
isDraft: args.isDraft,
isInvitational: args.isInvitational,
enableNoScreenToggle: args.enableNoScreenToggle,
@@ -765,6 +770,7 @@ async function updateTournamentTables(
thirdPlaceMatch: args.thirdPlaceMatch,
isRanked: args.isRanked,
isTest: existingSettings.isTest, // this one is not editable after creation
isLeague: args.isLeague,
isDraft: args.isDraft,
isInvitational: args.isInvitational,
enableNoScreenToggle: args.enableNoScreenToggle,

View File

@@ -144,6 +144,7 @@ export const action: ActionFunction = async ({ request }) => {
: undefined,
isRanked: data.isRanked,
isTest: data.isTest,
isLeague: data.isLeague,
isDraft: data.isDraft,
isInvitational: data.isInvitational,
enableNoScreenToggle: data.enableNoScreenToggle,

View File

@@ -52,6 +52,9 @@ export const tags = {
COLLEGIATE: {
color: "#FFC107",
},
LEAGUE: {
color: "#80DEEA",
},
};
export const CALENDAR_EVENT = {

View File

@@ -123,7 +123,8 @@ export const calendarNewBaseSchema = v.object({
}),
tags: checkboxGroup({
label: "labels.tags",
items: CALENDAR_EVENT.TAGS.map((tag) => ({
// derived from the league setting, never picked by hand
items: CALENDAR_EVENT.TAGS.filter((tag) => tag !== "LEAGUE").map((tag) => ({
value: tag,
label: `options.tag.${tag}` as const,
})),
@@ -188,6 +189,10 @@ export const calendarNewBaseSchema = v.object({
bottomText: "bottomTexts.invitational",
}),
isTest: toggle({ label: "labels.test", bottomText: "bottomTexts.test" }),
isLeague: toggle({
label: "labels.league",
bottomText: "bottomTexts.league",
}),
isDraft: toggle({
label: "labels.draft",
bottomText: "bottomTexts.draftInfo",

View File

@@ -3,7 +3,9 @@ import { myScheduleData } from "~/features/availability/core/MySchedule.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
import {
findUpcomingLeagueMatches,
findUpcomingTeamEvents,
leagueMatchToSidebarEvent,
scrimToSidebarEvent,
teamEventToSidebarEvent,
tournamentToSidebarEvent,
@@ -28,11 +30,14 @@ export const loader = async () => {
);
const mySchedule = await myScheduleData(user.id);
const teamEvents = await findUpcomingTeamEvents(user.id);
const leagueMatches = await findUpcomingLeagueMatches(user.id);
const myTeams = await TeamRepository.findAllMemberOfByUserId(user.id);
const registered = tournamentsData.participatingFor
.map(tournamentToSidebarEvent)
.sort((a, b) => a.startsAt - b.startsAt);
// xxx: rethink my events, maybe show all events in one list with filters instead of tabs
const registered = [
...tournamentsData.participatingFor.map(tournamentToSidebarEvent),
...leagueMatches.map(leagueMatchToSidebarEvent),
].sort((a, b) => a.startsAt - b.startsAt);
const hosting = tournamentsData.organizingFor
.map(tournamentToSidebarEvent)

View File

@@ -44,6 +44,7 @@ import {
defaultBracketsFormValues,
progressionToFormValues,
} from "../calendar-progression-form";
import type { CalendarEventTag } from "../calendar-types";
import { datesToRegClosesAt } from "../calendar-utils";
import { BracketProgressionFormFields } from "../components/BracketProgressionFormFields";
import { loader } from "../loaders/calendar.new.server";
@@ -194,7 +195,7 @@ function useDefaultValues() {
? ""
: (data.eventToEdit?.bracketUrl ?? ""),
discordInviteCode: baseEvent?.discordInviteCode ?? "",
tags: baseEvent?.tags ?? [],
tags: (baseEvent?.tags ?? []).filter(isPickableTag),
badges: baseEvent?.badgePrizes?.map((b) => b.id) ?? [],
trophyId: baseEvent?.trophy?.id ?? null,
avatarImgId: existingImage(
@@ -222,6 +223,7 @@ function useDefaultValues() {
requireInGameNames: settings?.requireInGameNames ?? false,
isInvitational: settings?.isInvitational ?? false,
isTest: settings?.isTest ?? false,
isLeague: settings?.isLeague ?? false,
isDraft: settings?.isDraft ?? false,
requireSendouQParticipation: settings?.requireSendouQParticipation ?? false,
};
@@ -314,6 +316,7 @@ function CalendarNewFields() {
<FormField name="requireInGameNames" />
<FormField name="isInvitational" />
{!isEditing ? <FormField name="isTest" /> : null}
<FormField name="isLeague" />
<DraftField />
{isAdmin ? <FormField name="requireSendouQParticipation" /> : null}
</>
@@ -440,6 +443,13 @@ function MemberCountFields() {
);
}
/** The league tag is derived from the league setting, so the picker never holds it. */
function isPickableTag(
tag: CalendarEventTag,
): tag is Exclude<CalendarEventTag, "LEAGUE"> {
return tag !== "LEAGUE";
}
function DraftField() {
const data = useLoaderData<typeof loader>();

View File

@@ -41,6 +41,7 @@ export function calendarNewFormValues(
requireInGameNames: false,
isInvitational: false,
isTest: false,
isLeague: false,
isDraft: false,
requireSendouQParticipation: false,
...overrides,

View File

@@ -268,6 +268,24 @@ export async function updateRoomExpiresAt(
.execute();
}
/** Sets rooms' expiry, e.g. to wind down league match rooms once their set is decided. */
export async function updateRoomsExpiresAt(
roomIds: Array<number | null>,
expiresAt: Date,
trx?: Transaction<DB>,
) {
const idsToUpdate = roomIds.filter((id) => id !== null);
if (idsToUpdate.length === 0) return;
const executor = trx ?? db;
await executor
.updateTable("ChatRoom")
.set({ expiresAt: dateToDatabaseTimestamp(expiresAt) })
.where("ChatRoom.id", "in", idsToUpdate)
.execute();
}
/** Marks rooms' owner activity as concluded, or active again (a reopened tournament match). */
export async function updateRoomsInactive(
roomIds: Array<number | null>,

View File

@@ -24,7 +24,11 @@ export type SystemMessageType =
| "MAP_PICKED"
| "MAP_BANNED"
| "MODE_PICKED"
| "MODE_BANNED";
| "MODE_BANNED"
| "LEAGUE_TIMES_PROPOSED"
| "LEAGUE_TIME_PICKED"
| "LEAGUE_RESCHEDULE_DECLINED"
| "LEAGUE_TIME_SET_BY_ORGANIZER";
export type PersistedSystemMessageType = Extract<
SystemMessageType,
@@ -40,6 +44,10 @@ export type PersistedSystemMessageType = Extract<
| "MAP_BANNED"
| "MODE_PICKED"
| "MODE_BANNED"
| "LEAGUE_TIMES_PROPOSED"
| "LEAGUE_TIME_PICKED"
| "LEAGUE_RESCHEDULE_DECLINED"
| "LEAGUE_TIME_SET_BY_ORGANIZER"
>;
export type UnthrottledSystemMessageType = Extract<

View File

@@ -146,6 +146,18 @@ function MessageLog({
case "MODE_BANNED": {
return t("common:chat.systemMsg.modeBanned", { name });
}
case "LEAGUE_TIMES_PROPOSED": {
return t("common:chat.systemMsg.leagueTimesProposed", { name });
}
case "LEAGUE_TIME_PICKED": {
return t("common:chat.systemMsg.leagueTimePicked", { name });
}
case "LEAGUE_RESCHEDULE_DECLINED": {
return t("common:chat.systemMsg.leagueRescheduleDeclined", { name });
}
case "LEAGUE_TIME_SET_BY_ORGANIZER": {
return t("common:chat.systemMsg.leagueTimeSetByOrganizer", { name });
}
default: {
return null;
}

View File

@@ -0,0 +1,178 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import * as LiveStreamFactory from "~/db/seed/factories/LiveStreamFactory";
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
import * as TournamentMatchScheduleFactory from "~/db/seed/factories/TournamentMatchScheduleFactory";
import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import type { TournamentSettings } from "~/db/tables-json";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server";
import {
clearAllTournamentDataCache,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import {
getLiveTournamentStreamerTwitchNames,
getLiveTournamentStreams,
getUpcomingLeagueCastStreams,
} from "./streams.server";
const users = UserFactory.pool();
const organizerId = () => users.id(1);
const streamerId = () => users.id(2);
const opponentId = () => users.id(3);
const HOUR = 60 * 60;
const DAY = 24 * HOUR;
const ROUND_ROBIN: TournamentSettings["bracketProgression"] = [
{
name: "Division 1",
type: "round_robin",
requiresCheckIn: false,
settings: {},
},
];
/** A started one-set league in the registry with `streamerId` streaming; the set is agreed for `scheduledAt`. */
async function runningLeagueSet({
scheduledAt,
castAccount,
isPlayed = false,
}: {
scheduledAt: number;
castAccount?: string;
isPlayed?: boolean;
}) {
const league = await TournamentFactory.create(
{
authorId: organizerId(),
startTimes: [dateToDatabaseTimestamp(new Date()) - 7 * DAY],
bracketProgression: ROUND_ROBIN,
minMembersPerTeam: 1,
},
{ isLeague: true, tier: 3 },
);
for (const userId of [streamerId(), opponentId()]) {
await TournamentTeamFactory.create(
{ tournamentId: league.id, memberUserIds: [userId] },
{ isCheckedIn: true },
);
}
const [match] = await TournamentFactory.startBracket(league.id);
await TournamentMatchScheduleFactory.schedule({
matchId: match.id,
scheduledAt,
});
if (castAccount) {
await TournamentFactory.castMatch({
tournamentId: league.id,
matchId: match.id,
twitchAccount: castAccount,
});
}
await LiveStreamFactory.replaceAll([
{ userId: streamerId(), twitch: "streamer_channel" },
]);
await TournamentRepository.updateCastTwitchAccounts({
tournamentId: league.id,
castTwitchAccounts: castAccount ? [castAccount] : [],
});
if (isPlayed) {
await TournamentFactory.endSets(league.id);
}
clearAllTournamentDataCache();
RunningTournaments.clear();
RunningTournaments.add(await tournamentFromDB(league.id));
return { league, match };
}
describe("getLiveTournamentStreams", () => {
beforeEach(async () => {
await users.create(3);
});
afterEach(() => {
RunningTournaments.clear();
vi.useRealTimers();
});
test("a league set inside its live window with a member streaming is one entry", async () => {
const scheduledAt = databaseTimestampNow() + 10 * 60;
const { league, match } = await runningLeagueSet({ scheduledAt });
const streams = getLiveTournamentStreams();
expect(streams).toHaveLength(1);
expect(streams[0]).toMatchObject({
id: `league-match-${match.id}`,
url: `/to/${league.id}/matches/${match.id}`,
startsAt: scheduledAt - 30 * 60,
tier: 3,
});
expect(streams[0].subtitle).toContain("Division 1");
expect(getLiveTournamentStreamerTwitchNames()).toEqual([
"streamer_channel",
]);
});
test("a league set outside its live window is not live even with a member streaming", async () => {
await runningLeagueSet({ scheduledAt: databaseTimestampNow() + 2 * HOUR });
expect(getLiveTournamentStreams()).toHaveLength(0);
expect(getLiveTournamentStreamerTwitchNames()).toHaveLength(0);
});
});
describe("getUpcomingLeagueCastStreams", () => {
beforeEach(async () => {
await users.create(3);
});
afterEach(() => {
RunningTournaments.clear();
});
test("a set marked for cast shows as upcoming at its agreed time", async () => {
const scheduledAt = databaseTimestampNow() + DAY;
const { match } = await runningLeagueSet({
scheduledAt,
castAccount: "league_cast",
});
expect(getUpcomingLeagueCastStreams()).toEqual([
expect.objectContaining({
id: `league-match-${match.id}`,
startsAt: scheduledAt,
}),
]);
});
test("a set nobody marked for cast is not upcoming", async () => {
await runningLeagueSet({ scheduledAt: databaseTimestampNow() + DAY });
expect(getUpcomingLeagueCastStreams()).toHaveLength(0);
});
test("a set played before its agreed time is not upcoming", async () => {
await runningLeagueSet({
scheduledAt: databaseTimestampNow() + DAY,
castAccount: "league_cast",
isPlayed: true,
});
expect(getUpcomingLeagueCastStreams()).toHaveLength(0);
});
test("a set further than three days away is not upcoming yet", async () => {
await runningLeagueSet({
scheduledAt: databaseTimestampNow() + 4 * DAY,
castAccount: "league_cast",
});
expect(getUpcomingLeagueCastStreams()).toHaveLength(0);
});
});

View File

@@ -1,12 +1,20 @@
import { addDays } from "date-fns";
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import type {
Tournament,
TournamentStream,
} from "~/features/tournament-bracket/core/Tournament";
import * as LeagueScheduling from "~/features/tournament-match/core/LeagueScheduling";
import { cache } from "~/utils/cache.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { tournamentStreamsPage } from "~/utils/urls";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import { tournamentMatchPage, tournamentStreamsPage } from "~/utils/urls";
export const COMBINED_STREAMS_KEY = "combined-streams";
/** Sets the organizer marked for cast show up as upcoming this many days ahead. */
const UPCOMING_LEAGUE_CAST_WINDOW_DAYS = 3;
export function clearCombinedStreamsCache() {
cache.delete(COMBINED_STREAMS_KEY);
}
@@ -26,11 +34,21 @@ export type SidebarStream = {
twitchUsername?: string;
};
/** One entry per streamed tournament, and per streamed league set as those are played on their own schedule. */
export function getLiveTournamentStreams(): SidebarStream[] {
const streams: SidebarStream[] = [];
for (const tournament of RunningTournaments.all) {
if (tournament.isLeague) continue;
if (tournament.isLeague) {
for (const set of liveLeagueSets(tournament)) {
streams.push({
...leagueSetStream(tournament, set),
startsAt: LeagueScheduling.liveWindow(set.scheduledAt).startsAt,
});
}
continue;
}
if (tournament.streams.length === 0) continue;
streams.push({
@@ -48,14 +66,41 @@ export function getLiveTournamentStreams(): SidebarStream[] {
return streams;
}
/** Lowercased Twitch usernames of all members and casters streaming a currently live tournament. */
/** League sets the organizer marked for cast, coming up within days, so they show as upcoming even with nobody live yet. */
export function getUpcomingLeagueCastStreams(): SidebarStream[] {
const now = databaseTimestampNow();
const horizon = dateToDatabaseTimestamp(
addDays(new Date(), UPCOMING_LEAGUE_CAST_WINDOW_DAYS),
);
const liveIds = new Set(
getLiveTournamentStreams().map((stream) => stream.id),
);
return RunningTournaments.all.flatMap((tournament) => {
if (!tournament.isLeague) return [];
return leagueSets(tournament).flatMap((set) => {
const stream = leagueSetStream(tournament, set);
if (liveIds.has(stream.id)) return [];
if (set.castAccount === null) return [];
if (set.hasWinner) return [];
if (set.scheduledAt < now || set.scheduledAt > horizon) return [];
return [{ ...stream, startsAt: set.scheduledAt }];
});
});
}
/** Lowercased Twitch usernames of all members and casters streaming a currently live tournament or league set. */
export function getLiveTournamentStreamerTwitchNames(): string[] {
const names: string[] = [];
for (const tournament of RunningTournaments.all) {
if (tournament.isLeague) continue;
const streams = tournament.isLeague
? liveLeagueSets(tournament).flatMap((set) => set.streams)
: tournament.streams;
for (const stream of tournament.streams) {
for (const stream of streams) {
names.push(stream.twitchUserName.toLowerCase());
}
}
@@ -63,6 +108,111 @@ export function getLiveTournamentStreamerTwitchNames(): string[] {
return names;
}
interface LeagueSet {
id: number;
bracketIdx: number;
scheduledAt: number;
hasWinner: boolean;
teamNames: [string, string];
memberUserIds: number[];
/** The Twitch account the organizer marked the set to be casted on, if any. */
castAccount: string | null;
}
/** Every set of the league with an agreed time and both teams. */
function leagueSets(tournament: Tournament): LeagueSet[] {
const castByMatchId = new Map(
[
...(tournament.ctx.castedMatchesInfo?.lockedMatches ?? []),
...(tournament.ctx.castedMatchesInfo?.castedMatches ?? []),
].map((cast) => [cast.matchId, cast.twitchAccount]),
);
return tournament.brackets.flatMap((bracket, bracketIdx) => {
if (bracket.preview) return [];
return bracket.data.match.flatMap((match) => {
const teamOne = match.opponent1?.id
? tournament.teamById(match.opponent1.id)
: null;
const teamTwo = match.opponent2?.id
? tournament.teamById(match.opponent2.id)
: null;
if (!teamOne || !teamTwo || typeof match.scheduledAt !== "number") {
return [];
}
return [
{
id: match.id,
bracketIdx,
scheduledAt: match.scheduledAt,
hasWinner: match.winnerSide !== null,
teamNames: [teamOne.name, teamTwo.name] as [string, string],
memberUserIds: [...teamOne.memberUserIds, ...teamTwo.memberUserIds],
castAccount: castByMatchId.get(match.id) ?? null,
},
];
});
});
}
/** League sets inside their live window that a member of either team, or their cast account, streams. */
function liveLeagueSets(
tournament: Tournament,
): Array<LeagueSet & { streams: TournamentStream[] }> {
const now = databaseTimestampNow();
return leagueSets(tournament).flatMap((set) => {
if (
!LeagueScheduling.isLive({
scheduledAt: set.scheduledAt,
hasWinner: set.hasWinner,
now,
})
) {
return [];
}
const streams = tournament.streams.filter(
(stream) =>
(stream.userId !== null && set.memberUserIds.includes(stream.userId)) ||
(set.castAccount !== null &&
stream.twitchUserName.toLowerCase() ===
set.castAccount.toLowerCase()),
);
if (streams.length === 0) return [];
return [{ ...set, streams }];
});
}
function leagueSetStream(
tournament: Tournament,
set: LeagueSet,
): SidebarStream {
const divisionIdx = tournament.leagueDivisionOfBracket(set.bracketIdx);
const divisionName = tournament.leagueDivisions.find(
(division) => division.idx === divisionIdx,
)?.name;
return {
id: `league-match-${set.id}`,
name: `${set.teamNames[0]} vs. ${set.teamNames[1]}`,
imageUrl: tournament.ctx.logoUrl,
url: tournamentMatchPage({
tournamentId: tournament.ctx.id,
matchId: set.id,
}),
subtitle: divisionName
? `${divisionName} · ${tournament.ctx.name}`
: tournament.ctx.name,
startsAt: set.scheduledAt,
tier: tournament.divisionTierOfBracket(set.bracketIdx),
membersPerTeam: tournament.minMembersPerTeam,
};
}
function deriveCurrentRound(tournament: Tournament): string {
for (const bracket of tournament.brackets.toReversed()) {
if (bracket.preview) continue;

View File

@@ -36,6 +36,9 @@ const NOTIFICATION_URGENCY: Record<Notification["type"], Urgency> = {
SCRIM_SCHEDULED: "high",
SCRIM_CANCELED: "high",
SCRIM_STARTING_SOON: "high",
TO_LEAGUE_TIMES_PROPOSED: "high",
TO_LEAGUE_MATCH_SCHEDULED: "high",
TO_LEAGUE_MATCH_STARTING_SOON: "high",
SCRIM_AUTO_DELETED: "normal",
COMMISSIONS_CLOSED: "normal",
FRIEND_REQUEST_RECEIVED: "normal",

View File

@@ -44,6 +44,10 @@ const RESOLUTION_TRIGGERS = {
SCRIM_SCHEDULED: "visits the scrim's page, or the scrim gets canceled",
SCRIM_CANCELED: null,
SCRIM_STARTING_SOON: "visits the scrim's page, or the scrim gets canceled",
TO_LEAGUE_TIMES_PROPOSED: "visits the set's match page",
TO_LEAGUE_MATCH_SCHEDULED: "visits the set's match page",
TO_LEAGUE_MATCH_STARTING_SOON:
"visits the set's match page, or the set gets rescheduled",
SCRIM_AUTO_DELETED: null,
COMMISSIONS_CLOSED: null,
FRIEND_REQUEST_RECEIVED:

View File

@@ -85,6 +85,18 @@ export type Notification =
"SCRIM_STARTING_SOON",
{ id: number; opponentTeamName: string }
>
| NotificationItem<
"TO_LEAGUE_TIMES_PROPOSED",
{ tournamentId: number; matchId: number; opponentTeamName: string }
>
| NotificationItem<
"TO_LEAGUE_MATCH_SCHEDULED",
{ tournamentId: number; matchId: number; opponentTeamName: string }
>
| NotificationItem<
"TO_LEAGUE_MATCH_STARTING_SOON",
{ tournamentId: number; matchId: number; opponentTeamName: string }
>
| NotificationItem<"SCRIM_AUTO_DELETED", { at: number }>
| NotificationItem<"COMMISSIONS_CLOSED", { discordId: string }>
| NotificationItem<

View File

@@ -15,6 +15,7 @@ import {
scrimsPage,
sendouQMatchPage,
teamSchedulePage,
tournamentMatchPage,
tournamentRegisterPage,
tournamentSubsPage,
tournamentTeamPage,
@@ -55,6 +56,9 @@ export const notificationNavIcon = (type: Notification["type"]) => {
case "TO_TEST_CREATED":
case "TO_LIKE_RECEIVED":
case "TO_LIKE_ACCEPTED":
case "TO_LEAGUE_TIMES_PROPOSED":
case "TO_LEAGUE_MATCH_SCHEDULED":
case "TO_LEAGUE_MATCH_STARTING_SOON":
return "medal";
case "SCRIM_NEW_REQUEST":
case "SCRIM_SCHEDULED":
@@ -143,6 +147,14 @@ export const notificationLink = (
case "TO_LIKE_ACCEPTED": {
return tournamentSubsPage(notification.meta.tournamentId);
}
case "TO_LEAGUE_TIMES_PROPOSED":
case "TO_LEAGUE_MATCH_SCHEDULED":
case "TO_LEAGUE_MATCH_STARTING_SOON": {
return tournamentMatchPage({
tournamentId: notification.meta.tournamentId,
matchId: notification.meta.matchId,
});
}
case "TEAM_EVENT_ADDED": {
return teamSchedulePage(notification.meta.teamCustomUrl);
}

View File

@@ -1,5 +1,5 @@
import { cachified } from "@epic-web/cachified";
import { addDays, addWeeks } from "date-fns";
import { addDays, addWeeks, subHours } from "date-fns";
import { href } from "react-router";
import * as R from "remeda";
import * as ExternalStreamRepository from "~/features/admin/ExternalStreamRepository.server";
@@ -12,6 +12,7 @@ import {
COMBINED_STREAMS_KEY,
getLiveTournamentStreamerTwitchNames,
getLiveTournamentStreams,
getUpcomingLeagueCastStreams,
type SidebarStream,
} from "~/features/core/streams/streams.server";
import * as FriendRepository from "~/features/friends/FriendRepository.server";
@@ -32,8 +33,12 @@ import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.serv
import { scrimsSearchParams } from "~/features/scrims/scrims-search-params";
import { getSendouQSidebarStreams } from "~/features/sendouq-streams/core/streams.server";
import { getViewerTimezone } from "~/features/timezone/timezone-context.server";
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
import {
type TournamentTierNumber,
WORST_TIER_NUMBER,
} from "~/features/tournament/core/tiering";
import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server";
import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server";
import { cache, ttl } from "~/utils/cache.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import type { CommonUser } from "~/utils/kysely.server";
@@ -42,6 +47,7 @@ import {
discordAvatarUrl,
navIconUrl,
teamSchedulePage,
tournamentMatchPage,
twitchUrl,
userPage,
} from "~/utils/urls";
@@ -55,7 +61,7 @@ export type SidebarEvent = {
/** Whose avatar the event shows instead of a logo of its own. */
user: CommonUser | null;
startsAt: number;
type: "tournament" | "scrim" | "teamEvent";
type: "tournament" | "scrim" | "teamEvent" | "leagueMatch";
scrimStatus?: "booked" | "looking" | "requestPending";
};
@@ -106,6 +112,7 @@ export async function resolveSidebarData(user: AuthenticatedUser | undefined) {
await FriendRepository.findPendingReceivedRequestIds(userId);
const streamedSendouQMatches = await resolveSendouQMatchStreams();
const teamEvents = await findUpcomingTeamEvents(userId);
const leagueMatches = await findUpcomingLeagueMatches(userId);
const scheduleNudge = await showScheduleNudge(user);
const seenTournamentIds = new Set<number>();
@@ -133,11 +140,16 @@ export async function resolveSidebarData(user: AuthenticatedUser | undefined) {
teamEventToSidebarEvent,
);
const leagueMatchEvents: SidebarEvent[] = leagueMatches.map(
leagueMatchToSidebarEvent,
);
const events = [
...tournamentEvents,
...savedEvents,
...scrimEvents,
...teamEventEvents,
...leagueMatchEvents,
]
.sort((a, b) => a.startsAt - b.startsAt)
.slice(0, MAX_EVENTS_VISIBLE);
@@ -236,6 +248,16 @@ async function combinedStreams(): Promise<SidebarStream[]> {
});
}
for (const stream of getUpcomingLeagueCastStreams()) {
ranked.push({
stream,
score: StreamRanking.upcomingTournamentTierToScore(
stream.tier ?? WORST_TIER_NUMBER,
stream.membersPerTeam,
),
});
}
for (const { sidebarStream, tier } of sendouQEntries) {
const score = tier ? StreamRanking.sendouQTierToScore(tier) : 9;
ranked.push({ stream: sidebarStream, score });
@@ -448,6 +470,43 @@ export function findUpcomingTeamEvents(userId: number) {
});
}
/** A league set already started counts as an event for an hour, its players may still be looking for the page. */
const LEAGUE_MATCH_STARTED_GRACE_HOURS = 1;
/** The user's league sets agreed to be played within two weeks, ongoing ones included. */
export function findUpcomingLeagueMatches(userId: number) {
const now = new Date();
return TournamentMatchRepository.findScheduledByUserId({
userId,
startsAt: dateToDatabaseTimestamp(
subHours(now, LEAGUE_MATCH_STARTED_GRACE_HOURS),
),
endsAt: dateToDatabaseTimestamp(addDays(now, TEAM_EVENT_WINDOW_DAYS)),
});
}
type UpcomingLeagueMatch = Awaited<
ReturnType<typeof TournamentMatchRepository.findScheduledByUserId>
>[number];
export function leagueMatchToSidebarEvent(
match: UpcomingLeagueMatch,
): SidebarEvent {
return {
id: match.id,
name: `${match.ownTeamName} vs. ${match.opponentTeamName}`,
url: tournamentMatchPage({
tournamentId: match.tournamentId,
matchId: match.id,
}),
logoUrl: match.logoUrl,
user: null,
startsAt: match.scheduledAt,
type: "leagueMatch" as const,
};
}
type UpcomingTeamEvent = Awaited<
ReturnType<typeof AvailabilityRepository.findAllUpcomingTeamEventsByUserId>
>[number];

View File

@@ -12,6 +12,7 @@ import {
import { resolveMatchMapList } from "~/features/tournament-match/core/mapList.server";
import { reportScore } from "~/features/tournament-match/core/reportScore.server";
import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server";
import { databaseTimestampNow } from "~/utils/dates";
import { invariant } from "~/utils/invariant";
import * as BracketRepository from "./BracketRepository.server";
import * as Engine from "./core/engine";
@@ -43,19 +44,24 @@ const ROUND_ROBIN: TournamentSettings["bracketProgression"] = [
const setupStartedMatch = async (
overrides?: Partial<Parameters<typeof TournamentFactory.create>[0]>,
options?: Parameters<typeof TournamentFactory.create>[1],
startBracketArgs?: Parameters<typeof TournamentFactory.startBracket>[1],
) => {
const authorId = users.id(1);
const teamAlphaUserIds = [users.id(2), users.id(3), users.id(4), users.id(5)];
const teamBravoUserIds = [users.id(6), users.id(7), users.id(8), users.id(9)];
const tournament = await TournamentFactory.create({ authorId, ...overrides });
const tournament = await TournamentFactory.create(
{ authorId, ...overrides },
options,
);
for (const memberUserIds of [teamAlphaUserIds, teamBravoUserIds]) {
await TournamentTeamFactory.create(
{ tournamentId: tournament.id, memberUserIds },
{ isCheckedIn: true },
);
}
await TournamentFactory.startBracket(tournament.id);
await TournamentFactory.startBracket(tournament.id, startBracketArgs);
const match = await db
.selectFrom("TournamentMatch")
@@ -158,6 +164,60 @@ describe("BracketRepository.applyMatchChanges", () => {
});
});
describe("BracketRepository league chat room expiry", () => {
const setupLeagueMatch = () =>
setupStartedMatch({ bracketProgression: ROUND_ROBIN }, { isLeague: true });
test("a league match's room lives two months", async () => {
const setup = await setupLeagueMatch();
expect(await roomLifespanDays(setup.chatRoomId)).toBe(60);
});
test("completing a league match cuts its room down to a week", async () => {
const setup = await setupLeagueMatch();
await playOutMatch(setup);
expect(await roomLifespanDays(setup.chatRoomId)).toBe(7);
});
test("reopening a league match restores its room's lifespan", async () => {
const setup = await setupLeagueMatch();
await playOutMatch(setup);
await executeBracketOperation({
tournamentId: setup.tournamentId,
tournament: await tournamentFromDB(setup.tournamentId),
operation: (bracketData) =>
Engine.reopenMatch(bracketData, setup.matchId),
endDroppedTeams: false,
});
expect(await roomLifespanDays(setup.chatRoomId)).toBe(60);
});
test("a real-time league bracket's room lives a week, also after reopening", async () => {
const setup = await setupStartedMatch(
{ bracketProgression: ROUND_ROBIN },
{ isLeague: true },
{ isRealtime: true },
);
expect(await roomLifespanDays(setup.chatRoomId)).toBe(7);
await playOutMatch(setup);
await executeBracketOperation({
tournamentId: setup.tournamentId,
tournament: await tournamentFromDB(setup.tournamentId),
operation: (bracketData) =>
Engine.reopenMatch(bracketData, setup.matchId),
endDroppedTeams: false,
});
expect(await roomLifespanDays(setup.chatRoomId)).toBe(7);
});
});
describe("BracketRepository.findByTournamentId", () => {
test("counts each opponent's KO wins into totalKos", async () => {
const setup = await setupStartedMatch({ bracketProgression: ROUND_ROBIN });
@@ -197,6 +257,11 @@ describe("BracketRepository.findByTournamentId", () => {
});
});
const roomLifespanDays = async (id: number) =>
Math.round(
((await roomById(id)).expiresAt - databaseTimestampNow()) / (24 * 60 * 60),
);
const roomById = (id: number) =>
db
.selectFrom("ChatRoom")

View File

@@ -19,8 +19,8 @@ import type {
} from "./core/engine/types";
const CHAT_ROOM_LIFESPAN_DAYS = 7;
// league rounds can be scheduled weeks out and all rooms are created on insertBracket
const LEAGUE_CHAT_ROOM_LIFESPAN_DAYS = 30;
// scheduled league sets can be postponed to the end of the season, so their rooms live until the set is decided
const LEAGUE_CHAT_ROOM_LIFESPAN_DAYS = 60;
/**
* Full BracketData of all stages, with score/totalKos aggregated over TournamentMatchGameResult.
@@ -80,7 +80,7 @@ export async function findByTournamentId(
"TournamentRound.section",
"TournamentRound.number",
"TournamentRound.maps",
"TournamentRound.defaultPlayTime",
"TournamentRound.isPlayableAt",
])
.where("TournamentStage.tournamentId", "=", tournamentId)
.orderBy("TournamentRound.stageId", "asc")
@@ -101,6 +101,7 @@ export async function findByTournamentId(
"TournamentMatch.roundId",
"TournamentMatch.number",
"TournamentMatch.startedAt",
"TournamentMatch.scheduledAt",
"TournamentMatch.winnerSide",
// totalKos is never persisted, it is aggregated fresh from the game results
serializedOpponentWithKos("opponentOne").as("opponent1"),
@@ -142,11 +143,12 @@ export function insertBracket(args: {
tournamentId: number;
name: string;
bracket: BracketData;
/** League rounds are all playable from the start, so their chat rooms live longer. */
/** League rounds are all playable from the start, so their chat rooms live longer unless the stage is real-time. */
isLeague: boolean;
}): Promise<{ stageId: number }> {
const stageInput = args.bracket.stage[0];
if (!stageInput) throw new Error("Bracket has no stage");
const hasScheduling = args.isLeague && !stageInput.settings.isRealtime;
return db.transaction().execute(async (trx) => {
const stage = await trx
@@ -202,6 +204,7 @@ export function insertBracket(args: {
section: round.section,
number: round.number,
maps: JSON.stringify(round.maps),
isPlayableAt: round.isPlayableAt ?? null,
})),
)
.returning(["id"])
@@ -217,7 +220,7 @@ export function insertBracket(args: {
(match) => statuses.get(match.id) === "STARTED",
);
const startedChatRoomIds = await insertMatchChatRooms(
{ count: startedMatches.length, isLeague: args.isLeague },
{ count: startedMatches.length, hasScheduling },
trx,
);
const chatRoomIdByMatchId = new Map(
@@ -258,7 +261,7 @@ export async function applyMatchChanges(
args: {
previousData: BracketData;
result: EngineResult;
/** League rounds are all playable from the start, so their chat rooms live longer. */
/** League rounds are all playable from the start, so their chat rooms live longer unless the stage is real-time. */
isLeague: boolean;
},
trx: Transaction<DB>,
@@ -284,7 +287,14 @@ export async function applyMatchChanges(
trx,
);
return syncChatRoomInactive(args.previousData, args.result.data, trx);
return syncChatRoomInactive(
{
previousData: args.previousData,
data: args.result.data,
isLeague: args.isLeague,
},
trx,
);
}
/**
@@ -298,6 +308,7 @@ async function syncStartedAt(
const { previousData, data } = args;
const previousStatuses = matchStatuses(previousData);
const statuses = matchStatuses(data);
const scheduledMatchIds = matchIdsWithScheduling(data, args.isLeague);
const wasPending = (matchId: number) =>
previousStatuses.get(matchId) === "PENDING";
@@ -331,16 +342,21 @@ async function syncStartedAt(
.where("TournamentMatch.id", "in", startedMatchIds)
.where("TournamentMatch.chatRoomId", "is", null)
.execute();
const chatRoomIds = await insertMatchChatRooms(
{ count: roomlessMatches.length, isLeague: args.isLeague },
trx,
);
for (const [i, match] of roomlessMatches.entries()) {
await trx
.updateTable("TournamentMatch")
.set({ chatRoomId: chatRoomIds[i] })
.where("TournamentMatch.id", "=", match.id)
.execute();
for (const hasScheduling of [true, false]) {
const matches = roomlessMatches.filter(
(match) => scheduledMatchIds.has(match.id) === hasScheduling,
);
const chatRoomIds = await insertMatchChatRooms(
{ count: matches.length, hasScheduling },
trx,
);
for (const [i, match] of matches.entries()) {
await trx
.updateTable("TournamentMatch")
.set({ chatRoomId: chatRoomIds[i] })
.where("TournamentMatch.id", "=", match.id)
.execute();
}
}
}
@@ -355,12 +371,16 @@ async function syncStartedAt(
/**
* Completing marks the chat room inactive, losing the winner again (reopen, undone final game) reactivates it.
* A scheduled league set's long room lifespan is cut short on completion and restored on reopen.
*
* @returns ids of the rewritten chat rooms
*/
async function syncChatRoomInactive(
previousData: BracketData,
data: BracketData,
{
previousData,
data,
isLeague,
}: { previousData: BracketData; data: BracketData; isLeague: boolean },
trx: Transaction<DB>,
): Promise<number[]> {
const previousStatuses = matchStatuses(previousData);
@@ -378,16 +398,50 @@ async function syncChatRoomInactive(
.filter((match) => wasCompleted(match.id) && !isCompleted(match.id))
.map((match) => match.id);
return [
...(await updateMatchChatRoomsInactive(completedMatchIds, true, trx)),
...(await updateMatchChatRoomsInactive(reopenedMatchIds, false, trx)),
];
const completedChatRoomIds = await updateMatchChatRoomsInactive(
completedMatchIds,
true,
trx,
);
const reopenedChatRoomIds = await updateMatchChatRoomsInactive(
reopenedMatchIds,
false,
trx,
);
const scheduledMatchIds = matchIdsWithScheduling(data, isLeague);
if (scheduledMatchIds.size > 0) {
const hasScheduling = (matchId: number) => scheduledMatchIds.has(matchId);
await ChatRepository.updateRoomsExpiresAt(
await matchChatRoomIds(completedMatchIds.filter(hasScheduling), trx),
addDays(new Date(), CHAT_ROOM_LIFESPAN_DAYS),
trx,
);
await ChatRepository.updateRoomsExpiresAt(
await matchChatRoomIds(reopenedMatchIds.filter(hasScheduling), trx),
addDays(new Date(), LEAGUE_CHAT_ROOM_LIFESPAN_DAYS),
trx,
);
}
return [...completedChatRoomIds, ...reopenedChatRoomIds];
}
async function updateMatchChatRoomsInactive(
matchIds: number[],
inactive: boolean,
trx: Transaction<DB>,
): Promise<number[]> {
const chatRoomIds = await matchChatRoomIds(matchIds, trx);
await ChatRepository.updateRoomsInactive(chatRoomIds, inactive, trx);
return chatRoomIds;
}
async function matchChatRoomIds(
matchIds: number[],
trx: Transaction<DB>,
): Promise<number[]> {
if (matchIds.length === 0) return [];
@@ -399,10 +453,24 @@ async function updateMatchChatRoomsInactive(
.$narrowType<{ chatRoomId: NotNull }>()
.execute();
const chatRoomIds = matches.map((match) => match.chatRoomId);
await ChatRepository.updateRoomsInactive(chatRoomIds, inactive, trx);
return matches.map((match) => match.chatRoomId);
}
return chatRoomIds;
/** Matches whose sets the teams schedule, i.e. a league's matches outside its real-time stages. */
function matchIdsWithScheduling(data: BracketData, isLeague: boolean) {
if (!isLeague) return new Set<number>();
const scheduledStageIds = new Set(
data.stage
.filter((stage) => !stage.settings.isRealtime)
.map((stage) => stage.id),
);
return new Set(
data.match
.filter((match) => scheduledStageIds.has(match.stageId))
.map((match) => match.id),
);
}
/** INSERTs a generated round's matches (swiss advance). */
@@ -410,8 +478,8 @@ export async function insertRoundMatches(
args: {
stageId: number;
round: GeneratedRound;
/** League rounds are all playable from the start, so their chat rooms live longer. */
isLeague: boolean;
/** The teams schedule the sets (league), so their chat rooms live longer. */
hasScheduling: boolean;
},
trx?: Transaction<DB>,
): Promise<void> {
@@ -427,7 +495,7 @@ export async function insertRoundMatches(
const playableMatches = args.round.matches.filter(hasBothOpponents);
const chatRoomIds = await insertMatchChatRooms(
{ count: playableMatches.length, isLeague: args.isLeague },
{ count: playableMatches.length, hasScheduling: args.hasScheduling },
trx,
);
const chatRoomIdByMatch = new Map(
@@ -525,7 +593,7 @@ function serializeOpponent(opponent: ParticipantResult | null): string | null {
}
function insertMatchChatRooms(
args: { count: number; isLeague: boolean },
args: { count: number; hasScheduling: boolean },
trx: Transaction<DB>,
) {
return ChatRepository.insertRooms(
@@ -533,7 +601,7 @@ function insertMatchChatRooms(
type: "TOURNAMENT_MATCH",
expiresAt: addDays(
new Date(),
args.isLeague
args.hasScheduling
? LEAGUE_CHAT_ROOM_LIFESPAN_DAYS
: CHAT_ROOM_LIFESPAN_DAYS,
),

View File

@@ -189,7 +189,13 @@ async function startedSwissFirstRound(tournamentId: number) {
};
}
/** The dialog submits its switches as checkbox values, the schema turns them into booleans. */
const checkboxValue = (isChecked: boolean) =>
(isChecked ? "on" : "off") as unknown as boolean;
const RANKED_MODE_ORDER: ModeShort[] = ["SZ", "TC", "RM"];
/** Any fixed point in time works. */
const PLAYABLE_AT = 1_800_000_000;
/** Turf War is not among the ranked modes a default team picked tournament plays. */
const MODE_ORDER_WITH_UNPLAYED_MODE: ModeShort[] = ["SZ", "TW", "TC"];
@@ -207,6 +213,7 @@ describe("Brackets action START_BRACKET", () => {
_action: "START_BRACKET",
bracketIdx: 0,
thirdPlaceMatchLinked: false,
isRealtime: false,
maps: rounds.map((round) =>
teamPickedRoundMaps(round, MODE_ORDER_WITH_UNPLAYED_MODE),
),
@@ -232,6 +239,7 @@ describe("Brackets action START_BRACKET", () => {
_action: "START_BRACKET",
bracketIdx: 0,
thirdPlaceMatchLinked: false,
isRealtime: false,
maps: rounds.map((round) =>
teamPickedRoundMaps(round, RANKED_MODE_ORDER),
),
@@ -252,6 +260,40 @@ describe("Brackets action START_BRACKET", () => {
expect(round.maps?.list).toBeFalsy();
}
});
test.each([
{ isRealtime: false, hasScheduling: true, isPlayableAt: PLAYABLE_AT },
{ isRealtime: true, hasScheduling: false, isPlayableAt: null },
])(
"starts a league bracket with isRealtime $isRealtime",
async ({ isRealtime, hasScheduling, isPlayableAt }) => {
const tournament = await createTeamPickedTournament(organizerId(), {
isLeague: true,
});
const rounds = await previewRounds(tournament.id);
await bracketsAction(
{
_action: "START_BRACKET",
bracketIdx: 0,
thirdPlaceMatchLinked: false,
isRealtime: checkboxValue(isRealtime),
maps: rounds.map((round) => ({
...teamPickedRoundMaps(round, RANKED_MODE_ORDER),
isPlayableAt: PLAYABLE_AT,
})),
},
{ user: organizerId(), params: { id: String(tournament.id) } },
);
const bracket = (await tournamentFromDB(tournament.id)).bracketByIdx(0);
invariant(bracket && !bracket.preview);
expect(bracket.hasScheduling).toBe(hasScheduling);
for (const round of bracket.data.round) {
expect(round.isPlayableAt).toBe(isPlayableAt);
}
},
);
});
describe("Brackets action PREPARE_MAPS", () => {
@@ -316,12 +358,18 @@ describe("Brackets action PREPARE_MAPS", () => {
});
/** Single elimination tournament whose teams pick their own maps, every team checked in and ready to start. */
async function createTeamPickedTournament(authorId: number) {
const tournament = await TournamentFactory.create({
authorId,
minMembersPerTeam: 1,
mapPickingStyle: "AUTO",
});
async function createTeamPickedTournament(
authorId: number,
options?: { isLeague?: boolean },
) {
const tournament = await TournamentFactory.create(
{
authorId,
minMembersPerTeam: 1,
mapPickingStyle: "AUTO",
},
options,
);
for (const userId of users.ids(TEAM_COUNT)) {
await TournamentTeamFactory.create(
{ tournamentId: tournament.id, memberUserIds: [userId] },

View File

@@ -75,12 +75,17 @@ export const action: ActionFunction = async ({ params, request }) => {
"Mode order includes a mode not played in the tournament",
);
const maps = hasThirdPlaceMatch
const isRealtime = tournament.isLeague && data.isRealtime;
const linkedMaps = hasThirdPlaceMatch
? adjustLinkedRounds({
maps: data.maps,
thirdPlaceMatchLinked: data.thirdPlaceMatchLinked,
})
: data.maps;
const maps = isRealtime
? linkedMaps.map((round) => ({ ...round, isPlayableAt: null }))
: linkedMaps;
const abDivisions =
bracket.type === "round_robin" && bracket.settings?.hasAbDivisions
@@ -103,7 +108,8 @@ export const action: ActionFunction = async ({ params, request }) => {
type: bracket.type,
seeding,
settings: bracket.settings,
independentRounds: tournament.isLeague,
independentRounds: tournament.isLeague && !isRealtime,
isRealtime,
abDivisions,
maps,
});
@@ -261,7 +267,7 @@ export const action: ActionFunction = async ({ params, request }) => {
await BracketRepository.insertRoundMatches({
stageId,
round: round.value,
isLeague: tournament.isLeague,
hasScheduling: bracket.hasScheduling,
});
emitTournamentUpdate = true;

View File

@@ -403,7 +403,7 @@ function MatchVods({ vods }: MatchVodsProps) {
function MatchTimer({ match, bracket }: Pick<MatchProps, "match" | "bracket">) {
const tournament = useTournament();
if (tournament.isLeague) return null;
if (bracket.hasScheduling) return null;
if (!match.startedAt) return null;
const isOver = Boolean(match.winnerSide);

View File

@@ -3,7 +3,7 @@ import { differenceInMinutes } from "date-fns";
import { LocaleTime } from "~/components/LocaleTime";
import type { TournamentRoundMaps } from "~/db/tables-json";
import { useTournament } from "~/features/tournament/tournament-context";
import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-utils";
import { leagueRoundPlayableAt } from "~/features/tournament/tournament-utils";
import { useAutoRerender } from "~/hooks/useAutoRerender";
import { databaseTimestampToDate } from "~/utils/dates";
import type { Unpacked } from "~/utils/types";
@@ -148,7 +148,7 @@ function useLeagueRoundStartDate(bracketIdx: number, roundId: number) {
if (!tournament.isLeague) return null;
return resolveLeagueRoundStartDate(
return leagueRoundPlayableAt(
tournament,
tournament.bracketByIdx(bracketIdx) ?? undefined,
roundId,

View File

@@ -14,6 +14,11 @@
gap: var(--s-8);
}
.playableAt {
max-width: 260px;
margin-block-start: var(--s-2);
}
.roundControls {
display: flex;
gap: var(--s-2);

View File

@@ -10,6 +10,7 @@ import {
import * as React from "react";
import { useTranslation } from "react-i18next";
import { type FetcherWithComponents, Link, useFetcher } from "react-router";
import { SendouDatePicker } from "~/components/elements/DatePicker";
import { SendouDialog } from "~/components/elements/Dialog";
import {
SendouSelect,
@@ -17,6 +18,7 @@ import {
SendouSelectItemSection,
searchContains,
} from "~/components/elements/Select";
import { SendouSwitch } from "~/components/elements/Switch";
import { ModeImage, StageImage } from "~/components/Image";
import { InfoPopover } from "~/components/InfoPopover";
import { Input } from "~/components/Input";
@@ -33,6 +35,7 @@ import type {
RoundData,
} from "~/features/tournament-bracket/core/engine/types";
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
import * as LeagueScheduling from "~/features/tournament-match/core/LeagueScheduling";
import { modesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { nullFilledArray } from "~/utils/arrays";
@@ -187,6 +190,20 @@ export function BracketMapListDialog({
countType,
});
});
// leagues: when each round's sets become playable, entered next to its maps
const [playableAts, setPlayableAts] = React.useState<
Map<number, number | null>
>(
() =>
new Map(
preparedMaps?.maps.map((map) => [
map.roundId,
map.isPlayableAt ?? null,
]) ?? [],
),
);
const [isRealtime, setIsRealtime] = React.useState(false);
const hasPlayableAts = tournament.isLeague && !isRealtime;
const [pickBanStyle, setPickBanStyle] = React.useState(
Array.from(maps.values()).find((round) => round.pickBan)?.pickBan ??
"COUNTERPICK",
@@ -349,6 +366,15 @@ export function BracketMapListDialog({
bracket.type === "double_elimination") &&
!eliminationTeamCount;
const roundMapsInput = Array.from(maps.entries()).map(([key, value]) => ({
...value,
roundId: key,
section: rounds.find((r) => r.id === key)?.section ?? null,
type: countType,
customFlow: value.pickBan === "CUSTOM" ? customFlow : undefined,
isPlayableAt: hasPlayableAts ? (playableAts.get(key) ?? null) : undefined,
}));
return (
<SendouDialog
heading={`Maplist selection (${bracket.name})`}
@@ -363,18 +389,15 @@ export function BracketMapListDialog({
name="thirdPlaceMatchLinked"
value={thirdPlaceMatchLinked ? "on" : "off"}
/>
<input
type="hidden"
name="isRealtime"
value={isRealtime ? "on" : "off"}
/>
<input
type="hidden"
name="maps"
value={JSON.stringify(
Array.from(maps.entries()).map(([key, value]) => ({
...value,
roundId: key,
section: rounds.find((r) => r.id === key)?.section ?? null,
type: countType,
customFlow: value.pickBan === "CUSTOM" ? customFlow : undefined,
})),
)}
value={JSON.stringify(roundMapsInput)}
/>
{isPreparing &&
(bracket.type === "single_elimination" ||
@@ -524,6 +547,12 @@ export function BracketMapListDialog({
onPatternsChange={setPatterns}
/>
) : null}
{tournament.isLeague && !isPreparing ? (
<RealtimeSwitch
isRealtime={isRealtime}
onChange={setIsRealtime}
/>
) : null}
</div>
{tournament.mapPool.length > 0 &&
!needsToPickEliminationTeamCount ? (
@@ -587,6 +616,17 @@ export function BracketMapListDialog({
key={round.id}
name={round.name}
maps={roundMaps}
playableAt={
hasPlayableAts
? {
value: playableAts.get(round.id) ?? null,
onChange: (value) =>
setPlayableAts(
new Map(playableAts).set(round.id, value),
),
}
: undefined
}
onHoverMap={setHoveredMap}
unlink={
showUnlinkButton
@@ -721,6 +761,13 @@ export function BracketMapListDialog({
Invalid selection: tournament progression decreases in map
count
</div>
) : !LeagueScheduling.playableAtsAreAscending(
roundMapsInput,
) ? (
<div className="mt-4 text-warning text-center">
Invalid selection: a round is playable before the round
preceding it
</div>
) : !validateCustomFlow() ? (
<div className="mt-4 text-warning text-center">
Invalid selection: custom pick/ban flow is invalid
@@ -895,6 +942,33 @@ function EliminationTeamCountSelect({
);
}
function RealtimeSwitch({
isRealtime,
onChange,
}: {
isRealtime: boolean;
onChange: (isRealtime: boolean) => void;
}) {
const { t } = useTranslation(["tournament"]);
return (
<div>
<div className="stack horizontal xs items-center">
<Label htmlFor="is-realtime">{t("tournament:mapList.realtime")}</Label>
<InfoPopover tiny className={styles.infoPopover}>
{t("tournament:mapList.realtimeInfo")}
</InfoPopover>
</div>
<SendouSwitch
id="is-realtime"
isSelected={isRealtime}
onChange={onChange}
data-testid="realtime-switch"
/>
</div>
);
}
function GlobalCountTypeSelect({
defaultValue,
onSetCountType,
@@ -975,6 +1049,7 @@ const serializedMapMode = (
function RoundMapList({
name,
maps,
playableAt,
onHoverMap,
onCountChange,
onPickBanChange,
@@ -985,6 +1060,11 @@ function RoundMapList({
}: {
name: string;
maps: Omit<TournamentRoundMaps, "type">;
/** Leagues: when the round's sets become playable. */
playableAt?: {
value: number | null;
onChange: (value: number | null) => void;
};
onHoverMap: (map: string | null) => void;
onCountChange: (count: number) => void;
onPickBanChange: (hasPickBan: boolean) => void;
@@ -993,12 +1073,31 @@ function RoundMapList({
link?: () => void;
hoveredMap: string | null;
}) {
const { t } = useTranslation(["forms"]);
const minCount = TOURNAMENT.AVAILABLE_BEST_OF[0];
const maxCount = TOURNAMENT.AVAILABLE_BEST_OF.at(-1)!;
return (
<div>
<h3>{name}</h3>
{playableAt ? (
<div className={styles.playableAt}>
<SendouDatePicker
label={t("forms:labels.roundPlayableFrom")}
granularity="day"
value={
playableAt.value !== null
? LeagueScheduling.playableDate(playableAt.value)
: null
}
onChange={(value) =>
playableAt.onChange(
value ? LeagueScheduling.playableAtFromDate(value) : null,
)
}
/>
</div>
) : null}
<div className={styles.roundControls}>
<button
type="button"

View File

@@ -123,6 +123,11 @@ export abstract class Bracket {
return this.tournament.regularCheckInHasEnded;
}
/** League bracket whose sets the teams schedule, false when it was started to be played in real time. */
get hasScheduling() {
return this.tournament.isLeague && !this.data.stage[0]?.settings.isRealtime;
}
/** Unplayed matches filled in with the expected results. Simulating is expensive so it happens on first access only. */
get simulatedData(): BracketData | undefined {
if (!this._simulatedData) {

View File

@@ -55,6 +55,8 @@ export async function tournamentData(tournamentId: number) {
participatedUsers:
await TournamentRepository.findParticipatedUserIdsById(tournamentId),
streams: await fetchTournamentStreams(tournamentId),
divisionTiers:
await TournamentRepository.findDivisionTiersByTournamentId(tournamentId),
ctx: {
...ctx,
tentativeTier,

View File

@@ -6,6 +6,7 @@ import type {
} from "~/db/tables-json";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import * as TeamPick from "~/features/tournament/core/TeamPick";
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import {
modesIncluded,
@@ -108,6 +109,8 @@ type TournamentArgs = {
participatedUsers?: number[];
/** Live streams of the tournament. Absent in the views whose loader does not ship them. */
streams?: TournamentStream[];
/** Tier of each league division (starting bracket) that has one. Absent in the views whose loader does not ship them. */
divisionTiers?: Array<{ bracketIdx: number; tier: TournamentTierNumber }>;
};
/** The progress status of a team member in a running tournament, as resolved by {@link Tournament.teamMemberOfProgressStatus}. */
@@ -862,6 +865,14 @@ export class Tournament {
return idx;
}
/** Whether the teams schedule the set, see {@link Bracket.hasScheduling}. */
matchHasScheduling(matchId: number) {
const bracketIdx = this.matchIdToBracketIdx(matchId);
if (bracketIdx === null) return false;
return this.bracketByIdx(bracketIdx)?.hasScheduling ?? false;
}
canFinalize(user: OptionalIdObject) {
// underground bracket can be skipped
const relevantBrackets = this.bracketsMeta.filter(
@@ -1000,6 +1011,17 @@ export class Tournament {
return this.ctx.settings.isLeague === true;
}
/** Tier of the division the bracket belongs to, the tournament's own tier when the division has none. */
divisionTierOfBracket(bracketIdx: number): TournamentTierNumber | null {
const divisionIdx = this.leagueDivisionOfBracket(bracketIdx);
return (
this.args.divisionTiers?.find(
(division) => division.bracketIdx === divisionIdx,
)?.tier ?? this.ctx.tier
);
}
/** Many first brackets whose progressions advance independently (so not all teams can meet). */
get isMultiStartingBracket() {
let count = 0;

View File

@@ -21,7 +21,10 @@ export function create(input: CreateBracketInput): BracketData {
const data = createResolved({
type: input.type,
seeding: input.seeding,
settings: resolveStageSettings(input),
settings: {
...resolveStageSettings(input),
...(input.isRealtime ? { isRealtime: true } : {}),
},
abDivisions: input.abDivisions,
number: input.number,
});
@@ -79,18 +82,16 @@ function attachRoundMaps(
throw new Error("Invalid map list count");
}
const mapsByRoundNumber = new Map(
mapsInput.map((input) => [
resolveRound(input.roundId).number,
toRoundMaps(input),
]),
const inputByRoundNumber = new Map(
mapsInput.map((input) => [resolveRound(input.roundId).number, input]),
);
for (const round of data.round) {
const maps = mapsByRoundNumber.get(round.number);
if (!maps)
const input = inputByRoundNumber.get(round.number);
if (!input)
throw new Error(`No maps found for round number ${round.number}`);
round.maps = { ...maps };
round.maps = toRoundMaps(input);
round.isPlayableAt = input.isPlayableAt ?? null;
}
return;
@@ -101,7 +102,9 @@ function attachRoundMaps(
}
for (const input of mapsInput) {
resolveRound(input.roundId).maps = toRoundMaps(input);
const round = resolveRound(input.roundId);
round.maps = toRoundMaps(input);
round.isPlayableAt = input.isPlayableAt ?? null;
}
for (const round of data.round) {
@@ -110,6 +113,6 @@ function attachRoundMaps(
}
function toRoundMaps(input: RoundMapsInput): TournamentRoundMaps {
const { roundId, section, ...maps } = input;
const { roundId, section, isPlayableAt, ...maps } = input;
return maps;
}

View File

@@ -50,6 +50,9 @@ export interface StageSettings {
/** Optional final between semi-final losers. */
consolationFinal?: boolean;
/** Leagues: sets are played in real time like in a regular tournament instead of the teams scheduling them. */
isRealtime?: boolean;
}
export interface ParticipantResult {
@@ -89,8 +92,8 @@ export interface RoundData {
/** Restarts from 1 per group, and in an elimination group per section. */
number: number;
maps?: TournamentRoundMaps | null;
/** Datetime the round is played by default (leagues). */
defaultPlayTime?: number | null;
/** Leagues: the round's sets are playable from this time on. */
isPlayableAt?: number | null;
}
export interface MatchResults {
@@ -108,6 +111,8 @@ export interface MatchData extends MatchResults {
roundId: number;
number: number;
startedAt?: number | null;
/** Leagues: the time the teams (or the organizer) agreed the set is played at. */
scheduledAt?: number | null;
}
/** Whole state of one tournament's brackets. Never mutated in place, every engine operation returns a new one. */
@@ -142,6 +147,8 @@ export interface CreateBracketInput {
settings: TournamentStageSettings | null;
/** (Round robin only) Whether matches are playable independently of rounds (league divisions). */
independentRounds?: boolean;
/** Leagues: sets are played in real time like in a regular tournament instead of the teams scheduling them. */
isRealtime?: boolean;
/** Parallel to seeding; required when settings.hasAbDivisions. 0 = A, 1 = B. */
abDivisions?: (0 | 1)[];
/** Stage number within the tournament. Defaults to 1 (local data; the repository assigns the real number on insert). */
@@ -157,11 +164,16 @@ export interface CreateBracketInput {
export type RoundMapsInput = TournamentRoundMaps & {
roundId: number;
section?: RoundSection | null;
/** Leagues: the round's sets are playable from this time on. */
isPlayableAt?: number | null;
};
/** {@link CreateBracketInput} with settings already resolved to internal {@link StageSettings}. */
export interface ResolvedCreateBracketInput
extends Omit<CreateBracketInput, "settings" | "independentRounds"> {
extends Omit<
CreateBracketInput,
"settings" | "independentRounds" | "isRealtime"
> {
settings: StageSettings;
}

View File

@@ -3,6 +3,7 @@ import type { TournamentData } from "../Tournament.server";
/** Low Ink with groups (swiss) finished but none of the follow-up brackets started */
export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
streams: [],
divisionTiers: [],
data: {
stage: [
{

View File

@@ -5,6 +5,7 @@ export const SWIM_OR_SINK_167 = (
overrides?: TournamentData["ctx"]["bracketProgressionOverrides"],
): TournamentData => ({
streams: [],
divisionTiers: [],
data: {
stage: [
{

View File

@@ -3,6 +3,7 @@ import type { TournamentData } from "../Tournament.server";
/** Zones Weekly 38 with every round of swiss finished, last round's matches not generated */
export const ZONES_WEEKLY_38 = (): TournamentData => ({
streams: [],
divisionTiers: [],
data: {
stage: [
{

View File

@@ -3,6 +3,7 @@ import type { TournamentData } from "../Tournament.server";
export const PADDLING_POOL_257 = () =>
({
streams: [],
divisionTiers: [],
data: {
stage: [
{
@@ -2551,6 +2552,7 @@ export const PADDLING_POOL_257 = () =>
export const PADDLING_POOL_255 = () =>
({
streams: [],
divisionTiers: [],
data: {
stage: [
{
@@ -5144,6 +5146,7 @@ export const IN_THE_ZONE_32 = ({
}) =>
({
streams: [],
divisionTiers: [],
data: {
stage: [
{

View File

@@ -29,7 +29,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const hasDivisionToShow =
divisionIdx !== null &&
tournament.visibleBracketsMetaOfDivision(divisionIdx).length > 0;
if (tournament.isLeague && !hasDivisionToShow) {
if (tournament.leagueDivisions.length > 1 && !hasDivisionToShow) {
throw redirect(tournamentDivisionsPage(tournament.ctx.id));
}

View File

@@ -21,12 +21,7 @@
}
.participant {
outline: 3px solid var(--color-bg-higher);
outline-offset: 3px;
& svg {
fill: var(--color-fg-accent);
}
border-color: var(--color-fg-accent);
}
.participantCounts {

View File

@@ -16,7 +16,8 @@ export default function TournamentDivisionsPage() {
const ownTeam = tournament.teamMemberOfByUser(user);
const ownDivisionIdx = ownTeam ? (ownTeam.startingBracketIdx ?? 0) : null;
if (!tournament.isLeague) {
// a single division has nothing to choose between, its brackets page is the one
if (tournament.leagueDivisions.length <= 1) {
return (
<Redirect
to={tournamentBracketsPage({ tournamentId: tournament.ctx.id })}

View File

@@ -7,6 +7,8 @@ import {
ACTION_TYPES,
WHO_SIDES,
} from "~/features/tournament-bracket/tournament-bracket-constants";
import * as LeagueScheduling from "~/features/tournament-match/core/LeagueScheduling";
import { leagueScheduleSchemas } from "~/features/tournament-match/tournament-match-schemas";
import {
_action,
checkboxValueToBoolean,
@@ -95,6 +97,7 @@ export const matchSchema = v.union([
}),
reportWeaponSchema,
undoWeaponReportSchema,
...leagueScheduleSchemas,
]);
export const bracketIdx = v.pipe(
@@ -136,7 +139,18 @@ const tournamentRoundMaps = v.object({
type: v.picklist(["BEST_OF", "PLAY_ALL"]),
pickBan: v.nullish(v.picklist(PickBan.types)),
customFlow: customPickBanFlow,
isPlayableAt: v.nullish(v.pipe(v.number(), v.integer(), v.minValue(0))),
});
const tournamentRoundMapsList = preprocess(
safeJSONParse,
v.pipe(
v.array(tournamentRoundMaps),
v.check(
(maps) => LeagueScheduling.playableAtsAreAscending(maps),
"A round can't be playable before the round preceding it",
),
),
);
export const bracketSchema = v.union([
v.object({
_action: _action("START_BRACKET"),
@@ -145,12 +159,16 @@ export const bracketSchema = v.union([
preprocess(checkboxValueToBoolean, v.boolean()),
false,
),
maps: preprocess(safeJSONParse, v.array(tournamentRoundMaps)),
isRealtime: v.optional(
preprocess(checkboxValueToBoolean, v.boolean()),
false,
),
maps: tournamentRoundMapsList,
}),
v.object({
_action: _action("PREPARE_MAPS"),
bracketIdx,
maps: preprocess(safeJSONParse, v.array(tournamentRoundMaps)),
maps: tournamentRoundMapsList,
thirdPlaceMatchLinked: v.optional(
preprocess(checkboxValueToBoolean, v.boolean()),
false,

View File

@@ -1,7 +1,10 @@
import { beforeEach, describe, expect, test } from "vitest";
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
import * as TournamentMatchScheduleFactory from "~/db/seed/factories/TournamentMatchScheduleFactory";
import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import type { TournamentSettings } from "~/db/tables-json";
import { databaseTimestampNow } from "~/utils/dates";
import * as TournamentMatchRepository from "./TournamentMatchRepository.server";
const TEAMS_PER_POOL = 2;
@@ -85,3 +88,211 @@ describe("findByTournamentTeamId", () => {
expect(winnerSet.teamSide).not.toBe(loserSet.teamSide);
});
});
const ROUND_ROBIN: TournamentSettings["bracketProgression"] = [
{
name: "Groups",
type: "round_robin",
requiresCheckIn: false,
settings: {},
},
];
const HOUR = 60 * 60;
/** A started one-set league of two single-member teams. */
async function leagueSet() {
const league = await TournamentFactory.create(
{
authorId: users.id(1),
bracketProgression: ROUND_ROBIN,
minMembersPerTeam: 1,
},
{ isLeague: true },
);
const teams: Awaited<ReturnType<typeof TournamentTeamFactory.create>>[] = [];
for (const userId of [users.id(1), users.id(2)]) {
teams.push(
await TournamentTeamFactory.create(
{ tournamentId: league.id, memberUserIds: [userId] },
{ isCheckedIn: true },
),
);
}
const [match] = await TournamentFactory.startBracket(league.id);
return { league, match, teams };
}
describe("scheduleMatch", () => {
beforeEach(async () => {
await users.create(2);
});
test("agrees the time and clears both teams' candidates", async () => {
const { match, teams } = await leagueSet();
const at = databaseTimestampNow() + HOUR;
for (const [index, team] of teams.entries()) {
await TournamentMatchScheduleFactory.propose({
matchId: match.id,
tournamentTeamId: team.id,
authorId: users.id(index + 1),
proposedAts: [at, at + HOUR],
});
}
await TournamentMatchRepository.scheduleMatch({
matchId: match.id,
scheduledAt: at,
setByOrganizer: false,
});
const updated = await TournamentMatchRepository.findMatchById(match.id);
expect(updated?.scheduledAt).toBe(at);
expect(updated?.scheduleSetByOrganizer).toBe(0);
expect(
await TournamentMatchRepository.findScheduleProposalsByMatchId(match.id),
).toHaveLength(0);
});
test("the organizer's time closes the board", async () => {
const { match } = await leagueSet();
const at = databaseTimestampNow() + HOUR;
await TournamentMatchRepository.scheduleMatch({
matchId: match.id,
scheduledAt: at,
setByOrganizer: true,
});
expect(
(await TournamentMatchRepository.findMatchById(match.id))
?.scheduleSetByOrganizer,
).toBe(1);
});
});
describe("replaceScheduleProposals", () => {
beforeEach(async () => {
await users.create(2);
});
test("adds missing times, keeps listed ones and takes the rest off", async () => {
const { match, teams } = await leagueSet();
const at = databaseTimestampNow() + HOUR;
await TournamentMatchScheduleFactory.propose({
matchId: match.id,
tournamentTeamId: teams[0].id,
authorId: users.id(1),
proposedAts: [at, at + HOUR],
});
const added = await TournamentMatchRepository.replaceScheduleProposals({
matchId: match.id,
tournamentTeamId: teams[0].id,
authorId: users.id(1),
proposedAts: [at, at + 2 * HOUR],
});
expect(added).toHaveLength(1);
const proposals =
await TournamentMatchRepository.findScheduleProposalsByMatchId(match.id);
expect(proposals.map((proposal) => proposal.proposedAt)).toEqual([
at,
at + 2 * HOUR,
]);
});
test("an empty list takes only that team's candidates off", async () => {
const { match, teams } = await leagueSet();
const at = databaseTimestampNow() + HOUR;
for (const [index, team] of teams.entries()) {
await TournamentMatchScheduleFactory.propose({
matchId: match.id,
tournamentTeamId: team.id,
authorId: users.id(index + 1),
proposedAts: [at + index * HOUR],
});
}
await TournamentMatchRepository.replaceScheduleProposals({
matchId: match.id,
tournamentTeamId: teams[0].id,
authorId: users.id(1),
proposedAts: [],
});
const proposals =
await TournamentMatchRepository.findScheduleProposalsByMatchId(match.id);
expect(proposals.map((proposal) => proposal.tournamentTeamId)).toEqual([
teams[1].id,
]);
});
});
describe("deleteScheduleProposalsByTeam", () => {
beforeEach(async () => {
await users.create(2);
});
test("declining a reschedule takes only the requesting team's candidates off", async () => {
const { match, teams } = await leagueSet();
const at = databaseTimestampNow() + HOUR;
for (const [index, team] of teams.entries()) {
await TournamentMatchScheduleFactory.propose({
matchId: match.id,
tournamentTeamId: team.id,
authorId: users.id(index + 1),
proposedAts: [at + index * HOUR],
});
}
const deletedCount =
await TournamentMatchRepository.deleteScheduleProposalsByTeam({
matchId: match.id,
tournamentTeamId: teams[1].id,
});
expect(deletedCount).toBe(1);
const proposals =
await TournamentMatchRepository.findScheduleProposalsByMatchId(match.id);
expect(proposals.map((proposal) => proposal.tournamentTeamId)).toEqual([
teams[0].id,
]);
expect(proposals[0].author.id).toBe(users.id(1));
});
});
describe("findScheduledBetween", () => {
beforeEach(async () => {
await users.create(2);
});
test("lists undecided sets agreed inside the window with both rosters", async () => {
const { match, teams } = await leagueSet();
const now = databaseTimestampNow();
await TournamentMatchScheduleFactory.schedule({
matchId: match.id,
scheduledAt: now + HOUR / 2,
});
const inWindow = await TournamentMatchRepository.findScheduledBetween({
startsAt: now,
endsAt: now + HOUR,
});
expect(inWindow).toHaveLength(1);
const ascending = (a: number, b: number) => a - b;
expect(
inWindow[0].members.map((member) => member.userId).toSorted(ascending),
).toEqual([users.id(1), users.id(2)].toSorted(ascending));
expect(
[inWindow[0].teamOneId, inWindow[0].teamTwoId].toSorted(ascending),
).toEqual(teams.map((team) => team.id).toSorted(ascending));
expect(
await TournamentMatchRepository.findScheduledBetween({
startsAt: now + HOUR,
endsAt: now + 2 * HOUR,
}),
).toHaveLength(0);
});
});

View File

@@ -6,6 +6,7 @@ import type { Side } from "~/features/tournament-bracket/core/engine/types";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { invariant } from "~/utils/invariant";
import {
commonUserJsonObject,
commonUserSelect,
jsonArrayFrom,
tournamentLogoWithDefault,
@@ -75,9 +76,12 @@ export async function findMatchById(id: number) {
"TournamentMatch.winnerSide",
"TournamentMatch.chatRoomId",
"TournamentMatch.startedAt",
"TournamentMatch.scheduledAt",
"TournamentMatch.scheduleSetByOrganizer",
"Tournament.mapPickingStyle",
"TournamentRound.id as roundId",
"TournamentRound.maps as roundMaps",
"TournamentRound.isPlayableAt as roundIsPlayableAt",
"Tournament.id as tournamentId",
jsonArrayFrom(
eb
@@ -593,3 +597,337 @@ export function findByTournamentTeamId(tournamentTeamId: number) {
.orderBy("TournamentRound.number", "asc")
.execute();
}
/** Open candidate times of the set's scheduling board, earliest first, with who put them up. */
export function findScheduleProposalsByMatchId(matchId: number) {
return db
.selectFrom("TournamentMatchScheduleProposal")
.innerJoin("User", "User.id", "TournamentMatchScheduleProposal.authorId")
.select((eb) => [
"TournamentMatchScheduleProposal.id",
"TournamentMatchScheduleProposal.tournamentTeamId",
"TournamentMatchScheduleProposal.proposedAt",
"TournamentMatchScheduleProposal.createdAt",
commonUserJsonObject(eb).as("author"),
])
.where("TournamentMatchScheduleProposal.matchId", "=", matchId)
.orderBy("TournamentMatchScheduleProposal.proposedAt", "asc")
.execute();
}
/** Per match of the tournament, when its last game was reported. */
export function findLastResultAtsByTournamentId(tournamentId: number) {
return db
.selectFrom("TournamentMatch")
.innerJoin(
"TournamentStage",
"TournamentStage.id",
"TournamentMatch.stageId",
)
.select((eb) => [
"TournamentMatch.id",
eb
.selectFrom("TournamentMatchGameResult")
.select(({ fn }) =>
fn.max("TournamentMatchGameResult.createdAt").as("lastResultAt"),
)
.whereRef(
"TournamentMatchGameResult.matchId",
"=",
"TournamentMatch.id",
)
.as("lastResultAt"),
])
.where("TournamentStage.tournamentId", "=", tournamentId)
.execute();
}
/** Undecided league sets of the users' teams agreed to be played inside the window, one row per member; the blocks their schedules show. */
export function findScheduledByUserIds({
userIds,
startsAt,
endsAt,
}: {
userIds: Array<number>;
startsAt: number;
endsAt: number;
}) {
if (userIds.length === 0) return Promise.resolve([]);
return scheduledMatchesQuery()
.innerJoin(
"TournamentTeamMember",
"TournamentTeamMember.tournamentTeamId",
"TournamentTeam.id",
)
.select(["TournamentTeamMember.userId", "CalendarEvent.name"])
.where("TournamentTeamMember.userId", "in", userIds)
.where("TournamentMatch.scheduledAt", ">=", startsAt)
.where("TournamentMatch.scheduledAt", "<", endsAt)
.execute();
}
/** Undecided league sets of the user's teams agreed to be played inside the window, for the sidebar's events. */
export function findScheduledByUserId({
userId,
startsAt,
endsAt,
}: {
userId: number;
startsAt: number;
endsAt: number;
}) {
return scheduledMatchesQuery()
.innerJoin(
"TournamentTeamMember",
"TournamentTeamMember.tournamentTeamId",
"TournamentTeam.id",
)
.innerJoin("TournamentTeam as Opponent", (join) =>
join.on((eb) =>
eb.or([
eb.and([
eb(opponentOneId, "!=", eb.ref("TournamentTeam.id")),
eb(opponentOneId, "=", eb.ref("Opponent.id")),
]),
eb.and([
eb(opponentTwoId, "!=", eb.ref("TournamentTeam.id")),
eb(opponentTwoId, "=", eb.ref("Opponent.id")),
]),
]),
),
)
.select((eb) => [
"CalendarEvent.name as tournamentName",
tournamentLogoWithDefault(eb).as("logoUrl"),
"TournamentTeam.name as ownTeamName",
"Opponent.name as opponentTeamName",
])
.where("TournamentTeamMember.userId", "=", userId)
.where("TournamentMatch.scheduledAt", ">=", startsAt)
.where("TournamentMatch.scheduledAt", "<", endsAt)
.orderBy("TournamentMatch.scheduledAt", "asc")
.execute();
}
/** Undecided league sets agreed to be played inside the window, with both rosters. */
export function findScheduledBetween({
startsAt,
endsAt,
}: {
startsAt: number;
endsAt: number;
}) {
return db
.selectFrom("TournamentMatch")
.innerJoin(
"TournamentStage",
"TournamentStage.id",
"TournamentMatch.stageId",
)
.innerJoin("TournamentTeam as TeamOne", (join) =>
join.on(opponentOneId, "=", sql.ref("TeamOne.id")),
)
.innerJoin("TournamentTeam as TeamTwo", (join) =>
join.on(opponentTwoId, "=", sql.ref("TeamTwo.id")),
)
.select((eb) => [
"TournamentMatch.id",
"TournamentMatch.scheduledAt",
"TournamentStage.tournamentId",
"TeamOne.id as teamOneId",
"TeamOne.name as teamOneName",
"TeamTwo.id as teamTwoId",
"TeamTwo.name as teamTwoName",
jsonArrayFrom(
eb
.selectFrom("TournamentTeamMember")
.select([
"TournamentTeamMember.userId",
"TournamentTeamMember.tournamentTeamId",
])
.where((innerEb) =>
innerEb.or([
innerEb(
"TournamentTeamMember.tournamentTeamId",
"=",
innerEb.ref("TeamOne.id"),
),
innerEb(
"TournamentTeamMember.tournamentTeamId",
"=",
innerEb.ref("TeamTwo.id"),
),
]),
),
).as("members"),
])
.where("TournamentMatch.winnerSide", "is", null)
.where("TournamentMatch.scheduledAt", ">=", startsAt)
.where("TournamentMatch.scheduledAt", "<", endsAt)
.$narrowType<{ scheduledAt: NotNull }>()
.execute();
}
/** Puts the candidate times on the set's board; ones the team already has there are skipped. */
export function insertScheduleProposals({
matchId,
tournamentTeamId,
authorId,
proposedAts,
}: {
matchId: number;
tournamentTeamId: number;
authorId: number;
proposedAts: Array<number>;
}) {
return db
.insertInto("TournamentMatchScheduleProposal")
.values(
proposedAts.map((proposedAt) => ({
matchId,
tournamentTeamId,
authorId,
proposedAt,
})),
)
.onConflict((oc) => oc.doNothing())
.returning("id")
.execute();
}
/** Makes `proposedAts` the team's candidates on the set's board: missing ones are added, ones not listed are taken off. Returns the added rows. */
export function replaceScheduleProposals({
matchId,
tournamentTeamId,
authorId,
proposedAts,
}: {
matchId: number;
tournamentTeamId: number;
authorId: number;
proposedAts: Array<number>;
}) {
return db.transaction().execute(async (trx) => {
await trx
.deleteFrom("TournamentMatchScheduleProposal")
.where("TournamentMatchScheduleProposal.matchId", "=", matchId)
.where(
"TournamentMatchScheduleProposal.tournamentTeamId",
"=",
tournamentTeamId,
)
.$if(proposedAts.length > 0, (qb) =>
qb.where(
"TournamentMatchScheduleProposal.proposedAt",
"not in",
proposedAts,
),
)
.execute();
if (proposedAts.length === 0) return [];
return trx
.insertInto("TournamentMatchScheduleProposal")
.values(
proposedAts.map((proposedAt) => ({
matchId,
tournamentTeamId,
authorId,
proposedAt,
})),
)
.onConflict((oc) => oc.doNothing())
.returning("id")
.execute();
});
}
export function findScheduleProposalById(id: number) {
return db
.selectFrom("TournamentMatchScheduleProposal")
.selectAll()
.where("TournamentMatchScheduleProposal.id", "=", id)
.executeTakeFirst();
}
/** Takes one team's candidates off the board, e.g. when the other team declines a reschedule. Returns the count deleted. */
export async function deleteScheduleProposalsByTeam({
matchId,
tournamentTeamId,
}: {
matchId: number;
tournamentTeamId: number;
}) {
const result = await db
.deleteFrom("TournamentMatchScheduleProposal")
.where("TournamentMatchScheduleProposal.matchId", "=", matchId)
.where(
"TournamentMatchScheduleProposal.tournamentTeamId",
"=",
tournamentTeamId,
)
.executeTakeFirst();
return Number(result.numDeletedRows);
}
/** Agrees the set's time, clearing the board; `setByOrganizer` closes the board for the teams. */
export function scheduleMatch({
matchId,
scheduledAt,
setByOrganizer,
}: {
matchId: number;
scheduledAt: number;
setByOrganizer: boolean;
}) {
return db.transaction().execute(async (trx) => {
await trx
.updateTable("TournamentMatch")
.set({
scheduledAt,
scheduleSetByOrganizer: toDBBoolean(setByOrganizer),
})
.where("TournamentMatch.id", "=", matchId)
.execute();
await trx
.deleteFrom("TournamentMatchScheduleProposal")
.where("TournamentMatchScheduleProposal.matchId", "=", matchId)
.execute();
});
}
/** Undecided matches with an agreed time and one of their teams joined as `TournamentTeam`. */
function scheduledMatchesQuery() {
return db
.selectFrom("TournamentMatch")
.innerJoin(
"TournamentStage",
"TournamentStage.id",
"TournamentMatch.stageId",
)
.innerJoin(
"CalendarEvent",
"CalendarEvent.tournamentId",
"TournamentStage.tournamentId",
)
.innerJoin("TournamentTeam", (join) =>
join.on((eb) =>
eb.or([
eb(opponentOneId, "=", eb.ref("TournamentTeam.id")),
eb(opponentTwoId, "=", eb.ref("TournamentTeam.id")),
]),
),
)
.select([
"TournamentMatch.id",
"TournamentMatch.scheduledAt",
"TournamentStage.tournamentId",
"TournamentTeam.id as tournamentTeamId",
])
.where("TournamentMatch.winnerSide", "is", null)
.$narrowType<{ scheduledAt: NotNull }>();
}

View File

@@ -1,7 +1,10 @@
import type { ActionFunction } from "react-router";
import * as R from "remeda";
import { db } from "~/db/sql";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import type { PersistedSystemMessageType } from "~/features/chat/chat-types";
import { notify } from "~/features/notifications/core/notify.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
@@ -25,7 +28,7 @@ import {
tournamentChannel,
} from "~/features/tournament-bracket/tournament-bracket-utils";
import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import { invariant } from "~/utils/invariant";
import { logger } from "~/utils/logger";
import {
@@ -38,6 +41,7 @@ import { noDuplicates } from "~/utils/schema";
import { errorIsSqliteUniqueConstraintFailure } from "~/utils/sql";
import { assertUnreachable } from "~/utils/types";
import { executeRoll } from "../core/executeRoll.server";
import * as LeagueScheduling from "../core/LeagueScheduling";
import { resolveMatchMapList } from "../core/mapList.server";
import { reportScore } from "../core/reportScore.server";
import type { FindMatchById } from "../TournamentMatchRepository.server";
@@ -672,6 +676,160 @@ export const action: ActionFunction = async ({ params, request }) => {
break;
}
case "PROPOSE_TIMES": {
const team = leagueTeamOfUser(tournament, match, user.id);
errorToastIfFalsy(team, "Not a member of either team");
const schedule = leagueSchedule(tournament, match);
const proposals =
await TournamentMatchRepository.findScheduleProposalsByMatchId(
match.id,
);
const proposedAts = R.unique(data.times.map(dateToDatabaseTimestamp));
const error = LeagueScheduling.validateProposals({
proposedAts,
existingProposedAts: proposals
.filter((proposal) => proposal.tournamentTeamId === team.id)
.map((proposal) => proposal.proposedAt),
phase: schedule.phase,
isPlayableAt: match.roundIsPlayableAt,
now: schedule.now,
setByOrganizer: Boolean(match.scheduleSetByOrganizer),
});
errorToastIfFalsy(!error, PROPOSAL_ERROR_MESSAGES[error ?? "NOT_OPEN"]);
const added = await TournamentMatchRepository.replaceScheduleProposals({
matchId: match.id,
tournamentTeamId: team.id,
authorId: user.id,
proposedAts,
});
emitMatchUpdate = true;
emitTournamentUpdate = true;
if (added.length === 0) break;
sendLeagueChatMessage(match, "LEAGUE_TIMES_PROPOSED", user.id);
notify({
userIds: team.opponent.memberUserIds,
notification: {
type: "TO_LEAGUE_TIMES_PROPOSED",
meta: {
tournamentId,
matchId: match.id,
opponentTeamName: team.name,
},
pictureUrl: tournament.ctx.logoUrl,
},
});
break;
}
case "ACCEPT_PROPOSAL": {
const schedule = leagueSchedule(tournament, match);
errorToastIfFalsy(
schedule.phase !== "CLOSED" && schedule.phase !== "NOT_OPEN",
"Set can't be scheduled",
);
const proposal = notFoundIfNullish(
await TournamentMatchRepository.findScheduleProposalById(
data.proposalId,
),
);
errorToastIfFalsy(
proposal.matchId === match.id,
"Not this set's candidate",
);
const team = leagueTeamOfUser(tournament, match, user.id);
const isOtherTeamsCandidate =
team !== null && proposal.tournamentTeamId !== team.id;
const isOrganizer = tournament.isOrganizer(user);
errorToastIfFalsy(
isOtherTeamsCandidate || isOrganizer,
"Only the other team can pick a candidate",
);
errorToastIfFalsy(
!match.scheduleSetByOrganizer || isOrganizer,
"The organizer set the time of this set",
);
errorToastIfFalsy(
LeagueScheduling.isAcceptableProposal({
proposedAt: proposal.proposedAt,
now: schedule.now,
}),
"The time has already passed",
);
const setByOrganizer = !isOtherTeamsCandidate;
await TournamentMatchRepository.scheduleMatch({
matchId: match.id,
scheduledAt: proposal.proposedAt,
setByOrganizer,
});
sendLeagueChatMessage(
match,
setByOrganizer ? "LEAGUE_TIME_SET_BY_ORGANIZER" : "LEAGUE_TIME_PICKED",
user.id,
);
await notifyLeagueMatchScheduled({
tournament,
match,
actorId: user.id,
});
emitMatchUpdate = true;
emitTournamentUpdate = true;
break;
}
case "REJECT_RESCHEDULE": {
const team = leagueTeamOfUser(tournament, match, user.id);
errorToastIfFalsy(team, "Not a member of either team");
errorToastIfFalsy(match.scheduledAt !== null, "Set has no time yet");
const deletedCount =
await TournamentMatchRepository.deleteScheduleProposalsByTeam({
matchId: match.id,
tournamentTeamId: team.opponent.id,
});
if (deletedCount === 0) break;
sendLeagueChatMessage(match, "LEAGUE_RESCHEDULE_DECLINED", user.id);
emitMatchUpdate = true;
emitTournamentUpdate = true;
break;
}
case "ORGANIZER_SET_TIME": {
requireTournamentOrganizer(tournament, user);
errorToastIfFalsy(
leagueSchedule(tournament, match).phase !== "CLOSED",
"Set can't be scheduled",
);
await TournamentMatchRepository.scheduleMatch({
matchId: match.id,
scheduledAt: dateToDatabaseTimestamp(data.scheduledAt),
setByOrganizer: true,
});
sendLeagueChatMessage(match, "LEAGUE_TIME_SET_BY_ORGANIZER", user.id);
await notifyLeagueMatchScheduled({
tournament,
match,
actorId: user.id,
});
emitMatchUpdate = true;
emitTournamentUpdate = true;
break;
}
default: {
assertUnreachable(data);
}
@@ -738,6 +896,116 @@ export const action: ActionFunction = async ({ params, request }) => {
return null;
};
const PROPOSAL_ERROR_MESSAGES: Record<LeagueScheduling.ProposalError, string> =
{
NOT_OPEN: "Scheduling is not open for this set",
BEFORE_PLAYABLE: "The round is not playable that early",
IN_PAST: "Candidate times must be in the future",
TOO_MANY: "Too many candidate times",
ORGANIZER_LOCKED: "The organizer set the time of this set",
};
function leagueSchedule(
tournament: Tournament,
match: NonNullable<FindMatchById>,
) {
const now = databaseTimestampNow();
return {
now,
phase: LeagueScheduling.phase({
hasScheduling: tournament.matchHasScheduling(match.id),
isOver: Boolean(match.winnerSide),
hasBothTeams: Boolean(match.opponentOne?.id && match.opponentTwo?.id),
isPlayableAt: match.roundIsPlayableAt,
scheduledAt: match.scheduledAt,
now,
}),
};
}
/** The user's team of the set with the opposing team's roster next to it, null when they play for neither. */
function leagueTeamOfUser(
tournament: Tournament,
match: NonNullable<FindMatchById>,
userId: number,
) {
const teamIds = [match.opponentOne?.id, match.opponentTwo?.id];
const ownTeamId = match.players.find(
(player) => player.id === userId,
)?.tournamentTeamId;
const opponentId = teamIds.find(
(teamId) => typeof teamId === "number" && teamId !== ownTeamId,
);
if (typeof ownTeamId !== "number" || typeof opponentId !== "number") {
return null;
}
const nameOf = (teamId: number) => tournament.teamById(teamId)?.name ?? "";
return {
id: ownTeamId,
name: nameOf(ownTeamId),
opponent: {
id: opponentId,
name: nameOf(opponentId),
memberUserIds: match.players
.filter((player) => player.tournamentTeamId === opponentId)
.map((player) => player.id),
},
};
}
function sendLeagueChatMessage(
match: NonNullable<FindMatchById>,
type: PersistedSystemMessageType,
authorUserId: number,
) {
if (!match.chatRoomId) return;
void ChatSystemMessage.sendPersisted({
roomId: match.chatRoomId,
type,
authorUserId,
});
}
/** Both rosters learn the set has a time; each is told the other team's name. */
async function notifyLeagueMatchScheduled({
tournament,
match,
actorId,
}: {
tournament: Tournament;
match: NonNullable<FindMatchById>;
actorId: number;
}) {
await resolveNotifications({
userIds: match.players.map((player) => player.id),
type: "TO_LEAGUE_MATCH_STARTING_SOON",
meta: { matchId: match.id },
});
for (const player of match.players) {
const team = leagueTeamOfUser(tournament, match, player.id);
if (!team) continue;
notify({
userIds: [player.id],
defaultSeenUserIds: [actorId],
notification: {
type: "TO_LEAGUE_MATCH_SCHEDULED",
meta: {
tournamentId: tournament.ctx.id,
matchId: match.id,
opponentTeamName: team.opponent.name,
},
pictureUrl: tournament.ctx.logoUrl,
},
});
}
}
/** Room of the brackets page views rendering this match; the whole tournament's room if its bracket can't be resolved. */
function matchResultsRoom(
tournament: Tournament,

View File

@@ -1,9 +1,10 @@
import { Undo2 } from "lucide-react";
import { CalendarClock, Undo2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import { SendouTabPanel } from "~/components/elements/Tabs";
import { MatchActionTab } from "~/components/match-page/MatchActionTab";
import { TAB_KEYS } from "~/components/match-page/MatchTabs";
import { matchPageSearchParams } from "~/components/match-page/match-page-search-params";
import { useMatchWeaponReport } from "~/components/match-page/useMatchWeaponReport";
import { WeaponReporter } from "~/components/match-page/WeaponReporter";
import { useUser } from "~/features/auth/core/user";
@@ -12,6 +13,7 @@ import { isSetOverByScore } from "~/features/tournament-bracket/core/engine";
import { matchSchema } from "~/features/tournament-bracket/tournament-bracket-schemas";
import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-bracket/tournament-bracket-utils";
import { useActionSubmit } from "~/hooks/useActionSubmit";
import { useSearchParam } from "~/modules/search-params/hooks";
import { databaseTimestampToJavascriptTimestamp } from "~/utils/dates";
import type { CommonUser } from "~/utils/kysely.server";
import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server";
@@ -121,7 +123,12 @@ export function TournamentMatchActionTab({
ko: typeof ko === "boolean" ? ko : undefined,
});
}}
actionButtons={<UndoReportButton scoreSum={scoreSum} />}
actionButtons={
<>
<UndoReportButton scoreSum={scoreSum} />
{data.schedule.canSeeBoard ? <RescheduleButton /> : null}
</>
}
secondaryAction={
weaponReport ? <WeaponReporter {...weaponReport} /> : null
}
@@ -129,6 +136,24 @@ export function TournamentMatchActionTab({
);
}
/** Jumps to the schedule tab, where the set's agreed time can be moved. */
function RescheduleButton() {
const { t } = useTranslation(["tournament"]);
const [, setTab] = useSearchParam(matchPageSearchParams, "tab");
return (
<SendouButton
variant="minimal"
size="miniscule"
icon={<CalendarClock size={16} />}
onClick={() => setTab(TAB_KEYS.SCHEDULE)}
data-testid="reschedule-button"
>
{t("tournament:match.schedule.reschedule")}
</SendouButton>
);
}
export function UndoReportButton({ scoreSum }: { scoreSum: number }) {
const { t } = useTranslation(["q"]);
const undoReport = useActionSubmit(matchSchema);

View File

@@ -19,9 +19,12 @@ import { useUser } from "~/features/auth/core/user";
import { useTournament } from "~/features/tournament/tournament-context";
import type { MatchStatus } from "~/features/tournament-bracket/core/engine";
import { matchSchema } from "~/features/tournament-bracket/tournament-bracket-schemas";
import { SendouForm } from "~/form/SendouForm";
import { useActionSubmit } from "~/hooks/useActionSubmit";
import { databaseTimestampToDate, getDateAtNextFullHour } from "~/utils/dates";
import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server";
import { type MatchPageTeam, useMatch } from "../match-page-context";
import { organizerSetLeagueTimeSchema } from "../tournament-match-schemas";
import { OrganizerMatchMapListDialog } from "./OrganizerMatchMapListDialog";
import styles from "./TournamentMatchAdminTab.module.css";
@@ -45,6 +48,10 @@ export function TournamentMatchAdminTab({
isOrganizer && !data.matchIsOver && data.match.startedAt !== null;
const topActionsVisible = !!teamOne && !!teamTwo;
const scheduleSectionVisible =
isOrganizer &&
data.schedule.hasScheduling &&
data.schedule.phase !== "CLOSED";
const castSectionVisible = !data.matchIsOver;
const editScoresVisible =
isOrganizer && !!teamOne && !!teamTwo && data.results.length > 0;
@@ -59,6 +66,9 @@ export function TournamentMatchAdminTab({
{canEndSet ? <EndSetPopover teams={[teamOne!, teamTwo!]} /> : null}
</div>
) : null}
{scheduleSectionVisible ? (
<AdminScheduleSection schedule={data.schedule} />
) : null}
{castSectionVisible ? (
<AdminCastSection
matchId={data.match.id}
@@ -73,6 +83,40 @@ export function TournamentMatchAdminTab({
);
}
/** The organizer's final say on when the set is played; a set time closes the candidate board for the teams. */
function AdminScheduleSection({
schedule,
}: {
schedule: TournamentMatchLoaderData["schedule"];
}) {
const { t } = useTranslation(["tournament"]);
return (
<section className={styles.castSection}>
<div className={styles.castLabelRow}>
<Label spaced={false}>{t("tournament:match.admin.setTime")}</Label>
<InfoPopover tiny>
{t("tournament:match.admin.setTimeInfo")}
</InfoPopover>
</div>
<SendouForm
schema={organizerSetLeagueTimeSchema}
defaultValues={{
scheduledAt: schedule.scheduledAt
? databaseTimestampToDate(schedule.scheduledAt)
: getDateAtNextFullHour(new Date()),
}}
submitButtonText={t("tournament:match.admin.setTime")}
submitButtonSize="small"
submitButtonTestId="organizer-set-time-button"
fullWidth
>
{({ FormField }) => <FormField name="scheduledAt" />}
</SendouForm>
</section>
);
}
function AdminCastSection({
matchId,
matchStatus,

View File

@@ -1,5 +1,6 @@
import { differenceInMinutes } from "date-fns";
import {
CalendarClock,
Flag,
Gavel,
Hourglass,
@@ -20,6 +21,7 @@ import {
preloadStageBanners,
} from "~/components/match-page/MatchBanner";
import { MatchBannerBottomRow } from "~/components/match-page/MatchBannerBottomRow";
import { MatchBannerScheduledTime } from "~/components/match-page/MatchBannerScheduledTime";
import { MatchBannerStartedAt } from "~/components/match-page/MatchBannerStartedAt";
import { MatchBannerTimer } from "~/components/match-page/MatchBannerTimer";
import { MatchBannerTopRow } from "~/components/match-page/MatchBannerTopRow";
@@ -31,6 +33,7 @@ import { useAutoRerender } from "~/hooks/useAutoRerender";
import type { ModeShort } from "~/modules/in-game-lists/types";
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator/types";
import { databaseTimestampToDate } from "~/utils/dates";
import * as LeagueScheduling from "../core/LeagueScheduling";
import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server";
import { useMatch } from "../match-page-context";
import { resolveHostingTeam } from "../tournament-match-utils";
@@ -46,6 +49,13 @@ export function TournamentMatchBanner({
month: "numeric",
year: "numeric",
});
const { formatter: scheduleFormatter } = useDateTimeFormat({
weekday: "short",
day: "numeric",
month: "numeric",
hour: "numeric",
minute: "2-digit",
});
const tournament = useTournament();
const {
currentMap,
@@ -86,10 +96,11 @@ export function TournamentMatchBanner({
tournament,
});
const { leagueRoundLocked } = data.bracketContext;
const leagueRoundStartDate = data.bracketContext.leagueRoundStartDate
? databaseTimestampToDate(data.bracketContext.leagueRoundStartDate)
: null;
const { schedule } = data;
const leagueRoundStartDate =
schedule.isPlayableAt !== null
? LeagueScheduling.playableDate(schedule.isPlayableAt)
: null;
const pickBanBanner = resolvePickBanBanner(data, tournament, t);
@@ -134,10 +145,30 @@ export function TournamentMatchBanner({
stageIds={data.results.map((result) => result.stageId)}
/>
)
) : leagueRoundLocked ? (
) : schedule.phase === "NOT_OPEN" ? (
<IconBanner
icon={<CalendarClock size={32} />}
header={t("tournament:match.schedule.notOpen.header")}
subtitle={t("tournament:match.schedule.notOpen.subtitle", {
date: scheduleFormatter.format(
databaseTimestampToDate(schedule.opensAt),
),
})}
testId="league-not-open-banner"
/>
) : schedule.phase === "UNSCHEDULED" ? (
<IconBanner
icon={<CalendarClock size={32} />}
header={t("tournament:match.schedule.unscheduled.header")}
subtitle={t("tournament:match.schedule.unscheduled.subtitle")}
testId="league-unscheduled-banner"
/>
) : schedule.phase === "SCHEDULED_LOCKED" && schedule.scheduledAt ? (
<IconBanner
icon={<Lock size={32} />}
header={t("tournament:match.leagueLocked.header")}
header={scheduleFormatter.format(
databaseTimestampToDate(schedule.scheduledAt),
)}
subtitle={
leagueRoundStartDate
? t("tournament:match.leagueLocked.subtitle", {
@@ -146,6 +177,7 @@ export function TournamentMatchBanner({
})
: undefined
}
testId="league-scheduled-locked-banner"
/>
) : matchIsLocked ? (
<IconBanner
@@ -243,12 +275,30 @@ function TournamentMatchBannerTopRow({
const currentTime = useAutoRerender("ten seconds");
const { scores } = useMatch();
if (
!data.match.startedAt ||
!data.match.opponentOne ||
!data.match.opponentTwo
)
return null;
if (!data.match.opponentOne || !data.match.opponentTwo) return null;
const score = {
alpha: scores[0],
bravo: scores[1],
isFinal: Boolean(data.match.winnerSide),
count: data.match.roundMaps.count,
bestOf: data.match.roundMaps.type === "BEST_OF",
};
// league sets start at bracket start, only the agreed time says anything
if (data.schedule.hasScheduling) {
return (
<MatchBannerTopRow score={score}>
{data.schedule.scheduledAt ? (
<MatchBannerScheduledTime
time={databaseTimestampToDate(data.schedule.scheduledAt)}
/>
) : null}
</MatchBannerTopRow>
);
}
if (!data.match.startedAt) return null;
const startedAt = databaseTimestampToDate(data.match.startedAt);
const totalMinutes = differenceInMinutes(currentTime, startedAt);
@@ -266,15 +316,7 @@ function TournamentMatchBannerTopRow({
});
return (
<MatchBannerTopRow
score={{
alpha: scores[0],
bravo: scores[1],
isFinal: Boolean(data.match.winnerSide),
count: data.match.roundMaps.count,
bestOf: data.match.roundMaps.type === "BEST_OF",
}}
>
<MatchBannerTopRow score={score}>
{data.matchIsOver ? (
<MatchBannerStartedAt time={startedAt} endTime={endedAt} />
) : (

View File

@@ -0,0 +1,122 @@
.root {
display: flex;
flex-direction: column;
gap: var(--s-6);
container-type: inline-size;
}
.agreedTime {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-2);
font-size: var(--font-sm);
}
.agreedTimeIcon {
color: var(--color-success);
}
.agreedTimeValue {
font-weight: var(--weight-semi);
}
.setByOrganizer {
display: inline-flex;
align-items: center;
gap: var(--s-1);
font-size: var(--font-xs);
color: var(--color-text-high);
}
.board {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--s-4);
@container (width < 560px) {
grid-template-columns: 1fr;
}
}
.column {
display: flex;
flex-direction: column;
gap: var(--s-2);
padding: var(--s-3);
border-radius: var(--radius-box);
background-color: var(--color-bg);
border: var(--border-style);
}
.columnHeading {
font-size: var(--font-sm);
font-weight: var(--weight-semi);
margin: 0;
}
.candidates {
display: flex;
flex-direction: column;
gap: var(--s-1-5);
list-style: none;
padding: 0;
margin: 0;
}
.candidate {
display: flex;
align-items: center;
gap: var(--s-2);
font-size: var(--font-xs);
}
.candidatePassed {
opacity: 0.5;
}
.candidateDot {
display: inline-flex;
width: 0.75rem;
justify-content: center;
flex-shrink: 0;
}
.candidateTime {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
font-weight: var(--weight-semi);
}
.proposeSection {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.availability {
display: flex;
flex-direction: column;
gap: var(--s-3);
padding: var(--s-3);
border-radius: var(--radius-box);
background-color: var(--color-bg);
border: var(--border-style);
font-size: var(--font-xs);
}
.windowText {
font-weight: var(--weight-body);
color: var(--color-text-high);
}
.rows {
display: flex;
flex-direction: column;
gap: var(--s-2-5);
list-style: none;
padding: 0;
margin: 0;
}

View File

@@ -0,0 +1,409 @@
import clsx from "clsx";
import { Check, Lock, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ActionButton } from "~/components/ActionButton";
import { Alert } from "~/components/Alert";
import { SendouTabPanel } from "~/components/elements/Tabs";
import { LocaleTime } from "~/components/LocaleTime";
import { TAB_KEYS } from "~/components/match-page/MatchTabs";
import { matchPageSearchParams } from "~/components/match-page/match-page-search-params";
import { useUser } from "~/features/auth/core/user";
import type {
PlayableWindowTier,
WindowSchedule,
} from "~/features/availability/availability-types";
import {
PlayableWindowsSummary,
TierDot,
} from "~/features/availability/components/PlayableWindowsSummary";
import {
AvailabilityMemberRow,
type AvailabilityPanelUser,
AvailabilitySummary,
availabilityRowStatus,
} from "~/features/availability/components/RegistrationAvailabilityPanel";
import * as Availability from "~/features/availability/core/Availability";
import { useTournament } from "~/features/tournament/tournament-context";
import { matchSchema } from "~/features/tournament-bracket/tournament-bracket-schemas";
import { SendouForm } from "~/form/SendouForm";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useSearchParam } from "~/modules/search-params/hooks";
import { databaseTimestampToDate, getDateAtNextFullHour } from "~/utils/dates";
import * as LeagueScheduling from "../core/LeagueScheduling";
import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server";
import { type MatchPageTeam, useMatch } from "../match-page-context";
import { proposeLeagueTimesSchema } from "../tournament-match-schemas";
import styles from "./TournamentMatchScheduleTab.module.css";
const CANDIDATE_TIME_FORMAT: Intl.DateTimeFormatOptions = {
weekday: "short",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
};
type Schedule = TournamentMatchLoaderData["schedule"];
type Proposal = Schedule["proposals"][number];
/**
* Where the two teams agree on when the set is played: each side's candidate times, of which only
* the other side's can be picked, and how the candidates fit the viewer's own roster.
*/
export function TournamentMatchScheduleTab({
data,
}: {
data: TournamentMatchLoaderData;
}) {
const { t } = useTranslation(["tournament"]);
const tournament = useTournament();
const user = useUser();
const {
teams: [teamOne, teamTwo],
ownTeamId,
} = useMatch();
const { schedule } = data;
if (!teamOne || !teamTwo) return null;
const isOrganizer = tournament.isOrganizer(user);
const isScheduled =
schedule.phase === "SCHEDULED" || schedule.phase === "SCHEDULED_LOCKED";
const boardClosed = schedule.scheduleSetByOrganizer;
const playableWindows = schedule.availability
? Availability.playableWindows({
members: schedule.availability.members,
minPlayers: schedule.availability.minPlayers,
})
: null;
const candidateTier = (proposedAt: number): PlayableWindowTier | null => {
if (!playableWindows) return null;
const set = LeagueScheduling.busyBlock(proposedAt);
const covering = playableWindows.filter(
(window) =>
window.startsAt <= set.startsAt && window.endsAt >= set.endsAt,
);
if (covering.some((window) => window.tier === "FULL")) return "FULL";
if (covering.length > 0) return "ONE_SHORT";
return null;
};
// the viewer's own team first, so their candidates are the left column
const orderedTeams =
ownTeamId === teamTwo.id ? [teamTwo, teamOne] : [teamOne, teamTwo];
return (
<SendouTabPanel id={TAB_KEYS.SCHEDULE}>
<div className={styles.root} data-testid="schedule-tab">
{isScheduled && schedule.scheduledAt ? (
<div className={styles.agreedTime} data-testid="agreed-time">
<Check size={18} className={styles.agreedTimeIcon} />
<span>{t("tournament:match.schedule.agreedTime")}</span>
<LocaleTime
date={schedule.scheduledAt}
options={CANDIDATE_TIME_FORMAT}
className={styles.agreedTimeValue}
inline
/>
{boardClosed ? (
<span className={styles.setByOrganizer}>
<Lock size={12} />{" "}
{t("tournament:match.schedule.setByOrganizer")}
</span>
) : null}
</div>
) : null}
{boardClosed ? (
<Alert variation="INFO" tiny>
{t("tournament:match.schedule.boardClosed")}
</Alert>
) : (
<>
{isScheduled ? (
<div className="text-xs text-lighter">
{t("tournament:match.schedule.rescheduleInfo")}
</div>
) : null}
<div className={styles.board}>
{orderedTeams.map((team) => (
<CandidateColumn
key={team.id}
team={team}
proposals={schedule.proposals.filter(
(proposal) => proposal.tournamentTeamId === team.id,
)}
isOwn={team.id === ownTeamId}
canPick={
(ownTeamId !== null && team.id !== ownTeamId) ||
(isOrganizer && ownTeamId === null)
}
canReject={
isScheduled && ownTeamId !== null && team.id !== ownTeamId
}
now={schedule.now}
candidateTier={candidateTier}
/>
))}
</div>
{ownTeamId !== null ? (
<ProposeTimesForm
isReschedule={isScheduled}
isPlayableAt={schedule.isPlayableAt}
ownProposedAts={schedule.proposals
.filter(
(proposal) =>
proposal.tournamentTeamId === ownTeamId &&
LeagueScheduling.isAcceptableProposal({
proposedAt: proposal.proposedAt,
now: schedule.now,
}),
)
.map((proposal) => proposal.proposedAt)}
/>
) : null}
</>
)}
{schedule.availability && ownTeamId !== null ? (
<OwnTeamAvailability
availability={schedule.availability}
team={orderedTeams[0]}
windows={playableWindows ?? []}
/>
) : null}
</div>
</SendouTabPanel>
);
}
function CandidateColumn({
team,
proposals,
isOwn,
canPick,
canReject,
now,
candidateTier,
}: {
team: MatchPageTeam;
proposals: Array<Proposal>;
isOwn: boolean;
canPick: boolean;
canReject: boolean;
now: number;
candidateTier: (proposedAt: number) => PlayableWindowTier | null;
}) {
const { t } = useTranslation(["tournament"]);
const [, setTab] = useSearchParam(matchPageSearchParams, "tab");
// the tab is the default one only while the set has no time, so acting pins it in the URL
const stayOnTab = () => setTab(TAB_KEYS.SCHEDULE);
const { formatter: candidateTimeFormatter } = useDateTimeFormat(
CANDIDATE_TIME_FORMAT,
);
const someCandidateHasTier = proposals.some(
(proposal) => candidateTier(proposal.proposedAt) !== null,
);
return (
<section
className={styles.column}
data-testid={isOwn ? "own-candidates" : "opponent-candidates"}
>
<h3 className={styles.columnHeading}>{team.name}</h3>
{proposals.length === 0 ? (
<div className="text-xs text-lighter">
{t("tournament:match.schedule.noCandidates")}
</div>
) : (
<ul className={styles.candidates}>
{proposals.map((proposal) => {
const tier = candidateTier(proposal.proposedAt);
const passed = !LeagueScheduling.isAcceptableProposal({
proposedAt: proposal.proposedAt,
now,
});
return (
<li
key={proposal.id}
className={clsx(styles.candidate, {
[styles.candidatePassed]: passed,
})}
data-testid="candidate-time"
>
{someCandidateHasTier ? (
<span className={styles.candidateDot}>
{tier ? <TierDot full={tier === "FULL"} /> : null}
</span>
) : null}
<span className={styles.candidateTime}>
<LocaleTime
date={proposal.proposedAt}
options={CANDIDATE_TIME_FORMAT}
inline
/>
</span>
{canPick && !passed ? (
<ActionButton
schema={matchSchema}
action="ACCEPT_PROPOSAL"
fields={{ proposalId: proposal.id }}
size="small"
icon={<Check />}
testId="pick-candidate-button"
confirm={{
dialogHeading: t(
"tournament:match.schedule.pickConfirm",
{
time: candidateTimeFormatter.format(
proposal.proposedAt,
),
},
),
submitButtonText: t("tournament:match.schedule.pick"),
submitButtonVariant: "primary",
}}
onClick={stayOnTab}
>
{t("tournament:match.schedule.pick")}
</ActionButton>
) : null}
</li>
);
})}
</ul>
)}
{canReject && proposals.length > 0 ? (
<ActionButton
schema={matchSchema}
action="REJECT_RESCHEDULE"
variant="minimal-destructive"
size="small"
icon={<X />}
className="mt-2"
testId="reject-reschedule-button"
onClick={stayOnTab}
>
{t("tournament:match.schedule.keepCurrentTime")}
</ActionButton>
) : null}
</section>
);
}
function ProposeTimesForm({
isReschedule,
isPlayableAt,
ownProposedAts,
}: {
isReschedule: boolean;
isPlayableAt: number | null;
/** The team's candidates still ahead, which the form starts from and a submit replaces. */
ownProposedAts: Array<number>;
}) {
const { t } = useTranslation(["tournament"]);
const earliest = Math.max(
getDateAtNextFullHour(new Date()).getTime(),
isPlayableAt !== null ? databaseTimestampToDate(isPlayableAt).getTime() : 0,
);
const hasProposed = ownProposedAts.length > 0;
// xxx: you can spam propose to spam the chat
// xxx: remove the green dot after proposing to indicate availability
return (
<section className={styles.proposeSection}>
<h3 className={styles.columnHeading}>
{isReschedule
? t("tournament:match.schedule.requestAnotherTime")
: t("tournament:match.schedule.proposeTimes")}
</h3>
<SendouForm
// remount when the board changes so the form keeps mirroring what the team has up
key={ownProposedAts.join(",")}
schema={proposeLeagueTimesSchema}
defaultValues={{
times: hasProposed
? ownProposedAts.map(databaseTimestampToDate)
: [new Date(earliest)],
}}
submitButtonText={
hasProposed
? t("tournament:match.schedule.updateTimes")
: t("tournament:match.schedule.propose")
}
submitButtonSize="small"
submitButtonTestId="propose-times-button"
fullWidth
>
{({ FormField }) => <FormField name="times" />}
</SendouForm>
</section>
);
}
function OwnTeamAvailability({
availability,
team,
windows,
}: {
availability: NonNullable<Schedule["availability"]>;
team: MatchPageTeam;
windows: ReturnType<typeof Availability.playableWindows>;
}) {
const { t } = useTranslation(["schedule", "tournament"]);
const { formatter } = useDateTimeFormat(CANDIDATE_TIME_FORMAT);
const scheduleByUserId = new Map(
availability.members.map((member) => [member.userId, member]),
);
const roster: Array<AvailabilityPanelUser> = team.members.map((member) => ({
...member,
id: member.userId,
}));
const entryOf = (member: WindowSchedule | undefined) =>
member
? {
userId: member.userId,
availability: Availability.availabilityInWindow({
reported: member.reported,
slots: member.ranges,
busy: member.busy,
window: availability.window,
}),
}
: undefined;
return (
<section className={styles.availability} data-testid="own-availability">
<h3 className={styles.columnHeading}>
{t("tournament:match.schedule.availabilityTitle", { team: team.name })}{" "}
<span className={styles.windowText}>
{formatter.formatRange(
availability.window.startsAt,
availability.window.endsAt,
)}
</span>
</h3>
<ul className={styles.rows}>
{roster.map((member) => (
<AvailabilityMemberRow
key={member.id}
user={member}
entry={entryOf(scheduleByUserId.get(member.id))}
/>
))}
</ul>
<AvailabilitySummary
statuses={roster.map((member) =>
availabilityRowStatus(entryOf(scheduleByUserId.get(member.id))),
)}
/>
<PlayableWindowsSummary
windows={windows}
minPlayers={availability.minPlayers}
/>
</section>
);
}

View File

@@ -24,6 +24,7 @@ import { type MatchPageTeam, useMatch } from "../match-page-context";
import { TournamentMatchActionPickBanTab } from "./TournamentMatchActionPickBanTab";
import { TournamentMatchActionTab } from "./TournamentMatchActionTab";
import { TournamentMatchAdminTab } from "./TournamentMatchAdminTab";
import { TournamentMatchScheduleTab } from "./TournamentMatchScheduleTab";
export function TournamentMatchTabs({
data,
@@ -77,7 +78,12 @@ export function TournamentMatchTabs({
).map((m, i) => ({ ...m, pickedBy: pickBanData?.pickedBySlot.get(i) }));
return (
<MatchTabs tabs={tabs}>
<MatchTabs
tabs={tabs}
defaultTab={
data.schedule.phase === "UNSCHEDULED" ? TAB_KEYS.SCHEDULE : undefined
}
>
{tabs.includes(TAB_KEYS.RESULT) ? (
<MatchResultTab
teams={resolveTimelineTeams(opponentOneId, opponentTwoId, tournament)}
@@ -91,6 +97,9 @@ export function TournamentMatchTabs({
/>
) : null}
<TournamentMatchRosterTab data={data} />
{tabs.includes(TAB_KEYS.SCHEDULE) ? (
<TournamentMatchScheduleTab data={data} />
) : null}
{tabs.includes(TAB_KEYS.ACTION) ? (
isPickBanStep && turnOfResult ? (
<TournamentMatchActionPickBanTab

View File

@@ -0,0 +1,342 @@
import { describe, expect, test } from "vitest";
import * as LeagueScheduling from "./LeagueScheduling";
const HOUR = 60 * 60;
const DAY = 24 * HOUR;
/** Monday 2027-01-25 00:00 UTC; any fixed point works. */
const PLAYABLE_AT = 1_800_000_000;
describe("LeagueScheduling.phase", () => {
const base = {
hasScheduling: true,
isOver: false,
hasBothTeams: true,
isPlayableAt: PLAYABLE_AT,
scheduledAt: null,
};
test.each([
{
why: "no scheduling (not a league or a real-time bracket)",
args: { ...base, hasScheduling: false, now: PLAYABLE_AT },
expected: "CLOSED",
},
{
why: "set over",
args: { ...base, isOver: true, now: PLAYABLE_AT },
expected: "CLOSED",
},
{
why: "team missing",
args: { ...base, hasBothTeams: false, now: PLAYABLE_AT },
expected: "CLOSED",
},
{
why: "more than a day before playable",
args: { ...base, now: PLAYABLE_AT - DAY - 1 },
expected: "NOT_OPEN",
},
{
why: "a day before playable",
args: { ...base, now: PLAYABLE_AT - DAY },
expected: "UNSCHEDULED",
},
{
why: "playable and no time",
args: { ...base, now: PLAYABLE_AT + HOUR },
expected: "UNSCHEDULED",
},
{
why: "no playable time at all",
args: { ...base, isPlayableAt: null, now: 0 },
expected: "UNSCHEDULED",
},
{
why: "time agreed before the round is playable",
args: { ...base, scheduledAt: PLAYABLE_AT + HOUR, now: PLAYABLE_AT - 1 },
expected: "SCHEDULED_LOCKED",
},
{
why: "time agreed and the round is playable",
args: { ...base, scheduledAt: PLAYABLE_AT + HOUR, now: PLAYABLE_AT },
expected: "SCHEDULED",
},
{
why: "time agreed and no playable time",
args: { ...base, isPlayableAt: null, scheduledAt: PLAYABLE_AT, now: 0 },
expected: "SCHEDULED",
},
])("$why -> $expected", ({ args, expected }) => {
expect(LeagueScheduling.phase(args)).toBe(expected);
});
});
describe("LeagueScheduling.opensAt", () => {
test("a day before the round is playable", () => {
expect(LeagueScheduling.opensAt(PLAYABLE_AT)).toBe(PLAYABLE_AT - DAY);
});
test("right away without a playable time", () => {
expect(LeagueScheduling.opensAt(null)).toBe(0);
});
});
describe("LeagueScheduling.playableAtFromDate", () => {
test("opens at the start of the picked day in UTC+14", () => {
expect(
LeagueScheduling.playableAtFromDate(new Date(2027, 0, 25, 18, 30)),
).toBe(Date.UTC(2027, 0, 24, 10) / 1000);
});
});
describe("LeagueScheduling.playableDate", () => {
test("gives back the picked day", () => {
const picked = new Date(2027, 0, 25);
expect(
LeagueScheduling.playableDate(
LeagueScheduling.playableAtFromDate(picked),
),
).toEqual(picked);
});
});
describe("LeagueScheduling.playableAtsAreAscending", () => {
test.each([
{ why: "ascending", playableAts: [DAY, 2 * DAY, 3 * DAY], expected: true },
{ why: "same day twice", playableAts: [DAY, DAY], expected: true },
{ why: "descending", playableAts: [2 * DAY, DAY], expected: false },
{ why: "no times", playableAts: [null, null], expected: true },
{
why: "descending past a round without a time",
playableAts: [2 * DAY, null, DAY],
expected: false,
},
])("$why", ({ playableAts, expected }) => {
expect(
LeagueScheduling.playableAtsAreAscending(
playableAts.map((isPlayableAt) => ({ section: null, isPlayableAt })),
),
).toBe(expected);
});
test("compares rounds only within their section", () => {
expect(
LeagueScheduling.playableAtsAreAscending([
{ section: "winners", isPlayableAt: 2 * DAY },
{ section: "losers", isPlayableAt: DAY },
]),
).toBe(true);
});
});
describe("LeagueScheduling.validateProposals", () => {
const base = {
phase: "UNSCHEDULED" as const,
isPlayableAt: PLAYABLE_AT,
now: PLAYABLE_AT - HOUR,
existingProposedAts: [],
setByOrganizer: false,
};
test.each([
{
why: "valid candidate after playable",
args: { ...base, proposedAts: [PLAYABLE_AT + HOUR] },
expected: null,
},
{
why: "candidate exactly at playable",
args: { ...base, proposedAts: [PLAYABLE_AT] },
expected: null,
},
{
why: "board closed by the organizer",
args: { ...base, setByOrganizer: true, proposedAts: [PLAYABLE_AT] },
expected: "ORGANIZER_LOCKED",
},
{
why: "scheduling not open yet",
args: { ...base, phase: "NOT_OPEN" as const, proposedAts: [PLAYABLE_AT] },
expected: "NOT_OPEN",
},
{
why: "set closed",
args: { ...base, phase: "CLOSED" as const, proposedAts: [PLAYABLE_AT] },
expected: "NOT_OPEN",
},
{
why: "rescheduling an agreed set",
args: {
...base,
phase: "SCHEDULED" as const,
proposedAts: [PLAYABLE_AT],
},
expected: null,
},
{
why: "candidate before playable",
args: { ...base, proposedAts: [PLAYABLE_AT - 1] },
expected: "BEFORE_PLAYABLE",
},
{
why: "candidate in the past without a playable time",
args: {
...base,
isPlayableAt: null,
now: PLAYABLE_AT,
proposedAts: [PLAYABLE_AT - 1],
},
expected: "IN_PAST",
},
{
why: "one bad candidate spoils the batch",
args: { ...base, proposedAts: [PLAYABLE_AT + HOUR, PLAYABLE_AT - 1] },
expected: "BEFORE_PLAYABLE",
},
{
why: "over the cap",
args: {
...base,
proposedAts: Array.from(
{
length:
LeagueScheduling.LEAGUE_SCHEDULING.MAX_OPEN_PROPOSALS_PER_TEAM +
1,
},
(_, i) => PLAYABLE_AT + (i + 1) * HOUR,
),
},
expected: "TOO_MANY",
},
{
why: "exactly at the cap",
args: {
...base,
proposedAts: Array.from(
{
length:
LeagueScheduling.LEAGUE_SCHEDULING.MAX_OPEN_PROPOSALS_PER_TEAM,
},
(_, i) => PLAYABLE_AT + (i + 1) * HOUR,
),
},
expected: null,
},
{
why: "keeping a candidate that has since passed",
args: {
...base,
now: PLAYABLE_AT + HOUR,
existingProposedAts: [PLAYABLE_AT],
proposedAts: [PLAYABLE_AT, PLAYABLE_AT + 2 * HOUR],
},
expected: null,
},
{
why: "clearing every candidate",
args: { ...base, existingProposedAts: [PLAYABLE_AT], proposedAts: [] },
expected: null,
},
])("$why -> $expected", ({ args, expected }) => {
expect(LeagueScheduling.validateProposals(args)).toBe(expected);
});
});
describe("LeagueScheduling.isAcceptableProposal", () => {
test("a candidate that passed can't be picked", () => {
expect(
LeagueScheduling.isAcceptableProposal({
proposedAt: PLAYABLE_AT,
now: PLAYABLE_AT,
}),
).toBe(false);
expect(
LeagueScheduling.isAcceptableProposal({
proposedAt: PLAYABLE_AT + 1,
now: PLAYABLE_AT,
}),
).toBe(true);
});
});
describe("LeagueScheduling.isLive", () => {
const scheduledAt = PLAYABLE_AT + 20 * HOUR;
test.each([
{ why: "31 minutes before", now: scheduledAt - 31 * 60, expected: false },
{ why: "30 minutes before", now: scheduledAt - 30 * 60, expected: true },
{ why: "at the time", now: scheduledAt, expected: true },
{ why: "59 minutes after", now: scheduledAt + 59 * 60, expected: true },
{ why: "an hour after", now: scheduledAt + HOUR, expected: false },
])("$why -> $expected", ({ now, expected }) => {
expect(
LeagueScheduling.isLive({ scheduledAt, hasWinner: false, now }),
).toBe(expected);
});
test("a decided set is not live", () => {
expect(
LeagueScheduling.isLive({
scheduledAt,
hasWinner: true,
now: scheduledAt,
}),
).toBe(false);
});
});
describe("LeagueScheduling.availabilityWindow", () => {
test("from playable to the next round while the round is ahead", () => {
expect(
LeagueScheduling.availabilityWindow({
now: PLAYABLE_AT - HOUR,
isPlayableAt: PLAYABLE_AT,
nextIsPlayableAt: PLAYABLE_AT + 7 * DAY,
}),
).toEqual({ startsAt: PLAYABLE_AT, endsAt: PLAYABLE_AT + 7 * DAY });
});
test("from now once the round is playable", () => {
expect(
LeagueScheduling.availabilityWindow({
now: PLAYABLE_AT + DAY,
isPlayableAt: PLAYABLE_AT,
nextIsPlayableAt: PLAYABLE_AT + 7 * DAY,
}),
).toEqual({ startsAt: PLAYABLE_AT + DAY, endsAt: PLAYABLE_AT + 7 * DAY });
});
test("a week without a next round", () => {
expect(
LeagueScheduling.availabilityWindow({
now: PLAYABLE_AT,
isPlayableAt: PLAYABLE_AT,
nextIsPlayableAt: null,
}),
).toEqual({ startsAt: PLAYABLE_AT, endsAt: PLAYABLE_AT + 7 * DAY });
});
test("a week when the next round already opened", () => {
expect(
LeagueScheduling.availabilityWindow({
now: PLAYABLE_AT + 8 * DAY,
isPlayableAt: PLAYABLE_AT,
nextIsPlayableAt: PLAYABLE_AT + 7 * DAY,
}),
).toEqual({
startsAt: PLAYABLE_AT + 8 * DAY,
endsAt: PLAYABLE_AT + 15 * DAY,
});
});
test("a week from now without any playable time", () => {
expect(
LeagueScheduling.availabilityWindow({
now: PLAYABLE_AT,
isPlayableAt: null,
nextIsPlayableAt: null,
}),
).toEqual({ startsAt: PLAYABLE_AT, endsAt: PLAYABLE_AT + 7 * DAY });
});
});

View File

@@ -0,0 +1,225 @@
import type { TimeRange } from "~/features/availability/availability-types";
const HOUR_SECONDS = 60 * 60;
const DAY_SECONDS = 24 * HOUR_SECONDS;
export const LEAGUE_SCHEDULING = {
/** Teams can put candidate times up this long before the round becomes playable. */
OPENS_BEFORE_PLAYABLE_SECONDS: DAY_SECONDS,
/** Sanity cap on a team's open candidates per set. */
MAX_OPEN_PROPOSALS_PER_TEAM: 6,
/** How long before the agreed time a set counts as live while a member streams. */
LIVE_BEFORE_SECONDS: HOUR_SECONDS / 2,
/** How long after the agreed time a set without a winner still counts as live. */
LIVE_AFTER_SECONDS: HOUR_SECONDS,
/** Length of the busy block a scheduled set puts on its players' schedules. */
SET_DURATION_SECONDS: HOUR_SECONDS,
/** Availability is shown until the next round opens, or this long when there is no next round. */
DEFAULT_WINDOW_SECONDS: 7 * DAY_SECONDS,
/** The "starting soon" notification goes out this long before the agreed time. */
STARTING_SOON_SECONDS: HOUR_SECONDS,
/** A round's playable date starts in the earliest time zone (UTC+14), so it is playable wherever that date has begun. */
EARLIEST_UTC_OFFSET_SECONDS: 14 * HOUR_SECONDS,
} as const;
/**
* `CLOSED` = nothing to schedule (no scheduling in the bracket, over, or a team missing), `NOT_OPEN` = the board opens
* later, `UNSCHEDULED` = the teams are agreeing on a time, `SCHEDULED_LOCKED` = a time is agreed but the
* round is not playable yet, `SCHEDULED` = agreed and playable.
*/
export type Phase =
| "CLOSED"
| "NOT_OPEN"
| "UNSCHEDULED"
| "SCHEDULED_LOCKED"
| "SCHEDULED";
export type ProposalError =
| "NOT_OPEN"
| "BEFORE_PLAYABLE"
| "IN_PAST"
| "TOO_MANY"
| "ORGANIZER_LOCKED";
/** Which point of the scheduling flow the set is at; every timestamp in unix seconds. */
export function phase({
hasScheduling,
isOver,
hasBothTeams,
isPlayableAt,
scheduledAt,
now,
}: {
/** False outside leagues and in a league's real-time brackets. */
hasScheduling: boolean;
isOver: boolean;
hasBothTeams: boolean;
isPlayableAt: number | null;
scheduledAt: number | null;
now: number;
}): Phase {
if (!hasScheduling || isOver || !hasBothTeams) return "CLOSED";
const isPlayable = isPlayableAt === null || now >= isPlayableAt;
if (scheduledAt !== null) {
return isPlayable ? "SCHEDULED" : "SCHEDULED_LOCKED";
}
if (now < opensAt(isPlayableAt)) return "NOT_OPEN";
return "UNSCHEDULED";
}
/** When teams can start putting times on the board: a day before the round is playable, right away without a playable time. */
export function opensAt(isPlayableAt: number | null) {
if (isPlayableAt === null) return 0;
return isPlayableAt - LEAGUE_SCHEDULING.OPENS_BEFORE_PLAYABLE_SECONDS;
}
/** When a round picked to be playable on `date`'s calendar day (read in local time) opens: the start of that day in UTC+14. */
export function playableAtFromDate(date: Date) {
return (
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 1000 -
LEAGUE_SCHEDULING.EARLIEST_UTC_OFFSET_SECONDS
);
}
/** The calendar day a round is playable from, as local midnight, so formatting it shows the organizer's picked date in any time zone. */
export function playableDate(isPlayableAt: number) {
const earliestZoneDate = new Date(
(isPlayableAt + LEAGUE_SCHEDULING.EARLIEST_UTC_OFFSET_SECONDS) * 1000,
);
return new Date(
earliestZoneDate.getUTCFullYear(),
earliestZoneDate.getUTCMonth(),
earliestZoneDate.getUTCDate(),
);
}
/** Whether no round becomes playable before an earlier round of its section; rounds in play order, those without a time skipped. */
export function playableAtsAreAscending(
rounds: Array<{ section?: string | null; isPlayableAt?: number | null }>,
) {
const latestBySection = new Map<string | null, number>();
for (const round of rounds) {
if (typeof round.isPlayableAt !== "number") continue;
const section = round.section ?? null;
const latest = latestBySection.get(section);
if (latest !== undefined && round.isPlayableAt < latest) return false;
latestBySection.set(section, round.isPlayableAt);
}
return true;
}
/** Whether a team may replace its candidate times with `proposedAts` now, and if not, why; times it already has up are not rechecked. */
export function validateProposals({
proposedAts,
existingProposedAts,
phase: currentPhase,
isPlayableAt,
now,
setByOrganizer,
}: {
/** The team's full new set of candidates. */
proposedAts: Array<number>;
/** The team's candidates currently on the board. */
existingProposedAts: Array<number>;
phase: Phase;
isPlayableAt: number | null;
now: number;
setByOrganizer: boolean;
}): ProposalError | null {
if (setByOrganizer) return "ORGANIZER_LOCKED";
if (currentPhase === "CLOSED" || currentPhase === "NOT_OPEN") {
return "NOT_OPEN";
}
if (proposedAts.length > LEAGUE_SCHEDULING.MAX_OPEN_PROPOSALS_PER_TEAM) {
return "TOO_MANY";
}
const added = proposedAts.filter(
(proposedAt) => !existingProposedAts.includes(proposedAt),
);
if (added.some((proposedAt) => proposedAt <= now)) return "IN_PAST";
if (
isPlayableAt !== null &&
added.some((proposedAt) => proposedAt < isPlayableAt)
) {
return "BEFORE_PLAYABLE";
}
return null;
}
/** Whether a candidate time can still be picked: it has to be ahead. */
export function isAcceptableProposal({
proposedAt,
now,
}: {
proposedAt: number;
now: number;
}) {
return proposedAt > now;
}
/** The span around the agreed time in which the set counts as live while somebody streams it. */
export function liveWindow(scheduledAt: number): TimeRange {
return {
startsAt: scheduledAt - LEAGUE_SCHEDULING.LIVE_BEFORE_SECONDS,
endsAt: scheduledAt + LEAGUE_SCHEDULING.LIVE_AFTER_SECONDS,
};
}
/** Whether the set is inside its live window and still undecided. */
export function isLive({
scheduledAt,
hasWinner,
now,
}: {
scheduledAt: number;
hasWinner: boolean;
now: number;
}) {
if (hasWinner) return false;
const window = liveWindow(scheduledAt);
return now >= window.startsAt && now < window.endsAt;
}
/** The span a scheduled set blocks on its players' schedules. */
export function busyBlock(scheduledAt: number): TimeRange {
return {
startsAt: scheduledAt,
endsAt: scheduledAt + LEAGUE_SCHEDULING.SET_DURATION_SECONDS,
};
}
/**
* The span the availability panel covers: from the round becoming playable (or now, once it is) to
* the next round becoming playable, a week when there is no next round or it opens no later.
*/
export function availabilityWindow({
now,
isPlayableAt,
nextIsPlayableAt,
}: {
now: number;
isPlayableAt: number | null;
nextIsPlayableAt: number | null;
}): TimeRange {
const startsAt = Math.max(now, isPlayableAt ?? now);
const endsAt =
nextIsPlayableAt !== null && nextIsPlayableAt > startsAt
? nextIsPlayableAt
: startsAt + LEAGUE_SCHEDULING.DEFAULT_WINDOW_SECONDS;
return { startsAt, endsAt };
}

View File

@@ -1,16 +1,18 @@
import cachified from "@epic-web/cachified";
import type { LoaderFunctionArgs } from "react-router";
import type { WindowSchedule } from "~/features/availability/availability-types";
import * as Availability from "~/features/availability/core/Availability";
import * as VisibleSchedules from "~/features/availability/core/VisibleSchedules.server";
import * as RouteChatRooms from "~/features/chat/RouteChatRooms.server";
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
import * as ScannerIngestRepository from "~/features/scanner-ingest/ScannerIngestRepository.server";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import {
isLeagueRoundLocked,
resolveLeagueRoundStartDate,
} from "~/features/tournament/tournament-utils";
import type { Bracket } from "~/features/tournament-bracket/core/Bracket";
import { matchEndedEarly } from "~/features/tournament-bracket/core/engine";
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import {
tournamentFromParams,
tournamentTeamsFullCached,
@@ -19,12 +21,13 @@ import { matchPageParamsSchema } from "~/features/tournament-bracket/tournament-
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { databaseTimestampNow } from "~/utils/dates";
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
import { logger } from "~/utils/logger";
import type { SerializeFrom } from "~/utils/remix";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import { executeRoll } from "../core/executeRoll.server";
import * as LeagueScheduling from "../core/LeagueScheduling";
import { mapListFromResults, resolveMapList } from "../core/mapList.server";
import * as TournamentMatchRepository from "../TournamentMatchRepository.server";
@@ -164,24 +167,26 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
const isTournamentStaff = tournament.isOrganizer(user);
const isParticipant = match.players.some((p) => p.id === user?.id);
const leagueRoundLocked = isLeagueRoundLocked(tournament, match.roundId);
const bracketIdx = tournament.matchIdToBracketIdx(matchId);
const bracket =
typeof bracketIdx === "number" ? tournament.bracketByIdx(bracketIdx) : null;
const schedule = await resolveLeagueSchedule({
tournament,
match,
bracket,
user,
isParticipant,
matchIsOver,
});
const canJoin =
!matchIsOver &&
match.opponentOne?.id != null &&
match.opponentTwo?.id != null &&
(isParticipant || tournament.isOrganizerOrStreamer(user)) &&
!leagueRoundLocked;
const bracketIdx = tournament.matchIdToBracketIdx(matchId);
const bracket =
typeof bracketIdx === "number" ? tournament.bracketByIdx(bracketIdx) : null;
const leagueRoundStartDate = leagueRoundLocked
? resolveLeagueRoundStartDate(
tournament,
bracket ?? undefined,
match.roundId,
)
: null;
(schedule.phase === "CLOSED" || schedule.phase === "SCHEDULED");
return {
...(await UserCardRepository.findAllByUserIdsCached({
@@ -217,6 +222,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
: [],
),
canJoin,
schedule,
// the views can't derive these themselves, the layout ships no bracket match data
bracketContext: {
bracketIdx,
@@ -230,10 +236,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
),
names: tournament.matchContextNamesById(matchId),
canBeReopened: tournament.matchCanBeReopened(matchId),
leagueRoundLocked,
leagueRoundStartDate: leagueRoundStartDate
? dateToDatabaseTimestamp(leagueRoundStartDate)
: null,
},
pickBanEventCount: pickBanEvents.length,
pickBanEvents: pickBanEvents.map((e) => ({
@@ -244,3 +246,156 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
})),
};
};
const WEEK_SECONDS = 7 * 24 * 60 * 60;
/**
* Where the set is in the league scheduling flow. The candidate board is only for the two teams
* and organizers/streamers, the availability panel only for the viewer's own team.
*/
async function resolveLeagueSchedule({
tournament,
match,
bracket,
user,
isParticipant,
matchIsOver,
}: {
tournament: Tournament;
match: NonNullable<TournamentMatchRepository.FindMatchById>;
bracket: Bracket | null;
user: { id: number } | undefined;
isParticipant: boolean;
matchIsOver: boolean;
}) {
const now = databaseTimestampNow();
const hasScheduling = bracket?.hasScheduling ?? false;
const isPlayableAt = hasScheduling ? match.roundIsPlayableAt : null;
const phase = LeagueScheduling.phase({
hasScheduling,
isOver: matchIsOver,
hasBothTeams: Boolean(match.opponentOne?.id && match.opponentTwo?.id),
isPlayableAt,
scheduledAt: match.scheduledAt,
now,
});
const ownTeamId =
match.players.find((player) => player.id === user?.id)?.tournamentTeamId ??
null;
const canSeeBoard =
hasScheduling && (isParticipant || tournament.isOrganizerOrStreamer(user));
const boardOpen = phase !== "CLOSED" && phase !== "NOT_OPEN";
if (hasScheduling && user) {
for (const type of [
"TO_LEAGUE_TIMES_PROPOSED",
"TO_LEAGUE_MATCH_SCHEDULED",
"TO_LEAGUE_MATCH_STARTING_SOON",
] as const) {
await resolveNotifications({
userIds: [user.id],
type,
meta: { matchId: match.id },
});
}
}
return {
hasScheduling,
phase,
now,
isPlayableAt,
opensAt: LeagueScheduling.opensAt(isPlayableAt),
scheduledAt: match.scheduledAt,
scheduleSetByOrganizer: Boolean(match.scheduleSetByOrganizer),
ownTeamId,
canSeeBoard,
proposals:
canSeeBoard && boardOpen
? await TournamentMatchRepository.findScheduleProposalsByMatchId(
match.id,
)
: [],
availability:
user && ownTeamId && boardOpen
? await ownTeamAvailability({
tournament,
viewerId: user.id,
ownTeamId,
window: LeagueScheduling.availabilityWindow({
now,
isPlayableAt,
nextIsPlayableAt: nextRoundPlayableAt(bracket, match),
}),
})
: null,
};
}
function nextRoundPlayableAt(
bracket: Bracket | null,
match: NonNullable<TournamentMatchRepository.FindMatchById>,
) {
const round = bracket?.data.round.find((r) => r.id === match.roundId);
if (!round) return null;
return (
bracket?.data.round.find(
(r) =>
r.groupId === round.groupId &&
r.section === round.section &&
r.number === round.number + 1,
)?.isPlayableAt ?? null
);
}
/** Every member of the viewer's own team, sharing implied by the roster. The set's own league doesn't count as busy. */
async function ownTeamAvailability({
tournament,
viewerId,
ownTeamId,
window,
}: {
tournament: Tournament;
viewerId: number;
ownTeamId: number;
window: { startsAt: number; endsAt: number };
}) {
const memberUserIds = tournament.teamById(ownTeamId)?.memberUserIds ?? [];
const { reportedWeeks, busyByUserId } = await VisibleSchedules.findByUserIds({
userIds: memberUserIds,
viewerId,
...window,
excludeTournamentId: tournament.ctx.id,
bypassVisibility: true,
});
return {
window,
minPlayers: tournament.minMembersPerTeam,
members: memberUserIds.map((userId): WindowSchedule => {
const memberWeeks = reportedWeeks.filter(
(week) => week.userId === userId,
);
const busy = busyByUserId.get(userId) ?? [];
return {
userId,
reported: memberWeeks.some(
(week) =>
week.weekStartsAt < window.endsAt &&
week.weekStartsAt + WEEK_SECONDS > window.startsAt,
),
ranges: Availability.subtract(
Availability.clip(
memberWeeks.flatMap((week) => week.slots),
window,
),
busy,
),
busy: busy.filter((block) => Availability.overlaps(block, window)),
};
}),
};
}

View File

@@ -0,0 +1,160 @@
import { type LoaderFunctionArgs, redirect } from "react-router";
import * as R from "remeda";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import { tournamentFromParams } from "~/features/tournament-bracket/core/Tournament.server";
import { tournamentBracketsPage } from "~/features/tournament-bracket/tournament-bracket-urls";
import { databaseTimestampNow } from "~/utils/dates";
import type { SerializeFrom } from "~/utils/remix";
import * as LeagueScheduling from "../core/LeagueScheduling";
import * as TournamentMatchRepository from "../TournamentMatchRepository.server";
import {
ALL_DIVISIONS,
tournamentMatchesSearchParams,
} from "../tournament-matches-search-params";
export type TournamentMatchesLoaderData = SerializeFrom<typeof loader>;
export type TournamentMatchesLoaderMatch =
TournamentMatchesLoaderData["matches"][number];
/**
* Every set of one league division, or of all of them, with where it is in the scheduling flow.
* Without a division in the URL the viewer's own is shown, or the first one for anyone else.
*/
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const { tournament, tournamentId, user } = await tournamentFromParams(
params,
{ for: "view" },
);
if (!tournament.isLeague || !tournament.hasStarted) {
throw redirect(tournamentBracketsPage({ tournamentId }));
}
const searchParams = tournamentMatchesSearchParams.parse(request);
const ownTeam = tournament.teamMemberOfByUser(user);
const divisionIdx =
searchParams.division === ALL_DIVISIONS
? null
: resolveDivisionIdx({
tournament,
requested: searchParams.division,
ownDivisionIdx: ownTeam ? (ownTeam.startingBracketIdx ?? 0) : null,
});
const lastResultAts = new Map(
(
await TournamentMatchRepository.findLastResultAtsByTournamentId(
tournamentId,
)
).map((row) => [row.id, row.lastResultAt]),
);
const now = databaseTimestampNow();
const streamingParticipantIds = tournament.streamingParticipantIds;
const castedMatchIds = new Set(
[
...(tournament.ctx.castedMatchesInfo?.lockedMatches ?? []),
...(tournament.ctx.castedMatchesInfo?.castedMatches ?? []),
].map((cast) => cast.matchId),
);
const matches = tournament.brackets.flatMap((bracket, bracketIdx) => {
if (bracket.preview || !bracket.hasScheduling) return [];
if (
divisionIdx !== null &&
tournament.leagueDivisionOfBracket(bracketIdx) !== divisionIdx
) {
return [];
}
return bracket.data.match.flatMap((match) => {
const teamOne = match.opponent1?.id
? tournament.teamById(match.opponent1.id)
: null;
const teamTwo = match.opponent2?.id
? tournament.teamById(match.opponent2.id)
: null;
if (!teamOne || !teamTwo) return [];
const round = bracket.data.round.find((r) => r.id === match.roundId);
const scheduledAt = match.scheduledAt ?? null;
const members = [...teamOne.memberUserIds, ...teamTwo.memberUserIds];
return [
{
id: match.id,
bracketIdx,
bracketName: bracket.name,
roundName:
tournament.matchContextNamesById(match.id)
?.roundNameWithoutMatchIdentifier ?? "",
roundNumber: round?.number ?? 0,
teams: [teamOne, teamTwo].map((team) => ({
id: team.id,
name: team.name,
logoUrl: team.logoUrl,
score:
(team.id === teamOne.id ? match.opponent1 : match.opponent2)
?.score ?? 0,
})),
winnerTeamId:
match.winnerSide === "opponent1"
? teamOne.id
: match.winnerSide === "opponent2"
? teamTwo.id
: null,
scheduledAt,
isSchedulable:
LeagueScheduling.phase({
hasScheduling: true,
isOver: match.winnerSide !== null,
hasBothTeams: true,
isPlayableAt: round?.isPlayableAt ?? null,
scheduledAt,
now,
}) === "UNSCHEDULED",
lastResultAt: lastResultAts.get(match.id) ?? null,
isCasted: castedMatchIds.has(match.id),
isLive:
scheduledAt !== null &&
LeagueScheduling.isLive({
scheduledAt,
hasWinner: match.winnerSide !== null,
now,
}) &&
members.some((userId) => streamingParticipantIds.includes(userId)),
isOwn: ownTeam
? teamOne.id === ownTeam.id || teamTwo.id === ownTeam.id
: false,
},
];
});
});
return {
divisionIdx,
matches: R.sortBy(
matches,
(match) => match.scheduledAt ?? Number.POSITIVE_INFINITY,
),
};
};
function resolveDivisionIdx({
tournament,
requested,
ownDivisionIdx,
}: {
tournament: Tournament;
requested: number | null;
ownDivisionIdx: number | null;
}) {
const divisions = tournament.leagueDivisions;
const isDivision = (idx: number | null) =>
idx !== null && divisions.some((division) => division.idx === idx);
if (isDivision(requested)) return requested;
if (isDivision(ownDivisionIdx)) return ownDivisionIdx;
return divisions[0]?.idx ?? 0;
}

View File

@@ -34,6 +34,8 @@ type MatchPageContextValue = {
isPickBanStep: boolean;
matchIsLocked: boolean;
waitingForPreviousMatch: boolean;
/** The viewer's team of the set when they play for one. */
ownTeamId: number | null;
joinPool: string | null;
joinPass: string | null;
};
@@ -117,6 +119,9 @@ export function MatchPageProvider({
});
const waitingForPreviousMatch = data.match.status === "PENDING";
// a league set is played once the teams have agreed on a time and the round is playable
const leagueBlocksPlay =
data.schedule.phase !== "CLOSED" && data.schedule.phase !== "SCHEDULED";
const joinInfo = resolveJoinInfo({ tournament, data, teams });
@@ -137,7 +142,11 @@ export function MatchPageProvider({
isPickBanStep,
isAdminEligible:
tournament.isOrganizerOrStreamer(user) && !tournament.ctx.isFinalized,
leagueRoundLocked: data.bracketContext.leagueRoundLocked,
leagueBlocksPlay,
hasScheduleBoard:
data.schedule.canSeeBoard &&
data.schedule.phase !== "CLOSED" &&
data.schedule.phase !== "NOT_OPEN",
lockedForCast,
waitingForPreviousMatch,
});
@@ -156,6 +165,7 @@ export function MatchPageProvider({
isPickBanStep,
matchIsLocked: lockedForCast,
waitingForPreviousMatch,
ownTeamId: data.schedule.ownTeamId,
joinPool: joinInfo?.pool ?? null,
joinPass: joinInfo?.pass ?? null,
}}
@@ -182,7 +192,8 @@ function resolveVisibleTabs({
hasPickBanEvents,
isPickBanStep,
isAdminEligible,
leagueRoundLocked,
leagueBlocksPlay,
hasScheduleBoard,
lockedForCast,
waitingForPreviousMatch,
}: {
@@ -194,14 +205,18 @@ function resolveVisibleTabs({
hasPickBanEvents: boolean;
isPickBanStep: boolean;
isAdminEligible: boolean;
leagueRoundLocked: boolean;
leagueBlocksPlay: boolean;
hasScheduleBoard: boolean;
lockedForCast: boolean;
waitingForPreviousMatch: boolean;
}): MatchTabKey[] {
const tabs: MatchTabKey[] = [TAB_KEYS.ROSTERS];
if (hasScheduleBoard) {
tabs.push(TAB_KEYS.SCHEDULE);
}
if (
!leagueRoundLocked &&
!leagueBlocksPlay &&
!waitingForPreviousMatch &&
(isPickBanStep ||
(canReportScore &&

View File

@@ -392,4 +392,39 @@ describe("Tournament match page", () => {
await expect(loadMatchData()).resolves.toBeDefined();
});
});
describe("league scheduling", () => {
const startLeagueMatch = async (isRealtime: boolean) => {
const league = await TournamentFactory.create(
{ authorId: organizerId },
{ isLeague: true },
);
await createTournamentTeam(league.id, users.ids(ROSTER_SIZE));
await createTournamentTeam(
league.id,
users.ids(ROSTER_SIZE * 2).slice(ROSTER_SIZE),
);
const [match] = await TournamentFactory.startBracket(league.id, {
isRealtime,
});
return { id: String(league.id), mid: String(match.id) };
};
test.each([
{ isRealtime: false, hasScheduling: true, phase: "UNSCHEDULED" },
{ isRealtime: true, hasScheduling: false, phase: "CLOSED" },
])(
"a set of a league bracket with isRealtime $isRealtime is $phase",
async ({ isRealtime, hasScheduling, phase }) => {
const data = await tournamentMatchLoader({
params: await startLeagueMatch(isRealtime),
});
expect(data.schedule.hasScheduling).toBe(hasScheduling);
expect(data.schedule.phase).toBe(phase);
},
);
});
});

View File

@@ -15,7 +15,7 @@ import { tournamentMatchChannel } from "../tournament-match-utils";
export { action, loader };
export const handle: SendouRouteHandle = {
i18n: ["q", "user"],
i18n: ["q", "user", "schedule"],
};
export default function TournamentMatchPage() {

View File

@@ -0,0 +1,123 @@
.divisionSelect {
max-width: 240px;
}
.list {
display: flex;
flex-direction: column;
gap: var(--s-2);
list-style: none;
padding: 0;
margin: 0;
}
.row {
border-radius: var(--radius-box);
background-color: var(--color-bg-high);
border: var(--border-width) solid transparent;
transition: background-color 0.2s;
&:hover {
background-color: var(--color-bg-higher);
}
}
.ownRow {
border-color: var(--color-fg-accent);
}
.rowLink {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--s-1) var(--s-4);
align-items: center;
padding: var(--s-2) var(--s-3);
color: var(--color-text);
font-size: var(--font-xs);
}
.round {
font-size: var(--font-2xs);
color: var(--color-text-high);
font-weight: var(--weight-semi);
}
.teams {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-2);
min-width: 0;
}
.team {
display: inline-flex;
align-items: center;
gap: var(--s-1-5);
font-weight: var(--weight-semi);
min-width: 0;
}
.teamName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.loser {
color: var(--color-text-high);
}
.vs {
color: var(--color-text-high);
font-weight: var(--weight-body);
margin-inline-end: var(--s-1);
}
.score {
font-variant-numeric: tabular-nums;
color: var(--color-fg-accent);
}
.badges {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--s-1);
align-self: start;
}
.meta {
align-self: end;
justify-self: end;
text-align: end;
}
.time {
font-weight: var(--weight-semi);
white-space: nowrap;
}
.muted {
color: var(--color-text-high);
}
.badge {
display: inline-flex;
align-items: center;
gap: var(--s-1);
height: var(--selector-size-xs);
padding: 0 var(--s-1-5);
border-radius: var(--radius-selector);
background-color: var(--color-bg-higher);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
white-space: nowrap;
text-transform: uppercase;
}
.liveBadge {
padding: 0 var(--s-1);
background-color: var(--color-fill-accent);
color: var(--color-fg-on-accent);
}

View File

@@ -0,0 +1,301 @@
import clsx from "clsx";
import { Tv } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link, useLoaderData } from "react-router";
import * as R from "remeda";
import { Avatar } from "~/components/Avatar";
import { SendouSelect, SendouSelectItem } from "~/components/elements/Select";
import {
SendouTab,
SendouTabList,
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import { LocaleTime } from "~/components/LocaleTime";
import { useTournament } from "~/features/tournament/tournament-context";
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
import { tournamentMatchPage } from "~/utils/urls";
import type {
TournamentMatchesLoaderData,
TournamentMatchesLoaderMatch,
} from "../loaders/to.$id.matches.server";
import {
ALL_DIVISIONS,
TOURNAMENT_MATCHES_TABS,
type TournamentMatchesTab,
tournamentMatchesSearchParams,
} from "../tournament-matches-search-params";
import styles from "./to.$id.matches.module.css";
export { loader } from "../loaders/to.$id.matches.server";
const TIME_FORMAT: Intl.DateTimeFormatOptions = {
weekday: "short",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
};
export default function TournamentMatchesPage() {
const { t } = useTranslation(["tournament"]);
const tournament = useTournament();
const data = useLoaderData<TournamentMatchesLoaderData>();
const [{ tab }, setParams] = useSearchParamsTyped(
tournamentMatchesSearchParams,
);
const isTab = (value: unknown): value is TournamentMatchesTab =>
TOURNAMENT_MATCHES_TABS.some((candidate) => candidate === value);
return (
<div className="stack md">
{tournament.leagueDivisions.length > 1 ? (
<SendouSelect
aria-label={t("tournament:matches.division")}
items={[
{
id: ALL_DIVISIONS,
name: t("tournament:matches.allDivisions"),
},
...tournament.leagueDivisions.map((division) => ({
id: String(division.idx),
name: division.name,
})),
]}
selectedKey={
data.divisionIdx === null ? ALL_DIVISIONS : String(data.divisionIdx)
}
onSelectionChange={(key) =>
setParams({
division: key === ALL_DIVISIONS ? ALL_DIVISIONS : Number(key),
})
}
className={styles.divisionSelect}
data-testid="matches-division-select"
>
{({ id, name }) => (
<SendouSelectItem key={id} id={id}>
{name}
</SendouSelectItem>
)}
</SendouSelect>
) : null}
<SendouTabs
selectedKey={tab}
onSelectionChange={(key) => {
if (isTab(key)) setParams({ tab: key });
}}
>
<SendouTabList>
{TOURNAMENT_MATCHES_TABS.map((key) => (
<SendouTab key={key} id={key} data-testid={`matches-tab-${key}`}>
{t(`tournament:matches.tabs.${key}`)}
</SendouTab>
))}
</SendouTabList>
<SendouTabPanel id="scheduled">
<ScheduledSets matches={data.matches} />
</SendouTabPanel>
<SendouTabPanel id="unscheduled">
<UnscheduledSets matches={data.matches} />
</SendouTabPanel>
<SendouTabPanel id="past">
<PastSets matches={data.matches} />
</SendouTabPanel>
</SendouTabs>
</div>
);
}
function ScheduledSets({
matches,
}: {
matches: Array<TournamentMatchesLoaderMatch>;
}) {
const { t } = useTranslation(["tournament"]);
const scheduled = R.sortBy(
matches.filter(
(match) => match.scheduledAt !== null && match.winnerTeamId === null,
),
(match) => match.scheduledAt ?? 0,
);
if (scheduled.length === 0) {
return <EmptyState>{t("tournament:matches.empty.scheduled")}</EmptyState>;
}
return (
<ul className={styles.list} data-testid="scheduled-sets">
{scheduled.map((match) => (
<SetRow
key={match.id}
match={match}
badges={<SetBadges match={match} />}
>
{match.scheduledAt !== null ? (
<LocaleTime
date={match.scheduledAt}
options={TIME_FORMAT}
className={styles.time}
inline
/>
) : null}
</SetRow>
))}
</ul>
);
}
function UnscheduledSets({
matches,
}: {
matches: Array<TournamentMatchesLoaderMatch>;
}) {
const { t } = useTranslation(["tournament"]);
const unscheduled = matches.filter((match) => match.isSchedulable);
if (unscheduled.length === 0) {
return <EmptyState>{t("tournament:matches.empty.unscheduled")}</EmptyState>;
}
const sorted = R.sortBy(
unscheduled,
(match) => match.bracketIdx,
(match) => match.roundNumber,
(match) => match.roundName,
);
return (
<ul className={styles.list} data-testid="unscheduled-sets">
{sorted.map((match) => (
<SetRow key={match.id} match={match} />
))}
</ul>
);
}
function PastSets({
matches,
}: {
matches: Array<TournamentMatchesLoaderMatch>;
}) {
const { t } = useTranslation(["tournament"]);
const past = R.sortBy(
matches.filter((match) => match.winnerTeamId !== null),
[(match) => match.lastResultAt ?? match.scheduledAt ?? 0, "desc"],
);
if (past.length === 0) {
return <EmptyState>{t("tournament:matches.empty.past")}</EmptyState>;
}
return (
<ul className={styles.list} data-testid="past-sets">
{past.map((match) => (
<SetRow key={match.id} match={match} showScore>
{match.lastResultAt !== null ? (
<LocaleTime
date={match.lastResultAt}
options={TIME_FORMAT}
className={styles.muted}
inline
/>
) : null}
</SetRow>
))}
</ul>
);
}
function SetRow({
match,
showScore = false,
badges,
children,
}: {
match: TournamentMatchesLoaderMatch;
showScore?: boolean;
badges?: React.ReactNode;
children?: React.ReactNode;
}) {
const tournament = useTournament();
return (
<li
className={clsx(styles.row, { [styles.ownRow]: match.isOwn })}
data-testid={`set-row-${match.id}`}
>
<Link
to={tournamentMatchPage({
tournamentId: tournament.ctx.id,
matchId: match.id,
})}
className={styles.rowLink}
>
<span className={styles.round}>
{tournament.leagueDivisions.length > 1 && !showScore
? `${match.bracketName} · `
: null}
{match.roundName}
</span>
<span className={styles.badges}>{badges}</span>
<span className={styles.teams}>
{match.teams.map((team, index) => (
<span
key={team.id}
className={clsx(styles.team, {
[styles.loser]: showScore && match.winnerTeamId !== team.id,
})}
>
{index === 1 ? <span className={styles.vs}>vs.</span> : null}
<Avatar
size="xxs"
url={team.logoUrl ?? undefined}
identiconInput={team.name}
/>
<span className={styles.teamName}>{team.name}</span>
{showScore ? (
<span className={styles.score}>{team.score}</span>
) : null}
</span>
))}
</span>
<span className={styles.meta}>{children}</span>
</Link>
</li>
);
}
function SetBadges({ match }: { match: TournamentMatchesLoaderMatch }) {
const { t } = useTranslation(["tournament"]);
return (
<>
{match.isLive ? (
<span
className={clsx(styles.badge, styles.liveBadge)}
data-testid="live-badge"
>
{t("tournament:matches.live")}
</span>
) : null}
{match.isCasted ? (
<span className={styles.badge} data-testid="cast-badge">
<Tv size={12} /> {t("tournament:matches.cast")}
</span>
) : null}
</>
);
}
function EmptyState({ children }: { children: React.ReactNode }) {
return (
<div className="text-center text-lighter font-semi-bold py-4">
{children}
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { add } from "date-fns";
import * as v from "valibot";
import { array, datetime, stringConstant } from "~/form/fields";
import { _action, id } from "~/utils/schema";
import { LEAGUE_SCHEDULING } from "./core/LeagueScheduling";
const CANDIDATE_MAX_DAYS_AHEAD = 60;
const leagueTimeField = (label: "labels.candidateTime" | "labels.setTime") =>
datetime({
label,
min: () => new Date(),
max: () => add(new Date(), { days: CANDIDATE_MAX_DAYS_AHEAD }),
minMessage: "errors.dateInPast",
});
/** A team's full set of candidate times for its league set, replacing what it had up. */
export const proposeLeagueTimesSchema = v.object({
_action: stringConstant("PROPOSE_TIMES"),
times: array({
bottomText: "bottomTexts.candidateTimes",
max: LEAGUE_SCHEDULING.MAX_OPEN_PROPOSALS_PER_TEAM,
field: leagueTimeField("labels.candidateTime"),
}),
});
/** The organizer's final say on when the set is played. */
export const organizerSetLeagueTimeSchema = v.object({
_action: stringConstant("ORGANIZER_SET_TIME"),
scheduledAt: leagueTimeField("labels.setTime"),
});
export const leagueScheduleSchemas = [
proposeLeagueTimesSchema,
organizerSetLeagueTimeSchema,
v.object({
_action: _action("ACCEPT_PROPOSAL"),
proposalId: id,
}),
v.object({
_action: _action("REJECT_RESCHEDULE"),
}),
] as const;

View File

@@ -0,0 +1,24 @@
import { describe, test } from "vitest";
import {
assertDecodesToDefault,
assertRoundTrips,
} from "~/modules/search-params/search-params-test-utils";
import { tournamentMatchesSearchParams } from "./tournament-matches-search-params";
describe("tournamentMatchesSearchParams", () => {
test("round-trips", () => {
assertRoundTrips(tournamentMatchesSearchParams, {
tab: ["scheduled", "unscheduled", "past"],
division: [null, 0, 3, "all"],
});
});
test("malformed values decode to defaults", () => {
assertDecodesToDefault(tournamentMatchesSearchParams, "tab", [["garbage"]]);
assertDecodesToDefault(tournamentMatchesSearchParams, "division", [
["-1"],
["abc"],
[""],
]);
});
});

View File

@@ -0,0 +1,39 @@
import * as v from "valibot";
import * as SearchParams from "~/modules/search-params/search-params";
import { codec, SP } from "~/modules/search-params/search-params";
export const TOURNAMENT_MATCHES_TABS = [
"scheduled",
"unscheduled",
"past",
] as const;
export type TournamentMatchesTab = (typeof TOURNAMENT_MATCHES_TABS)[number];
export const ALL_DIVISIONS = "all";
const divisionCodec = codec(
v.nullable(
v.union([
v.literal(ALL_DIVISIONS),
v.pipe(v.number(), v.integer(), v.minValue(0)),
]),
),
{
decode: (value) =>
value === ALL_DIVISIONS || value.trim() === "" ? value : Number(value),
encode: (value) => String(value),
},
);
export const tournamentMatchesSearchParams = SearchParams.define({
tab: SP.param(v.picklist(TOURNAMENT_MATCHES_TABS), {
default: "scheduled",
loader: false,
}),
/** Starting bracket idx of the league division whose sets are listed, or every division; null resolves to the viewer's own division or the first one. */
division: SP.custom(divisionCodec, {
default: null,
loader: true,
}),
});

View File

@@ -1861,6 +1861,18 @@ export function updateTeamSeeds({
});
}
/** Tier of every league division (starting bracket) of the tournament that has one. */
export function findDivisionTiersByTournamentId(tournamentId: number) {
return db
.selectFrom("TournamentDivisionTier")
.select([
"TournamentDivisionTier.bracketIdx",
"TournamentDivisionTier.tier",
])
.where("TournamentDivisionTier.tournamentId", "=", tournamentId)
.execute();
}
/**
* Records the tier of one division (= starting bracket) from its checked-in teams and sets the
* tournament's own tier to the best of its divisions (the same thing when there is one division).

View File

@@ -1,5 +1,6 @@
import clsx from "clsx";
import {
CalendarClock,
ClipboardCheck,
LayoutGrid,
Medal,
@@ -29,6 +30,7 @@ type NavItemKey =
| "register"
| "brackets"
| "divisions"
| "matches"
| "teams"
| "streams"
| "results"
@@ -49,6 +51,7 @@ const PRIORITY_ORDER: NavItemKey[] = [
"register",
"brackets",
"divisions",
"matches",
"teams",
"results",
"lfg",
@@ -171,8 +174,8 @@ function useNavItems({
};
}
// a league's brackets are reached through its divisions page, one division at a time
if (tournament.isLeague) {
// a league with several divisions reaches its brackets through the divisions page, one division at a time
if (tournament.leagueDivisions.length > 1) {
items.divisions = {
key: "divisions",
label: t("tournament:nav.divisions"),
@@ -190,6 +193,16 @@ function useNavItems({
};
}
if (tournament.isLeague && tournament.hasStarted) {
items.matches = {
key: "matches",
label: t("tournament:nav.matches"),
to: "matches",
icon: <CalendarClock />,
testId: "matches-tab",
};
}
items.teams = {
key: "teams",
label: t("tournament:nav.teams", {

View File

@@ -1,5 +1,5 @@
import clsx from "clsx";
import { AlertCircle, Check, UserRound, UsersRound, X } from "lucide-react";
import { Check, UserRound, UsersRound, X } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useFetcher, useLoaderData } from "react-router";
@@ -351,12 +351,6 @@ function RegistrationProgress({
status: completedIfTruthy(checkedIn),
}
: null,
tournament.isLeague
? {
name: t("tournament:pre.steps.googleSheet"),
status: "notice" as const,
}
: null,
].filter((step) => step !== null);
const regClosesBeforeStart =
@@ -389,8 +383,6 @@ function RegistrationProgress({
className="color-success"
data-testid={`checkmark-icon-num-${i + 1}`}
/>
) : step.status === "notice" ? (
<AlertCircle className="color-info" />
) : (
<X className="color-error" />
)}

View File

@@ -1,9 +1,7 @@
import { sub } from "date-fns";
import * as R from "remeda";
import type { CastedMatchesInfo, TeamPickSettings } from "~/db/tables-json";
import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort } from "~/modules/in-game-lists/types";
import { databaseTimestampToDate } from "~/utils/dates";
import { SHORT_NANOID_LENGTH } from "~/utils/id";
import type { Tables } from "../../db/tables";
import * as Seasons from "../mmr/core/Seasons";
@@ -11,6 +9,7 @@ import type { Bracket as BracketClass } from "../tournament-bracket/core/Bracket
import type { ParsedBracket } from "../tournament-bracket/core/Progression";
import * as Progression from "../tournament-bracket/core/Progression";
import type { Tournament as TournamentClass } from "../tournament-bracket/core/Tournament";
import * as LeagueScheduling from "../tournament-match/core/LeagueScheduling";
import * as TeamPick from "./core/TeamPick";
/**
@@ -80,8 +79,8 @@ export function tournamentInWeaponReportingWindow({
return tournamentStartTime > windowStart;
}
/** Datetime the league round is played by default, or null if the round has no default play time. */
export function resolveLeagueRoundStartDate(
/** Time the league round's sets are playable from, or null when the round has no such time (or the tournament is no league). */
export function leagueRoundPlayableAt(
tournament: TournamentClass,
bracket: BracketClass | undefined,
roundId: number,
@@ -89,25 +88,9 @@ export function resolveLeagueRoundStartDate(
if (!tournament.isLeague) return null;
const round = bracket?.data.round.find((r) => r.id === roundId);
if (!round?.defaultPlayTime) return null;
if (!round?.isPlayableAt) return null;
return databaseTimestampToDate(round.defaultPlayTime);
}
const EARLIEST_TIMEZONE_OFFSET_HOURS = 14;
export function isLeagueRoundLocked(
tournament: TournamentClass,
roundId: number,
) {
const bracket = tournament.brackets.find((b) =>
b.data.round.some((r) => r.id === roundId),
);
const date = resolveLeagueRoundStartDate(tournament, bracket, roundId);
if (!date) return false;
return sub(date, { hours: EARLIEST_TIMEZONE_OFFSET_HOURS }) > new Date();
return LeagueScheduling.playableDate(round.isPlayableAt);
}
export function validateCanJoinTeam({

View File

@@ -8,6 +8,7 @@ import { FormField } from "./FormField";
import {
array,
checkboxGroup,
datetime,
fieldset,
radioGroup,
select,
@@ -74,6 +75,10 @@ const CHECKBOX_GROUP = v.object({
}),
});
const DATETIME = v.object({
startTime: datetime({ label: "labels.startTime" }),
});
const TIME_RANGE = v.object({
times: timeRangeOptional({}),
});
@@ -251,6 +256,32 @@ describe("SendouForm", () => {
});
});
describe("datetime field", () => {
test("shows required error on submit when empty", async () => {
const screen = await renderForm(DATETIME);
await screen.getByRole("button", { name: "Submit" }).click();
await expect
.element(screen.getByText("This field is required"))
.toBeVisible();
});
test("shows invalid date error on submit when the date is incomplete", async () => {
const screen = await renderForm(DATETIME, {
defaultValues: { startTime: new Date(2026, 8, 15, 18, 0) },
});
await userEvent.click(screen.getByLabelText("Start time").element());
await userEvent.keyboard("{Backspace}");
await screen.getByRole("button", { name: "Submit" }).click();
await expect
.element(screen.getByText("Date is incomplete or doesn't exist"))
.toBeVisible();
});
});
describe("text area", () => {
test("renders textarea element", async () => {
const schema = v.object({

View File

@@ -29,11 +29,30 @@
gap: var(--s-4);
}
.itemRow {
display: flex;
align-items: flex-start;
gap: var(--s-2);
width: 100%;
&:not(:has(.itemInput label)) .labelSpacer {
display: none;
}
}
.itemInput {
flex: 1;
}
.removeButtonColumn {
display: flex;
flex-direction: column;
}
.labelSpacer {
visibility: hidden;
}
.removeButton {
height: var(--field-size);
align-self: flex-end;
}

View File

@@ -3,6 +3,7 @@ import type * as React from "react";
import { useTranslation } from "react-i18next";
import { isDeepEqual, omit } from "remeda";
import { SendouButton } from "~/components/elements/Button";
import { SendouLabel } from "~/components/elements/Label";
import { FormMessage } from "~/components/FormMessage";
import type { FormFieldProps } from "../types";
import styles from "./ArrayFormField.module.css";
@@ -139,23 +140,26 @@ export function ArrayFormField({
</ArrayItemFieldset>
))
: Array.from({ length: visibleCount }).map((_, idx) => (
<div
key={itemKey(idx)}
className="stack horizontal sm items-start w-full"
>
<div key={itemKey(idx)} className={styles.itemRow}>
<div className={styles.itemInput}>
{renderItem(idx, `${name}[${idx}]`)}
</div>
{canRemoveAt(idx) ? (
<SendouButton
icon={<Trash />}
aria-label="Remove item"
size="small"
variant="minimal-destructive"
onClick={() => handleRemoveAt(idx)}
className={styles.removeButton}
data-testid={`${name}-remove-item-button`}
/>
<div className={styles.removeButtonColumn}>
{/* same height as the item's label so the button lines up with the input, not the error below it */}
<span aria-hidden className={styles.labelSpacer}>
<SendouLabel>&nbsp;</SendouLabel>
</span>
<SendouButton
icon={<Trash />}
aria-label="Remove item"
size="small"
variant="minimal-destructive"
onClick={() => handleRemoveAt(idx)}
className={styles.removeButton}
data-testid={`${name}-remove-item-button`}
/>
</div>
) : null}
</div>
))}

View File

@@ -1,3 +1,4 @@
import * as React from "react";
import { SendouDatePicker } from "~/components/elements/DatePicker";
import type { FormFieldProps } from "../types";
import { errorMessageId } from "../utils";
@@ -25,10 +26,19 @@ export function DatetimeFormField({
granularity = "minute",
disabled,
}: DatetimeFormFieldProps) {
const [hasBadInput, setHasBadInput] = React.useState(false);
const { translatedLabel, translatedError, translatedBottomText } =
useTranslatedTexts({ label, error, bottomText });
useTranslatedTexts({
label,
error: error && hasBadInput ? "forms:errors.invalidDate" : error,
bottomText,
});
const handleChange = (val: Date | null) => {
const handleChange = (
val: Date | null,
{ isBadInput }: { isBadInput: boolean },
) => {
setHasBadInput(isBadInput);
onChange(val ?? undefined);
};

View File

@@ -228,6 +228,7 @@ export default [
),
],
),
route("matches", "features/tournament-match/routes/to.$id.matches.tsx"),
route(
"matches/:mid",
"features/tournament-match/routes/to.$id.matches.$mid.tsx",

View File

@@ -13,6 +13,7 @@ import { DeleteOrphanArtTagsRoutine } from "./deleteOrphanArtTags";
import { EvictStaleRunningTournamentsRoutine } from "./evictStaleRunningTournaments";
import { ExpireReadyChecksRoutine } from "./expireReadyChecks";
import { NotifyCheckInStartRoutine } from "./notifyCheckInStart";
import { NotifyLeagueMatchStartingSoonRoutine } from "./notifyLeagueMatchStartingSoon";
import { NotifyPlusServerVotingRoutine } from "./notifyPlusServerVoting";
import { NotifyScheduleTeamReminderRoutine } from "./notifyScheduleTeamReminder";
import { NotifyScrimStartingSoonRoutine } from "./notifyScrimStartingSoon";
@@ -33,6 +34,7 @@ export const everyHourAt00 = [
NotifyPlusServerVotingRoutine,
NotifyCheckInStartRoutine,
NotifyScrimStartingSoonRoutine,
NotifyLeagueMatchStartingSoonRoutine,
SyncSplatoonRotationsRoutine,
SyncTournamentVodsRoutine,
];

View File

@@ -0,0 +1,44 @@
import { notify } from "../features/notifications/core/notify.server";
import { LEAGUE_SCHEDULING } from "../features/tournament-match/core/LeagueScheduling";
import * as TournamentMatchRepository from "../features/tournament-match/TournamentMatchRepository.server";
import { databaseTimestampNow } from "../utils/dates";
import { logger } from "../utils/logger";
import { Routine } from "./routine.server";
export const NotifyLeagueMatchStartingSoonRoutine = new Routine({
name: "NotifyLeagueMatchStartingSoon",
func: async () => {
const now = databaseTimestampNow();
const matches = await TournamentMatchRepository.findScheduledBetween({
startsAt: now,
endsAt: now + LEAGUE_SCHEDULING.STARTING_SOON_SECONDS,
});
for (const match of matches) {
logger.info(
`Notifying league set starting soon for match ${match.id} with ${match.members.length} participants`,
);
const sides = [
{ id: match.teamOneId, opponentName: match.teamTwoName },
{ id: match.teamTwoId, opponentName: match.teamOneName },
];
for (const side of sides) {
await notify({
notification: {
type: "TO_LEAGUE_MATCH_STARTING_SOON",
meta: {
tournamentId: match.tournamentId,
matchId: match.id,
opponentTeamName: side.opponentName,
},
},
userIds: match.members
.filter((member) => member.tournamentTeamId === side.id)
.map((member) => member.userId),
});
}
}
},
});

View File

@@ -322,6 +322,8 @@ export const tournamentDivisionsPage = (tournamentId: number) =>
`/to/${tournamentId}/divisions`;
export const tournamentResultsPage = (tournamentId: number) =>
`/to/${tournamentId}/results`;
export const tournamentMatchesPage = (tournamentId: number) =>
`/to/${tournamentId}/matches`;
export const tournamentMatchPage = ({
tournamentId,
matchId,

View File

@@ -0,0 +1,13 @@
---
navItem: medal
type: feature
---
League format v2 & publicly available
- Every league set gets a schedule tab where both teams put up times they could play and pick one of the other team's. Once agreed, the set can be played from the moment its round is playable
- The tab shows when your own roster is free, based on your teammates' availability
- Leagues have a matches page listing the division's scheduled, unscheduled and past sets
- Scheduled sets show in the sidebar's events, in your availability as busy, and among the sidebar's streams when a member streams them or the organizer marks them for cast
- Organizers set when each round becomes playable when starting a division's bracket, and mark a tournament a league when creating it. Leagues carry a League tag on the calendar
- A bracket can instead be started to be played in real time like a regular tournament, for example top 4 playoffs after a scheduled round robin
- Notifications for times proposed to your team, a set getting its time and a set starting soon

View File

@@ -0,0 +1,4 @@
---
type: bug
---
Date fields say when a date is incomplete or doesn't exist instead of claiming the field is required

View File

@@ -2,6 +2,7 @@ import type { Page } from "@playwright/test";
import { tournamentMatchPage } from "~/utils/urls";
import {
expect,
fillDateTimeField,
navigate,
selectWeapon,
submit,
@@ -12,13 +13,14 @@ import { TournamentNav } from "./tournament-nav";
type Side = 1 | 2;
type RosterSide = "alpha" | "bravo";
type Tab = "action" | "admin" | "result" | "rosters";
type Tab = "action" | "admin" | "result" | "rosters" | "schedule";
const TAB_LABELS: Record<Tab, string> = {
action: "Action",
admin: "Admin",
result: "Result",
rosters: "Rosters",
schedule: "Schedule",
};
/** `/to/:id/matches/:mid`. The match page splits its UI into URL-driven tabs
@@ -61,9 +63,48 @@ export class TournamentMatchPage {
.getByRole("button", { name: "Submit", exact: true })
.last(),
undoWeaponButton: page.getByRole("button", { name: "Undo weapon" }),
// league scheduling
unscheduledBanner: page.getByTestId("league-unscheduled-banner"),
scheduleTab: page.getByTestId("schedule-tab"),
agreedTime: page.getByTestId("agreed-time"),
ownCandidates: page.getByTestId("own-candidates"),
opponentCandidates: page.getByTestId("opponent-candidates"),
candidateTimes: page.getByTestId("candidate-time"),
pickCandidateButtons: page.getByTestId("pick-candidate-button"),
rejectRescheduleButton: page.getByTestId("reject-reschedule-button"),
proposeTimesButton: page.getByTestId("propose-times-button"),
organizerSetTimeButton: page.getByTestId("organizer-set-time-button"),
setByOrganizerText: page.getByText("Set by the organizer"),
};
}
/** Puts one candidate time on the set's board from the schedule tab's form. */
async proposeTime(date: Date) {
await expect(this.locators.scheduleTab).toBeVisible();
await fillDateTimeField({
scope: this.locators.scheduleTab,
label: "Time",
date,
});
await submit(this.page, "propose-times-button");
}
/** Picks the `nth` candidate of the other team. */
async pickCandidate(nth = 0) {
await this.locators.pickCandidateButtons.nth(nth).click();
await submit(this.page, "confirm-button");
}
/** The organizer's final say on the set's time, from the admin tab. */
async organizerSetTime(date: Date) {
await fillDateTimeField({
scope: this.page.getByRole("tabpanel", { name: TAB_LABELS.admin }),
label: "Set time",
date,
});
await submit(this.page, "organizer-set-time-button");
}
async goto({
tournamentId,
matchId,

View File

@@ -2,7 +2,7 @@ import { subMinutes } from "date-fns";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { expect, impersonate, test } from "./helpers/playwright";
import { TournamentDivisionsPage } from "./pages/tournament/tournament-divisions-page";
import { TournamentBracketsPage } from "./pages/tournament/tournament-brackets-page";
import { TournamentSeedsPage } from "./pages/tournament/tournament-seeds-page";
const TEAMS_PER_DIVISION = 6;
@@ -65,16 +65,9 @@ test.describe("Tournament A/B divisions", () => {
await seeds.saveAbDivisions();
// a league's brackets are reached through its divisions page
const divisions = new TournamentDivisionsPage(page);
await divisions.goto(tournament.id);
await expect(divisions.locators.divisionLinks).toHaveCount(1);
await expect(divisions.divisionLink("Groups stage")).toContainText(
`${teamCount} teams`,
);
const brackets = await divisions.openDivision("Groups stage");
// a one-division league has no divisions page, its brackets page is the one
const brackets = new TournamentBracketsPage(page);
await brackets.goto(tournament.id);
await brackets.finalize();
await expect(brackets.locators.bracketsViewer).toBeVisible();

View File

@@ -0,0 +1,188 @@
import { addDays, addHours, subDays } from "date-fns";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { tournamentMatchesPage } from "~/utils/urls";
import {
expect,
impersonate,
isNotVisible,
navigate,
test,
} from "./helpers/playwright";
import {
createTeams,
ROUND_ROBIN,
startedTournamentTimes,
TO_MAP_POOL,
} from "./helpers/tournament";
import { TournamentDivisionsPage } from "./pages/tournament/tournament-divisions-page";
import { TournamentMatchPage } from "./pages/tournament/tournament-match-page";
/** A two team league whose only round has been playable since yesterday, so its set can be scheduled and played. */
async function createLeagueSet(
factories: Parameters<Parameters<typeof test>[2]>[0]["factories"],
) {
const tournament = await factories.TournamentFactory.create(
{
authorId: ADMIN_ID,
startTimes: startedTournamentTimes(),
bracketProgression: ROUND_ROBIN,
mapPoolMaps: TO_MAP_POOL,
},
{ isLeague: true },
);
const [nzapTeam, opponentTeam] = await createTeams(factories, tournament.id, [
{ members: [NZAP_TEST_ID] },
{},
]);
const [match] = await factories.TournamentFactory.startBracket(
tournament.id,
{
isPlayableAt: () => dateToDatabaseTimestamp(subDays(new Date(), 1)),
},
);
return { tournament, nzapTeam, opponentTeam, matchId: match.id };
}
test.describe("Tournament league", () => {
test("teams agree on a time, decline a reschedule, the organizer overrides it and the set gets played", async ({
page,
factories,
}) => {
test.slow();
const { tournament, opponentTeam, matchId } =
await createLeagueSet(factories);
const matchPage = new TournamentMatchPage(page);
const nzapTime = addHours(addDays(new Date(), 2), 1);
const opponentTime = addHours(addDays(new Date(), 3), 1);
// N-ZAP proposes a time; the set has no time yet so the schedule tab opens by itself
await impersonate(page, NZAP_TEST_ID);
await matchPage.goto({ tournamentId: tournament.id, matchId });
await expect(matchPage.locators.unscheduledBanner).toBeVisible();
await isNotVisible(matchPage.locators.stageBanner);
await matchPage.proposeTime(nzapTime);
await expect(
matchPage.locators.ownCandidates.getByTestId("candidate-time"),
).toHaveCount(1);
// only the other team can pick a candidate
await isNotVisible(matchPage.locators.pickCandidateButtons);
// the opponent answers with a time of their own, then picks N-ZAP's after all
await impersonate(page, opponentTeam.ownerUserId);
await matchPage.goto({ tournamentId: tournament.id, matchId });
await expect(
matchPage.locators.opponentCandidates.getByTestId("candidate-time"),
).toHaveCount(1);
await matchPage.proposeTime(opponentTime);
await expect(matchPage.locators.candidateTimes).toHaveCount(2);
await matchPage.pickCandidate();
await expect(matchPage.locators.agreedTime).toBeVisible();
await expect(matchPage.locators.candidateTimes).toHaveCount(0);
// agreed and playable: the stage banner is back and the set can be reported
await expect(matchPage.locators.stageBanner).toBeVisible();
// N-ZAP asks for another time as a favor, the opponent keeps the agreed one
await impersonate(page, NZAP_TEST_ID);
await matchPage.goto({ tournamentId: tournament.id, matchId });
await matchPage.openTab("schedule");
await matchPage.proposeTime(addHours(nzapTime, 2));
await expect(matchPage.locators.candidateTimes).toHaveCount(1);
await impersonate(page, opponentTeam.ownerUserId);
await matchPage.goto({ tournamentId: tournament.id, matchId });
await matchPage.openTab("schedule");
await expect(matchPage.locators.rejectRescheduleButton).toBeVisible();
await matchPage.locators.rejectRescheduleButton.click();
await expect(matchPage.locators.candidateTimes).toHaveCount(0);
await expect(matchPage.locators.agreedTime).toBeVisible();
// the organizer's time is final and closes the board
await impersonate(page, ADMIN_ID);
await matchPage.goto({ tournamentId: tournament.id, matchId });
await matchPage.openTab("admin");
await matchPage.organizerSetTime(addHours(opponentTime, 3));
await matchPage.openTab("schedule");
await expect(matchPage.locators.setByOrganizerText).toBeVisible();
await isNotVisible(matchPage.locators.proposeTimesButton);
// the scheduled set shows up on the matches page, and gets played like any other
await navigate({ page, url: tournamentMatchesPage(tournament.id) });
await expect(page.getByTestId("scheduled-sets")).toBeVisible();
await expect(page.getByTestId(`set-row-${matchId}`)).toBeVisible();
await impersonate(page, NZAP_TEST_ID);
await matchPage.goto({ tournamentId: tournament.id, matchId });
await matchPage.openTab("action");
await matchPage.reportResult({ mapsToReport: 2 });
await expect(matchPage.locators.finalBanner).toBeVisible();
await navigate({ page, url: tournamentMatchesPage(tournament.id) });
await page.getByTestId("matches-tab-past").click();
await expect(
page.getByTestId("past-sets").getByTestId(`set-row-${matchId}`),
).toBeVisible();
});
test("a set can't be played before the teams agree on a time or the round is playable", async ({
page,
factories,
}) => {
const tournament = await factories.TournamentFactory.create(
{
authorId: ADMIN_ID,
startTimes: startedTournamentTimes(),
bracketProgression: ROUND_ROBIN,
mapPoolMaps: TO_MAP_POOL,
},
{ isLeague: true },
);
await createTeams(factories, tournament.id, [
{ members: [NZAP_TEST_ID] },
{},
]);
// playable in a week: the board opens a day before that
const [match] = await factories.TournamentFactory.startBracket(
tournament.id,
{
isPlayableAt: () => dateToDatabaseTimestamp(addDays(new Date(), 7)),
},
);
await impersonate(page, NZAP_TEST_ID);
const matchPage = new TournamentMatchPage(page);
await matchPage.goto({ tournamentId: tournament.id, matchId: match.id });
await expect(page.getByTestId("league-not-open-banner")).toBeVisible();
await isNotVisible(matchPage.locators.scheduleTab);
await isNotVisible(matchPage.locators.stageBanner);
});
test("a league of several divisions lists them on the divisions page", async ({
page,
factories,
}) => {
const tournament = await factories.TournamentFactory.create(
{
authorId: ADMIN_ID,
startTimes: startedTournamentTimes(),
bracketProgression: [
{ ...ROUND_ROBIN[0], name: "Division 1" },
{ ...ROUND_ROBIN[0], name: "Division 2" },
],
mapPoolMaps: TO_MAP_POOL,
},
{ isLeague: true },
);
await createTeams(factories, tournament.id, [{}, {}, {}, {}]);
await impersonate(page, ADMIN_ID);
const divisions = new TournamentDivisionsPage(page);
await divisions.goto(tournament.id);
await expect(divisions.locators.divisionLinks).toHaveCount(2);
});
});

View File

@@ -102,6 +102,12 @@
"notifications.text.SCRIM_CANCELED": "",
"notifications.title.SCRIM_STARTING_SOON": "",
"notifications.text.SCRIM_STARTING_SOON": "",
"notifications.title.TO_LEAGUE_TIMES_PROPOSED": "",
"notifications.text.TO_LEAGUE_TIMES_PROPOSED": "",
"notifications.title.TO_LEAGUE_MATCH_SCHEDULED": "",
"notifications.text.TO_LEAGUE_MATCH_SCHEDULED": "",
"notifications.title.TO_LEAGUE_MATCH_STARTING_SOON": "",
"notifications.text.TO_LEAGUE_MATCH_STARTING_SOON": "",
"notifications.title.SCRIM_AUTO_DELETED": "",
"notifications.text.SCRIM_AUTO_DELETED": "",
"notifications.title.COMMISSIONS_CLOSED": "",
@@ -264,6 +270,7 @@
"tag.name.SR": "Salmon Run",
"tag.name.CARDS": "Tableturf Battle",
"tag.name.COLLEGIATE": "",
"tag.name.LEAGUE": "",
"weapon.category.SHOOTERS": "Skydere",
"weapon.category.BLASTERS": "Blastere",
"weapon.category.ROLLERS": "Malerruller",
@@ -379,6 +386,10 @@
"chat.systemMsg.mapBanned": "",
"chat.systemMsg.modePicked": "",
"chat.systemMsg.modeBanned": "",
"chat.systemMsg.leagueTimesProposed": "",
"chat.systemMsg.leagueTimePicked": "",
"chat.systemMsg.leagueRescheduleDeclined": "",
"chat.systemMsg.leagueTimeSetByOrganizer": "",
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",

View File

@@ -127,6 +127,12 @@
"labels.scrimRequestStartTime": "",
"bottomTexts.scrimRequestStartTime": "",
"labels.duration": "",
"bottomTexts.candidateTimes": "",
"labels.candidateTime": "",
"labels.setTime": "",
"labels.roundPlayableFrom": "",
"labels.league": "",
"bottomTexts.league": "",
"options.duration.30m": "",
"options.duration.1h": "",
"options.duration.1h30m": "",
@@ -140,6 +146,7 @@
"errors.dateTooFarAway": "",
"errors.dateTooEarly": "",
"errors.dateTooLate": "",
"errors.invalidDate": "",
"errors.dateTooFarInFuture": "",
"errors.minUsersExcludingYourself": "",
"errors.usersMustBeUnique": "",
@@ -220,6 +227,7 @@
"options.tag.LAN": "",
"options.tag.QUALIFIER": "",
"options.tag.COLLEGIATE": "",
"options.tag.LEAGUE": "",
"options.tag.ONES": "",
"options.tag.DUOS": "",
"options.tag.TRIOS": "",

Some files were not shown because too many files have changed in this diff Show More