Availability on team register page

This commit is contained in:
Kalle
2026-08-23 16:09:26 +03:00
parent 48bb6dc998
commit 20c5112566
30 changed files with 1174 additions and 14 deletions

View File

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

View File

@@ -76,7 +76,7 @@ export const addTeamEventSchema = v.object({
}),
});
export const deleteTeamEventSchema = v.object({
const deleteTeamEventSchema = v.object({
_action: _action("DELETE_EVENT"),
eventId: id,
});

View File

@@ -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<TimeRange> }
| { status: "busy"; block: BusyBlock }
| { status: "unavailable" }
| { status: "unknown" };

View File

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

View File

@@ -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<RegistrationAvailability>;
type PanelEntry = NonNullable<PanelData["entries"]>[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<PanelUser>;
/** Friends not on the shown roster and not in the tournament, panel keeps the free ones. */
subCandidates: Array<PanelUser>;
}) {
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 (
<section className={styles.panel}>
<h4 className={styles.heading}>{t("schedule:registration.title")}</h4>
<div className={styles.mutedText}>
{t("schedule:registration.beyondHorizon", {
date: dateFormatter.format(availability.beyondHorizon.opensAt),
})}
</div>
</section>
);
}
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 (
<section className={styles.panel}>
<h4 className={styles.heading}>
{t("schedule:registration.title")} ·{" "}
<span className={styles.headingWindow}>
{windowFormatter.formatRange(
availability.window.startsAt,
availability.window.endsAt,
)}{" "}
({t("schedule:registration.estimated")})
</span>
</h4>
<ul className={styles.rows}>
{roster.map((user) => (
<MemberRow
key={user.id}
user={user}
entry={entryByUserId.get(user.id)}
/>
))}
</ul>
<SummaryLine roster={roster} entryByUserId={entryByUserId} />
{freeSubs.length > 0 ? (
<div className={styles.subsSection}>
<h5 className={styles.subsHeading}>
{t("schedule:registration.friends")}
</h5>
<ul className={styles.rows}>
{freeSubs.map((user) => (
<MemberRow
key={user.id}
user={user}
entry={entryByUserId.get(user.id)}
/>
))}
</ul>
</div>
) : null}
</section>
);
}
function MemberRow({ user, entry }: { user: PanelUser; entry?: PanelEntry }) {
return (
<li
className={styles.row}
data-testid={`availability-row-${user.id}`}
data-status={rowStatus(entry)}
>
<StatusIcon status={rowStatus(entry)} />
<Avatar user={user} size="xxs" />
<span className={styles.name}>{user.username}</span>
<RowDetail entry={entry} />
{entry?.notes.map((note) => (
<span key={note} className={styles.note}>
<Flag size={12} className={styles.noteFlag} /> {note}
</span>
))}
</li>
);
}
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 (
<span className={styles.mutedText}>
{t("schedule:registration.notVisible")}
</span>
);
}
const availability = entry.availability;
switch (availability.status) {
case "available":
case "partial":
return <RangesText ranges={availability.ranges} />;
case "unavailable":
return (
<span className={styles.mutedText}>
{t("schedule:team.notAvailable")}
</span>
);
case "unknown":
return (
<span className={styles.mutedText}>
{t("schedule:team.noSchedule")}
</span>
);
case "busy":
return (
<span className={styles.busy}>
<span className={styles.busyName}>
{availability.block.name ?? t("schedule:commitment.scrim")}
</span>
</span>
);
}
}
function RangesText({ ranges }: { ranges: Array<TimeRange> }) {
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 (
<span className={styles.ranges}>{ranges.map(rangeText).join(" · ")}</span>
);
}
function StatusIcon({ status }: { status: RowStatus }) {
switch (status) {
case "available":
return <Check size={16} className={styles.iconAvailable} />;
case "partial":
return <Clock size={14} className={styles.iconPartial} />;
case "unavailable":
return <X size={16} className={styles.iconUnavailable} />;
case "busy":
return <CalendarX size={14} className={styles.iconUnavailable} />;
case "unknown":
return <HelpCircle size={14} className={styles.iconUnknown} />;
case "hidden":
return <EyeOff size={14} className={styles.iconUnknown} />;
}
}
function SummaryLine({
roster,
entryByUserId,
}: {
roster: Array<PanelUser>;
entryByUserId: Map<number, PanelEntry>;
}) {
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 <div className={styles.summary}>{parts.join(" · ")}</div>;
}

View File

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

View File

@@ -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<TimeRange>;
busy: Array<BusyBlock>;
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

View File

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

View File

@@ -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<number>;
startsAt: number;
endsAt: number;
excludeTournamentId?: number;
}): Promise<Map<number, Array<BusyBlock>>> {
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,

View File

@@ -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<typeof registrationAvailability>
>;
/**
* 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<Tables["TournamentStage"]["type"]>;
teamCount: number;
};
userIds: Array<number>;
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 };
}

View File

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

View File

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

View File

@@ -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 ? <TournamentRosterAvailability ownTeam={ownTeam} /> : null}
{tournament.isLeague &&
tournament.ctx.organization?.id === LUTI_ORGANIZATION_ID ? (
<GoogleFormsLink />
@@ -256,6 +268,7 @@ function ReadOnlyRegistrationForms() {
members={team.members}
/>
<TeamInfo ownTeam={team} canUnregister={false} readOnly />
<TournamentRosterAvailability ownTeam={team} />
<FillRoster ownTeam={team} ownTeamCheckedIn={checkedIn} readOnly />
{tournament.teamsPrePickMaps ? (
<TeamCounterPickMapPoolPicker readOnly mapPool={team.mapPool ?? []} />
@@ -585,6 +598,7 @@ function RegisterTeamFields({ readOnly = false }: { readOnly?: boolean }) {
<FormField name="teamId" options={teamOptions} />
</div>
) : null}
{!data?.ownTeam ? <SelectedTeamAvailability /> : null}
{!isLinked ? (
<>
<div className={styles.sectionInputContainer}>
@@ -967,3 +981,98 @@ function TeamCounterPickMapPoolPicker({
</div>
);
}
function TournamentRosterAvailability({
ownTeam,
}: {
ownTeam: TournamentTeamFull;
}) {
const data = useLoaderData<TournamentRegisterPageLoader>();
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 (
<RegistrationAvailabilityPanel
availability={availability}
roster={roster}
subCandidates={subCandidates({
data,
tournament,
rosterUserIds: roster.map((user) => user.id),
})}
/>
);
}
function SelectedTeamAvailability() {
const data = useLoaderData<TournamentRegisterPageLoader>();
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 (
<RegistrationAvailabilityPanel
availability={availability}
roster={roster}
subCandidates={subCandidates({
data,
tournament,
rosterUserIds: roster.map((user) => 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<typeof useLoaderData<TournamentRegisterPageLoader>>;
tournament: ReturnType<typeof useTournament>;
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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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