Scrim schedule request

This commit is contained in:
Kalle
2026-08-25 21:59:16 +03:00
parent 16ea3510c1
commit e0207dc578
33 changed files with 883 additions and 38 deletions

View File

@@ -74,3 +74,31 @@ export type WindowAvailability =
| { status: "busy"; block: BusyBlock }
| { status: "unavailable" }
| { status: "unknown" };
/**
* How one person's schedule relates to a window, as the surfaces showing a
* roster's fit render it. `notes` is left out by the surfaces that have no day
* notes at hand.
*/
export interface WindowAvailabilityEntry {
userId: number;
availability: WindowAvailability;
notes?: Array<string>;
}
/**
* What is known about one person inside a window: the material
* `Availability.availabilityInWindow` resolves a status from. Sent to the
* browser as is by the surfaces that ask about many windows at once, so that
* narrowing one down (picking a start inside a post's flexibility) needs no
* further round trip.
*/
export interface WindowSchedule {
userId: number;
/** Whether they filled in the week the window falls in. */
reported: boolean;
/** Their effective availability inside the window. */
ranges: Array<TimeRange>;
/** Their commitments overlapping the window. */
busy: Array<BusyBlock>;
}

View File

@@ -14,7 +14,7 @@ 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 { TimeRange, WindowAvailabilityEntry } from "../availability-types";
import type { RegistrationAvailability } from "../core/RegistrationAvailability.server";
import styles from "./RegistrationAvailabilityPanel.module.css";
@@ -27,9 +27,7 @@ export interface AvailabilityPanelUser {
}
export type AvailabilityPanelData = SerializeFrom<RegistrationAvailability>;
export type AvailabilityPanelEntry = NonNullable<
AvailabilityPanelData["entries"]
>[number];
export type AvailabilityPanelEntry = WindowAvailabilityEntry;
export type AvailabilityRowStatus =
| AvailabilityPanelEntry["availability"]["status"]
@@ -187,7 +185,7 @@ export function AvailabilityMemberRow({
</span>
{showAvailability ? <AvailabilityRowDetail entry={entry} /> : null}
{showAvailability
? entry?.notes.map((note) => (
? entry?.notes?.map((note) => (
<span key={note} className={styles.note}>
<Flag size={12} className={styles.noteFlag} /> {note}
</span>

View File

@@ -3,6 +3,7 @@ import * as AvailabilityWeekFactory from "~/db/seed/factories/AvailabilityWeekFa
import * as TeamEventFactory from "~/db/seed/factories/TeamEventFactory";
import * as TeamFactory from "~/db/seed/factories/TeamFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { AVAILABILITY } from "../availability-constants";
import * as Availability from "./Availability";
import * as RosterSchedule from "./RosterSchedule.server";
@@ -91,3 +92,100 @@ describe("RosterSchedule.rosterScheduleData", () => {
});
});
});
describe("RosterSchedule.windowSchedules", () => {
const window = (id: number, from: number, to: number) => ({
id,
startsAt: currentWeekStartsAt() + from * HOUR,
endsAt: currentWeekStartsAt() + to * HOUR,
});
const schedulesOf = async (
windows: Array<ReturnType<typeof window>>,
userIds: Array<number> = [memberId()],
) => RosterSchedule.windowSchedules({ windows, userIds });
beforeEach(async () => {
await users.create(2);
});
test("reports what the member has free inside the window", async () => {
await AvailabilityWeekFactory.create({
userId: memberId(),
weekStartsAt: currentWeekStartsAt(),
timezone: TIMEZONE,
slots: [
{
startsAt: currentWeekStartsAt() + 18 * HOUR,
endsAt: currentWeekStartsAt() + 22 * HOUR,
},
],
});
const [schedules] = await schedulesOf([window(1, 20, 23)]);
expect(schedules.members).toEqual([
{
userId: memberId(),
reported: true,
ranges: [
{
startsAt: currentWeekStartsAt() + 20 * HOUR,
endsAt: currentWeekStartsAt() + 22 * HOUR,
},
],
busy: [],
},
]);
});
test("cuts a commitment out of the availability and reports it", async () => {
await AvailabilityWeekFactory.create({
userId: memberId(),
weekStartsAt: currentWeekStartsAt(),
timezone: TIMEZONE,
slots: [
{
startsAt: currentWeekStartsAt() + 18 * HOUR,
endsAt: currentWeekStartsAt() + 22 * HOUR,
},
],
});
const team = await TeamFactory.create({
memberUserIds: [memberId(), teammateId()],
});
await TeamEventFactory.create({
teamId: team.id,
authorId: memberId(),
name: "VoD review",
startsAt: currentWeekStartsAt() + 19 * HOUR,
endsAt: currentWeekStartsAt() + 20 * HOUR,
});
const [schedules] = await schedulesOf([window(1, 18, 22)]);
expect(schedules.members[0].ranges).toEqual([
{
startsAt: currentWeekStartsAt() + 18 * HOUR,
endsAt: currentWeekStartsAt() + 19 * HOUR,
},
{
startsAt: currentWeekStartsAt() + 20 * HOUR,
endsAt: currentWeekStartsAt() + 22 * HOUR,
},
]);
expect(schedules.members[0].busy).toHaveLength(1);
});
test("marks a week the member never filled in as not reported", async () => {
const [schedules] = await schedulesOf([window(1, 18, 20)]);
expect(schedules.members[0].reported).toBe(false);
});
test("leaves out a window past the reportable horizon", async () => {
const beyond = 24 * 7 * (AVAILABILITY.WEEK_HORIZON + 1);
expect(await schedulesOf([window(1, beyond, beyond + 2)])).toEqual([]);
});
});

View File

@@ -1,10 +1,13 @@
import { addWeeks } from "date-fns";
import * as R from "remeda";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
} from "~/utils/dates";
import type { SerializeFrom } from "~/utils/remix";
import * as AvailabilityRepository from "../AvailabilityRepository.server";
import { AVAILABILITY } from "../availability-constants";
import type { TimeRange } from "../availability-types";
import type { TimeRange, WindowSchedule } from "../availability-types";
import * as Availability from "./Availability";
import * as Commitments from "./Commitments.server";
@@ -115,3 +118,74 @@ function weekView({ range, timezone }: { range: TimeRange; timezone: string }) {
}),
};
}
/**
* What the given users' schedules say about each of the given windows: what
* they reported inside it, the commitments overriding that and whether they
* filled in the week it falls in at all.
*
* The windows are resolved in one go so that a page showing many of them (the
* scrim browsing page's fit indicators) reads the schedules once. Windows past
* the reportable horizon are left out — nothing could be known about them.
*/
export async function windowSchedules({
windows,
userIds,
}: {
windows: Array<TimeRange & { id: number }>;
userIds: Array<number>;
}) {
// the horizon's last week starts at the current week's start at the latest,
// so nothing inside it reaches this far
const horizonEndsAt = dateToDatabaseTimestamp(
addWeeks(new Date(), AVAILABILITY.WEEK_HORIZON),
);
const withinHorizon = windows.filter(
(window) => window.startsAt < horizonEndsAt,
);
if (withinHorizon.length === 0 || userIds.length === 0) return [];
const range = {
startsAt: Math.min(...withinHorizon.map((window) => window.startsAt)),
endsAt: Math.max(...withinHorizon.map((window) => window.endsAt)),
};
const [reportedWeeks, busyByUserId] = await Promise.all([
AvailabilityRepository.findAllWeeksByUserIds({ userIds, ...range }),
Commitments.busyBlocksByUserIds({ userIds, ...range }),
]);
return withinHorizon.map((window) => ({
id: window.id,
members: userIds.map((userId): WindowSchedule => {
const memberWeeks = reportedWeeks.filter(
(week) => week.userId === userId,
);
const busy = (busyByUserId.get(userId) ?? []).filter((block) =>
Availability.overlaps(block, window),
);
return {
userId,
// which week a window falls in is a question about the member's own
// clock, the same one they filled the week in on
reported: memberWeeks.some(
(week) =>
Availability.weekStartsAt(
databaseTimestampToDate(window.startsAt),
week.timezone,
) === week.weekStartsAt,
),
ranges: Availability.clip(
Availability.subtract(
memberWeeks.flatMap((week) => week.slots),
busy,
),
window,
),
busy,
};
}),
}));
}

