mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-28 14:18:04 -05:00
Availability on team register page
This commit is contained in:
@@ -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];
|
||||
|
||||
@@ -76,7 +76,7 @@ export const addTeamEventSchema = v.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const deleteTeamEventSchema = v.object({
|
||||
const deleteTeamEventSchema = v.object({
|
||||
_action: _action("DELETE_EVENT"),
|
||||
eventId: id,
|
||||
});
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
Reference in New Issue
Block a user