diff --git a/app/components/FormWithConfirm.tsx b/app/components/FormWithConfirm.tsx index 4f959abd0..6557c0943 100644 --- a/app/components/FormWithConfirm.tsx +++ b/app/components/FormWithConfirm.tsx @@ -130,7 +130,10 @@ export function FormWithConfirm({ {children ? React.cloneElement(children, { - onClick: openDialog, + onClick: () => { + children.props.onClick?.(); + openDialog(); + }, type: "button", }) : null} diff --git a/app/components/elements/DatePicker.browser.test.tsx b/app/components/elements/DatePicker.browser.test.tsx index c7d6153fc..6133be70d 100644 --- a/app/components/elements/DatePicker.browser.test.tsx +++ b/app/components/elements/DatePicker.browser.test.tsx @@ -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 }); }); }); diff --git a/app/components/elements/DatePicker.tsx b/app/components/elements/DatePicker.tsx index 47e36c9fa..c9f063093 100644 --- a/app/components/elements/DatePicker.tsx +++ b/app/components/elements/DatePicker.tsx @@ -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} diff --git a/app/components/match-page/MatchTabs.tsx b/app/components/match-page/MatchTabs.tsx index 2aedcee06..7a8ff53c3 100644 --- a/app/components/match-page/MatchTabs.tsx +++ b/app/components/match-page/MatchTabs.tsx @@ -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; /** tabs showing a warning-colored alert icon */ alertTabs?: Array; + /** 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 = { rosters: , + schedule: , action: , result: , stats: , @@ -33,17 +44,26 @@ const TAB_ICONS: Record = { 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 ( diff --git a/app/components/match-page/match-page-search-params.test.ts b/app/components/match-page/match-page-search-params.test.ts index 297691cd0..bd83d9dc3 100644 --- a/app/components/match-page/match-page-search-params.test.ts +++ b/app/components/match-page/match-page-search-params.test.ts @@ -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"], }); }); diff --git a/app/components/match-page/match-page-search-params.ts b/app/components/match-page/match-page-search-params.ts index 5ce766932..a9825f4ab 100644 --- a/app/components/match-page/match-page-search-params.ts +++ b/app/components/match-page/match-page-search-params.ts @@ -4,6 +4,7 @@ import { SP } from "~/modules/search-params/search-params"; const MATCH_PAGE_TABS = [ "rosters", + "schedule", "action", "result", "stats", diff --git a/app/db/seed/dev/availability.ts b/app/db/seed/dev/availability.ts index 944e1e77b..a2a4e82ed 100644 --- a/app/db/seed/dev/availability.ts +++ b/app/db/seed/dev/availability.ts @@ -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 diff --git a/app/db/seed/dev/misc.ts b/app/db/seed/dev/misc.ts index a50ccf35b..2708fd14c 100644 --- a/app/db/seed/dev/misc.ts +++ b/app/db/seed/dev/misc.ts @@ -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()) { diff --git a/app/db/seed/dev/organizations.ts b/app/db/seed/dev/organizations.ts index aa94dc163..1540026db 100644 --- a/app/db/seed/dev/organizations.ts +++ b/app/db/seed/dev/organizations.ts @@ -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"] }, + ]; } diff --git a/app/db/seed/dev/tournaments.ts b/app/db/seed/dev/tournaments.ts index bbdd73fbc..feb683dd2 100644 --- a/app/db/seed/dev/tournaments.ts +++ b/app/db/seed/dev/tournaments.ts @@ -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 { 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(); 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 }) { + 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>[] = []; + 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; +}) { const nzapTeamIds: number[] = []; - const seriesLogoImgIds = new Map(); for (let i = 0; i < HISTORICAL_COUNT; i++) { const progression = faker.helpers.weightedArrayElement([ diff --git a/app/db/seed/factories/TournamentFactory.ts b/app/db/seed/factories/TournamentFactory.ts index d2d019dfb..e3888277a 100644 --- a/app/db/seed/factories/TournamentFactory.ts +++ b/app/db/seed/factories/TournamentFactory.ts @@ -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; +export type RoundMaps = Omit; /** 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; /** 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 { 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; + 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>, + 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); diff --git a/app/db/seed/factories/TournamentMatchScheduleFactory.ts b/app/db/seed/factories/TournamentMatchScheduleFactory.ts new file mode 100644 index 000000000..4567c88e9 --- /dev/null +++ b/app/db/seed/factories/TournamentMatchScheduleFactory.ts @@ -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; + /** 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, + }); +} diff --git a/app/db/seed/factories/TournamentStaffFactory.ts b/app/db/seed/factories/TournamentStaffFactory.ts new file mode 100644 index 000000000..080053670 --- /dev/null +++ b/app/db/seed/factories/TournamentStaffFactory.ts @@ -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 }; + }, +}); diff --git a/app/db/seed/factories/TournamentTeamFactory.ts b/app/db/seed/factories/TournamentTeamFactory.ts index 6e32952f2..e05229ef6 100644 --- a/app/db/seed/factories/TournamentTeamFactory.ts +++ b/app/db/seed/factories/TournamentTeamFactory.ts @@ -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), diff --git a/app/db/tables-json.ts b/app/db/tables-json.ts index c9c726feb..24af94ba4 100644 --- a/app/db/tables-json.ts +++ b/app/db/tables-json.ts @@ -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; diff --git a/app/db/tables.ts b/app/db/tables.ts index e15be88e9..c842bd10f 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -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; +} + +/** 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; + matchId: number; + tournamentTeamId: number; + authorId: number; + /** The candidate time. */ + proposedAt: number; + createdAt: Generated; } /** 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; - /** 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; diff --git a/app/features/availability/components/RegistrationAvailabilityPanel.module.css b/app/features/availability/components/RegistrationAvailabilityPanel.module.css index c3231a116..37c46debc 100644 --- a/app/features/availability/components/RegistrationAvailabilityPanel.module.css +++ b/app/features/availability/components/RegistrationAvailabilityPanel.module.css @@ -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; } diff --git a/app/features/availability/components/RegistrationAvailabilityPanel.tsx b/app/features/availability/components/RegistrationAvailabilityPanel.tsx index 66140fbf4..bc046a5ce 100644 --- a/app/features/availability/components/RegistrationAvailabilityPanel.tsx +++ b/app/features/availability/components/RegistrationAvailabilityPanel.tsx @@ -301,7 +301,14 @@ function RangesText({ ranges }: { ranges: Array }) { const rangeText = useRangeText(); return ( - {ranges.map(rangeText).join(" · ")} + + {ranges.map((range, index) => ( + + {index > 0 ? "· " : null} + {rangeText(range)} + + ))} + ); } diff --git a/app/features/availability/core/Commitments.server.test.ts b/app/features/availability/core/Commitments.server.test.ts index e32ac799b..0875b00c5 100644 --- a/app/features/availability/core/Commitments.server.test.ts +++ b/app/features/availability/core/Commitments.server.test.ts @@ -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(), diff --git a/app/features/availability/core/Commitments.server.ts b/app/features/availability/core/Commitments.server.ts index 8bff66153..98a907ae5 100644 --- a/app/features/availability/core/Commitments.server.ts +++ b/app/features/availability/core/Commitments.server.ts @@ -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 = [ @@ -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( diff --git a/app/features/availability/core/VisibleSchedules.server.ts b/app/features/availability/core/VisibleSchedules.server.ts index 78d25d9d7..a895fffd8 100644 --- a/app/features/availability/core/VisibleSchedules.server.ts +++ b/app/features/availability/core/VisibleSchedules.server.ts @@ -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; 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({ diff --git a/app/features/calendar/CalendarRepository.server.test.ts b/app/features/calendar/CalendarRepository.server.test.ts index 56392ff77..f06132053 100644 --- a/app/features/calendar/CalendarRepository.server.test.ts +++ b/app/features/calendar/CalendarRepository.server.test.ts @@ -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"]); + }); +}); diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index 8aa12442b..1ff4f8575 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -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 = 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 = 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, diff --git a/app/features/calendar/actions/calendar.new.server.ts b/app/features/calendar/actions/calendar.new.server.ts index c900089ed..66e815404 100644 --- a/app/features/calendar/actions/calendar.new.server.ts +++ b/app/features/calendar/actions/calendar.new.server.ts @@ -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, diff --git a/app/features/calendar/calendar-constants.ts b/app/features/calendar/calendar-constants.ts index 04d5e62d0..a5f90eca9 100644 --- a/app/features/calendar/calendar-constants.ts +++ b/app/features/calendar/calendar-constants.ts @@ -52,6 +52,9 @@ export const tags = { COLLEGIATE: { color: "#FFC107", }, + LEAGUE: { + color: "#80DEEA", + }, }; export const CALENDAR_EVENT = { diff --git a/app/features/calendar/calendar-new-schemas.ts b/app/features/calendar/calendar-new-schemas.ts index bf686ffc9..ae12d61de 100644 --- a/app/features/calendar/calendar-new-schemas.ts +++ b/app/features/calendar/calendar-new-schemas.ts @@ -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", diff --git a/app/features/calendar/loaders/events.server.ts b/app/features/calendar/loaders/events.server.ts index 2d07b7384..8efbfece0 100644 --- a/app/features/calendar/loaders/events.server.ts +++ b/app/features/calendar/loaders/events.server.ts @@ -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) diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx index 4e97dd3fa..d5fd0a057 100644 --- a/app/features/calendar/routes/calendar.new.tsx +++ b/app/features/calendar/routes/calendar.new.tsx @@ -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() { {!isEditing ? : null} + {isAdmin ? : 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 { + return tag !== "LEAGUE"; +} + function DraftField() { const data = useLoaderData(); diff --git a/app/features/calendar/tests/fixtures.ts b/app/features/calendar/tests/fixtures.ts index dc7d6c87e..6ab76fb5f 100644 --- a/app/features/calendar/tests/fixtures.ts +++ b/app/features/calendar/tests/fixtures.ts @@ -41,6 +41,7 @@ export function calendarNewFormValues( requireInGameNames: false, isInvitational: false, isTest: false, + isLeague: false, isDraft: false, requireSendouQParticipation: false, ...overrides, diff --git a/app/features/chat/ChatRepository.server.ts b/app/features/chat/ChatRepository.server.ts index d37955085..fc7043bb2 100644 --- a/app/features/chat/ChatRepository.server.ts +++ b/app/features/chat/ChatRepository.server.ts @@ -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, + expiresAt: Date, + trx?: Transaction, +) { + 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, diff --git a/app/features/chat/chat-types.ts b/app/features/chat/chat-types.ts index 9f20ffde9..8b52266a7 100644 --- a/app/features/chat/chat-types.ts +++ b/app/features/chat/chat-types.ts @@ -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< diff --git a/app/features/chat/components/Chat.tsx b/app/features/chat/components/Chat.tsx index a8fbd4217..a2377ba53 100644 --- a/app/features/chat/components/Chat.tsx +++ b/app/features/chat/components/Chat.tsx @@ -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; } diff --git a/app/features/core/streams/streams.server.test.ts b/app/features/core/streams/streams.server.test.ts new file mode 100644 index 000000000..5416b8d50 --- /dev/null +++ b/app/features/core/streams/streams.server.test.ts @@ -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); + }); +}); diff --git a/app/features/core/streams/streams.server.ts b/app/features/core/streams/streams.server.ts index 27a21e121..171a989ef 100644 --- a/app/features/core/streams/streams.server.ts +++ b/app/features/core/streams/streams.server.ts @@ -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 { + 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; diff --git a/app/features/notifications/core/notify.server.ts b/app/features/notifications/core/notify.server.ts index 7df7bfb0c..a95b62333 100644 --- a/app/features/notifications/core/notify.server.ts +++ b/app/features/notifications/core/notify.server.ts @@ -36,6 +36,9 @@ const NOTIFICATION_URGENCY: Record = { 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", diff --git a/app/features/notifications/core/resolve.server.ts b/app/features/notifications/core/resolve.server.ts index 0293ca476..71fe87cf0 100644 --- a/app/features/notifications/core/resolve.server.ts +++ b/app/features/notifications/core/resolve.server.ts @@ -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: diff --git a/app/features/notifications/notifications-types.ts b/app/features/notifications/notifications-types.ts index 2955fcc8a..c59c56e03 100644 --- a/app/features/notifications/notifications-types.ts +++ b/app/features/notifications/notifications-types.ts @@ -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< diff --git a/app/features/notifications/notifications-utils.ts b/app/features/notifications/notifications-utils.ts index 0d8f659d1..f05245d8a 100644 --- a/app/features/notifications/notifications-utils.ts +++ b/app/features/notifications/notifications-utils.ts @@ -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); } diff --git a/app/features/sidebar/core/sidebar.server.ts b/app/features/sidebar/core/sidebar.server.ts index ae7f6b0f5..64914e4cc 100644 --- a/app/features/sidebar/core/sidebar.server.ts +++ b/app/features/sidebar/core/sidebar.server.ts @@ -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(); @@ -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 { }); } + 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 +>[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 >[number]; diff --git a/app/features/tournament-bracket/BracketRepository.server.test.ts b/app/features/tournament-bracket/BracketRepository.server.test.ts index f779585f4..9c3d1e6b6 100644 --- a/app/features/tournament-bracket/BracketRepository.server.test.ts +++ b/app/features/tournament-bracket/BracketRepository.server.test.ts @@ -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[0]>, + options?: Parameters[1], + startBracketArgs?: Parameters[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") diff --git a/app/features/tournament-bracket/BracketRepository.server.ts b/app/features/tournament-bracket/BracketRepository.server.ts index 97d33f12f..6da016bb7 100644 --- a/app/features/tournament-bracket/BracketRepository.server.ts +++ b/app/features/tournament-bracket/BracketRepository.server.ts @@ -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, @@ -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, ): Promise { 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, +): Promise { + const chatRoomIds = await matchChatRoomIds(matchIds, trx); + await ChatRepository.updateRoomsInactive(chatRoomIds, inactive, trx); + + return chatRoomIds; +} + +async function matchChatRoomIds( + matchIds: number[], + trx: Transaction, ): Promise { 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(); + + 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, ): Promise { @@ -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, ) { 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, ), diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.server.test.ts b/app/features/tournament-bracket/actions/to.$id.brackets.server.test.ts index 0bf1fd112..2d075daa7 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.server.test.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.server.test.ts @@ -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] }, diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts index ce6fdac61..4117320a7 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts @@ -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; diff --git a/app/features/tournament-bracket/components/Bracket/Match.tsx b/app/features/tournament-bracket/components/Bracket/Match.tsx index 693c85387..3deccfe90 100644 --- a/app/features/tournament-bracket/components/Bracket/Match.tsx +++ b/app/features/tournament-bracket/components/Bracket/Match.tsx @@ -403,7 +403,7 @@ function MatchVods({ vods }: MatchVodsProps) { function MatchTimer({ match, bracket }: Pick) { const tournament = useTournament(); - if (tournament.isLeague) return null; + if (bracket.hasScheduling) return null; if (!match.startedAt) return null; const isOver = Boolean(match.winnerSide); diff --git a/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx b/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx index 7d970afa4..92cac568f 100644 --- a/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx +++ b/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx @@ -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, diff --git a/app/features/tournament-bracket/components/BracketMapListDialog.module.css b/app/features/tournament-bracket/components/BracketMapListDialog.module.css index da1a2e8d8..90ea51ec2 100644 --- a/app/features/tournament-bracket/components/BracketMapListDialog.module.css +++ b/app/features/tournament-bracket/components/BracketMapListDialog.module.css @@ -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); diff --git a/app/features/tournament-bracket/components/BracketMapListDialog.tsx b/app/features/tournament-bracket/components/BracketMapListDialog.tsx index 721d2ff90..188621dc6 100644 --- a/app/features/tournament-bracket/components/BracketMapListDialog.tsx +++ b/app/features/tournament-bracket/components/BracketMapListDialog.tsx @@ -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 + >( + () => + 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 ( + ({ - ...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 ? ( + + ) : null} {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 + ) : !LeagueScheduling.playableAtsAreAscending( + roundMapsInput, + ) ? ( +
+ Invalid selection: a round is playable before the round + preceding it +
) : !validateCustomFlow() ? (
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 ( +
+
+ + + {t("tournament:mapList.realtimeInfo")} + +
+ +
+ ); +} + 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; + /** 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 (

{name}

+ {playableAt ? ( +
+ + playableAt.onChange( + value ? LeagueScheduling.playableAtFromDate(value) : null, + ) + } + /> +
+ ) : null}
) : null} + {scheduleSectionVisible ? ( + + ) : null} {castSectionVisible ? ( +
+ + + {t("tournament:match.admin.setTimeInfo")} + +
+ + {({ FormField }) => } + + + ); +} + function AdminCastSection({ matchId, matchStatus, diff --git a/app/features/tournament-match/components/TournamentMatchBanner.tsx b/app/features/tournament-match/components/TournamentMatchBanner.tsx index db2d8ad76..dcaabacae 100644 --- a/app/features/tournament-match/components/TournamentMatchBanner.tsx +++ b/app/features/tournament-match/components/TournamentMatchBanner.tsx @@ -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" ? ( + } + 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" ? ( + } + header={t("tournament:match.schedule.unscheduled.header")} + subtitle={t("tournament:match.schedule.unscheduled.subtitle")} + testId="league-unscheduled-banner" + /> + ) : schedule.phase === "SCHEDULED_LOCKED" && schedule.scheduledAt ? ( } - 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 ? ( + {data.schedule.scheduledAt ? ( + + ) : null} + + ); + } + + if (!data.match.startedAt) return null; const startedAt = databaseTimestampToDate(data.match.startedAt); const totalMinutes = differenceInMinutes(currentTime, startedAt); @@ -266,15 +316,7 @@ function TournamentMatchBannerTopRow({ }); return ( - + {data.matchIsOver ? ( ) : ( diff --git a/app/features/tournament-match/components/TournamentMatchScheduleTab.module.css b/app/features/tournament-match/components/TournamentMatchScheduleTab.module.css new file mode 100644 index 000000000..f57d96702 --- /dev/null +++ b/app/features/tournament-match/components/TournamentMatchScheduleTab.module.css @@ -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; +} diff --git a/app/features/tournament-match/components/TournamentMatchScheduleTab.tsx b/app/features/tournament-match/components/TournamentMatchScheduleTab.tsx new file mode 100644 index 000000000..1e24ca50f --- /dev/null +++ b/app/features/tournament-match/components/TournamentMatchScheduleTab.tsx @@ -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 ( + +
+ {isScheduled && schedule.scheduledAt ? ( +
+ + {t("tournament:match.schedule.agreedTime")} + + {boardClosed ? ( + + {" "} + {t("tournament:match.schedule.setByOrganizer")} + + ) : null} +
+ ) : null} + {boardClosed ? ( + + {t("tournament:match.schedule.boardClosed")} + + ) : ( + <> + {isScheduled ? ( +
+ {t("tournament:match.schedule.rescheduleInfo")} +
+ ) : null} +
+ {orderedTeams.map((team) => ( + 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} + /> + ))} +
+ {ownTeamId !== null ? ( + + proposal.tournamentTeamId === ownTeamId && + LeagueScheduling.isAcceptableProposal({ + proposedAt: proposal.proposedAt, + now: schedule.now, + }), + ) + .map((proposal) => proposal.proposedAt)} + /> + ) : null} + + )} + {schedule.availability && ownTeamId !== null ? ( + + ) : null} +
+
+ ); +} + +function CandidateColumn({ + team, + proposals, + isOwn, + canPick, + canReject, + now, + candidateTier, +}: { + team: MatchPageTeam; + proposals: Array; + 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 ( +
+

{team.name}

+ {proposals.length === 0 ? ( +
+ {t("tournament:match.schedule.noCandidates")} +
+ ) : ( +
    + {proposals.map((proposal) => { + const tier = candidateTier(proposal.proposedAt); + const passed = !LeagueScheduling.isAcceptableProposal({ + proposedAt: proposal.proposedAt, + now, + }); + + return ( +
  • + {someCandidateHasTier ? ( + + {tier ? : null} + + ) : null} + + + + {canPick && !passed ? ( + } + 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")} + + ) : null} +
  • + ); + })} +
+ )} + {canReject && proposals.length > 0 ? ( + } + className="mt-2" + testId="reject-reschedule-button" + onClick={stayOnTab} + > + {t("tournament:match.schedule.keepCurrentTime")} + + ) : null} +
+ ); +} + +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; +}) { + 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 ( +
+

+ {isReschedule + ? t("tournament:match.schedule.requestAnotherTime") + : t("tournament:match.schedule.proposeTimes")} +

+ + {({ FormField }) => } + +
+ ); +} + +function OwnTeamAvailability({ + availability, + team, + windows, +}: { + availability: NonNullable; + team: MatchPageTeam; + windows: ReturnType; +}) { + 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 = 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 ( +
+

+ {t("tournament:match.schedule.availabilityTitle", { team: team.name })}{" "} + + {formatter.formatRange( + availability.window.startsAt, + availability.window.endsAt, + )} + +

+
    + {roster.map((member) => ( + + ))} +
+ + availabilityRowStatus(entryOf(scheduleByUserId.get(member.id))), + )} + /> + +
+ ); +} diff --git a/app/features/tournament-match/components/TournamentMatchTabs.tsx b/app/features/tournament-match/components/TournamentMatchTabs.tsx index d4f876c82..7330e9197 100644 --- a/app/features/tournament-match/components/TournamentMatchTabs.tsx +++ b/app/features/tournament-match/components/TournamentMatchTabs.tsx @@ -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 ( - + {tabs.includes(TAB_KEYS.RESULT) ? ( ) : null} + {tabs.includes(TAB_KEYS.SCHEDULE) ? ( + + ) : null} {tabs.includes(TAB_KEYS.ACTION) ? ( isPickBanStep && turnOfResult ? ( { + 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 }); + }); +}); diff --git a/app/features/tournament-match/core/LeagueScheduling.ts b/app/features/tournament-match/core/LeagueScheduling.ts new file mode 100644 index 000000000..4c8737759 --- /dev/null +++ b/app/features/tournament-match/core/LeagueScheduling.ts @@ -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(); + + 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; + /** The team's candidates currently on the board. */ + existingProposedAts: Array; + 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 }; +} diff --git a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts index 02085867f..c42f1112d 100644 --- a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts @@ -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; + 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, +) { + 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)), + }; + }), + }; +} diff --git a/app/features/tournament-match/loaders/to.$id.matches.server.ts b/app/features/tournament-match/loaders/to.$id.matches.server.ts new file mode 100644 index 000000000..50d30b4cf --- /dev/null +++ b/app/features/tournament-match/loaders/to.$id.matches.server.ts @@ -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; + +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; +} diff --git a/app/features/tournament-match/match-page-context.tsx b/app/features/tournament-match/match-page-context.tsx index 6d416a3ff..4a887c1c0 100644 --- a/app/features/tournament-match/match-page-context.tsx +++ b/app/features/tournament-match/match-page-context.tsx @@ -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 && diff --git a/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts b/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts index 78d3eea03..c9019b648 100644 --- a/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts +++ b/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts @@ -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); + }, + ); + }); }); diff --git a/app/features/tournament-match/routes/to.$id.matches.$mid.tsx b/app/features/tournament-match/routes/to.$id.matches.$mid.tsx index 630de01f5..e6dd0c706 100644 --- a/app/features/tournament-match/routes/to.$id.matches.$mid.tsx +++ b/app/features/tournament-match/routes/to.$id.matches.$mid.tsx @@ -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() { diff --git a/app/features/tournament-match/routes/to.$id.matches.module.css b/app/features/tournament-match/routes/to.$id.matches.module.css new file mode 100644 index 000000000..d597cb0e1 --- /dev/null +++ b/app/features/tournament-match/routes/to.$id.matches.module.css @@ -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); +} diff --git a/app/features/tournament-match/routes/to.$id.matches.tsx b/app/features/tournament-match/routes/to.$id.matches.tsx new file mode 100644 index 000000000..a9f668330 --- /dev/null +++ b/app/features/tournament-match/routes/to.$id.matches.tsx @@ -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(); + const [{ tab }, setParams] = useSearchParamsTyped( + tournamentMatchesSearchParams, + ); + + const isTab = (value: unknown): value is TournamentMatchesTab => + TOURNAMENT_MATCHES_TABS.some((candidate) => candidate === value); + + return ( +
+ {tournament.leagueDivisions.length > 1 ? ( + ({ + 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 }) => ( + + {name} + + )} + + ) : null} + { + if (isTab(key)) setParams({ tab: key }); + }} + > + + {TOURNAMENT_MATCHES_TABS.map((key) => ( + + {t(`tournament:matches.tabs.${key}`)} + + ))} + + + + + + + + + + + +
+ ); +} + +function ScheduledSets({ + matches, +}: { + matches: Array; +}) { + 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 {t("tournament:matches.empty.scheduled")}; + } + + return ( +
    + {scheduled.map((match) => ( + } + > + {match.scheduledAt !== null ? ( + + ) : null} + + ))} +
