diff --git a/app/db/seed/dev/tournaments.ts b/app/db/seed/dev/tournaments.ts index 02e875348..89ce9c1d4 100644 --- a/app/db/seed/dev/tournaments.ts +++ b/app/db/seed/dev/tournaments.ts @@ -151,6 +151,7 @@ export async function seedTournaments({ users, organizations, rosters, + teams, trophies, }); await seedPaddlingPool({ users, organizations, rosters }); @@ -175,13 +176,16 @@ type Ctx = { }; /** #1 double elim, TO maps — reg open and a couple of days out, so it has both - * registered teams (some of them still short of a full roster) and LFG teams. */ + * registered teams (some of them still short of a full roster) and LFG teams. + * The admin registers with Alliance Rogue on a roster whose availability mixes + * every state the registration page's panel can show. */ async function seedInTheZone({ users, organizations, rosters, + teams, trophies, -}: Ctx & { trophies: SeededTrophies }) { +}: Ctx & { teams: SeededTeams; trophies: SeededTrophies }) { const name = nameFor("In The Zone"); const startsAt = dateToDatabaseTimestamp(daysFromNow(2)); @@ -198,10 +202,28 @@ async function seedInTheZone({ trophyId: trophies.ids[0], }); + // availability panel states, in roster order: the admin and multiRange are + // fully available, weekend is free only from an hour in, unavailable + // submitted an empty week and the captain (N-ZAP) reports nothing at all + const [, multiRangeId, , unavailableId, weekendId] = + teams.allianceRogue.playerUserIds; + const allianceRogueRoster: Roster = { + teamId: teams.allianceRogueId, + name: teams.squads.find((squad) => squad.teamId === teams.allianceRogueId)! + .name, + memberUserIds: [ + users.adminId, + multiRangeId, + weekendId, + unavailableId, + users.nzapId, + ], + }; + const teamRosters = rosters.take({ teamCount: 10, teamSize: 4, - pinned: [{ teamIdx: 0, userId: users.adminId }], + preset: [allianceRogueRoster], }); for (const [i, roster] of teamRosters.entries()) { @@ -429,7 +451,9 @@ async function seedTournamentExtras(tournamentId: number, users: SeededUsers) { await TournamentStreamerFactory.create({ tournamentId, twitchAccount }); } - const lfgUserIds = [users.nzapId, ...users.showcaseIds.slice(90, 95)]; + // N-ZAP used to be the demo LFG poster, but he registers with Alliance + // Rogue now — a player cannot both be on a team and look for one + const lfgUserIds = users.showcaseIds.slice(90, 96); const lfgTeamIds: number[] = []; for (const [i, userId] of lfgUserIds.entries()) { @@ -486,17 +510,24 @@ function rosterBuilder(users: SeededUsers, teams: SeededTeams) { /** Rosters for one tournament: some of the site's teams registering as * themselves, core players spread over the rest, and the remaining seats drawn * without replacement within the tournament. A `pinned` user is added to a - * roster of their own as its owner, and kept out of everybody else's. */ + * roster of their own as its owner, and kept out of everybody else's. A + * `preset` roster takes the first team slots exactly as given, its members + * kept out of every other roster. */ take({ teamCount, teamSize, pinned = [], + preset = [], }: { teamCount: number; teamSize: number; pinned?: Array<{ teamIdx: number; userId: number }>; + preset?: Roster[]; }): Roster[] { - const pinnedUserIds = new Set(pinned.map((pin) => pin.userId)); + const pinnedUserIds = new Set([ + ...pinned.map((pin) => pin.userId), + ...preset.flatMap((roster) => roster.memberUserIds), + ]); const registering = faker.helpers .shuffle( teams.squads.filter((squad) => @@ -506,7 +537,10 @@ function rosterBuilder(users: SeededUsers, teams: SeededTeams) { .slice(0, Math.round(teamCount * REGISTERED_TEAM_SHARE)); // a tournament can not have two teams of the same name - const takenNames = new Set(registering.map((squad) => squad.name)); + const takenNames = new Set([ + ...registering.map((squad) => squad.name), + ...preset.map((roster) => roster.name), + ]); const takenUserIds = new Set([ ...pinnedUserIds, @@ -517,13 +551,16 @@ function rosterBuilder(users: SeededUsers, teams: SeededTeams) { const shuffled = faker.helpers.shuffle(pool.filter(isFree)); const freeCorePlayers = corePlayers.filter(isFree); - // the teams of the site take the first team slots a pin does not want + // the teams of the site take the first team slots a preset or a pin + // does not want const pinnedIdxs = new Set(pinned.map((pin) => pin.teamIdx)); const registeringIdxs = Array.from({ length: teamCount }, (_, i) => i) - .filter((i) => !pinnedIdxs.has(i)) + .filter((i) => !pinnedIdxs.has(i) && i >= preset.length) .slice(0, registering.length); return Array.from({ length: teamCount }, (_, i) => { + if (i < preset.length) return preset[i]; + const registeringIdx = registeringIdxs.indexOf(i); if (registeringIdx !== -1) { const squad = registering[registeringIdx]; diff --git a/app/features/availability/availability-schemas.ts b/app/features/availability/availability-schemas.ts index 3884a8754..a967eb3c2 100644 --- a/app/features/availability/availability-schemas.ts +++ b/app/features/availability/availability-schemas.ts @@ -76,7 +76,7 @@ export const addTeamEventSchema = v.object({ }), }); -export const deleteTeamEventSchema = v.object({ +const deleteTeamEventSchema = v.object({ _action: _action("DELETE_EVENT"), eventId: id, }); diff --git a/app/features/availability/availability-types.ts b/app/features/availability/availability-types.ts index 9f3260947..9236394ae 100644 --- a/app/features/availability/availability-types.ts +++ b/app/features/availability/availability-types.ts @@ -59,3 +59,18 @@ export interface BusyBlock extends TimeRange { type: "tournament" | "scrim" | "teamEvent"; name: string | null; } + +/** + * How one person's schedule relates to an event's window: + * - `available` — reported availability covers the whole window + * - `partial` — covers part of it; `ranges` show which part + * - `unavailable` — a week was reported, none of it overlaps the window + * - `busy` — a commitment elsewhere overlaps the window, overriding whatever + * was reported + * - `unknown` — no reported week covers the window + */ +export type WindowAvailability = + | { status: "available" | "partial"; ranges: Array } + | { status: "busy"; block: BusyBlock } + | { status: "unavailable" } + | { status: "unknown" }; diff --git a/app/features/availability/components/RegistrationAvailabilityPanel.module.css b/app/features/availability/components/RegistrationAvailabilityPanel.module.css new file mode 100644 index 000000000..d3f1a9915 --- /dev/null +++ b/app/features/availability/components/RegistrationAvailabilityPanel.module.css @@ -0,0 +1,128 @@ +.panel { + display: flex; + flex-direction: column; + gap: var(--s-2-5); + padding: var(--s-3); + background-color: var(--color-bg-high); + border-radius: var(--radius-box); + font-size: var(--font-xs); +} + +.heading { + font-size: var(--font-sm); + color: var(--color-text); +} + +.headingWindow { + font-weight: var(--weight-body); + color: var(--color-text-high); +} + +.rows { + display: flex; + flex-direction: column; + gap: var(--s-1-5); + list-style: none; + padding: 0; + margin: 0; +} + +.row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--s-1-5); + + & > svg { + flex-shrink: 0; + } +} + +.name { + font-weight: var(--weight-semi); +} + +.ranges { + color: var(--color-text-high); + font-variant-numeric: tabular-nums; +} + +.mutedText { + color: var(--color-text-high); +} + +.note { + display: inline-flex; + align-items: center; + gap: var(--s-1); + color: var(--color-text-high); + font-size: var(--font-3xs); + font-style: italic; +} + +.noteFlag { + color: var(--color-text-accent); + flex-shrink: 0; +} + +.busy { + display: inline-flex; + align-items: center; + max-width: 12rem; + padding: var(--s-0-5) var(--s-1-5); + background: repeating-linear-gradient( + -45deg, + var(--color-bg-higher) 0 5px, + transparent 5px 10px + ); + border-radius: var(--radius-full); + + & .busyName { + max-width: 100%; + padding-inline: var(--s-1); + font-size: var(--font-3xs); + color: var(--color-text-high); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + background-color: var(--color-bg); + border-radius: var(--radius-full); + } +} + +.summary { + font-size: var(--font-xs); + font-weight: var(--weight-semi); + color: var(--color-text); +} + +.subsSection { + display: flex; + flex-direction: column; + gap: var(--s-1-5); + border-top: 1px solid var(--color-border); + padding-top: var(--s-2); +} + +.subsHeading { + font-size: var(--font-xs); + color: var(--color-text-high); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.iconAvailable { + color: var(--color-success); +} + +.iconPartial { + color: var(--color-warning); +} + +.iconUnavailable { + color: var(--color-error); +} + +.iconUnknown { + color: var(--color-text-high); +} diff --git a/app/features/availability/components/RegistrationAvailabilityPanel.tsx b/app/features/availability/components/RegistrationAvailabilityPanel.tsx new file mode 100644 index 000000000..6eaa9cea6 --- /dev/null +++ b/app/features/availability/components/RegistrationAvailabilityPanel.tsx @@ -0,0 +1,255 @@ +import { + CalendarX, + Check, + Clock, + EyeOff, + Flag, + HelpCircle, + X, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Avatar } from "~/components/Avatar"; +import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; +import { databaseTimestampToDate } from "~/utils/dates"; +import type { SerializeFrom } from "~/utils/remix"; +import type { TimeRange } from "../availability-types"; +import type { RegistrationAvailability } from "../core/RegistrationAvailability.server"; +import styles from "./RegistrationAvailabilityPanel.module.css"; + +interface PanelUser { + id: number; + username: string; + discordId: string; + discordAvatar: string | null; + customAvatarUrl?: string | null; +} + +type PanelData = SerializeFrom; +type PanelEntry = NonNullable[number]; + +/** + * The tournament registration page's availability panel: how each member of + * the roster relates to the event's estimated window, plus the friends who + * could sub (the ones actually free during it). + */ +export function RegistrationAvailabilityPanel({ + availability, + roster, + subCandidates, +}: { + availability: PanelData; + roster: Array; + /** Friends not on the shown roster and not in the tournament, panel keeps the free ones. */ + subCandidates: Array; +}) { + const { t } = useTranslation(["schedule"]); + const { formatter: windowFormatter } = useDateTimeFormat({ + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); + const { formatter: dateFormatter } = useDateTimeFormat({ + month: "long", + day: "numeric", + }); + + if (availability.beyondHorizon) { + return ( +
+

{t("schedule:registration.title")}

+
+ {t("schedule:registration.beyondHorizon", { + date: dateFormatter.format(availability.beyondHorizon.opensAt), + })} +
+
+ ); + } + + const entryByUserId = new Map( + availability.entries.map((entry) => [entry.userId, entry]), + ); + + const freeSubs = subCandidates.filter((user) => { + const status = entryByUserId.get(user.id)?.availability.status; + return status === "available" || status === "partial"; + }); + + return ( +
+

+ {t("schedule:registration.title")} ·{" "} + + {windowFormatter.formatRange( + availability.window.startsAt, + availability.window.endsAt, + )}{" "} + ({t("schedule:registration.estimated")}) + +

+
    + {roster.map((user) => ( + + ))} +
+ + {freeSubs.length > 0 ? ( +
+
+ {t("schedule:registration.friends")} +
+
    + {freeSubs.map((user) => ( + + ))} +
+
+ ) : null} +
+ ); +} + +function MemberRow({ user, entry }: { user: PanelUser; entry?: PanelEntry }) { + return ( +
  • + + + {user.username} + + {entry?.notes.map((note) => ( + + {note} + + ))} +
  • + ); +} + +type RowStatus = + | PanelEntry["availability"]["status"] + /** On the roster, but their schedule is not visible to the viewer (neither a teammate nor a friend). */ + | "hidden"; + +function rowStatus(entry?: PanelEntry): RowStatus { + return entry?.availability.status ?? "hidden"; +} + +function RowDetail({ entry }: { entry?: PanelEntry }) { + const { t } = useTranslation(["schedule"]); + + // xxx: is this what we want? + if (!entry) { + return ( + + {t("schedule:registration.notVisible")} + + ); + } + + const availability = entry.availability; + + switch (availability.status) { + case "available": + case "partial": + return ; + case "unavailable": + return ( + + {t("schedule:team.notAvailable")} + + ); + case "unknown": + return ( + + {t("schedule:team.noSchedule")} + + ); + case "busy": + return ( + + + {availability.block.name ?? t("schedule:commitment.scrim")} + + + ); + } +} + +function RangesText({ ranges }: { ranges: Array }) { + const { formatter: timeFormatter } = useDateTimeFormat({ + hour: "numeric", + minute: "2-digit", + }); + + // formatRange expands to full dates when the ends fall on different + // calendar days, so a range crossing midnight formats its ends separately + // to stay times-only + const rangeText = (range: TimeRange) => + databaseTimestampToDate(range.startsAt).getDate() === + databaseTimestampToDate(range.endsAt).getDate() + ? timeFormatter.formatRange(range.startsAt, range.endsAt) + : `${timeFormatter.format(range.startsAt)} – ${timeFormatter.format(range.endsAt)}`; + + return ( + {ranges.map(rangeText).join(" · ")} + ); +} + +function StatusIcon({ status }: { status: RowStatus }) { + switch (status) { + case "available": + return ; + case "partial": + return ; + case "unavailable": + return ; + case "busy": + return ; + case "unknown": + return ; + case "hidden": + return ; + } +} + +function SummaryLine({ + roster, + entryByUserId, +}: { + roster: Array; + entryByUserId: Map; +}) { + const { t } = useTranslation(["schedule"]); + + const counts = { available: 0, partial: 0, out: 0, unknown: 0 }; + for (const user of roster) { + const status = rowStatus(entryByUserId.get(user.id)); + if (status === "available") counts.available++; + else if (status === "partial") counts.partial++; + else if (status === "unavailable" || status === "busy") counts.out++; + else counts.unknown++; + } + + const parts = (["available", "partial", "out", "unknown"] as const).flatMap( + (key) => + counts[key] > 0 + ? [t(`schedule:registration.summary.${key}`, { amount: counts[key] })] + : [], + ); + + return
    {parts.join(" · ")}
    ; +} diff --git a/app/features/availability/core/Availability.test.ts b/app/features/availability/core/Availability.test.ts index 9fec84b27..1c979e9d2 100644 --- a/app/features/availability/core/Availability.test.ts +++ b/app/features/availability/core/Availability.test.ts @@ -251,6 +251,150 @@ describe("Availability.clip", () => { }); }); +describe("Availability.availabilityInWindow", () => { + const window = range("2026-08-30", "18:00", "22:00"); + const busyBlock = (r: { startsAt: number; endsAt: number }) => ({ + ...r, + type: "tournament" as const, + name: "In The Zone 42", + }); + + test("a slot covering the whole window is available, ranges as reported", () => { + expect( + Availability.availabilityInWindow({ + reported: true, + slots: [range("2026-08-30", "17:00", "23:00")], + busy: [], + window, + }), + ).toEqual({ + status: "available", + ranges: [range("2026-08-30", "17:00", "23:00")], + }); + }); + + test("a slot covering part of the window is partial, ranges clipped to it", () => { + expect( + Availability.availabilityInWindow({ + reported: true, + slots: [range("2026-08-30", "19:00", "23:00")], + busy: [], + window, + }), + ).toEqual({ + status: "partial", + ranges: [range("2026-08-30", "19:00", "22:00")], + }); + }); + + test("split slots leaving a gap inside the window are partial even when they span it", () => { + expect( + Availability.availabilityInWindow({ + reported: true, + slots: [ + range("2026-08-30", "17:00", "19:00"), + range("2026-08-30", "20:00", "23:00"), + ], + busy: [], + window, + }), + ).toEqual({ + status: "partial", + ranges: [ + range("2026-08-30", "18:00", "19:00"), + range("2026-08-30", "20:00", "22:00"), + ], + }); + }); + + test("a reported week without overlap is unavailable", () => { + expect( + Availability.availabilityInWindow({ + reported: true, + slots: [range("2026-08-30", "12:00", "17:00")], + busy: [], + window, + }), + ).toEqual({ status: "unavailable" }); + }); + + test("a slot only touching the window start is unavailable", () => { + expect( + Availability.availabilityInWindow({ + reported: true, + slots: [range("2026-08-30", "12:00", "18:00")], + busy: [], + window, + }), + ).toEqual({ status: "unavailable" }); + }); + + test("no reported week is unknown", () => { + expect( + Availability.availabilityInWindow({ + reported: false, + slots: [], + busy: [], + window, + }), + ).toEqual({ status: "unknown" }); + }); + + test("a busy block overlapping the window wins over reported availability", () => { + const block = busyBlock(range("2026-08-30", "19:00", "21:00")); + + expect( + Availability.availabilityInWindow({ + reported: true, + slots: [range("2026-08-30", "17:00", "23:00")], + busy: [block], + window, + }), + ).toEqual({ status: "busy", block }); + }); + + test("a busy block wins even when nothing was reported", () => { + const block = busyBlock(range("2026-08-30", "18:00", "22:00")); + + expect( + Availability.availabilityInWindow({ + reported: false, + slots: [], + busy: [block], + window, + }), + ).toEqual({ status: "busy", block }); + }); + + test("a busy block outside the window changes nothing", () => { + expect( + Availability.availabilityInWindow({ + reported: true, + slots: [range("2026-08-30", "17:00", "23:00")], + busy: [busyBlock(range("2026-08-29", "18:00", "22:00"))], + window, + }), + ).toEqual({ + status: "available", + ranges: [range("2026-08-30", "17:00", "23:00")], + }); + }); + + test("a cross-midnight slot covers a window reaching past midnight", () => { + expect( + Availability.availabilityInWindow({ + reported: true, + slots: [range("2026-08-30", "20:00", "02:30", "2026-08-31")], + busy: [], + window: range("2026-08-30", "22:00", "02:00", "2026-08-31"), + }), + ).toEqual({ + status: "available", + ranges: [range("2026-08-30", "20:00", "02:30", "2026-08-31")], + }); + }); +}); + describe("Availability.isoWeekNumber", () => { test.each([ { why: "a midweek day", date: "2026-08-26", timezone: HELSINKI, week: 35 }, diff --git a/app/features/availability/core/Availability.ts b/app/features/availability/core/Availability.ts index 73c084207..d7254bc36 100644 --- a/app/features/availability/core/Availability.ts +++ b/app/features/availability/core/Availability.ts @@ -8,10 +8,12 @@ import { import invariant from "~/utils/invariant"; import { AVAILABILITY } from "../availability-constants"; import type { + BusyBlock, DayTimeRange, MemberAvailability, PlayableWindow, TimeRange, + WindowAvailability, } from "../availability-types"; const MINUTE_IN_SECONDS = 60; @@ -193,6 +195,46 @@ export function clip( }); } +/** + * How one person's schedule relates to an event's window. A busy block + * overlapping the window wins over anything reported — the person is committed + * elsewhere, whether or not their schedule is known. Otherwise the reported + * slots either cover the window (`available`, with the overlapping ranges as + * reported), cover part of it (`partial`, with the overlap clipped to the + * window so it reads as "which part"), miss it entirely (`unavailable`) or do + * not exist (`unknown`). + */ +export function availabilityInWindow({ + reported, + slots, + busy, + window, +}: { + reported: boolean; + slots: Array; + busy: Array; + window: TimeRange; +}): WindowAvailability { + const block = busy.find((candidate) => overlaps(candidate, window)); + if (block) return { status: "busy", block }; + + if (!reported) return { status: "unknown" }; + + const overlapping = normalize(slots).filter((range) => + overlaps(range, window), + ); + if (overlapping.length === 0) return { status: "unavailable" }; + + const covers = overlapping.some( + (range) => + range.startsAt <= window.startsAt && range.endsAt >= window.endsAt, + ); + + return covers + ? { status: "available", ranges: overlapping } + : { status: "partial", ranges: clip(overlapping, window) }; +} + /** * The windows the team could play in: spans * where `minPlayers` of the members (`FULL`) or one fewer (`ONE_SHORT`) are all diff --git a/app/features/availability/core/Commitments.server.test.ts b/app/features/availability/core/Commitments.server.test.ts index 13dff666d..edbda972d 100644 --- a/app/features/availability/core/Commitments.server.test.ts +++ b/app/features/availability/core/Commitments.server.test.ts @@ -170,6 +170,37 @@ describe("Commitments.busyBlocksByUserIds", () => { expect(await blocksOf(outsiderId())).toBeUndefined(); }); + test("excludeTournamentId leaves that tournament's registration out, others stay", async () => { + const excluded = await TournamentFactory.create({ + authorId: organizerId(), + startTimes: [WEEK_STARTS_AT + 3 * DAY], + }); + await TournamentTeamFactory.create({ + tournamentId: excluded.id, + memberUserIds: [memberId()], + }); + const other = await TournamentFactory.create({ + authorId: organizerId(), + name: "Elsewhere Open", + startTimes: [WEEK_STARTS_AT + 4 * DAY], + bracketProgression: DOUBLE_ELIMINATION, + }); + await TournamentTeamFactory.create({ + tournamentId: other.id, + memberUserIds: [memberId()], + }); + + const blocks = ( + await Commitments.busyBlocksByUserIds({ + userIds: [memberId()], + ...WINDOW, + excludeTournamentId: excluded.id, + }) + ).get(memberId()); + + expect(blocks?.map((block) => block.name)).toEqual(["Elsewhere Open"]); + }); + test("test and league tournaments are not blocks", async () => { const testTournament = await TournamentFactory.create({ authorId: organizerId(), diff --git a/app/features/availability/core/Commitments.server.ts b/app/features/availability/core/Commitments.server.ts index 72a3587c7..188162932 100644 --- a/app/features/availability/core/Commitments.server.ts +++ b/app/features/availability/core/Commitments.server.ts @@ -16,16 +16,19 @@ import * as TournamentDuration from "./TournamentDuration"; * {@link TournamentDuration.estimateSeconds}), accepted scrims (start + an * assumed length) and team events (their actual span). League registrations * are not blocks — a league runs over weeks and its matches are scheduled - * separately. + * separately. `excludeTournamentId` leaves that tournament's registrations + * out, for surfaces asking "busy elsewhere" while looking at that tournament. */ export async function busyBlocksByUserIds({ userIds, startsAt, endsAt, + excludeTournamentId, }: { userIds: Array; startsAt: number; endsAt: number; + excludeTournamentId?: number; }): Promise>> { if (userIds.length === 0) return new Map(); @@ -34,6 +37,7 @@ export async function busyBlocksByUserIds({ userIds, startsAt: startsAt - TournamentDuration.MAX_ESTIMATE_SECONDS, endsAt, + excludeTournamentId, }), ScrimPostRepository.findAllAcceptedByUserIds({ userIds, diff --git a/app/features/availability/core/RegistrationAvailability.server.ts b/app/features/availability/core/RegistrationAvailability.server.ts new file mode 100644 index 000000000..08a33e38b --- /dev/null +++ b/app/features/availability/core/RegistrationAvailability.server.ts @@ -0,0 +1,117 @@ +import { addWeeks, subWeeks } from "date-fns"; +import type { Tables } from "~/db/tables"; +import { databaseTimestampToDate } from "~/utils/dates"; +import * as AvailabilityRepository from "../AvailabilityRepository.server"; +import { AVAILABILITY } from "../availability-constants"; +import type { TimeRange } from "../availability-types"; +import * as Availability from "./Availability"; +import * as Commitments from "./Commitments.server"; +import * as TournamentDuration from "./TournamentDuration"; + +export type RegistrationAvailability = Awaited< + ReturnType +>; + +/** + * Availability of the given users for a tournament's estimated window + * (start + {@link TournamentDuration.estimateSeconds}), for the registration + * page's availability panel. The tournament's own registrations do not count + * as being busy — the panel asks whether people can play this very event. + * + * When the event starts past the reportable horizon there is nothing to + * compute: every schedule would be unknown, so the result is only when + * schedules for the event's week open up (the Monday its week becomes the + * "next week"). + */ +export async function registrationAvailability({ + tournament, + userIds, + timezone, +}: { + tournament: { + id: number; + startsAt: number; + minMembersPerTeam: number; + bracketTypes: Array; + teamCount: number; + }; + userIds: Array; + timezone: string; +}) { + const startDate = databaseTimestampToDate(tournament.startsAt); + + const horizon = Availability.weekRange( + addWeeks(new Date(), AVAILABILITY.WEEK_HORIZON - 1), + timezone, + ); + if (tournament.startsAt >= horizon.endsAt) { + return { + beyondHorizon: { + opensAt: Availability.weekStartsAt(subWeeks(startDate, 1), timezone), + }, + window: null, + entries: null, + }; + } + + const window: TimeRange = { + startsAt: tournament.startsAt, + endsAt: + tournament.startsAt + + TournamentDuration.estimateSeconds({ + minMembersPerTeam: tournament.minMembersPerTeam, + bracketTypes: tournament.bracketTypes, + teamCount: tournament.teamCount, + }), + }; + + const [weeks, busyByUserId] = await Promise.all([ + AvailabilityRepository.findAllWeeksByUserIds({ userIds, ...window }), + Commitments.busyBlocksByUserIds({ + userIds, + ...window, + excludeTournamentId: tournament.id, + }), + ]); + + const windowDates = [ + Availability.dateInTimezone(window.startsAt, timezone), + Availability.dateInTimezone(window.endsAt - 1, timezone), + ]; + + const entries = userIds.map((userId) => { + const userWeeks = weeks.filter((week) => week.userId === userId); + + return { + userId, + availability: Availability.availabilityInWindow({ + reported: userWeeks.some( + (week) => + Availability.weekStartsAt(startDate, week.timezone) === + week.weekStartsAt, + ), + slots: userWeeks.flatMap((week) => week.slots), + busy: busyByUserId.get(userId) ?? [], + window, + }), + notes: userWeeks.flatMap((week) => + week.dayNotes + .filter((note) => + windowDates.includes( + Availability.dateInTimezone( + Availability.localToTimestamp({ + date: note.date, + time: "12:00", + timezone: week.timezone, + }), + timezone, + ), + ), + ) + .map((note) => note.text), + ), + }; + }); + + return { beyondHorizon: null, window, entries }; +} diff --git a/app/features/tournament/TournamentTeamRepository.server.ts b/app/features/tournament/TournamentTeamRepository.server.ts index fc88966a3..ff74b1da5 100644 --- a/app/features/tournament/TournamentTeamRepository.server.ts +++ b/app/features/tournament/TournamentTeamRepository.server.ts @@ -927,16 +927,20 @@ async function findTeamRecentMaps( * teams and hidden events (test and draft tournaments) are excluded. Used to * resolve availability commitments, so alongside the event's name and start * the rows carry what estimating the tournament's duration needs: the - * settings and how many teams have registered so far. + * settings and how many teams have registered so far. `excludeTournamentId` + * leaves one tournament's own registrations out, for surfaces asking "busy + * elsewhere" while looking at that tournament. */ export function findAllRegistrationsByUserIds({ userIds, startsAt, endsAt, + excludeTournamentId, }: { userIds: Array; startsAt: number; endsAt: number; + excludeTournamentId?: number; }) { if (userIds.length === 0) return Promise.resolve([]); @@ -972,6 +976,9 @@ export function findAllRegistrationsByUserIds({ .where("CalendarEvent.hidden", "=", 0) .where("CalendarEventDate.startsAt", ">=", startsAt) .where("CalendarEventDate.startsAt", "<=", endsAt) + .$if(typeof excludeTournamentId === "number", (qb) => + qb.where("Tournament.id", "!=", excludeTournamentId!), + ) .execute(); } diff --git a/app/features/tournament/loaders/to.$id.register.server.ts b/app/features/tournament/loaders/to.$id.register.server.ts index 805f3b865..c28fcc35b 100644 --- a/app/features/tournament/loaders/to.$id.register.server.ts +++ b/app/features/tournament/loaders/to.$id.register.server.ts @@ -1,11 +1,16 @@ import type { LoaderFunctionArgs } from "react-router"; +import * as R from "remeda"; +import * as RegistrationAvailability from "~/features/availability/core/RegistrationAvailability.server"; import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; import * as TeamRepository from "~/features/team/TeamRepository.server"; +import { getViewerTimezone } from "~/features/timezone/timezone-context.server"; import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server"; +import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; import { tournamentFromParams, tournamentTeamsFullCached, } from "~/features/tournament-bracket/core/Tournament.server"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; export const loader = async ({ params }: LoaderFunctionArgs) => { const { tournament, tournamentId, user } = await tournamentFromParams( @@ -15,12 +20,21 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { if (!user) return null; const teamMemberOf = tournament.teamMemberOfByUser(user); + const friendPlayers = await SQGroupRepository.findFriendsAndTeammates( + user.id, + ); + const availability = await rosterAvailability({ + tournament, + userId: user.id, + friendIds: friendPlayers.friends.map((friend) => friend.id), + }); if (!teamMemberOf) { return { ownTeam: null, mapPool: null, - friendPlayers: null, + friendPlayers, + availability, teams: await TeamRepository.findAllMemberOfByUserId(user.id), isSaved: await SavedCalendarEventRepository.isSaved({ userId: user.id, @@ -37,10 +51,40 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { return { ownTeam, mapPool: ownTeam?.mapPool ?? null, - friendPlayers: await SQGroupRepository.findFriendsAndTeammates(user.id), + friendPlayers, + availability, teams: await TeamRepository.findAllMemberOfByUserId(user.id), isSaved: false, }; }; +function rosterAvailability({ + tournament, + userId, + friendIds, +}: { + tournament: Tournament; + userId: number; + friendIds: Array; +}) { + if (tournament.isLeague) return null; + + const startsAt = dateToDatabaseTimestamp(tournament.ctx.startsAt); + if (tournament.ctx.startsAt <= new Date()) return null; + + return RegistrationAvailability.registrationAvailability({ + tournament: { + id: tournament.ctx.id, + startsAt, + minMembersPerTeam: tournament.minMembersPerTeam, + bracketTypes: tournament.ctx.settings.bracketProgression.map( + (bracket) => bracket.type, + ), + teamCount: tournament.ctx.teams.length, + }, + userIds: R.unique([userId, ...friendIds]), + timezone: getViewerTimezone() ?? "UTC", + }); +} + export type TournamentRegisterPageLoader = typeof loader; diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index 0f5984f0d..4613f529d 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -3,6 +3,7 @@ import { AlertCircle, Check, Clipboard, X } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { useFetcher, useLoaderData } from "react-router"; +import * as R from "remeda"; import { ActionButton } from "~/components/ActionButton"; import { Alert } from "~/components/Alert"; import { Avatar } from "~/components/Avatar"; @@ -16,6 +17,8 @@ import { containerClassName } from "~/components/Main"; import { SubmitButton } from "~/components/SubmitButton"; import { Config } from "~/config"; import { useUser } from "~/features/auth/core/user"; +import { RegistrationAvailabilityPanel } from "~/features/availability/components/RegistrationAvailabilityPanel"; +import { timezoneMiddleware } from "~/features/timezone/timezone-middleware.server"; import { type CounterPickMapPool, CounterPickMapPoolPicker, @@ -32,6 +35,7 @@ import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; import { useAutoRerender } from "~/hooks/useAutoRerender"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useHydrated } from "~/hooks/useHydrated"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { LOG_IN_URL, SENDOU_INK_BASE_URL, @@ -50,10 +54,17 @@ import { deleteTeamMemberSchema, updateMapPoolSchema, } from "../tournament-schemas"; +import type { Route } from "./+types/to.$id.register"; import styles from "./to.$id.register.module.css"; export { action, loader }; +export const middleware: Route.MiddlewareFunction[] = [timezoneMiddleware]; + +export const handle: SendouRouteHandle = { + i18n: ["schedule"], +}; + export default function TournamentRegisterPage() { const user = useUser(); const tournament = useTournament(); @@ -222,6 +233,7 @@ function RegistrationForms({ readOnly = false }: { readOnly?: boolean }) { canUnregister={Boolean(ownTeam && !ownTeamCheckedIn)} /> ) : null} + {ownTeam ? : null} {tournament.isLeague && tournament.ctx.organization?.id === LUTI_ORGANIZATION_ID ? ( @@ -256,6 +268,7 @@ function ReadOnlyRegistrationForms() { members={team.members} /> + {tournament.teamsPrePickMaps ? ( @@ -585,6 +598,7 @@ function RegisterTeamFields({ readOnly = false }: { readOnly?: boolean }) { ) : null} + {!data?.ownTeam ? : null} {!isLinked ? ( <>
    @@ -967,3 +981,98 @@ function TeamCounterPickMapPoolPicker({
    ); } + +function TournamentRosterAvailability({ + ownTeam, +}: { + ownTeam: TournamentTeamFull; +}) { + const data = useLoaderData(); + const tournament = useTournament(); + + const availability = data?.availability; + if (!availability) return null; + + const roster = ownTeam.members.map((member) => ({ + id: member.userId, + username: member.username, + discordId: member.discordId, + discordAvatar: member.discordAvatar, + customAvatarUrl: member.customAvatarUrl, + })); + + return ( + user.id), + })} + /> + ); +} + +function SelectedTeamAvailability() { + const data = useLoaderData(); + const tournament = useTournament(); + const { values } = useFormFieldContext(); + + const availability = data?.availability; + const teamId = values.teamId ? Number(values.teamId) : null; + if (!availability || !teamId) return null; + + const roster = (data?.friendPlayers?.friends ?? []) + .filter((friend) => friend.teamId === teamId) + .map(panelUser); + if (roster.length === 0) return null; + + return ( + user.id), + })} + /> + ); +} + +function panelUser(user: { + id: number; + username: string; + discordId: string; + discordAvatar: string | null; + customAvatarUrl?: string | null; +}) { + return { + id: user.id, + username: user.username, + discordId: user.discordId, + discordAvatar: user.discordAvatar, + customAvatarUrl: user.customAvatarUrl, + }; +} + +function subCandidates({ + data, + tournament, + rosterUserIds, +}: { + data: ReturnType>; + tournament: ReturnType; + rosterUserIds: number[]; +}) { + const inTournament = (userId: number) => + tournament.ctx.teams.some((team) => team.memberUserIds.includes(userId)); + + return R.uniqueBy(data?.friendPlayers?.friends ?? [], (friend) => friend.id) + .filter( + (friend) => + !rosterUserIds.includes(friend.id) && !inTournament(friend.id), + ) + .map(panelUser); +} diff --git a/e2e/tournament.spec.ts b/e2e/tournament.spec.ts index f0e1ef094..40aa2b250 100644 --- a/e2e/tournament.spec.ts +++ b/e2e/tournament.spec.ts @@ -1,11 +1,14 @@ import { addHours, addMinutes } from "date-fns"; import { ADMIN_ID } from "~/features/admin/admin-constants"; +import * as Availability from "~/features/availability/core/Availability"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import { expect, impersonate, isNotVisible, + MACHINE_TIMEZONE, navigate, + setTimezoneCookie, test, } from "./helpers/playwright"; import { NotificationPopover } from "./pages/layout/notification-popover"; @@ -63,6 +66,86 @@ test.describe("Tournament", () => { await expect(register.stepCheckmark(3)).toBeVisible(); }); + test("shows the roster's availability for the event window", async ({ + page, + factories, + }) => { + const [partialMember, unknownMember, stranger, friend] = + await factories.UserFactory.createMany(4); + await factories.TeamFactory.create({ + memberUserIds: [ADMIN_ID, partialMember.id, unknownMember.id], + }); + await factories.FriendshipFactory.create({ + userOneId: ADMIN_ID, + userTwoId: friend.id, + }); + + const startsAt = addHours(new Date(), 2); + const tournament = await factories.TournamentFactory.create({ + authorId: ADMIN_ID, + startTimes: [dateToDatabaseTimestamp(startsAt)], + }); + await factories.TournamentTeamFactory.create({ + tournamentId: tournament.id, + memberUserIds: [ + ADMIN_ID, + partialMember.id, + unknownMember.id, + stranger.id, + ], + }); + + const { startsAt: weekStartsAt } = Availability.weekRange( + startsAt, + MACHINE_TIMEZONE, + ); + const coveringSlot = { + startsAt: dateToDatabaseTimestamp(startsAt), + endsAt: dateToDatabaseTimestamp(addHours(startsAt, 5)), + }; + for (const userId of [ADMIN_ID, friend.id]) { + await factories.AvailabilityWeekFactory.create({ + userId, + weekStartsAt, + timezone: MACHINE_TIMEZONE, + slots: [coveringSlot], + }); + } + await factories.AvailabilityWeekFactory.create({ + userId: partialMember.id, + weekStartsAt, + timezone: MACHINE_TIMEZONE, + slots: [ + { + startsAt: dateToDatabaseTimestamp(addHours(startsAt, 1)), + endsAt: coveringSlot.endsAt, + }, + ], + }); + + await impersonate(page); + await setTimezoneCookie(page); + const register = new TournamentRegisterPage(page); + await register.goto(tournament.id); + + const row = (userId: number) => + page.getByTestId(`availability-row-${userId}`); + await expect(row(ADMIN_ID)).toHaveAttribute("data-status", "available"); + await expect(row(partialMember.id)).toHaveAttribute( + "data-status", + "partial", + ); + await expect(row(unknownMember.id)).toHaveAttribute( + "data-status", + "unknown", + ); + // on the tournament roster without being a teammate or a friend, so + // their schedule is not the viewer's to see + await expect(row(stranger.id)).toHaveAttribute("data-status", "hidden"); + // the friend with an overlapping submitted range lands in the sub row + await expect(row(friend.id)).toHaveAttribute("data-status", "available"); + }); + test("checks in and appears on the bracket", async ({ page, factories }) => { const tournament = await factories.TournamentFactory.create({ authorId: ADMIN_ID, diff --git a/locales/da/schedule.json b/locales/da/schedule.json index 50c173f75..9dcbc1fc3 100644 --- a/locales/da/schedule.json +++ b/locales/da/schedule.json @@ -19,6 +19,15 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "registration.title": "", + "registration.estimated": "", + "registration.notVisible": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", "team.canPlay": "", "team.currentWeek": "", "team.hidden": "", diff --git a/locales/de/schedule.json b/locales/de/schedule.json index 50c173f75..9dcbc1fc3 100644 --- a/locales/de/schedule.json +++ b/locales/de/schedule.json @@ -19,6 +19,15 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "registration.title": "", + "registration.estimated": "", + "registration.notVisible": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", "team.canPlay": "", "team.currentWeek": "", "team.hidden": "", diff --git a/locales/en/schedule.json b/locales/en/schedule.json index c10fcf8cf..6aec15084 100644 --- a/locales/en/schedule.json +++ b/locales/en/schedule.json @@ -19,6 +19,15 @@ "events.none": "No events this week", "events.delete": "Delete event", "events.deleteConfirm": "Delete the event {{name}}?", + "registration.title": "Availability", + "registration.estimated": "estimated", + "registration.notVisible": "Schedule not shared with you", + "registration.friends": "Friends", + "registration.beyondHorizon": "Schedules for that week open {{date}}", + "registration.summary.available": "{{amount}} available", + "registration.summary.partial": "{{amount}} partial", + "registration.summary.out": "{{amount}} out", + "registration.summary.unknown": "{{amount}} unknown", "team.canPlay": "Team can play ({{players}}+)", "team.currentWeek": "This week", "team.hidden": "Only team members can see the team schedule", diff --git a/locales/es-ES/schedule.json b/locales/es-ES/schedule.json index 50c173f75..9dcbc1fc3 100644 --- a/locales/es-ES/schedule.json +++ b/locales/es-ES/schedule.json @@ -19,6 +19,15 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "registration.title": "", + "registration.estimated": "", + "registration.notVisible": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", "team.canPlay": "", "team.currentWeek": "", "team.hidden": "", diff --git a/locales/es-US/schedule.json b/locales/es-US/schedule.json index 50c173f75..9dcbc1fc3 100644 --- a/locales/es-US/schedule.json +++ b/locales/es-US/schedule.json @@ -19,6 +19,15 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "registration.title": "", + "registration.estimated": "", + "registration.notVisible": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", "team.canPlay": "", "team.currentWeek": "", "team.hidden": "", diff --git a/locales/fr-CA/schedule.json b/locales/fr-CA/schedule.json index 50c173f75..9dcbc1fc3 100644 --- a/locales/fr-CA/schedule.json +++ b/locales/fr-CA/schedule.json @@ -19,6 +19,15 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "registration.title": "", + "registration.estimated": "", + "registration.notVisible": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", "team.canPlay": "", "team.currentWeek": "", "team.hidden": "", diff --git a/locales/fr-EU/schedule.json b/locales/fr-EU/schedule.json index 50c173f75..9dcbc1fc3 100644 --- a/locales/fr-EU/schedule.json +++ b/locales/fr-EU/schedule.json @@ -19,6 +19,15 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "registration.title": "", + "registration.estimated": "", + "registration.notVisible": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", "team.canPlay": "", "team.currentWeek": "", "team.hidden": "", diff --git a/locales/he/schedule.json b/locales/he/schedule.json index 50c173f75..9dcbc1fc3 100644 --- a/locales/he/schedule.json +++ b/locales/he/schedule.json @@ -19,6 +19,15 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "registration.title": "", + "registration.estimated": "", + "registration.notVisible": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", "team.canPlay": "", "team.currentWeek": "", "team.hidden": "", diff --git a/locales/it/schedule.json b/locales/it/schedule.json index 50c173f75..9dcbc1fc3 100644 --- a/locales/it/schedule.json +++ b/locales/it/schedule.json @@ -19,6 +19,15 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "registration.title": "", + "registration.estimated": "", + "registration.notVisible": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", "team.canPlay": "", "team.currentWeek": "", "team.hidden": "", diff --git a/locales/ja/schedule.json b/locales/ja/schedule.json index 50c173f75..9dcbc1fc3 100644 --- a/locales/ja/schedule.json +++ b/locales/ja/schedule.json @@ -19,6 +19,15 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "registration.title": "", + "registration.estimated": "", + "registration.notVisible": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", "team.canPlay": "", "team.currentWeek": "", "team.hidden": "", diff --git a/locales/ko/schedule.json b/locales/ko/schedule.json index 50c173f75..9dcbc1fc3 100644 --- a/locales/ko/schedule.json +++ b/locales/ko/schedule.json @@ -19,6 +19,15 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "registration.title": "", + "registration.estimated": "", + "registration.notVisible": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", "team.canPlay": "", "team.currentWeek": "", "team.hidden": "", diff --git a/locales/nl/schedule.json b/locales/nl/schedule.json index 50c173f75..9dcbc1fc3 100644 --- a/locales/nl/schedule.json +++ b/locales/nl/schedule.json @@ -19,6 +19,15 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "registration.title": "", + "registration.estimated": "", + "registration.notVisible": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", "team.canPlay": "", "team.currentWeek": "", "team.hidden": "", diff --git a/locales/pl/schedule.json b/locales/pl/schedule.json index 50c173f75..9dcbc1fc3 100644 --- a/locales/pl/schedule.json +++ b/locales/pl/schedule.json @@ -19,6 +19,15 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "registration.title": "", + "registration.estimated": "", + "registration.notVisible": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", "team.canPlay": "", "team.currentWeek": "", "team.hidden": "", diff --git a/locales/pt-BR/schedule.json b/locales/pt-BR/schedule.json index 50c173f75..9dcbc1fc3 100644 --- a/locales/pt-BR/schedule.json +++ b/locales/pt-BR/schedule.json @@ -19,6 +19,15 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "registration.title": "", + "registration.estimated": "", + "registration.notVisible": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", "team.canPlay": "", "team.currentWeek": "", "team.hidden": "", diff --git a/locales/ru/schedule.json b/locales/ru/schedule.json index 50c173f75..9dcbc1fc3 100644 --- a/locales/ru/schedule.json +++ b/locales/ru/schedule.json @@ -19,6 +19,15 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "registration.title": "", + "registration.estimated": "", + "registration.notVisible": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", "team.canPlay": "", "team.currentWeek": "", "team.hidden": "", diff --git a/locales/zh/schedule.json b/locales/zh/schedule.json index 50c173f75..9dcbc1fc3 100644 --- a/locales/zh/schedule.json +++ b/locales/zh/schedule.json @@ -19,6 +19,15 @@ "events.none": "", "events.delete": "", "events.deleteConfirm": "", + "registration.title": "", + "registration.estimated": "", + "registration.notVisible": "", + "registration.friends": "", + "registration.beyondHorizon": "", + "registration.summary.available": "", + "registration.summary.partial": "", + "registration.summary.out": "", + "registration.summary.unknown": "", "team.canPlay": "", "team.currentWeek": "", "team.hidden": "",