View File

@@ -0,0 +1,53 @@
.stripe {
width: 100%;
height: auto;
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-1) var(--s-4);
margin-block-start: auto;
border-block-start: var(--border-style);
border-radius: 0;
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
&:hover {
background-color: var(--color-bg-high);
}
}
.stripeTeam {
color: var(--color-text-high);
text-transform: uppercase;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stripeCount {
margin-inline-start: auto;
color: var(--color-text);
white-space: nowrap;
}
.popover {
display: flex;
flex-direction: column;
gap: var(--s-3);
max-width: 20rem;
}
.rowsSection {
display: flex;
flex-direction: column;
gap: var(--s-3);
}
.rows {
display: flex;
flex-direction: column;
gap: var(--s-2-5);
list-style: none;
padding: 0;
margin: 0;
}

View File

@@ -0,0 +1,141 @@
import { useTranslation } from "react-i18next";
import { useLoaderData } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import {
AvailabilityMemberRow,
type AvailabilityPanelUser,
AvailabilityStatusDots,
AvailabilitySummary,
AvailabilityWindowText,
availabilityRowStatus,
} from "~/features/availability/components/RegistrationAvailabilityPanel";
import * as Scrim from "../core/Scrim";
import type { loader as scrimsLoader } from "../loaders/scrims.server";
import type { ScrimPost } from "../scrims-types";
import { requestStarts } from "../scrims-utils";
import styles from "./ScrimAvailability.module.css";
export interface ScrimRosterFit {
team: { id: number; name: string };
roster: Array<AvailabilityPanelUser>;
fit: Scrim.RosterFit;
}
/**
* How one of the viewer's teams fits a post they could request, resolved from
* the schedules the browsing page loaded. `teamId` picks the team (their main
* one by default) and `at` narrows the fit to one start inside the post's
* flexibility instead of the best one on offer.
*
* Null whenever there is nothing to show: no team, a post past the reportable
* horizon, or a week nobody filled in.
*/
export function useRosterFit({
post,
teamId,
at,
}: {
post: ScrimPost;
teamId?: number;
at?: number | null;
}): ScrimRosterFit | null {
const data = useLoaderData<typeof scrimsLoader>();
const team =
teamId !== undefined
? data.teams.find((team) => team.id === teamId)
: (data.teams.find((team) => team.isMainTeam) ?? data.teams[0]);
const schedules = data.availability.windows.find(
(window) => window.id === post.id,
);
if (!team || !schedules) return null;
const roster = Scrim.teamPlayers(team.members);
const fit = Scrim.rosterFit({
starts: at ? [at] : requestStarts({ post, now: data.availability.now }),
members: roster.flatMap((member) => {
const schedule = schedules.members.find(
(schedule) => schedule.userId === member.id,
);
return schedule ? [schedule] : [];
}),
});
if (!fit) return null;
return { team, roster, fit };
}
/**
* The post card's fit indicator: a stripe above the card's actions saying how
* much of the viewer's roster could play it, the who and when a click away.
*
* Left out when none of them could — a row of zeroes down the page is noise,
* and the request button says all there is to say then.
*/
export function ScrimFitStripe({ post }: { post: ScrimPost }) {
const { t } = useTranslation(["schedule"]);
const fit = useRosterFit({ post });
if (!fit || fit.fit.availableCount === 0) return null;
return (
<SendouPopover
trigger={
<SendouButton
variant="minimal"
className={styles.stripe}
testId="scrim-fit-indicator"
>
<span className={styles.stripeTeam}>{fit.team.name}</span>
<AvailabilityStatusDots statuses={rosterStatuses(fit)} />
<span className={styles.stripeCount}>
{t("schedule:scrims.availableOfRoster", {
amount: fit.fit.availableCount,
total: fit.roster.length,
})}
</span>
</SendouButton>
}
>
<div className={styles.popover}>
<AvailabilityWindowText window={fit.fit.window} />
<ScrimAvailabilityRows fit={fit} />
</div>
</SendouPopover>
);
}
/** The roster's members and how each of them relates to the scrim being requested. */
export function ScrimAvailabilityRows({ fit }: { fit: ScrimRosterFit }) {
const entryByUserId = new Map(
fit.fit.entries.map((entry) => [entry.userId, entry]),
);
return (
<div className={styles.rowsSection}>
<ul className={styles.rows}>
{fit.roster.map((member) => (
<AvailabilityMemberRow
key={member.id}
user={member}
entry={entryByUserId.get(member.id)}
/>
))}
</ul>
<AvailabilitySummary statuses={rosterStatuses(fit)} />
</div>
);
}
/** How each of the roster relates to the scrim, in roster order. */
function rosterStatuses(fit: ScrimRosterFit) {
const entryByUserId = new Map(
fit.fit.entries.map((entry) => [entry.userId, entry]),
);
return fit.roster.map((member) =>
availabilityRowStatus(entryByUserId.get(member.id)),
);
}

View File

@@ -37,6 +37,7 @@ import { scrimsActionSchema } from "../scrims-schemas";
import { scrimsSearchParams } from "../scrims-search-params";
import type { ScrimPost, ScrimPostRequest } from "../scrims-types";
import { formatFlexTimeDisplay } from "../scrims-utils";
import { ScrimFitStripe } from "./ScrimAvailability";
import styles from "./ScrimCard.module.css";
import { ScrimRequestModal } from "./ScrimRequestModal";
@@ -142,6 +143,10 @@ export function ScrimPostCard({
{post.text ? <ScrimExpandableText text={post.text} /> : null}
{action === "REQUEST" || action === "VIEW_REQUEST" ? (
<ScrimFitStripe post={post} />
) : null}
<div
className={clsx(styles.footer, isFilteredOut && styles.filteredFooter)}
>

View File

@@ -3,16 +3,21 @@ import { useLoaderData } from "react-router";
import { Divider } from "~/components/Divider";
import { SendouDialog } from "~/components/elements/Dialog";
import { FormMessage } from "~/components/FormMessage";
import { AvailabilityWindowText } from "~/features/availability/components/RegistrationAvailabilityPanel";
import type { CustomFieldRenderProps } from "~/form";
import { SendouForm } from "~/form/SendouForm";
import { SendouForm, useFormValue } from "~/form/SendouForm";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { nullFilledArray } from "~/utils/arrays";
import { databaseTimestampToDate } from "~/utils/dates";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
} from "~/utils/dates";
import type { loader as scrimsLoader } from "../loaders/scrims.server";
import { SCRIM } from "../scrims-constants";
import { scrimRequestFormSchema } from "../scrims-schemas";
import type { ScrimPost } from "../scrims-types";
import { generateTimeOptions } from "../scrims-utils";
import { ScrimAvailabilityRows, useRosterFit } from "./ScrimAvailability";
import { WithFormField } from "./WithFormField";
export function ScrimRequestModal({
@@ -77,6 +82,7 @@ export function ScrimRequestModal({
{post.rangeEndsAt ? (
<FormField name="at" options={timeOptions} />
) : null}
<ScrimRequestAvailability post={post} />
<FormField name="message" />
<FormMessage type="info">{t("scrims:autoCancelInfo")}</FormMessage>
</>
@@ -85,3 +91,32 @@ export function ScrimRequestModal({
</SendouDialog>
);
}
/** How the roster the request is made with fits the exact slot being asked for. */
function ScrimRequestAvailability({ post }: { post: ScrimPost }) {
const { t } = useTranslation(["schedule"]);
const from = useFormValue("from") as
| { mode: "TEAM"; teamId: number }
| { mode: "PICKUP" }
| null;
const at = useFormValue("at") as string | null;
const teamId = from?.mode === "TEAM" ? from.teamId : undefined;
const fit = useRosterFit({
post,
teamId,
at: at ? dateToDatabaseTimestamp(new Date(Number(at))) : null,
});
if (teamId === undefined || !fit) return null;
return (
<div className="stack sm">
<div className="text-sm font-semi-bold">
{t("schedule:registration.title")}
</div>
<AvailabilityWindowText window={fit.fit.window} />
<ScrimAvailabilityRows fit={fit} />
</div>
);
}

View File

@@ -20,7 +20,6 @@ import {
import trackStyles from "~/features/availability/components/ScheduleTracks.module.css";
import * as Availability from "~/features/availability/core/Availability";
import type { RosterScheduleData } from "~/features/availability/core/RosterSchedule.server";
import { getMemberRoleType } from "~/features/team/team-utils";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import {
databaseTimestampToDate,
@@ -382,13 +381,10 @@ function rosterUserIds({
const team = teams.find((team) => team.id === from.teamId);
if (!team) return [];
const players = team.members.filter(
(member) => getMemberRoleType(member) !== "OTHER",
);
const members =
players.length >= SCRIM.MIN_MEMBERS_PER_TEAM ? players : team.members;
return R.unique([viewerId, ...members.map((member) => member.id)]);
return R.unique([
viewerId,
...Scrim.teamPlayers(team.members).map((member) => member.id),
]);
}
function slotsOfDay({

View File

@@ -7,8 +7,10 @@ import {
isTrackingLocked,
participantIdsListFromAccepted,
pickableSlots,
rosterFit,
sideDisplayName,
sideOfUser,
teamPlayers,
} from "./Scrim";
const HOUR = 60 * 60;
@@ -661,3 +663,110 @@ describe("pickableSlots", () => {
expect(pickableSlots({ members, minPlayers: 4 })).toEqual([]);
});
});
describe("teamPlayers", () => {
const player = { id: 1, role: "FRONTLINE" as const, roleType: null };
const coach = { id: 2, role: "COACH" as const, roleType: null };
test("leaves the non-players out", () => {
const members = [
player,
{ ...coach, id: 3 },
...[4, 5, 6].map((id) => ({ ...player, id })),
];
expect(teamPlayers(members).map((member) => member.id)).toEqual([
1, 4, 5, 6,
]);
});
test("keeps everyone when the players alone could not field a team", () => {
const members = [player, { ...player, id: 2 }, { ...player, id: 3 }, coach];
expect(teamPlayers(members)).toHaveLength(4);
});
});
describe("rosterFit", () => {
const evening = (hours: number) => hours * HOUR;
const free = (userId: number, startsAt: number, endsAt: number) => ({
userId,
reported: true,
ranges: [{ startsAt, endsAt }],
busy: [],
});
test("measures the fit at the start the most of the roster is free for", () => {
const members = [
free(1, evening(18), evening(23)),
free(2, evening(18), evening(23)),
free(3, evening(18), evening(23)),
free(4, evening(20), evening(23)),
];
const fit = rosterFit({
starts: [evening(18), evening(19), evening(20)],
members,
});
expect(fit?.startsAt).toBe(evening(20));
expect(fit?.availableCount).toBe(4);
expect(fit?.window).toEqual({
startsAt: evening(20),
endsAt: evening(22),
});
});
test("gives the earliest of equally good starts", () => {
const members = [1, 2, 3, 4].map((userId) =>
free(userId, evening(18), evening(23)),
);
expect(
rosterFit({ starts: [evening(18), evening(19)], members })?.startsAt,
).toBe(evening(18));
});
test("leaves a member free for only part of the scrim out of the count", () => {
const members = [
free(1, evening(18), evening(23)),
free(2, evening(18), evening(19)),
];
const fit = rosterFit({ starts: [evening(18)], members });
expect(fit?.availableCount).toBe(1);
expect(fit?.entries[1].availability.status).toBe("partial");
});
test("reports a member committed elsewhere as busy", () => {
const members = [
{
...free(1, evening(18), evening(23)),
busy: [
{
startsAt: evening(19),
endsAt: evening(21),
type: "tournament" as const,
name: "ITZ",
},
],
},
];
expect(
rosterFit({ starts: [evening(18)], members })?.entries[0].availability
.status,
).toBe("busy");
});
test("returns null when nobody filled in the week", () => {
const members = [1, 2].map((userId) => ({
...free(userId, evening(18), evening(23)),
reported: false,
ranges: [],
}));
expect(rosterFit({ starts: [evening(18)], members })).toBeNull();
});
});

View File

@@ -6,8 +6,15 @@ import type {
MemberAvailability,
PlayableWindowTier,
TimeRange,
WindowAvailabilityEntry,
WindowSchedule,
} from "~/features/availability/availability-types";
import * as Availability from "~/features/availability/core/Availability";
import type {
MemberRole,
MemberRoleType,
} from "~/features/team/team-constants";
import { getMemberRoleType } from "~/features/team/team-utils";
import { databaseTimestampToDate } from "~/utils/dates";
import { logger } from "~/utils/logger";
import {
@@ -266,6 +273,86 @@ export function pickableSlots({
});
}
/**
* The members a scrim is played with: the team's players, or its whole roster
* when there are not enough players on it to field a team.
*/
export function teamPlayers<
T extends { role: MemberRole | null; roleType: MemberRoleType | null },
>(members: Array<T>): Array<T> {
const players = members.filter(
(member) => getMemberRoleType(member) !== "OTHER",
);
return players.length >= SCRIM.MIN_MEMBERS_PER_TEAM ? players : members;
}
export interface RosterFit {
/** The start the fit is measured at, the best one of those offered. */
startsAt: number;
/** The scrim played from that start, its length assumed. */
window: TimeRange;
entries: Array<WindowAvailabilityEntry>;
/** How many of the roster are free for the whole window. */
availableCount: number;
}
/**
* How well a roster fits a scrim post: the start among `starts` the most of
* them are free for, and how each member relates to a scrim played from it.
* Ties go to the earliest start.
*
* Null when nobody on the roster filled in the week the post falls in — a fit
* nothing is known about is not worth showing.
*/
export function rosterFit({
starts,
members,
}: {
starts: Array<number>;
members: Array<WindowSchedule>;
}): RosterFit | null {
if (members.length === 0 || members.every((member) => !member.reported)) {
return null;
}
const fits = starts.map((startsAt) => fitAt({ startsAt, members }));
return R.firstBy(fits, [(fit) => fit.availableCount, "desc"]) ?? null;
}
function fitAt({
startsAt,
members,
}: {
startsAt: number;
members: Array<WindowSchedule>;
}): RosterFit {
const window = {
startsAt,
endsAt: startsAt + AVAILABILITY.SCRIM_COMMITMENT_SECONDS,
};
const entries = members.map((member) => ({
userId: member.userId,
availability: Availability.availabilityInWindow({
reported: member.reported,
slots: member.ranges,
busy: member.busy,
window,
}),
}));
return {
startsAt,
window,
entries,
availableCount: entries.filter(
(entry) => entry.availability.status === "available",
).length,
};
}
/** Splits a "HH:mm" time range into segments, breaking a range that crosses midnight (e.g. 23:00 -> 01:00) into two. */
function timeRangeToSegments(start: string, end: string) {
return end < start

View File

@@ -3,12 +3,15 @@ import * as R from "remeda";
import * as AssociationsRepository from "~/features/associations/AssociationRepository.server";
import * as Association from "~/features/associations/core/Association";
import { getUser } from "~/features/auth/core/user.server";
import * as RosterSchedule from "~/features/availability/core/RosterSchedule.server";
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import * as TeamRepository from "../../team/TeamRepository.server";
import * as Scrim from "../core/Scrim";
import * as ScrimPostRepository from "../ScrimPostRepository.server";
import { scrimsSearchParams } from "../scrims-search-params";
import { dividePosts } from "../scrims-utils";
import type { ScrimPost } from "../scrims-types";
import { dividePosts, postSpan } from "../scrims-utils";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = getUser();
@@ -55,12 +58,19 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
]),
);
const dividedPosts = dividePosts(posts, user?.id);
const teams = user ? await TeamRepository.findAllByMemberUserId(user.id) : [];
return {
...(await UserCardRepository.findAllByUserIds({
userIds: cardUserIds,
})),
posts: dividePosts(posts, user?.id),
teams: user ? await TeamRepository.findAllByMemberUserId(user.id) : [],
posts: dividedPosts,
teams,
availability: await rosterAvailability({
posts: dividedPosts.neutral,
teams,
}),
filters,
canSaveAsDefault:
user != null &&
@@ -70,3 +80,35 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
),
};
};
/**
* How the viewer's teams relate to the posts they could request: the material
* the fit indicators on the post cards and in the request dialog are resolved
* from, one entry per post.
*/
async function rosterAvailability({
posts,
teams,
}: {
posts: Array<ScrimPost>;
teams: Awaited<ReturnType<typeof TeamRepository.findAllByMemberUserId>>;
}) {
const userIds = R.unique(
teams.flatMap((team) =>
Scrim.teamPlayers(team.members).map((member) => member.id),
),
);
const now = dateToDatabaseTimestamp(new Date());
return {
/** Server clock, so that the shown fit does not change on hydration. */
now,
windows: await RosterSchedule.windowSchedules({
windows: posts.map((post) => ({
id: post.id,
...postSpan({ post, now }),
})),
userIds,
}),
};
}

View File

@@ -48,7 +48,7 @@ import styles from "./scrims.module.css";
export type NewRequestFormFields = v.InferOutput<typeof newRequestSchema>;
export const handle: SendouRouteHandle = {
i18n: ["calendar", "scrims", "user", "q"],
i18n: ["calendar", "schedule", "scrims", "user", "q"],
breadcrumb: () => ({
imgPath: navIconUrl("scrims"),
href: scrimsPage(),

View File

@@ -1,10 +1,13 @@
import { describe, expect, test } from "vitest";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import {
formatFlexTimeDisplay,
generateTimeOptions,
parseLutiDivFromName,
parseMapPoolInput,
postSpan,
requestStarts,
} from "./scrims-utils";
describe("parseLutiDivFromName", () => {
@@ -323,3 +326,52 @@ describe("parseMapPoolInput", () => {
);
});
});
describe("requestStarts", () => {
const at = (time: string) =>
dateToDatabaseTimestamp(new Date(`2025-01-15T${time}:00`));
const post = { startsAt: at("19:00"), rangeEndsAt: at("20:30") };
test("offers every half hour of the post's flexibility", () => {
expect(requestStarts({ post, now: at("12:00") })).toEqual([
at("19:00"),
at("19:30"),
at("20:00"),
at("20:30"),
]);
});
test("drops the starts already gone by", () => {
expect(requestStarts({ post, now: at("19:45") })).toEqual([
at("20:00"),
at("20:30"),
]);
});
test("offers now for a post with no flexibility", () => {
expect(
requestStarts({
post: { startsAt: at("18:00"), rangeEndsAt: null },
now: at("19:00"),
}),
).toEqual([at("19:00")]);
});
test("offers now once the whole flexibility has passed", () => {
expect(requestStarts({ post, now: at("21:00") })).toEqual([at("21:00")]);
});
});
describe("postSpan", () => {
const at = (time: string) =>
dateToDatabaseTimestamp(new Date(`2025-01-15T${time}:00`));
test("reaches from the earliest start to the end of a scrim from the latest", () => {
expect(
postSpan({
post: { startsAt: at("19:00"), rangeEndsAt: at("20:30") },
now: at("12:00"),
}),
).toEqual({ startsAt: at("19:00"), endsAt: at("22:30") });
});
});

View File

@@ -1,7 +1,12 @@
import { differenceInMinutes } from "date-fns";
import * as R from "remeda";
import { AVAILABILITY } from "~/features/availability/availability-constants";
import type { TimeRange } from "~/features/availability/availability-types";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { databaseTimestampToDate } from "~/utils/dates";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
} from "~/utils/dates";
import * as Scrim from "./core/Scrim";
import { LUTI_DIVS } from "./scrims-constants";
import type { LutiDiv, ScrimPost } from "./scrims-types";
@@ -74,6 +79,50 @@ export const serializeLutiDiv = (div: LutiDiv): number => {
return Number(div);
};
/**
* The starts a request for the post can still be made for: its start, the half
* hours inside its start-time flexibility and the end of that flexibility,
* with the ones already past dropped. A post with no flexibility left offers
* `now` — "looking now" is what it means.
*/
export function requestStarts({
post,
now,
}: {
post: Pick<ScrimPost, "startsAt" | "rangeEndsAt">;
now: number;
}): Array<number> {
const starts = post.rangeEndsAt
? generateTimeOptions(
databaseTimestampToDate(post.startsAt),
databaseTimestampToDate(post.rangeEndsAt),
).map((timestamp) => dateToDatabaseTimestamp(new Date(timestamp)))
: [post.startsAt];
const upcoming = starts.filter((startsAt) => startsAt >= now);
return upcoming.length > 0 ? upcoming : [now];
}
/**
* The whole span the post's scrim could take up: from the earliest start still
* on offer to the end of a scrim played from the latest one.
*/
export function postSpan({
post,
now,
}: {
post: Pick<ScrimPost, "startsAt" | "rangeEndsAt">;
now: number;
}): TimeRange {
const starts = requestStarts({ post, now });
return {
startsAt: starts[0],
endsAt: starts[starts.length - 1] + AVAILABILITY.SCRIM_COMMITMENT_SECONDS,
};
}
export function generateTimeOptions(startDate: Date, endDate: Date): number[] {
const timestamps = new Set<number>();

View File

@@ -40,6 +40,7 @@ export class ScrimsPage {
limitedVisibilityPopover: page.getByTestId("limited-visibility-popover"),
tournamentPopover: page.getByTestId("tournament-popover-trigger"),
canceledLabel: page.getByText("Canceled"),
fitIndicator: page.getByTestId("scrim-fit-indicator"),
divsFilterPill: page.getByTestId("divs-filter"),
addFilterButton: page.getByTestId("add-filter-button"),
saveFiltersAsDefaultButton: page.getByTestId(
@@ -94,6 +95,11 @@ export class ScrimsPage {
await this.page.getByTestId("menu-item-divs-filter").click();
}
/** One roster member's row of the fit indicator's popover, its status in `data-status`. */
availabilityRow(userId: number) {
return this.page.getByTestId(`availability-row-${userId}`);
}
async openTab(tab: Tab) {
await this.page.getByRole("tab", { name: TAB_NAMES[tab] }).click();
}

View File

@@ -477,6 +477,62 @@ test.describe("Scrim schedule picker", () => {
});
});
test.describe("Scrim fit indicator", () => {
test("shows how much of the viewer's roster could play a post", async ({
page,
factories,
}) => {
const { memberUserIds } = await createTeamFor(factories, NZAP_TEST_ID);
const evening = nextWeekSlot(WEDNESDAY, "18:00", "23:00");
const withoutSchedule = memberUserIds[memberUserIds.length - 1];
for (const userId of memberUserIds.filter(
(userId) => userId !== withoutSchedule,
)) {
await factories.AvailabilityWeekFactory.create({
userId,
weekStartsAt: nextWeek().startsAt,
timezone: MACHINE_TIMEZONE,
slots: [evening],
});
}
await factories.ScrimPostFactory.create({
users: await createGroup(factories),
startsAt: evening.startsAt,
isScheduledForFuture: true,
});
await impersonate(page, NZAP_TEST_ID);
const scrims = new ScrimsPage(page);
await scrims.goto();
await scrims.openTab("available");
await expect(scrims.locators.fitIndicator).toContainText("3/4 available");
await scrims.locators.fitIndicator.click();
await expect(scrims.availabilityRow(NZAP_TEST_ID)).toHaveAttribute(
"data-status",
"available",
);
await expect(scrims.availabilityRow(withoutSchedule)).toHaveAttribute(
"data-status",
"unknown",
);
await page.keyboard.press("Escape");
await scrims.requestFirst();
// the same breakdown, for the slot the request would be made for
await expect(scrims.availabilityRow(NZAP_TEST_ID)).toHaveAttribute(
"data-status",
"available",
);
});
});
async function createTeamFor(factories: Factories, userId: number) {
const teammates = await factories.UserFactory.createMany(GROUP_SIZE - 1);

View File

@@ -42,5 +42,6 @@
"picker.noSchedule": "",
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": ""
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
}

View File

@@ -42,5 +42,6 @@
"picker.noSchedule": "",
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": ""
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
}

View File

@@ -42,5 +42,6 @@
"picker.noSchedule": "No schedule this week: {{users}}",
"picker.andOthers": "{{amount}} more",
"picker.legend.full": "{{players}}+ free",
"picker.legend.oneShort": "{{players}} free (sub?)"
"picker.legend.oneShort": "{{players}} free (sub?)",
"scrims.availableOfRoster": "{{amount}}/{{total}} available"
}

View File

@@ -42,5 +42,6 @@
"picker.noSchedule": "",
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": ""
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
}

View File

@@ -42,5 +42,6 @@
"picker.noSchedule": "",
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": ""
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
}

View File

@@ -42,5 +42,6 @@
"picker.noSchedule": "",
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": ""
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
}

View File

@@ -42,5 +42,6 @@
"picker.noSchedule": "",
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": ""
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
}

View File

@@ -42,5 +42,6 @@
"picker.noSchedule": "",
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": ""
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
}

View File

@@ -42,5 +42,6 @@
"picker.noSchedule": "",
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": ""
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
}

View File

@@ -42,5 +42,6 @@
"picker.noSchedule": "",
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": ""
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
}

View File

@@ -42,5 +42,6 @@
"picker.noSchedule": "",
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": ""
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
}

View File

@@ -42,5 +42,6 @@
"picker.noSchedule": "",
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": ""
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
}

View File

@@ -42,5 +42,6 @@
"picker.noSchedule": "",
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": ""
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
}

View File

@@ -42,5 +42,6 @@
"picker.noSchedule": "",
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": ""
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
}

View File

@@ -42,5 +42,6 @@
"picker.noSchedule": "",
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": ""
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
}

View File

@@ -42,5 +42,6 @@
"picker.noSchedule": "",
"picker.andOthers": "",
"picker.legend.full": "",
"picker.legend.oneShort": ""
"picker.legend.oneShort": "",
"scrims.availableOfRoster": ""
}