+ ); +} + +function UnscheduledSets({ + matches, +}: { + matches: Array; +}) { + const { t } = useTranslation(["tournament"]); + + const unscheduled = matches.filter((match) => match.isSchedulable); + + if (unscheduled.length === 0) { + return {t("tournament:matches.empty.unscheduled")}; + } + + const sorted = R.sortBy( + unscheduled, + (match) => match.bracketIdx, + (match) => match.roundNumber, + (match) => match.roundName, + ); + + return ( +
    + {sorted.map((match) => ( + + ))} +
+ ); +} + +function PastSets({ + matches, +}: { + matches: Array; +}) { + 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 {t("tournament:matches.empty.past")}; + } + + return ( +
    + {past.map((match) => ( + + {match.lastResultAt !== null ? ( + + ) : null} + + ))} +
+ ); +} + +function SetRow({ + match, + showScore = false, + badges, + children, +}: { + match: TournamentMatchesLoaderMatch; + showScore?: boolean; + badges?: React.ReactNode; + children?: React.ReactNode; +}) { + const tournament = useTournament(); + + return ( +
  • + + + {tournament.leagueDivisions.length > 1 && !showScore + ? `${match.bracketName} · ` + : null} + {match.roundName} + + {badges} + + {match.teams.map((team, index) => ( + + {index === 1 ? vs. : null} + + {team.name} + {showScore ? ( + {team.score} + ) : null} + + ))} + + {children} + +
  • + ); +} + +function SetBadges({ match }: { match: TournamentMatchesLoaderMatch }) { + const { t } = useTranslation(["tournament"]); + + return ( + <> + {match.isLive ? ( + + {t("tournament:matches.live")} + + ) : null} + {match.isCasted ? ( + + {t("tournament:matches.cast")} + + ) : null} + + ); +} + +function EmptyState({ children }: { children: React.ReactNode }) { + return ( +
    + {children} +
    + ); +} diff --git a/app/features/tournament-match/tournament-match-schemas.ts b/app/features/tournament-match/tournament-match-schemas.ts new file mode 100644 index 000000000..3100e9457 --- /dev/null +++ b/app/features/tournament-match/tournament-match-schemas.ts @@ -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; diff --git a/app/features/tournament-match/tournament-matches-search-params.test.ts b/app/features/tournament-match/tournament-matches-search-params.test.ts new file mode 100644 index 000000000..3392ea859 --- /dev/null +++ b/app/features/tournament-match/tournament-matches-search-params.test.ts @@ -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"], + [""], + ]); + }); +}); diff --git a/app/features/tournament-match/tournament-matches-search-params.ts b/app/features/tournament-match/tournament-matches-search-params.ts new file mode 100644 index 000000000..e40baba92 --- /dev/null +++ b/app/features/tournament-match/tournament-matches-search-params.ts @@ -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, + }), +}); diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 0f18db6a8..84e24d405 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -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). diff --git a/app/features/tournament/components/TournamentNav.tsx b/app/features/tournament/components/TournamentNav.tsx index 92db18bc2..b0fa036a0 100644 --- a/app/features/tournament/components/TournamentNav.tsx +++ b/app/features/tournament/components/TournamentNav.tsx @@ -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: , + testId: "matches-tab", + }; + } + items.teams = { key: "teams", label: t("tournament:nav.teams", { diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index 2e8942b4c..227156614 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -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" ? ( - ) : ( )} diff --git a/app/features/tournament/tournament-utils.ts b/app/features/tournament/tournament-utils.ts index 921b76a08..902a963ba 100644 --- a/app/features/tournament/tournament-utils.ts +++ b/app/features/tournament/tournament-utils.ts @@ -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({ diff --git a/app/form/SendouForm.browser.test.tsx b/app/form/SendouForm.browser.test.tsx index 837f19fc2..a98e56fa4 100644 --- a/app/form/SendouForm.browser.test.tsx +++ b/app/form/SendouForm.browser.test.tsx @@ -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({ diff --git a/app/form/fields/ArrayFormField.module.css b/app/form/fields/ArrayFormField.module.css index 2f477af8a..564f1e280 100644 --- a/app/form/fields/ArrayFormField.module.css +++ b/app/form/fields/ArrayFormField.module.css @@ -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; } diff --git a/app/form/fields/ArrayFormField.tsx b/app/form/fields/ArrayFormField.tsx index ab922e1bf..83e878ce4 100644 --- a/app/form/fields/ArrayFormField.tsx +++ b/app/form/fields/ArrayFormField.tsx @@ -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({ )) : Array.from({ length: visibleCount }).map((_, idx) => ( -
    +
    {renderItem(idx, `${name}[${idx}]`)}
    {canRemoveAt(idx) ? ( - } - aria-label="Remove item" - size="small" - variant="minimal-destructive" - onClick={() => handleRemoveAt(idx)} - className={styles.removeButton} - data-testid={`${name}-remove-item-button`} - /> +
    + {/* same height as the item's label so the button lines up with the input, not the error below it */} + +   + + } + aria-label="Remove item" + size="small" + variant="minimal-destructive" + onClick={() => handleRemoveAt(idx)} + className={styles.removeButton} + data-testid={`${name}-remove-item-button`} + /> +
    ) : null}
    ))} diff --git a/app/form/fields/DatetimeFormField.tsx b/app/form/fields/DatetimeFormField.tsx index 3d900b1ae..3a1bdf474 100644 --- a/app/form/fields/DatetimeFormField.tsx +++ b/app/form/fields/DatetimeFormField.tsx @@ -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); }; diff --git a/app/routes.ts b/app/routes.ts index 389bb3325..e35c30eb2 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -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", diff --git a/app/routines/list.server.ts b/app/routines/list.server.ts index d86004b67..c1063fbff 100644 --- a/app/routines/list.server.ts +++ b/app/routines/list.server.ts @@ -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, ]; diff --git a/app/routines/notifyLeagueMatchStartingSoon.ts b/app/routines/notifyLeagueMatchStartingSoon.ts new file mode 100644 index 000000000..14f6cf15d --- /dev/null +++ b/app/routines/notifyLeagueMatchStartingSoon.ts @@ -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), + }); + } + } + }, +}); diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 01c0e52df..16ea8b081 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -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, diff --git a/changelog/2026-09-21-league-scheduling.md b/changelog/2026-09-21-league-scheduling.md new file mode 100644 index 000000000..832d799e5 --- /dev/null +++ b/changelog/2026-09-21-league-scheduling.md @@ -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 diff --git a/changelog/2026-09-23-invalid-date-error.md b/changelog/2026-09-23-invalid-date-error.md new file mode 100644 index 000000000..e2de570b5 --- /dev/null +++ b/changelog/2026-09-23-invalid-date-error.md @@ -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 diff --git a/e2e/pages/tournament/tournament-match-page.ts b/e2e/pages/tournament/tournament-match-page.ts index ff8813fe5..a71074970 100644 --- a/e2e/pages/tournament/tournament-match-page.ts +++ b/e2e/pages/tournament/tournament-match-page.ts @@ -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 = { 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, diff --git a/e2e/tournament-ab-divisions.spec.ts b/e2e/tournament-ab-divisions.spec.ts index 2805d865e..c1f4f28cf 100644 --- a/e2e/tournament-ab-divisions.spec.ts +++ b/e2e/tournament-ab-divisions.spec.ts @@ -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(); diff --git a/e2e/tournament-league.spec.ts b/e2e/tournament-league.spec.ts new file mode 100644 index 000000000..4aafde165 --- /dev/null +++ b/e2e/tournament-league.spec.ts @@ -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[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); + }); +}); diff --git a/locales/da/common.json b/locales/da/common.json index 7acaf0d5c..8f9675b7b 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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": "", diff --git a/locales/da/forms.json b/locales/da/forms.json index 9e59ab0ab..de3cb4cda 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -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": "", diff --git a/locales/da/q.json b/locales/da/q.json index c1d0dfc7c..653f94a6e 100644 --- a/locales/da/q.json +++ b/locales/da/q.json @@ -176,6 +176,7 @@ "match.banner.playAll": "", "match.banner.vs": "", "match.tabs.rosters": "", + "match.tabs.schedule": "", "match.tabs.action": "", "match.tabs.result": "", "match.tabs.stats": "", diff --git a/locales/da/tournament.json b/locales/da/tournament.json index c41aa9ba4..e27b4ea8f 100644 --- a/locales/da/tournament.json +++ b/locales/da/tournament.json @@ -10,6 +10,7 @@ "nav.looking": "", "nav.subs": "Suppleanter", "nav.divisions": "", + "nav.matches": "", "nav.admin": "Admin", "findTeam": "", "registerNow": "", @@ -31,7 +32,6 @@ "pre.steps.roster": "Holdmedlemmer", "pre.steps.pool": "Banepulje", "pre.steps.check-in": "Indskrivning", - "pre.steps.googleSheet": "", "pre.footer": "Tilmeldingen til turneringen kan frit ændres inden turneringen starter", "pre.registrationClosesAt": "", "pre.friendCode.needed": "", @@ -78,6 +78,8 @@ "mapList.rerollAllMaps": "", "mapList.teamsPick": "", "mapList.customFlow": "", + "mapList.realtime": "", + "mapList.realtimeInfo": "", "rules.teamPick.stageRepeat": "", "team.label": "Hold", "team.teamPage": "", @@ -190,6 +192,37 @@ "match.endedEarly.subtitle": "", "match.leagueLocked.header": "", "match.leagueLocked.subtitle": "", + "match.schedule.notOpen.header": "", + "match.schedule.notOpen.subtitle": "", + "match.schedule.unscheduled.header": "", + "match.schedule.unscheduled.subtitle": "", + "matches.tabs.scheduled": "", + "matches.tabs.unscheduled": "", + "matches.tabs.past": "", + "matches.division": "", + "matches.allDivisions": "", + "matches.empty.scheduled": "", + "matches.empty.unscheduled": "", + "matches.empty.past": "", + "matches.cast": "", + "matches.live": "", + "match.schedule.reschedule": "", + "match.schedule.agreedTime": "", + "match.schedule.setByOrganizer": "", + "match.schedule.boardClosed": "", + "match.schedule.rescheduleInfo": "", + "match.schedule.noCandidates": "", + "match.schedule.pick": "", + "match.schedule.pickConfirm": "", + "match.schedule.keepCurrentTime": "", + "match.schedule.proposeTimes": "", + "match.schedule.requestAnotherTime": "", + "match.schedule.propose": "", + "match.schedule.updateTimes": "", + "match.schedule.availabilityTitle": "", + "match.admin.setTime": "", + "match.admin.setTimeInfo": "", + "match.admin.timeSet": "", "match.locked.header": "", "match.locked.subtitle": "", "match.waitingForTeams.header": "", diff --git a/locales/de/common.json b/locales/de/common.json index 8cdcc4ec2..b687aeae8 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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": "Revierdecks", "tag.name.COLLEGIATE": "", + "tag.name.LEAGUE": "", "weapon.category.SHOOTERS": "Kleckser", "weapon.category.BLASTERS": "Blaster", "weapon.category.ROLLERS": "Roller", @@ -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": "", diff --git a/locales/de/forms.json b/locales/de/forms.json index c631b4a7f..65aafec75 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -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": "", diff --git a/locales/de/q.json b/locales/de/q.json index 681fb9525..27aa4a1eb 100644 --- a/locales/de/q.json +++ b/locales/de/q.json @@ -176,6 +176,7 @@ "match.banner.playAll": "", "match.banner.vs": "", "match.tabs.rosters": "", + "match.tabs.schedule": "", "match.tabs.action": "", "match.tabs.result": "", "match.tabs.stats": "", diff --git a/locales/de/tournament.json b/locales/de/tournament.json index c6876e51e..e01f17314 100644 --- a/locales/de/tournament.json +++ b/locales/de/tournament.json @@ -10,6 +10,7 @@ "nav.looking": "", "nav.subs": "Ersatzspieler", "nav.divisions": "", + "nav.matches": "", "nav.admin": "Admin", "findTeam": "", "registerNow": "", @@ -31,7 +32,6 @@ "pre.steps.roster": "Volles Roster", "pre.steps.pool": "Arenenpool", "pre.steps.check-in": "Check-in", - "pre.steps.googleSheet": "", "pre.footer": "Registrierung kann vor Turnierstart frei verändert werden", "pre.registrationClosesAt": "", "pre.friendCode.needed": "", @@ -78,6 +78,8 @@ "mapList.rerollAllMaps": "", "mapList.teamsPick": "", "mapList.customFlow": "", + "mapList.realtime": "", + "mapList.realtimeInfo": "", "rules.teamPick.stageRepeat": "", "team.label": "Team", "team.teamPage": "", @@ -190,6 +192,37 @@ "match.endedEarly.subtitle": "", "match.leagueLocked.header": "", "match.leagueLocked.subtitle": "", + "match.schedule.notOpen.header": "", + "match.schedule.notOpen.subtitle": "", + "match.schedule.unscheduled.header": "", + "match.schedule.unscheduled.subtitle": "", + "matches.tabs.scheduled": "", + "matches.tabs.unscheduled": "", + "matches.tabs.past": "", + "matches.division": "", + "matches.allDivisions": "", + "matches.empty.scheduled": "", + "matches.empty.unscheduled": "", + "matches.empty.past": "", + "matches.cast": "", + "matches.live": "", + "match.schedule.reschedule": "", + "match.schedule.agreedTime": "", + "match.schedule.setByOrganizer": "", + "match.schedule.boardClosed": "", + "match.schedule.rescheduleInfo": "", + "match.schedule.noCandidates": "", + "match.schedule.pick": "", + "match.schedule.pickConfirm": "", + "match.schedule.keepCurrentTime": "", + "match.schedule.proposeTimes": "", + "match.schedule.requestAnotherTime": "", + "match.schedule.propose": "", + "match.schedule.updateTimes": "", + "match.schedule.availabilityTitle": "", + "match.admin.setTime": "", + "match.admin.setTimeInfo": "", + "match.admin.timeSet": "", "match.locked.header": "", "match.locked.subtitle": "", "match.waitingForTeams.header": "", diff --git a/locales/en/common.json b/locales/en/common.json index 2126831b1..102882f54 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -102,6 +102,12 @@ "notifications.text.SCRIM_CANCELED": "The scrim vs. {{opponentTeamName}} was canceled", "notifications.title.SCRIM_STARTING_SOON": "Scrim Starting Soon", "notifications.text.SCRIM_STARTING_SOON": "Your scrim vs. {{opponentTeamName}} is starting soon", + "notifications.title.TO_LEAGUE_TIMES_PROPOSED": "League Times Proposed", + "notifications.text.TO_LEAGUE_TIMES_PROPOSED": "{{opponentTeamName}} proposed times for your league set", + "notifications.title.TO_LEAGUE_MATCH_SCHEDULED": "League Set Scheduled", + "notifications.text.TO_LEAGUE_MATCH_SCHEDULED": "Your league set vs. {{opponentTeamName}} has been scheduled", + "notifications.title.TO_LEAGUE_MATCH_STARTING_SOON": "League Set Starting Soon", + "notifications.text.TO_LEAGUE_MATCH_STARTING_SOON": "Your league set vs. {{opponentTeamName}} is starting soon", "notifications.title.SCRIM_AUTO_DELETED": "Scrim Post Removed", "notifications.text.SCRIM_AUTO_DELETED": "Your scrim post was automatically removed because you booked a scrim around that time (± 1 hour)", "notifications.title.COMMISSIONS_CLOSED": "Commissions Closed", @@ -264,6 +270,7 @@ "tag.name.SR": "Salmon Run", "tag.name.CARDS": "Tableturf Battle", "tag.name.COLLEGIATE": "Collegiate", + "tag.name.LEAGUE": "League", "weapon.category.SHOOTERS": "Shooters", "weapon.category.BLASTERS": "Blasters", "weapon.category.ROLLERS": "Rollers", @@ -379,6 +386,10 @@ "chat.systemMsg.mapBanned": "{{name}} banned a map", "chat.systemMsg.modePicked": "{{name}} picked a mode", "chat.systemMsg.modeBanned": "{{name}} banned a mode", + "chat.systemMsg.leagueTimesProposed": "{{name}} proposed times for the set", + "chat.systemMsg.leagueTimePicked": "{{name}} picked a time for the set", + "chat.systemMsg.leagueRescheduleDeclined": "{{name}} declined the new times. The set keeps its current time", + "chat.systemMsg.leagueTimeSetByOrganizer": "{{name}} (organizer) set the time of the set", "chat.newMessages": "New messages", "chat.sidebar.title": "Chat", "chat.sidebar.noActiveChats": "No active chats", diff --git a/locales/en/forms.json b/locales/en/forms.json index f6f5f27be..ceea01e4e 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -127,6 +127,12 @@ "labels.scrimRequestStartTime": "Start time", "bottomTexts.scrimRequestStartTime": "Select a time within the post's time range", "labels.duration": "Duration", + "bottomTexts.candidateTimes": "The other team picks one of these, or answers with times of their own", + "labels.candidateTime": "Time", + "labels.setTime": "Set time", + "labels.roundPlayableFrom": "Playable from", + "labels.league": "League", + "bottomTexts.league": "Played over weeks. Every starting bracket is a division and the teams schedule their sets themselves.", "options.duration.30m": "30 minutes", "options.duration.1h": "1 hour", "options.duration.1h30m": "1.5 hours", @@ -140,6 +146,7 @@ "errors.dateTooFarAway": "Date must be within the weeks shown on the schedule", "errors.dateTooEarly": "Date is too early", "errors.dateTooLate": "Date is too late", + "errors.invalidDate": "Date is incomplete or doesn't exist", "errors.dateTooFarInFuture": "Date can not be more than 2 weeks in the future", "errors.minUsersExcludingYourself": "Must have at least {{min}} users excluding yourself", "errors.usersMustBeUnique": "Users must be unique", @@ -220,6 +227,7 @@ "options.tag.LAN": "LAN", "options.tag.QUALIFIER": "Qualifier", "options.tag.COLLEGIATE": "Collegiate", + "options.tag.LEAGUE": "League", "options.tag.ONES": "1v1", "options.tag.DUOS": "2v2", "options.tag.TRIOS": "3v3", diff --git a/locales/en/q.json b/locales/en/q.json index 7c5ce53a9..47fd83066 100644 --- a/locales/en/q.json +++ b/locales/en/q.json @@ -176,6 +176,7 @@ "match.banner.playAll": "Play all {{count}}", "match.banner.vs": "vs.", "match.tabs.rosters": "Rosters", + "match.tabs.schedule": "Schedule", "match.tabs.action": "Action", "match.tabs.result": "Result", "match.tabs.stats": "Stats", diff --git a/locales/en/tournament.json b/locales/en/tournament.json index 4731821fa..6384c7c53 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -10,6 +10,7 @@ "nav.looking": "LFG", "nav.subs": "Subs", "nav.divisions": "Divisions", + "nav.matches": "Matches", "nav.admin": "Admin", "findTeam": "Find team", "registerNow": "Register now", @@ -31,7 +32,6 @@ "pre.steps.roster": "Full roster", "pre.steps.pool": "Map pool", "pre.steps.check-in": "Check-in", - "pre.steps.googleSheet": "Google Sheet", "pre.footer": "Registration can be freely changed before the tournament starts", "pre.registrationClosesAt": "Registration closes at {{time}}", "pre.friendCode.needed": "To play tournaments on sendou.ink you'll need to register your friend code.", @@ -78,6 +78,8 @@ "mapList.rerollAllMaps": "Reroll all maps", "mapList.teamsPick": "Team's pick", "mapList.customFlow": "Custom flow", + "mapList.realtime": "Play in real time", + "mapList.realtimeInfo": "Sets are played right away like in a regular tournament, teams don't schedule them", "rules.teamPick.stageRepeat": "A stage can appear in at most {{cap}} modes of a team's picks", "team.label": "Team", "team.teamPage": "Team page", @@ -190,6 +192,37 @@ "match.endedEarly.subtitle": "Staff ended the match before it was decided", "match.leagueLocked.header": "Waiting for league round to start", "match.leagueLocked.subtitle": "Round playable from {{date}} onwards", + "match.schedule.notOpen.header": "Scheduling not open yet", + "match.schedule.notOpen.subtitle": "Times can be proposed from {{date}}", + "match.schedule.unscheduled.header": "Set has no time yet", + "match.schedule.unscheduled.subtitle": "Agree on a time with the other team", + "matches.tabs.scheduled": "Scheduled", + "matches.tabs.unscheduled": "Unscheduled", + "matches.tabs.past": "Past", + "matches.division": "Division", + "matches.allDivisions": "All divisions", + "matches.empty.scheduled": "No sets scheduled", + "matches.empty.unscheduled": "No sets to schedule right now", + "matches.empty.past": "No sets played yet", + "matches.cast": "Cast", + "matches.live": "Live", + "match.schedule.reschedule": "Reschedule", + "match.schedule.agreedTime": "Agreed time", + "match.schedule.setByOrganizer": "Set by the organizer", + "match.schedule.boardClosed": "The organizer set the time of this set. Contact them if it needs to change.", + "match.schedule.rescheduleInfo": "Either team can propose new times, but the other team doesn't have to agree. Without agreement, the current time stays.", + "match.schedule.noCandidates": "No times proposed", + "match.schedule.pick": "Pick", + "match.schedule.pickConfirm": "Play the set on {{time}}?", + "match.schedule.keepCurrentTime": "Keep the current time", + "match.schedule.proposeTimes": "Propose times you could play", + "match.schedule.requestAnotherTime": "Request another time", + "match.schedule.propose": "Propose", + "match.schedule.updateTimes": "Update times", + "match.schedule.availabilityTitle": "{{team}} availability", + "match.admin.setTime": "Set time", + "match.admin.setTimeInfo": "The time you set is final. The teams can't propose other times.", + "match.admin.timeSet": "Time set", "match.locked.header": "Match locked to be casted", "match.locked.subtitle": "Please wait for staff to unlock", "match.waitingForTeams.header": "Waiting for teams", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 15a995655..2430b058b 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -102,6 +102,12 @@ "notifications.text.SCRIM_CANCELED": "La scrim contra {{opponentTeamName}} se ha cancelado", "notifications.title.SCRIM_STARTING_SOON": "La scrim empieza pronto", "notifications.text.SCRIM_STARTING_SOON": "Tu scrim contra {{opponentTeamName}} empieza pronto", + "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": "Publicación de scrim eliminada", "notifications.text.SCRIM_AUTO_DELETED": "Tu publicación de scrim se ha eliminado automáticamente porque has reservado una scrim sobre esa hora (± 1 hora)", "notifications.title.COMMISSIONS_CLOSED": "Comisiones Cerradas", @@ -264,6 +270,7 @@ "tag.name.SR": "Salmon Run", "tag.name.CARDS": "Lucha carterritorial", "tag.name.COLLEGIATE": "Universitario", + "tag.name.LEAGUE": "", "weapon.category.SHOOTERS": "Lanzatintas", "weapon.category.BLASTERS": "Devastadores", "weapon.category.ROLLERS": "Rodillos", @@ -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": "Nuevos mensajes", "chat.sidebar.title": "Chat", "chat.sidebar.noActiveChats": "No hay chats activos", diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index 65aa1e49a..eda5b55f3 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -127,6 +127,12 @@ "labels.scrimRequestStartTime": "Hora de inicio", "bottomTexts.scrimRequestStartTime": "Selecciona una hora dentro del rango de tiempo de la publicación", "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": "La fecha es demasiado temprana", "errors.dateTooLate": "La fecha es demasiado tarde", + "errors.invalidDate": "", "errors.dateTooFarInFuture": "La fecha no puede ser más de 2 semanas en el futuro", "errors.minUsersExcludingYourself": "Debe haber al menos {{min}} usuarios sin contarte a ti", "errors.usersMustBeUnique": "Los usuarios deben ser únicos", @@ -220,6 +227,7 @@ "options.tag.LAN": "LAN", "options.tag.QUALIFIER": "Clasificatorio", "options.tag.COLLEGIATE": "Universitario", + "options.tag.LEAGUE": "", "options.tag.ONES": "1v1", "options.tag.DUOS": "2v2", "options.tag.TRIOS": "3v3", diff --git a/locales/es-ES/q.json b/locales/es-ES/q.json index 133034bf7..a9856d4e2 100644 --- a/locales/es-ES/q.json +++ b/locales/es-ES/q.json @@ -176,6 +176,7 @@ "match.banner.playAll": "Jugar los {{count}}", "match.banner.vs": "vs.", "match.tabs.rosters": "Plantillas", + "match.tabs.schedule": "", "match.tabs.action": "Acción", "match.tabs.result": "Resultado", "match.tabs.stats": "Estadísticas", diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json index af225a554..6f0189cd1 100644 --- a/locales/es-ES/tournament.json +++ b/locales/es-ES/tournament.json @@ -10,6 +10,7 @@ "nav.looking": "Busco equipo", "nav.subs": "Subs", "nav.divisions": "Divisiones", + "nav.matches": "", "nav.admin": "Admin", "findTeam": "Buscar equipo", "registerNow": "Inscríbete ahora", @@ -31,7 +32,6 @@ "pre.steps.roster": "Equipo lleno", "pre.steps.pool": "Grupo de mapas", "pre.steps.check-in": "Check-in", - "pre.steps.googleSheet": "", "pre.footer": "El registro puede ajustarse libremente antes de que empiece el torneo", "pre.registrationClosesAt": "", "pre.friendCode.needed": "", @@ -78,6 +78,8 @@ "mapList.rerollAllMaps": "", "mapList.teamsPick": "", "mapList.customFlow": "", + "mapList.realtime": "", + "mapList.realtimeInfo": "", "rules.teamPick.stageRepeat": "", "team.label": "Equipo", "team.teamPage": "", @@ -191,6 +193,37 @@ "match.endedEarly.subtitle": "El staff terminó el set antes de que se decidiera", "match.leagueLocked.header": "Esperando a que empiece la ronda de la liga", "match.leagueLocked.subtitle": "Ronda jugable a partir del {{date}}", + "match.schedule.notOpen.header": "", + "match.schedule.notOpen.subtitle": "", + "match.schedule.unscheduled.header": "", + "match.schedule.unscheduled.subtitle": "", + "matches.tabs.scheduled": "", + "matches.tabs.unscheduled": "", + "matches.tabs.past": "", + "matches.division": "", + "matches.allDivisions": "", + "matches.empty.scheduled": "", + "matches.empty.unscheduled": "", + "matches.empty.past": "", + "matches.cast": "", + "matches.live": "", + "match.schedule.reschedule": "", + "match.schedule.agreedTime": "", + "match.schedule.setByOrganizer": "", + "match.schedule.boardClosed": "", + "match.schedule.rescheduleInfo": "", + "match.schedule.noCandidates": "", + "match.schedule.pick": "", + "match.schedule.pickConfirm": "", + "match.schedule.keepCurrentTime": "", + "match.schedule.proposeTimes": "", + "match.schedule.requestAnotherTime": "", + "match.schedule.propose": "", + "match.schedule.updateTimes": "", + "match.schedule.availabilityTitle": "", + "match.admin.setTime": "", + "match.admin.setTimeInfo": "", + "match.admin.timeSet": "", "match.locked.header": "Set bloqueado para ser casteado", "match.locked.subtitle": "Por favor, espera a que el staff lo desbloquee", "match.waitingForTeams.header": "Esperando a los equipos", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index fb12eaf6b..c1bff1cb3 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -102,6 +102,12 @@ "notifications.text.SCRIM_CANCELED": "La scrim contra {{opponentTeamName}} se ha cancelado", "notifications.title.SCRIM_STARTING_SOON": "La scrim empieza pronto", "notifications.text.SCRIM_STARTING_SOON": "Tu scrim contra {{opponentTeamName}} empieza pronto", + "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": "Publicación de scrim eliminada", "notifications.text.SCRIM_AUTO_DELETED": "Tu publicación de scrim se ha eliminado automáticamente porque has reservado una scrim sobre esa hora (± 1 hora)", "notifications.title.COMMISSIONS_CLOSED": "Comisiones Cerradas", @@ -264,6 +270,7 @@ "tag.name.SR": "Salmon Run", "tag.name.CARDS": "Combate carterritorial", "tag.name.COLLEGIATE": "Universitario", + "tag.name.LEAGUE": "", "weapon.category.SHOOTERS": "Lanzatintas", "weapon.category.BLASTERS": "Lanzamotas", "weapon.category.ROLLERS": "Rodillos", @@ -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": "Nuevos mensajes", "chat.sidebar.title": "Chat", "chat.sidebar.noActiveChats": "No hay chats activos", diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index 3ab9ad9fe..5798e6631 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -127,6 +127,12 @@ "labels.scrimRequestStartTime": "Hora de inicio", "bottomTexts.scrimRequestStartTime": "Selecciona una hora dentro del rango de tiempo de la publicación", "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": "La fecha es demasiado temprana", "errors.dateTooLate": "La fecha es demasiado tarde", + "errors.invalidDate": "", "errors.dateTooFarInFuture": "La fecha no puede ser más de 2 semanas en el futuro", "errors.minUsersExcludingYourself": "Debe haber al menos {{min}} usuarios sin contarte a ti", "errors.usersMustBeUnique": "Los usuarios deben ser únicos", @@ -220,6 +227,7 @@ "options.tag.LAN": "LAN", "options.tag.QUALIFIER": "Clasificatorio", "options.tag.COLLEGIATE": "Universitario", + "options.tag.LEAGUE": "", "options.tag.ONES": "1v1", "options.tag.DUOS": "2v2", "options.tag.TRIOS": "3v3", diff --git a/locales/es-US/q.json b/locales/es-US/q.json index 14ef09983..2a2caea94 100644 --- a/locales/es-US/q.json +++ b/locales/es-US/q.json @@ -176,6 +176,7 @@ "match.banner.playAll": "Jugar los {{count}}", "match.banner.vs": "vs.", "match.tabs.rosters": "Plantillas", + "match.tabs.schedule": "", "match.tabs.action": "Acción", "match.tabs.result": "Resultado", "match.tabs.stats": "Estadísticas", diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json index 900ffc8ed..ae0b9925a 100644 --- a/locales/es-US/tournament.json +++ b/locales/es-US/tournament.json @@ -10,6 +10,7 @@ "nav.looking": "Busco equipo", "nav.subs": "Subs", "nav.divisions": "Divisiones", + "nav.matches": "", "nav.admin": "Admin", "findTeam": "Buscar equipo", "registerNow": "Inscríbete ahora", @@ -31,7 +32,6 @@ "pre.steps.roster": "Equipo lleno", "pre.steps.pool": "Grupo de escenarios", "pre.steps.check-in": "Check-in", - "pre.steps.googleSheet": "", "pre.footer": "El registro puede ajustarse antes de que empiece el torneo", "pre.registrationClosesAt": "", "pre.friendCode.needed": "", @@ -78,6 +78,8 @@ "mapList.rerollAllMaps": "", "mapList.teamsPick": "", "mapList.customFlow": "", + "mapList.realtime": "", + "mapList.realtimeInfo": "", "rules.teamPick.stageRepeat": "", "team.label": "Equipo", "team.teamPage": "", @@ -191,6 +193,37 @@ "match.endedEarly.subtitle": "El staff terminó el set antes de que se decidiera", "match.leagueLocked.header": "Esperando a que empiece la ronda de la liga", "match.leagueLocked.subtitle": "Ronda jugable a partir del {{date}}", + "match.schedule.notOpen.header": "", + "match.schedule.notOpen.subtitle": "", + "match.schedule.unscheduled.header": "", + "match.schedule.unscheduled.subtitle": "", + "matches.tabs.scheduled": "", + "matches.tabs.unscheduled": "", + "matches.tabs.past": "", + "matches.division": "", + "matches.allDivisions": "", + "matches.empty.scheduled": "", + "matches.empty.unscheduled": "", + "matches.empty.past": "", + "matches.cast": "", + "matches.live": "", + "match.schedule.reschedule": "", + "match.schedule.agreedTime": "", + "match.schedule.setByOrganizer": "", + "match.schedule.boardClosed": "", + "match.schedule.rescheduleInfo": "", + "match.schedule.noCandidates": "", + "match.schedule.pick": "", + "match.schedule.pickConfirm": "", + "match.schedule.keepCurrentTime": "", + "match.schedule.proposeTimes": "", + "match.schedule.requestAnotherTime": "", + "match.schedule.propose": "", + "match.schedule.updateTimes": "", + "match.schedule.availabilityTitle": "", + "match.admin.setTime": "", + "match.admin.setTimeInfo": "", + "match.admin.timeSet": "", "match.locked.header": "Set bloqueado para ser casteado", "match.locked.subtitle": "Por favor, espera a que el staff lo desbloquee", "match.waitingForTeams.header": "Esperando a los equipos", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index 68aef4638..68d72dbd0 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -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": "Cartes & Territoire", "tag.name.COLLEGIATE": "", + "tag.name.LEAGUE": "", "weapon.category.SHOOTERS": "Lanceurs", "weapon.category.BLASTERS": "Blasters", "weapon.category.ROLLERS": "Rouleaux", @@ -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": "", diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index 92240e233..7a18fe761 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -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": "", diff --git a/locales/fr-CA/q.json b/locales/fr-CA/q.json index cca72cc66..9fc51cadd 100644 --- a/locales/fr-CA/q.json +++ b/locales/fr-CA/q.json @@ -176,6 +176,7 @@ "match.banner.playAll": "", "match.banner.vs": "", "match.tabs.rosters": "", + "match.tabs.schedule": "", "match.tabs.action": "", "match.tabs.result": "", "match.tabs.stats": "", diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json index ff69377ec..1f0db46cc 100644 --- a/locales/fr-CA/tournament.json +++ b/locales/fr-CA/tournament.json @@ -10,6 +10,7 @@ "nav.looking": "", "nav.subs": "Remplaçants", "nav.divisions": "", + "nav.matches": "", "nav.admin": "Admin", "findTeam": "", "registerNow": "", @@ -31,7 +32,6 @@ "pre.steps.roster": "Participants", "pre.steps.pool": "Liste des stages", "pre.steps.check-in": "", - "pre.steps.googleSheet": "", "pre.footer": "L'inscription peut être librement modifiée avant le début du tournoi", "pre.registrationClosesAt": "", "pre.friendCode.needed": "", @@ -78,6 +78,8 @@ "mapList.rerollAllMaps": "", "mapList.teamsPick": "", "mapList.customFlow": "", + "mapList.realtime": "", + "mapList.realtimeInfo": "", "rules.teamPick.stageRepeat": "", "team.label": "Équipe", "team.teamPage": "", @@ -191,6 +193,37 @@ "match.endedEarly.subtitle": "", "match.leagueLocked.header": "", "match.leagueLocked.subtitle": "", + "match.schedule.notOpen.header": "", + "match.schedule.notOpen.subtitle": "", + "match.schedule.unscheduled.header": "", + "match.schedule.unscheduled.subtitle": "", + "matches.tabs.scheduled": "", + "matches.tabs.unscheduled": "", + "matches.tabs.past": "", + "matches.division": "", + "matches.allDivisions": "", + "matches.empty.scheduled": "", + "matches.empty.unscheduled": "", + "matches.empty.past": "", + "matches.cast": "", + "matches.live": "", + "match.schedule.reschedule": "", + "match.schedule.agreedTime": "", + "match.schedule.setByOrganizer": "", + "match.schedule.boardClosed": "", + "match.schedule.rescheduleInfo": "", + "match.schedule.noCandidates": "", + "match.schedule.pick": "", + "match.schedule.pickConfirm": "", + "match.schedule.keepCurrentTime": "", + "match.schedule.proposeTimes": "", + "match.schedule.requestAnotherTime": "", + "match.schedule.propose": "", + "match.schedule.updateTimes": "", + "match.schedule.availabilityTitle": "", + "match.admin.setTime": "", + "match.admin.setTimeInfo": "", + "match.admin.timeSet": "", "match.locked.header": "", "match.locked.subtitle": "", "match.waitingForTeams.header": "", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index 7cf0248a8..7b16df481 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -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": "Cartes & Territoire", "tag.name.COLLEGIATE": "Universitaire", + "tag.name.LEAGUE": "", "weapon.category.SHOOTERS": "Lanceurs", "weapon.category.BLASTERS": "Blasters", "weapon.category.ROLLERS": "Rouleaux", @@ -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": "Nouveau message", "chat.sidebar.title": "", "chat.sidebar.noActiveChats": "", diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index 26b5ea4c1..e829dda54 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -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": "", diff --git a/locales/fr-EU/q.json b/locales/fr-EU/q.json index 003fbb4a6..40d55a297 100644 --- a/locales/fr-EU/q.json +++ b/locales/fr-EU/q.json @@ -176,6 +176,7 @@ "match.banner.playAll": "", "match.banner.vs": "", "match.tabs.rosters": "", + "match.tabs.schedule": "", "match.tabs.action": "", "match.tabs.result": "", "match.tabs.stats": "", diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json index a580500c5..e28a3db9f 100644 --- a/locales/fr-EU/tournament.json +++ b/locales/fr-EU/tournament.json @@ -10,6 +10,7 @@ "nav.looking": "", "nav.subs": "Remplaçants", "nav.divisions": "", + "nav.matches": "", "nav.admin": "Admin", "findTeam": "", "registerNow": "", @@ -31,7 +32,6 @@ "pre.steps.roster": "Participants", "pre.steps.pool": "Liste des stages", "pre.steps.check-in": "Check-in", - "pre.steps.googleSheet": "", "pre.footer": "L'inscription peut être librement modifiée avant le début du tournoi", "pre.registrationClosesAt": "", "pre.friendCode.needed": "", @@ -78,6 +78,8 @@ "mapList.rerollAllMaps": "", "mapList.teamsPick": "", "mapList.customFlow": "", + "mapList.realtime": "", + "mapList.realtimeInfo": "", "rules.teamPick.stageRepeat": "", "team.label": "Équipe", "team.teamPage": "", @@ -191,6 +193,37 @@ "match.endedEarly.subtitle": "", "match.leagueLocked.header": "", "match.leagueLocked.subtitle": "", + "match.schedule.notOpen.header": "", + "match.schedule.notOpen.subtitle": "", + "match.schedule.unscheduled.header": "", + "match.schedule.unscheduled.subtitle": "", + "matches.tabs.scheduled": "", + "matches.tabs.unscheduled": "", + "matches.tabs.past": "", + "matches.division": "", + "matches.allDivisions": "", + "matches.empty.scheduled": "", + "matches.empty.unscheduled": "", + "matches.empty.past": "", + "matches.cast": "", + "matches.live": "", + "match.schedule.reschedule": "", + "match.schedule.agreedTime": "", + "match.schedule.setByOrganizer": "", + "match.schedule.boardClosed": "", + "match.schedule.rescheduleInfo": "", + "match.schedule.noCandidates": "", + "match.schedule.pick": "", + "match.schedule.pickConfirm": "", + "match.schedule.keepCurrentTime": "", + "match.schedule.proposeTimes": "", + "match.schedule.requestAnotherTime": "", + "match.schedule.propose": "", + "match.schedule.updateTimes": "", + "match.schedule.availabilityTitle": "", + "match.admin.setTime": "", + "match.admin.setTimeInfo": "", + "match.admin.timeSet": "", "match.locked.header": "", "match.locked.subtitle": "", "match.waitingForTeams.header": "", diff --git a/locales/he/common.json b/locales/he/common.json index 6a4ecb14b..7c5286319 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -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": "Shooters", "weapon.category.BLASTERS": "Blasters", "weapon.category.ROLLERS": "Rollers", @@ -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": "", diff --git a/locales/he/forms.json b/locales/he/forms.json index 4ccb80558..d6fecbddc 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -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": "", diff --git a/locales/he/q.json b/locales/he/q.json index 94a0f0c5b..7286733ac 100644 --- a/locales/he/q.json +++ b/locales/he/q.json @@ -176,6 +176,7 @@ "match.banner.playAll": "", "match.banner.vs": "", "match.tabs.rosters": "", + "match.tabs.schedule": "", "match.tabs.action": "", "match.tabs.result": "", "match.tabs.stats": "", diff --git a/locales/he/tournament.json b/locales/he/tournament.json index 10efa0241..3589d6126 100644 --- a/locales/he/tournament.json +++ b/locales/he/tournament.json @@ -10,6 +10,7 @@ "nav.looking": "", "nav.subs": "מחליפים", "nav.divisions": "", + "nav.matches": "", "nav.admin": "מנהל", "findTeam": "", "registerNow": "", @@ -31,7 +32,6 @@ "pre.steps.roster": "צוות מלא", "pre.steps.pool": "מאגר מפות", "pre.steps.check-in": "צ'ק-אין", - "pre.steps.googleSheet": "", "pre.footer": "הרשמה ניתנת לשינוי עד שהטורניר מתחיל", "pre.registrationClosesAt": "", "pre.friendCode.needed": "", @@ -78,6 +78,8 @@ "mapList.rerollAllMaps": "", "mapList.teamsPick": "", "mapList.customFlow": "", + "mapList.realtime": "", + "mapList.realtimeInfo": "", "rules.teamPick.stageRepeat": "", "team.label": "צוות", "team.teamPage": "", @@ -191,6 +193,37 @@ "match.endedEarly.subtitle": "", "match.leagueLocked.header": "", "match.leagueLocked.subtitle": "", + "match.schedule.notOpen.header": "", + "match.schedule.notOpen.subtitle": "", + "match.schedule.unscheduled.header": "", + "match.schedule.unscheduled.subtitle": "", + "matches.tabs.scheduled": "", + "matches.tabs.unscheduled": "", + "matches.tabs.past": "", + "matches.division": "", + "matches.allDivisions": "", + "matches.empty.scheduled": "", + "matches.empty.unscheduled": "", + "matches.empty.past": "", + "matches.cast": "", + "matches.live": "", + "match.schedule.reschedule": "", + "match.schedule.agreedTime": "", + "match.schedule.setByOrganizer": "", + "match.schedule.boardClosed": "", + "match.schedule.rescheduleInfo": "", + "match.schedule.noCandidates": "", + "match.schedule.pick": "", + "match.schedule.pickConfirm": "", + "match.schedule.keepCurrentTime": "", + "match.schedule.proposeTimes": "", + "match.schedule.requestAnotherTime": "", + "match.schedule.propose": "", + "match.schedule.updateTimes": "", + "match.schedule.availabilityTitle": "", + "match.admin.setTime": "", + "match.admin.setTimeInfo": "", + "match.admin.timeSet": "", "match.locked.header": "", "match.locked.subtitle": "", "match.waitingForTeams.header": "", diff --git a/locales/it/common.json b/locales/it/common.json index bceff16e7..99c8aecc1 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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": "Splattanza", "tag.name.COLLEGIATE": "Universitario", + "tag.name.LEAGUE": "", "weapon.category.SHOOTERS": "Armi a ripetizione", "weapon.category.BLASTERS": "Blaster", "weapon.category.ROLLERS": "Rulli", @@ -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": "Nuovi messaggi", "chat.sidebar.title": "", "chat.sidebar.noActiveChats": "", diff --git a/locales/it/forms.json b/locales/it/forms.json index 6a4e3091e..a37594c44 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -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": "", diff --git a/locales/it/q.json b/locales/it/q.json index aad5b878f..16c48dd4c 100644 --- a/locales/it/q.json +++ b/locales/it/q.json @@ -176,6 +176,7 @@ "match.banner.playAll": "", "match.banner.vs": "", "match.tabs.rosters": "", + "match.tabs.schedule": "", "match.tabs.action": "", "match.tabs.result": "", "match.tabs.stats": "", diff --git a/locales/it/tournament.json b/locales/it/tournament.json index dd4129d29..edd855ce0 100644 --- a/locales/it/tournament.json +++ b/locales/it/tournament.json @@ -10,6 +10,7 @@ "nav.looking": "", "nav.subs": "Sub", "nav.divisions": "", + "nav.matches": "", "nav.admin": "Admin", "findTeam": "", "registerNow": "", @@ -31,7 +32,6 @@ "pre.steps.roster": "Roster completo", "pre.steps.pool": "Pool mappe", "pre.steps.check-in": "Check-in", - "pre.steps.googleSheet": "", "pre.footer": "L'iscrizione può essere cambiata liberamente prima del torneo", "pre.registrationClosesAt": "", "pre.friendCode.needed": "", @@ -78,6 +78,8 @@ "mapList.rerollAllMaps": "", "mapList.teamsPick": "", "mapList.customFlow": "", + "mapList.realtime": "", + "mapList.realtimeInfo": "", "rules.teamPick.stageRepeat": "", "team.label": "Squadra", "team.teamPage": "", @@ -191,6 +193,37 @@ "match.endedEarly.subtitle": "", "match.leagueLocked.header": "", "match.leagueLocked.subtitle": "", + "match.schedule.notOpen.header": "", + "match.schedule.notOpen.subtitle": "", + "match.schedule.unscheduled.header": "", + "match.schedule.unscheduled.subtitle": "", + "matches.tabs.scheduled": "", + "matches.tabs.unscheduled": "", + "matches.tabs.past": "", + "matches.division": "", + "matches.allDivisions": "", + "matches.empty.scheduled": "", + "matches.empty.unscheduled": "", + "matches.empty.past": "", + "matches.cast": "", + "matches.live": "", + "match.schedule.reschedule": "", + "match.schedule.agreedTime": "", + "match.schedule.setByOrganizer": "", + "match.schedule.boardClosed": "", + "match.schedule.rescheduleInfo": "", + "match.schedule.noCandidates": "", + "match.schedule.pick": "", + "match.schedule.pickConfirm": "", + "match.schedule.keepCurrentTime": "", + "match.schedule.proposeTimes": "", + "match.schedule.requestAnotherTime": "", + "match.schedule.propose": "", + "match.schedule.updateTimes": "", + "match.schedule.availabilityTitle": "", + "match.admin.setTime": "", + "match.admin.setTimeInfo": "", + "match.admin.timeSet": "", "match.locked.header": "", "match.locked.subtitle": "", "match.waitingForTeams.header": "", diff --git a/locales/ja/common.json b/locales/ja/common.json index 9ba0ba8ad..dc8dbe8bb 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -102,6 +102,12 @@ "notifications.text.SCRIM_CANCELED": "{{opponentTeamName}}との対抗戦がキャンセルされました", "notifications.title.SCRIM_STARTING_SOON": "対抗戦が間もなく開始します", "notifications.text.SCRIM_STARTING_SOON": "{{opponentTeamName}}との対抗戦が間もなく開始します", + "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": "サーモンラン", "tag.name.CARDS": "ナワバトラー", "tag.name.COLLEGIATE": "大学生用", + "tag.name.LEAGUE": "", "weapon.category.SHOOTERS": "シューター", "weapon.category.BLASTERS": "ブラスター", "weapon.category.ROLLERS": "ローラー", @@ -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": "使用中のチャットはありません", diff --git a/locales/ja/forms.json b/locales/ja/forms.json index da4f0f1a4..2a72c7fdf 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -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": "", diff --git a/locales/ja/q.json b/locales/ja/q.json index b01d8d8f2..e20b2ba70 100644 --- a/locales/ja/q.json +++ b/locales/ja/q.json @@ -176,6 +176,7 @@ "match.banner.playAll": "", "match.banner.vs": "", "match.tabs.rosters": "", + "match.tabs.schedule": "", "match.tabs.action": "", "match.tabs.result": "", "match.tabs.stats": "", diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json index a5a33ba6c..e095c3a68 100644 --- a/locales/ja/tournament.json +++ b/locales/ja/tournament.json @@ -10,6 +10,7 @@ "nav.looking": "", "nav.subs": "サブ", "nav.divisions": "", + "nav.matches": "", "nav.admin": "管理", "findTeam": "", "registerNow": "", @@ -31,7 +32,6 @@ "pre.steps.roster": "全プレイヤー", "pre.steps.pool": "ステージプール", "pre.steps.check-in": "チェックイン", - "pre.steps.googleSheet": "", "pre.footer": "参加登録はトーナメント開始前であればいつでも変更できます", "pre.registrationClosesAt": "", "pre.friendCode.needed": "", @@ -78,6 +78,8 @@ "mapList.rerollAllMaps": "", "mapList.teamsPick": "", "mapList.customFlow": "", + "mapList.realtime": "", + "mapList.realtimeInfo": "", "rules.teamPick.stageRepeat": "", "team.label": "チーム", "team.teamPage": "", @@ -188,6 +190,37 @@ "match.endedEarly.subtitle": "", "match.leagueLocked.header": "", "match.leagueLocked.subtitle": "", + "match.schedule.notOpen.header": "", + "match.schedule.notOpen.subtitle": "", + "match.schedule.unscheduled.header": "", + "match.schedule.unscheduled.subtitle": "", + "matches.tabs.scheduled": "", + "matches.tabs.unscheduled": "", + "matches.tabs.past": "", + "matches.division": "", + "matches.allDivisions": "", + "matches.empty.scheduled": "", + "matches.empty.unscheduled": "", + "matches.empty.past": "", + "matches.cast": "", + "matches.live": "", + "match.schedule.reschedule": "", + "match.schedule.agreedTime": "", + "match.schedule.setByOrganizer": "", + "match.schedule.boardClosed": "", + "match.schedule.rescheduleInfo": "", + "match.schedule.noCandidates": "", + "match.schedule.pick": "", + "match.schedule.pickConfirm": "", + "match.schedule.keepCurrentTime": "", + "match.schedule.proposeTimes": "", + "match.schedule.requestAnotherTime": "", + "match.schedule.propose": "", + "match.schedule.updateTimes": "", + "match.schedule.availabilityTitle": "", + "match.admin.setTime": "", + "match.admin.setTimeInfo": "", + "match.admin.timeSet": "", "match.locked.header": "", "match.locked.subtitle": "", "match.waitingForTeams.header": "", diff --git a/locales/ko/common.json b/locales/ko/common.json index 23add5c0a..59e2e7cd3 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -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": "새먼런", "tag.name.CARDS": "영역 배틀러", "tag.name.COLLEGIATE": "", + "tag.name.LEAGUE": "", "weapon.category.SHOOTERS": "슈터", "weapon.category.BLASTERS": "블래스터", "weapon.category.ROLLERS": "롤러", @@ -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": "", diff --git a/locales/ko/forms.json b/locales/ko/forms.json index 4797a19d8..31d3adc3e 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -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": "", diff --git a/locales/ko/q.json b/locales/ko/q.json index 681fb9525..27aa4a1eb 100644 --- a/locales/ko/q.json +++ b/locales/ko/q.json @@ -176,6 +176,7 @@ "match.banner.playAll": "", "match.banner.vs": "", "match.tabs.rosters": "", + "match.tabs.schedule": "", "match.tabs.action": "", "match.tabs.result": "", "match.tabs.stats": "", diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json index 7fca89573..e52b790e3 100644 --- a/locales/ko/tournament.json +++ b/locales/ko/tournament.json @@ -10,6 +10,7 @@ "nav.looking": "", "nav.subs": "", "nav.divisions": "", + "nav.matches": "", "nav.admin": "", "findTeam": "", "registerNow": "", @@ -31,7 +32,6 @@ "pre.steps.roster": "", "pre.steps.pool": "", "pre.steps.check-in": "", - "pre.steps.googleSheet": "", "pre.footer": "", "pre.registrationClosesAt": "", "pre.friendCode.needed": "", @@ -78,6 +78,8 @@ "mapList.rerollAllMaps": "", "mapList.teamsPick": "", "mapList.customFlow": "", + "mapList.realtime": "", + "mapList.realtimeInfo": "", "rules.teamPick.stageRepeat": "", "team.label": "", "team.teamPage": "", @@ -188,6 +190,37 @@ "match.endedEarly.subtitle": "", "match.leagueLocked.header": "", "match.leagueLocked.subtitle": "", + "match.schedule.notOpen.header": "", + "match.schedule.notOpen.subtitle": "", + "match.schedule.unscheduled.header": "", + "match.schedule.unscheduled.subtitle": "", + "matches.tabs.scheduled": "", + "matches.tabs.unscheduled": "", + "matches.tabs.past": "", + "matches.division": "", + "matches.allDivisions": "", + "matches.empty.scheduled": "", + "matches.empty.unscheduled": "", + "matches.empty.past": "", + "matches.cast": "", + "matches.live": "", + "match.schedule.reschedule": "", + "match.schedule.agreedTime": "", + "match.schedule.setByOrganizer": "", + "match.schedule.boardClosed": "", + "match.schedule.rescheduleInfo": "", + "match.schedule.noCandidates": "", + "match.schedule.pick": "", + "match.schedule.pickConfirm": "", + "match.schedule.keepCurrentTime": "", + "match.schedule.proposeTimes": "", + "match.schedule.requestAnotherTime": "", + "match.schedule.propose": "", + "match.schedule.updateTimes": "", + "match.schedule.availabilityTitle": "", + "match.admin.setTime": "", + "match.admin.setTimeInfo": "", + "match.admin.timeSet": "", "match.locked.header": "", "match.locked.subtitle": "", "match.waitingForTeams.header": "", diff --git a/locales/nl/common.json b/locales/nl/common.json index a892c1c0b..65755b05a 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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": "", "tag.name.CARDS": "", "tag.name.COLLEGIATE": "", + "tag.name.LEAGUE": "", "weapon.category.SHOOTERS": "Spetters", "weapon.category.BLASTERS": "Blasters", "weapon.category.ROLLERS": "Rollers", @@ -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": "", diff --git a/locales/nl/forms.json b/locales/nl/forms.json index 708e1b018..1a917e897 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -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": "", diff --git a/locales/nl/q.json b/locales/nl/q.json index 681fb9525..27aa4a1eb 100644 --- a/locales/nl/q.json +++ b/locales/nl/q.json @@ -176,6 +176,7 @@ "match.banner.playAll": "", "match.banner.vs": "", "match.tabs.rosters": "", + "match.tabs.schedule": "", "match.tabs.action": "", "match.tabs.result": "", "match.tabs.stats": "", diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json index 68d7f0cf6..3773c10fd 100644 --- a/locales/nl/tournament.json +++ b/locales/nl/tournament.json @@ -10,6 +10,7 @@ "nav.looking": "", "nav.subs": "", "nav.divisions": "", + "nav.matches": "", "nav.admin": "", "findTeam": "", "registerNow": "", @@ -31,7 +32,6 @@ "pre.steps.roster": "", "pre.steps.pool": "", "pre.steps.check-in": "", - "pre.steps.googleSheet": "", "pre.footer": "", "pre.registrationClosesAt": "", "pre.friendCode.needed": "", @@ -78,6 +78,8 @@ "mapList.rerollAllMaps": "", "mapList.teamsPick": "", "mapList.customFlow": "", + "mapList.realtime": "", + "mapList.realtimeInfo": "", "rules.teamPick.stageRepeat": "", "team.label": "", "team.teamPage": "", @@ -190,6 +192,37 @@ "match.endedEarly.subtitle": "", "match.leagueLocked.header": "", "match.leagueLocked.subtitle": "", + "match.schedule.notOpen.header": "", + "match.schedule.notOpen.subtitle": "", + "match.schedule.unscheduled.header": "", + "match.schedule.unscheduled.subtitle": "", + "matches.tabs.scheduled": "", + "matches.tabs.unscheduled": "", + "matches.tabs.past": "", + "matches.division": "", + "matches.allDivisions": "", + "matches.empty.scheduled": "", + "matches.empty.unscheduled": "", + "matches.empty.past": "", + "matches.cast": "", + "matches.live": "", + "match.schedule.reschedule": "", + "match.schedule.agreedTime": "", + "match.schedule.setByOrganizer": "", + "match.schedule.boardClosed": "", + "match.schedule.rescheduleInfo": "", + "match.schedule.noCandidates": "", + "match.schedule.pick": "", + "match.schedule.pickConfirm": "", + "match.schedule.keepCurrentTime": "", + "match.schedule.proposeTimes": "", + "match.schedule.requestAnotherTime": "", + "match.schedule.propose": "", + "match.schedule.updateTimes": "", + "match.schedule.availabilityTitle": "", + "match.admin.setTime": "", + "match.admin.setTimeInfo": "", + "match.admin.timeSet": "", "match.locked.header": "", "match.locked.subtitle": "", "match.waitingForTeams.header": "", diff --git a/locales/pl/common.json b/locales/pl/common.json index c8d3d7139..c87076180 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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": "", "tag.name.CARDS": "", "tag.name.COLLEGIATE": "", + "tag.name.LEAGUE": "", "weapon.category.SHOOTERS": "Shootery", "weapon.category.BLASTERS": "Blastery", "weapon.category.ROLLERS": "Rollery", @@ -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": "", diff --git a/locales/pl/forms.json b/locales/pl/forms.json index 911201d74..fea753a1b 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -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": "", diff --git a/locales/pl/q.json b/locales/pl/q.json index 681fb9525..27aa4a1eb 100644 --- a/locales/pl/q.json +++ b/locales/pl/q.json @@ -176,6 +176,7 @@ "match.banner.playAll": "", "match.banner.vs": "", "match.tabs.rosters": "", + "match.tabs.schedule": "", "match.tabs.action": "", "match.tabs.result": "", "match.tabs.stats": "", diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json index 39f020161..985edc0fb 100644 --- a/locales/pl/tournament.json +++ b/locales/pl/tournament.json @@ -10,6 +10,7 @@ "nav.looking": "", "nav.subs": "", "nav.divisions": "", + "nav.matches": "", "nav.admin": "Admin", "findTeam": "", "registerNow": "", @@ -31,7 +32,6 @@ "pre.steps.roster": "", "pre.steps.pool": "", "pre.steps.check-in": "", - "pre.steps.googleSheet": "", "pre.footer": "", "pre.registrationClosesAt": "", "pre.friendCode.needed": "", @@ -78,6 +78,8 @@ "mapList.rerollAllMaps": "", "mapList.teamsPick": "", "mapList.customFlow": "", + "mapList.realtime": "", + "mapList.realtimeInfo": "", "rules.teamPick.stageRepeat": "", "team.label": "Drużyna", "team.teamPage": "", @@ -192,6 +194,37 @@ "match.endedEarly.subtitle": "", "match.leagueLocked.header": "", "match.leagueLocked.subtitle": "", + "match.schedule.notOpen.header": "", + "match.schedule.notOpen.subtitle": "", + "match.schedule.unscheduled.header": "", + "match.schedule.unscheduled.subtitle": "", + "matches.tabs.scheduled": "", + "matches.tabs.unscheduled": "", + "matches.tabs.past": "", + "matches.division": "", + "matches.allDivisions": "", + "matches.empty.scheduled": "", + "matches.empty.unscheduled": "", + "matches.empty.past": "", + "matches.cast": "", + "matches.live": "", + "match.schedule.reschedule": "", + "match.schedule.agreedTime": "", + "match.schedule.setByOrganizer": "", + "match.schedule.boardClosed": "", + "match.schedule.rescheduleInfo": "", + "match.schedule.noCandidates": "", + "match.schedule.pick": "", + "match.schedule.pickConfirm": "", + "match.schedule.keepCurrentTime": "", + "match.schedule.proposeTimes": "", + "match.schedule.requestAnotherTime": "", + "match.schedule.propose": "", + "match.schedule.updateTimes": "", + "match.schedule.availabilityTitle": "", + "match.admin.setTime": "", + "match.admin.setTimeInfo": "", + "match.admin.timeSet": "", "match.locked.header": "", "match.locked.subtitle": "", "match.waitingForTeams.header": "", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index bcf1b821b..923afeeaa 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -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": "Shooters", "weapon.category.BLASTERS": "Blasters", "weapon.category.ROLLERS": "Rollers", @@ -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": "", diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index a110f7042..ecf8f100b 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -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": "", diff --git a/locales/pt-BR/q.json b/locales/pt-BR/q.json index 5af9d1d5d..77d1ae62d 100644 --- a/locales/pt-BR/q.json +++ b/locales/pt-BR/q.json @@ -176,6 +176,7 @@ "match.banner.playAll": "", "match.banner.vs": "", "match.tabs.rosters": "", + "match.tabs.schedule": "", "match.tabs.action": "", "match.tabs.result": "", "match.tabs.stats": "", diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json index 56c494378..8bef884cf 100644 --- a/locales/pt-BR/tournament.json +++ b/locales/pt-BR/tournament.json @@ -10,6 +10,7 @@ "nav.looking": "", "nav.subs": "Inscritos", "nav.divisions": "", + "nav.matches": "", "nav.admin": "Administrador", "findTeam": "", "registerNow": "", @@ -31,7 +32,6 @@ "pre.steps.roster": "Lista de membros completa", "pre.steps.pool": "Seleção de mapas", "pre.steps.check-in": "Check-in", - "pre.steps.googleSheet": "", "pre.footer": "O registro pode ser livremente alterado antes do torneio começar", "pre.registrationClosesAt": "", "pre.friendCode.needed": "", @@ -78,6 +78,8 @@ "mapList.rerollAllMaps": "", "mapList.teamsPick": "", "mapList.customFlow": "", + "mapList.realtime": "", + "mapList.realtimeInfo": "", "rules.teamPick.stageRepeat": "", "team.label": "Time", "team.teamPage": "", @@ -191,6 +193,37 @@ "match.endedEarly.subtitle": "", "match.leagueLocked.header": "", "match.leagueLocked.subtitle": "", + "match.schedule.notOpen.header": "", + "match.schedule.notOpen.subtitle": "", + "match.schedule.unscheduled.header": "", + "match.schedule.unscheduled.subtitle": "", + "matches.tabs.scheduled": "", + "matches.tabs.unscheduled": "", + "matches.tabs.past": "", + "matches.division": "", + "matches.allDivisions": "", + "matches.empty.scheduled": "", + "matches.empty.unscheduled": "", + "matches.empty.past": "", + "matches.cast": "", + "matches.live": "", + "match.schedule.reschedule": "", + "match.schedule.agreedTime": "", + "match.schedule.setByOrganizer": "", + "match.schedule.boardClosed": "", + "match.schedule.rescheduleInfo": "", + "match.schedule.noCandidates": "", + "match.schedule.pick": "", + "match.schedule.pickConfirm": "", + "match.schedule.keepCurrentTime": "", + "match.schedule.proposeTimes": "", + "match.schedule.requestAnotherTime": "", + "match.schedule.propose": "", + "match.schedule.updateTimes": "", + "match.schedule.availabilityTitle": "", + "match.admin.setTime": "", + "match.admin.setTimeInfo": "", + "match.admin.timeSet": "", "match.locked.header": "", "match.locked.subtitle": "", "match.waitingForTeams.header": "", diff --git a/locales/ru/common.json b/locales/ru/common.json index c1fe2df7f..cd20cbd46 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -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": "Карты и район", "tag.name.COLLEGIATE": "Коллегиальный", + "tag.name.LEAGUE": "", "weapon.category.SHOOTERS": "Краскоматы", "weapon.category.BLASTERS": "Бластеры", "weapon.category.ROLLERS": "Валики", @@ -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": "", diff --git a/locales/ru/forms.json b/locales/ru/forms.json index 94de1aa6a..6bead6bdc 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -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": "", diff --git a/locales/ru/q.json b/locales/ru/q.json index 5e6f62024..7e4b4abcf 100644 --- a/locales/ru/q.json +++ b/locales/ru/q.json @@ -176,6 +176,7 @@ "match.banner.playAll": "", "match.banner.vs": "", "match.tabs.rosters": "", + "match.tabs.schedule": "", "match.tabs.action": "", "match.tabs.result": "", "match.tabs.stats": "", diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json index 8a18f52c2..44529a7d8 100644 --- a/locales/ru/tournament.json +++ b/locales/ru/tournament.json @@ -10,6 +10,7 @@ "nav.looking": "", "nav.subs": "Запасные", "nav.divisions": "", + "nav.matches": "", "nav.admin": "Администратор", "findTeam": "", "registerNow": "", @@ -31,7 +32,6 @@ "pre.steps.roster": "Полный состав", "pre.steps.pool": "Пул арен", "pre.steps.check-in": "Чек-ин", - "pre.steps.googleSheet": "", "pre.footer": "До начала турнира вся информация о команде может быть изменена", "pre.registrationClosesAt": "", "pre.friendCode.needed": "", @@ -78,6 +78,8 @@ "mapList.rerollAllMaps": "", "mapList.teamsPick": "", "mapList.customFlow": "", + "mapList.realtime": "", + "mapList.realtimeInfo": "", "rules.teamPick.stageRepeat": "", "team.label": "Команда", "team.teamPage": "", @@ -192,6 +194,37 @@ "match.endedEarly.subtitle": "", "match.leagueLocked.header": "", "match.leagueLocked.subtitle": "", + "match.schedule.notOpen.header": "", + "match.schedule.notOpen.subtitle": "", + "match.schedule.unscheduled.header": "", + "match.schedule.unscheduled.subtitle": "", + "matches.tabs.scheduled": "", + "matches.tabs.unscheduled": "", + "matches.tabs.past": "", + "matches.division": "", + "matches.allDivisions": "", + "matches.empty.scheduled": "", + "matches.empty.unscheduled": "", + "matches.empty.past": "", + "matches.cast": "", + "matches.live": "", + "match.schedule.reschedule": "", + "match.schedule.agreedTime": "", + "match.schedule.setByOrganizer": "", + "match.schedule.boardClosed": "", + "match.schedule.rescheduleInfo": "", + "match.schedule.noCandidates": "", + "match.schedule.pick": "", + "match.schedule.pickConfirm": "", + "match.schedule.keepCurrentTime": "", + "match.schedule.proposeTimes": "", + "match.schedule.requestAnotherTime": "", + "match.schedule.propose": "", + "match.schedule.updateTimes": "", + "match.schedule.availabilityTitle": "", + "match.admin.setTime": "", + "match.admin.setTimeInfo": "", + "match.admin.timeSet": "", "match.locked.header": "", "match.locked.subtitle": "", "match.waitingForTeams.header": "", diff --git a/locales/zh/common.json b/locales/zh/common.json index 5b5b6518e..502dfae1d 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -102,6 +102,12 @@ "notifications.text.SCRIM_CANCELED": "与 {{opponentTeamName}} 的对抗战已被取消", "notifications.title.SCRIM_STARTING_SOON": "对抗战即将开始", "notifications.text.SCRIM_STARTING_SOON": "您与 {{opponentTeamName}} 的对抗战即将开始", + "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": "由于您在此时间段前后(±1小时)已预约了对抗战,您的招募帖已被自动移除", "notifications.title.COMMISSIONS_CLOSED": "约稿已关闭", @@ -264,6 +270,7 @@ "tag.name.SR": "鲑鱼跑", "tag.name.CARDS": "占地斗士", "tag.name.COLLEGIATE": "高校", + "tag.name.LEAGUE": "", "weapon.category.SHOOTERS": "射击枪", "weapon.category.BLASTERS": "爆破枪", "weapon.category.ROLLERS": "滚筒", @@ -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": "暂无活跃聊天", diff --git a/locales/zh/forms.json b/locales/zh/forms.json index 2319084b8..50729abdc 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -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": "日期不能晚于当前时间超过 2 周", "errors.minUsersExcludingYourself": "除您自己外,至少需要包含 {{min}} 位用户", "errors.usersMustBeUnique": "用户不能重复", @@ -220,6 +227,7 @@ "options.tag.LAN": "线下", "options.tag.QUALIFIER": "资格赛", "options.tag.COLLEGIATE": "高校", + "options.tag.LEAGUE": "", "options.tag.ONES": "1v1", "options.tag.DUOS": "2v2", "options.tag.TRIOS": "3v3", diff --git a/locales/zh/q.json b/locales/zh/q.json index 3656b2397..0e50d557f 100644 --- a/locales/zh/q.json +++ b/locales/zh/q.json @@ -176,6 +176,7 @@ "match.banner.playAll": "打满 {{count}} 局", "match.banner.vs": "对战", "match.tabs.rosters": "名单", + "match.tabs.schedule": "", "match.tabs.action": "操作", "match.tabs.result": "结果", "match.tabs.stats": "统计数据", diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json index 6375c0b51..4ef1538cf 100644 --- a/locales/zh/tournament.json +++ b/locales/zh/tournament.json @@ -10,6 +10,7 @@ "nav.looking": "招募板", "nav.subs": "替补", "nav.divisions": "区块", + "nav.matches": "", "nav.admin": "管理", "findTeam": "寻找队伍", "registerNow": "立即报名", @@ -31,7 +32,6 @@ "pre.steps.roster": "完整阵容", "pre.steps.pool": "场地池", "pre.steps.check-in": "签到", - "pre.steps.googleSheet": "", "pre.footer": "报名信息在赛事开始前可以自由修改", "pre.registrationClosesAt": "", "pre.friendCode.needed": "", @@ -78,6 +78,8 @@ "mapList.rerollAllMaps": "", "mapList.teamsPick": "", "mapList.customFlow": "", + "mapList.realtime": "", + "mapList.realtimeInfo": "", "rules.teamPick.stageRepeat": "", "team.label": "队伍", "team.teamPage": "", @@ -189,6 +191,37 @@ "match.endedEarly.subtitle": "工作人员在分出胜负前终止了对局", "match.leagueLocked.header": "等待联赛轮次开始", "match.leagueLocked.subtitle": "该轮次从 {{date}} 开始", + "match.schedule.notOpen.header": "", + "match.schedule.notOpen.subtitle": "", + "match.schedule.unscheduled.header": "", + "match.schedule.unscheduled.subtitle": "", + "matches.tabs.scheduled": "", + "matches.tabs.unscheduled": "", + "matches.tabs.past": "", + "matches.division": "", + "matches.allDivisions": "", + "matches.empty.scheduled": "", + "matches.empty.unscheduled": "", + "matches.empty.past": "", + "matches.cast": "", + "matches.live": "", + "match.schedule.reschedule": "", + "match.schedule.agreedTime": "", + "match.schedule.setByOrganizer": "", + "match.schedule.boardClosed": "", + "match.schedule.rescheduleInfo": "", + "match.schedule.noCandidates": "", + "match.schedule.pick": "", + "match.schedule.pickConfirm": "", + "match.schedule.keepCurrentTime": "", + "match.schedule.proposeTimes": "", + "match.schedule.requestAnotherTime": "", + "match.schedule.propose": "", + "match.schedule.updateTimes": "", + "match.schedule.availabilityTitle": "", + "match.admin.setTime": "", + "match.admin.setTimeInfo": "", + "match.admin.timeSet": "", "match.locked.header": "对局已锁定以进行转播", "match.locked.subtitle": "请等待工作人员解锁", "match.waitingForTeams.header": "正在等待队伍", diff --git a/migrations/20260921052611-league-scheduling.ts b/migrations/20260921052611-league-scheduling.ts new file mode 100644 index 000000000..ae479d004 --- /dev/null +++ b/migrations/20260921052611-league-scheduling.ts @@ -0,0 +1,70 @@ +import { type Kysely, sql } from "kysely"; + +/** + * League sets are scheduled by the teams: candidate times go on a board per match, the agreed time + * lands on the match. A round's play time only says when it is playable from, hence the rename. + */ +export async function up(db: Kysely): Promise { + await db.transaction().execute(async (trx) => { + await trx.schema + .alterTable("TournamentRound") + .renameColumn("defaultPlayTime", "isPlayableAt") + .execute(); + + await trx.schema + .alterTable("TournamentMatch") + .addColumn("scheduledAt", "integer") + .execute(); + await trx.schema + .alterTable("TournamentMatch") + .addColumn("scheduleSetByOrganizer", "integer", (col) => + col.notNull().defaultTo(0), + ) + .execute(); + + await trx.schema + .createTable("TournamentMatchScheduleProposal") + .addColumn("id", "integer", (col) => col.primaryKey()) + .addColumn("matchId", "integer", (col) => + col.notNull().references("TournamentMatch.id").onDelete("cascade"), + ) + .addColumn("tournamentTeamId", "integer", (col) => + col.notNull().references("TournamentTeam.id").onDelete("cascade"), + ) + .addColumn("authorId", "integer", (col) => + col.notNull().references("User.id").onDelete("cascade"), + ) + .addColumn("proposedAt", "integer", (col) => col.notNull()) + .addColumn("createdAt", "integer", (col) => + col.notNull().defaultTo(sql`(strftime('%s', 'now'))`), + ) + .addUniqueConstraint("tournament_match_schedule_proposal_unique", [ + "matchId", + "tournamentTeamId", + "proposedAt", + ]) + .modifyEnd(sql`strict`) + .execute(); + + await trx.schema + .createIndex("tournament_match_schedule_proposal_match_id") + .on("TournamentMatchScheduleProposal") + .column("matchId") + .execute(); + await trx.schema + .createIndex("tournament_match_schedule_proposal_tournament_team_id") + .on("TournamentMatchScheduleProposal") + .column("tournamentTeamId") + .execute(); + await trx.schema + .createIndex("tournament_match_schedule_proposal_author_id") + .on("TournamentMatchScheduleProposal") + .column("authorId") + .execute(); + await trx.schema + .createIndex("tournament_match_scheduled_at") + .on("TournamentMatch") + .column("scheduledAt") + .execute(); + }); +} diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index 95c70abe1..b9d30f80b 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -1013,6 +1013,53 @@ export function buildCases(fx: Fixtures): { (tournamentTeamId) => TournamentMatchRepository.findByTournamentTeamId(tournamentTeamId), ); + add( + "TournamentMatchRepository.findScheduleProposalsByMatchId", + fx.scheduleProposal?.matchId ?? fx.heavyTournamentMatchId, + (matchId) => + TournamentMatchRepository.findScheduleProposalsByMatchId(matchId), + ); + add( + "TournamentMatchRepository.findLastResultAtsByTournamentId", + fx.heaviestBracketTournamentId, + (tournamentId) => + TournamentMatchRepository.findLastResultAtsByTournamentId(tournamentId), + ); + add( + "TournamentMatchRepository.findScheduledByUserIds", + both(fx.manyUserIds, fx.availabilityWindow), + ([userIds, window]) => + TournamentMatchRepository.findScheduledByUserIds({ + userIds, + startsAt: window.startsAt, + endsAt: window.endsAt, + }), + ); + add( + "TournamentMatchRepository.findScheduledByUserId", + both(fx.heavyUser, fx.availabilityWindow), + ([user, window]) => + TournamentMatchRepository.findScheduledByUserId({ + userId: user.id, + startsAt: window.startsAt, + endsAt: window.endsAt, + }), + ); + add( + "TournamentMatchRepository.findScheduledBetween", + fx.availabilityWindow, + (window) => + TournamentMatchRepository.findScheduledBetween({ + startsAt: window.startsAt, + endsAt: window.endsAt, + }), + ); + add( + "TournamentMatchRepository.findScheduleProposalById", + fx.scheduleProposal, + (proposal) => + TournamentMatchRepository.findScheduleProposalById(proposal.id), + ); add("TournamentOrganizationRepository.findBySlug", fx.heavyOrg, (org) => TournamentOrganizationRepository.findBySlug(org.slug), @@ -1268,6 +1315,12 @@ export function buildCases(fx: Fixtures): { addStatic("TournamentRepository.findRunningTournamentIds", () => TournamentRepository.findRunningTournamentIds(), ); + add( + "TournamentRepository.findDivisionTiersByTournamentId", + fx.heavyTournamentId, + (tournamentId) => + TournamentRepository.findDivisionTiersByTournamentId(tournamentId), + ); add( "TournamentTeamRepository.findAllByChatRoomIds", diff --git a/scripts/benchmark-db/fixtures.ts b/scripts/benchmark-db/fixtures.ts index be24714f1..b2a6d7224 100644 --- a/scripts/benchmark-db/fixtures.ts +++ b/scripts/benchmark-db/fixtures.ts @@ -38,6 +38,8 @@ export interface Fixtures { heavyResultsTournamentId: number | null; heavyTournamentMatchId: number | null; tournamentMatchGameResultId: number | null; + /** Newest proposal of the match with the most schedule proposals. */ + scheduleProposal: { id: number; matchId: number } | null; heavyTournamentTeamId: number | null; tournamentTeamPair: [number, number] | null; tournamentTeamInviteCode: string | null; @@ -164,6 +166,7 @@ export async function resolveFixtures(): Promise { heavyResultsTournamentId: await resolveHeavyResultsTournamentId(), heavyTournamentMatchId: await resolveHeavyTournamentMatchId(), tournamentMatchGameResultId: await resolveTournamentMatchGameResultId(), + scheduleProposal: await resolveScheduleProposal(), heavyTournamentTeamId: await resolveHeavyTournamentTeamId(heavyTournamentId), tournamentTeamPair: await resolveTournamentTeamPair(heavyTournamentId), @@ -496,6 +499,23 @@ async function resolveTournamentMatchGameResultId() { return row?.id ?? null; } +async function resolveScheduleProposal() { + const row = await db + .selectFrom("TournamentMatchScheduleProposal") + .select(({ fn }) => [ + "matchId", + fn.max("id").as("id"), + fn.countAll().as("count"), + ]) + .groupBy("matchId") + .orderBy("count", "desc") + .limit(1) + .executeTakeFirst(); + if (!row) return null; + + return { id: row.id, matchId: row.matchId }; +} + async function resolveHeavyTournamentTeamId(heavyTournamentId: number | null) { if (heavyTournamentId === null) return null;