diff --git a/app/features/availability/availability-types.ts b/app/features/availability/availability-types.ts index 9236394ae..05ca21921 100644 --- a/app/features/availability/availability-types.ts +++ b/app/features/availability/availability-types.ts @@ -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; +} + +/** + * 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; + /** Their commitments overlapping the window. */ + busy: Array; +} diff --git a/app/features/availability/components/RegistrationAvailabilityPanel.tsx b/app/features/availability/components/RegistrationAvailabilityPanel.tsx index 4ac9b4d44..905fd3c11 100644 --- a/app/features/availability/components/RegistrationAvailabilityPanel.tsx +++ b/app/features/availability/components/RegistrationAvailabilityPanel.tsx @@ -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; -export type AvailabilityPanelEntry = NonNullable< - AvailabilityPanelData["entries"] ->[number]; +export type AvailabilityPanelEntry = WindowAvailabilityEntry; export type AvailabilityRowStatus = | AvailabilityPanelEntry["availability"]["status"] @@ -187,7 +185,7 @@ export function AvailabilityMemberRow({ {showAvailability ? : null} {showAvailability - ? entry?.notes.map((note) => ( + ? entry?.notes?.map((note) => ( {note} diff --git a/app/features/availability/core/RosterSchedule.server.test.ts b/app/features/availability/core/RosterSchedule.server.test.ts index 73b12494d..29180b6f1 100644 --- a/app/features/availability/core/RosterSchedule.server.test.ts +++ b/app/features/availability/core/RosterSchedule.server.test.ts @@ -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>, + userIds: Array = [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([]); + }); +}); diff --git a/app/features/availability/core/RosterSchedule.server.ts b/app/features/availability/core/RosterSchedule.server.ts index 9a5124840..30350fba7 100644 --- a/app/features/availability/core/RosterSchedule.server.ts +++ b/app/features/availability/core/RosterSchedule.server.ts @@ -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; + userIds: Array; +}) { + // 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, + }; + }), + })); +} diff --git a/app/features/scrims/components/ScrimAvailability.module.css b/app/features/scrims/components/ScrimAvailability.module.css new file mode 100644 index 000000000..e6e2b52d5 --- /dev/null +++ b/app/features/scrims/components/ScrimAvailability.module.css @@ -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; +} diff --git a/app/features/scrims/components/ScrimAvailability.tsx b/app/features/scrims/components/ScrimAvailability.tsx new file mode 100644 index 000000000..49acbdf57 --- /dev/null +++ b/app/features/scrims/components/ScrimAvailability.tsx @@ -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; + 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(); + + 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 ( + + {fit.team.name} + + + {t("schedule:scrims.availableOfRoster", { + amount: fit.fit.availableCount, + total: fit.roster.length, + })} + + + } + > +
+ + +
+
+ ); +} + +/** 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 ( +
+
    + {fit.roster.map((member) => ( + + ))} +
+ +
+ ); +} + +/** 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)), + ); +} diff --git a/app/features/scrims/components/ScrimCard.tsx b/app/features/scrims/components/ScrimCard.tsx index c5101b2ee..bc2687617 100644 --- a/app/features/scrims/components/ScrimCard.tsx +++ b/app/features/scrims/components/ScrimCard.tsx @@ -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 ? : null} + {action === "REQUEST" || action === "VIEW_REQUEST" ? ( + + ) : null} +
diff --git a/app/features/scrims/components/ScrimRequestModal.tsx b/app/features/scrims/components/ScrimRequestModal.tsx index fac497a47..bee2981bf 100644 --- a/app/features/scrims/components/ScrimRequestModal.tsx +++ b/app/features/scrims/components/ScrimRequestModal.tsx @@ -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 ? ( ) : null} + {t("scrims:autoCancelInfo")} @@ -85,3 +91,32 @@ export function ScrimRequestModal({ ); } + +/** 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 ( +
+
+ {t("schedule:registration.title")} +
+ + +
+ ); +} diff --git a/app/features/scrims/components/ScrimSchedulePicker.tsx b/app/features/scrims/components/ScrimSchedulePicker.tsx index fdbe87c7b..401a83d2a 100644 --- a/app/features/scrims/components/ScrimSchedulePicker.tsx +++ b/app/features/scrims/components/ScrimSchedulePicker.tsx @@ -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({ diff --git a/app/features/scrims/core/Scrim.test.ts b/app/features/scrims/core/Scrim.test.ts index c1f733411..612ffab66 100644 --- a/app/features/scrims/core/Scrim.test.ts +++ b/app/features/scrims/core/Scrim.test.ts @@ -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(); + }); +}); diff --git a/app/features/scrims/core/Scrim.ts b/app/features/scrims/core/Scrim.ts index 936a1b449..bba4cbf5e 100644 --- a/app/features/scrims/core/Scrim.ts +++ b/app/features/scrims/core/Scrim.ts @@ -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): Array { + 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; + /** 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; + members: Array; +}): 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; +}): 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 diff --git a/app/features/scrims/loaders/scrims.server.ts b/app/features/scrims/loaders/scrims.server.ts index 70d578157..5bd14300b 100644 --- a/app/features/scrims/loaders/scrims.server.ts +++ b/app/features/scrims/loaders/scrims.server.ts @@ -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; + teams: Awaited>; +}) { + 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, + }), + }; +} diff --git a/app/features/scrims/routes/scrims.tsx b/app/features/scrims/routes/scrims.tsx index 843f9e49c..80adeb3cd 100644 --- a/app/features/scrims/routes/scrims.tsx +++ b/app/features/scrims/routes/scrims.tsx @@ -48,7 +48,7 @@ import styles from "./scrims.module.css"; export type NewRequestFormFields = v.InferOutput; export const handle: SendouRouteHandle = { - i18n: ["calendar", "scrims", "user", "q"], + i18n: ["calendar", "schedule", "scrims", "user", "q"], breadcrumb: () => ({ imgPath: navIconUrl("scrims"), href: scrimsPage(), diff --git a/app/features/scrims/scrims-utils.test.ts b/app/features/scrims/scrims-utils.test.ts index 914324c5e..a947d24ad 100644 --- a/app/features/scrims/scrims-utils.test.ts +++ b/app/features/scrims/scrims-utils.test.ts @@ -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") }); + }); +}); diff --git a/app/features/scrims/scrims-utils.ts b/app/features/scrims/scrims-utils.ts index 381dd6f33..962bc0481 100644 --- a/app/features/scrims/scrims-utils.ts +++ b/app/features/scrims/scrims-utils.ts @@ -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; + now: number; +}): Array { + 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; + 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(); diff --git a/e2e/pages/scrims/scrims-page.ts b/e2e/pages/scrims/scrims-page.ts index fd3150f6f..daccc5444 100644 --- a/e2e/pages/scrims/scrims-page.ts +++ b/e2e/pages/scrims/scrims-page.ts @@ -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(); } diff --git a/e2e/scrims.spec.ts b/e2e/scrims.spec.ts index 3d8237f6f..5d04f282b 100644 --- a/e2e/scrims.spec.ts +++ b/e2e/scrims.spec.ts @@ -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); diff --git a/locales/da/schedule.json b/locales/da/schedule.json index b52219448..6756b6f10 100644 --- a/locales/da/schedule.json +++ b/locales/da/schedule.json @@ -42,5 +42,6 @@ "picker.noSchedule": "", "picker.andOthers": "", "picker.legend.full": "", - "picker.legend.oneShort": "" + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" } diff --git a/locales/de/schedule.json b/locales/de/schedule.json index b52219448..6756b6f10 100644 --- a/locales/de/schedule.json +++ b/locales/de/schedule.json @@ -42,5 +42,6 @@ "picker.noSchedule": "", "picker.andOthers": "", "picker.legend.full": "", - "picker.legend.oneShort": "" + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" } diff --git a/locales/en/schedule.json b/locales/en/schedule.json index f5f9127d4..c259be6c3 100644 --- a/locales/en/schedule.json +++ b/locales/en/schedule.json @@ -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" } diff --git a/locales/es-ES/schedule.json b/locales/es-ES/schedule.json index b52219448..6756b6f10 100644 --- a/locales/es-ES/schedule.json +++ b/locales/es-ES/schedule.json @@ -42,5 +42,6 @@ "picker.noSchedule": "", "picker.andOthers": "", "picker.legend.full": "", - "picker.legend.oneShort": "" + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" } diff --git a/locales/es-US/schedule.json b/locales/es-US/schedule.json index b52219448..6756b6f10 100644 --- a/locales/es-US/schedule.json +++ b/locales/es-US/schedule.json @@ -42,5 +42,6 @@ "picker.noSchedule": "", "picker.andOthers": "", "picker.legend.full": "", - "picker.legend.oneShort": "" + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" } diff --git a/locales/fr-CA/schedule.json b/locales/fr-CA/schedule.json index b52219448..6756b6f10 100644 --- a/locales/fr-CA/schedule.json +++ b/locales/fr-CA/schedule.json @@ -42,5 +42,6 @@ "picker.noSchedule": "", "picker.andOthers": "", "picker.legend.full": "", - "picker.legend.oneShort": "" + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" } diff --git a/locales/fr-EU/schedule.json b/locales/fr-EU/schedule.json index b52219448..6756b6f10 100644 --- a/locales/fr-EU/schedule.json +++ b/locales/fr-EU/schedule.json @@ -42,5 +42,6 @@ "picker.noSchedule": "", "picker.andOthers": "", "picker.legend.full": "", - "picker.legend.oneShort": "" + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" } diff --git a/locales/he/schedule.json b/locales/he/schedule.json index b52219448..6756b6f10 100644 --- a/locales/he/schedule.json +++ b/locales/he/schedule.json @@ -42,5 +42,6 @@ "picker.noSchedule": "", "picker.andOthers": "", "picker.legend.full": "", - "picker.legend.oneShort": "" + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" } diff --git a/locales/it/schedule.json b/locales/it/schedule.json index b52219448..6756b6f10 100644 --- a/locales/it/schedule.json +++ b/locales/it/schedule.json @@ -42,5 +42,6 @@ "picker.noSchedule": "", "picker.andOthers": "", "picker.legend.full": "", - "picker.legend.oneShort": "" + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" } diff --git a/locales/ja/schedule.json b/locales/ja/schedule.json index b52219448..6756b6f10 100644 --- a/locales/ja/schedule.json +++ b/locales/ja/schedule.json @@ -42,5 +42,6 @@ "picker.noSchedule": "", "picker.andOthers": "", "picker.legend.full": "", - "picker.legend.oneShort": "" + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" } diff --git a/locales/ko/schedule.json b/locales/ko/schedule.json index b52219448..6756b6f10 100644 --- a/locales/ko/schedule.json +++ b/locales/ko/schedule.json @@ -42,5 +42,6 @@ "picker.noSchedule": "", "picker.andOthers": "", "picker.legend.full": "", - "picker.legend.oneShort": "" + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" } diff --git a/locales/nl/schedule.json b/locales/nl/schedule.json index b52219448..6756b6f10 100644 --- a/locales/nl/schedule.json +++ b/locales/nl/schedule.json @@ -42,5 +42,6 @@ "picker.noSchedule": "", "picker.andOthers": "", "picker.legend.full": "", - "picker.legend.oneShort": "" + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" } diff --git a/locales/pl/schedule.json b/locales/pl/schedule.json index b52219448..6756b6f10 100644 --- a/locales/pl/schedule.json +++ b/locales/pl/schedule.json @@ -42,5 +42,6 @@ "picker.noSchedule": "", "picker.andOthers": "", "picker.legend.full": "", - "picker.legend.oneShort": "" + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" } diff --git a/locales/pt-BR/schedule.json b/locales/pt-BR/schedule.json index b52219448..6756b6f10 100644 --- a/locales/pt-BR/schedule.json +++ b/locales/pt-BR/schedule.json @@ -42,5 +42,6 @@ "picker.noSchedule": "", "picker.andOthers": "", "picker.legend.full": "", - "picker.legend.oneShort": "" + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" } diff --git a/locales/ru/schedule.json b/locales/ru/schedule.json index b52219448..6756b6f10 100644 --- a/locales/ru/schedule.json +++ b/locales/ru/schedule.json @@ -42,5 +42,6 @@ "picker.noSchedule": "", "picker.andOthers": "", "picker.legend.full": "", - "picker.legend.oneShort": "" + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" } diff --git a/locales/zh/schedule.json b/locales/zh/schedule.json index b52219448..6756b6f10 100644 --- a/locales/zh/schedule.json +++ b/locales/zh/schedule.json @@ -42,5 +42,6 @@ "picker.noSchedule": "", "picker.andOthers": "", "picker.legend.full": "", - "picker.legend.oneShort": "" + "picker.legend.oneShort": "", + "scrims.availableOfRoster": "" }