diff --git a/app/features/availability/components/ScheduleTracks.module.css b/app/features/availability/components/ScheduleTracks.module.css new file mode 100644 index 000000000..5f8d41b4e --- /dev/null +++ b/app/features/availability/components/ScheduleTracks.module.css @@ -0,0 +1,161 @@ +.container { + container: tracks / inline-size; +} + +.tracks { + display: none; +} + +.list { + display: flex; + flex-direction: column; +} + +@container tracks (min-width: 40rem) { + .tracks { + display: grid; + grid-template-columns: max-content minmax(0, 1fr) max-content; + column-gap: var(--s-3); + row-gap: var(--s-2); + align-items: center; + } + + .list { + display: none; + } +} + +.axisToggle { + display: flex; + align-items: center; + gap: 2px; + align-self: end; + padding: 0; + background: none; + border: none; + font-size: var(--font-3xs); + line-height: 1rem; + color: var(--color-text-high); + white-space: nowrap; + cursor: pointer; + + &:hover { + color: var(--color-text); + } + + &:focus-visible { + outline: var(--focus-ring); + } + + &.axisLead { + justify-self: start; + } + + &.axisTrail { + justify-self: end; + } +} + +.axis { + position: relative; + height: 1rem; + align-self: end; + + & .axisLabel { + position: absolute; + top: 0; + transform: translateX(-50%); + font-size: var(--font-3xs); + line-height: 1rem; + color: var(--color-text-high); + white-space: nowrap; + + &.axisLabelFirst { + transform: translateX(0); + } + + &.axisLabelLast { + transform: translateX(-100%); + } + } +} + +.dayLabel { + display: flex; + align-items: center; + gap: var(--s-1); + font-size: var(--font-xs); + font-weight: var(--weight-semi); + white-space: nowrap; +} + +.noteFlag { + color: var(--color-text-accent); + flex-shrink: 0; +} + +.track { + position: relative; + height: 32px; + background-color: var(--color-bg-high); + border-radius: var(--radius-field); + touch-action: none; + user-select: none; +} + +.tick { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background-color: var(--color-bg-higher); + pointer-events: none; + + &.tickMidnight { + background-color: var(--color-border); + } +} + +.commitment { + position: absolute; + top: 3px; + bottom: 3px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + background: repeating-linear-gradient( + -45deg, + var(--color-bg-higher) 0 5px, + transparent 5px 10px + ); + border: 1px dashed var(--color-border-high); + border-radius: var(--radius-field); + pointer-events: none; + z-index: 2; +} + +.commitmentName { + 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); +} + +.commitmentChip { + padding: var(--s-0-5) var(--s-2); + background: repeating-linear-gradient( + -45deg, + var(--color-bg-higher) 0 5px, + transparent 5px 10px + ); + border: 1px dashed var(--color-border-high); + border-radius: var(--radius-full); + font-size: var(--font-xs); + color: var(--color-text-high); +} diff --git a/app/features/availability/components/ScheduleTracks.tsx b/app/features/availability/components/ScheduleTracks.tsx new file mode 100644 index 000000000..9d07fa578 --- /dev/null +++ b/app/features/availability/components/ScheduleTracks.tsx @@ -0,0 +1,199 @@ +import clsx from "clsx"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; +import { AVAILABILITY } from "../availability-constants"; +import type { DayTimeRange } from "../availability-types"; +import styles from "./ScheduleTracks.module.css"; + +const AXIS_LABEL_EVERY_HOURS = 2; +const MINUTES_IN_HOUR = 60; +/** However little is on the tracks, a compact window still reads as a stretch of a day. */ +const MIN_FITTED_SPAN_MINUTES = 4 * MINUTES_IN_HOUR; + +export type ClockWindow = ReturnType; + +/** + * The hours the day tracks put on screen and how a range of a day maps onto + * them. Defaults to the evening hours people play in, the expanders widening + * it towards the morning on either end. + * + * `fitTo` compacts it around what is actually on the tracks instead, for views + * that only show availability: the width then goes to the bars and their + * labels rather than to hours nobody is free in. The expanders still open the + * full day either way. + */ +export function useClockWindow({ + fitTo, +}: { + /** Everything drawn on the tracks, in minutes from their own day's midnight. */ + fitTo?: Array; +} = {}) { + const [earlierShown, setEarlierShown] = React.useState(false); + const [laterShown, setLaterShown] = React.useState(false); + + const fitted = fittedWindow(fitTo); + const defaultStart = fitted?.start ?? AVAILABILITY.TRACK_START_MINUTES; + const defaultEnd = fitted?.end ?? AVAILABILITY.TRACK_END_MINUTES; + + const trackStart = earlierShown + ? Math.min(AVAILABILITY.TRACK_EARLIER_START_MINUTES, defaultStart) + : defaultStart; + const trackEnd = laterShown + ? Math.max(AVAILABILITY.TRACK_LATER_END_MINUTES, defaultEnd) + : defaultEnd; + + const pct = (minutes: number) => + ((Math.min(Math.max(minutes, trackStart), trackEnd) - trackStart) / + (trackEnd - trackStart)) * + 100; + + const hours: Array = []; + for ( + let hour = trackStart / MINUTES_IN_HOUR; + hour <= trackEnd / MINUTES_IN_HOUR; + hour += AXIS_LABEL_EVERY_HOURS + ) { + hours.push(hour); + } + + return { + trackStart, + trackEnd, + hours, + earlierShown, + setEarlierShown, + laterShown, + setLaterShown, + pct, + barStyle: (range: DayTimeRange) => ({ + left: `${pct(range.start)}%`, + width: `${pct(range.end) - pct(range.start)}%`, + }), + }; +} + +/** + * Whole hours around everything on the tracks, the span rounded up so that a + * label lands on both edges. Null when there is nothing to fit, leaving the + * default window in place. + */ +function fittedWindow(ranges?: Array) { + if (!ranges || ranges.length === 0) return null; + + const start = + Math.floor( + Math.min(...ranges.map((range) => range.start)) / MINUTES_IN_HOUR, + ) * MINUTES_IN_HOUR; + const end = + Math.ceil(Math.max(...ranges.map((range) => range.end)) / MINUTES_IN_HOUR) * + MINUTES_IN_HOUR; + + const labelStep = AXIS_LABEL_EVERY_HOURS * MINUTES_IN_HOUR; + const span = Math.max(end - start, MIN_FITTED_SPAN_MINUTES); + + return { start, end: start + Math.ceil(span / labelStep) * labelStep }; +} + +/** The hour labels above the day tracks, with the expanders widening the clock window. */ +export function ClockAxis({ + clockWindow, + dayStartsAt, +}: { + clockWindow: ClockWindow; + /** Midnight of any of the shown days, the hour labels are read off it. */ + dayStartsAt: Date; +}) { + const { t } = useTranslation(["schedule"]); + const { formatter } = useDateTimeFormat({ hour: "numeric" }); + + const hourAt = (hour: number) => + new Date(dayStartsAt.getTime() + hour * MINUTES_IN_HOUR * 60 * 1000); + + return ( + <> + +
+ {clockWindow.hours.map((hour) => ( + + {formatter.format(hourAt(hour))} + + ))} +
+ + + ); +} + +/** The hour gridlines of one day track, midnight drawn stronger than the rest. */ +export function TrackTicks({ clockWindow }: { clockWindow: ClockWindow }) { + return clockWindow.hours + .filter( + (hour) => + hour * MINUTES_IN_HOUR > clockWindow.trackStart && + hour * MINUTES_IN_HOUR < clockWindow.trackEnd, + ) + .map((hour) => ( +
+ )); +} + +/** A commitment on a day track: a hatched block naming what the time is taken by. */ +export function TrackCommitment({ + clockWindow, + range, + name, +}: { + clockWindow: ClockWindow; + range: DayTimeRange; + name: string; +}) { + return ( +
+ {name} +
+ ); +} diff --git a/app/features/availability/components/WeekAvailabilityEditor.module.css b/app/features/availability/components/WeekAvailabilityEditor.module.css index a08c8f9de..6c79e1c51 100644 --- a/app/features/availability/components/WeekAvailabilityEditor.module.css +++ b/app/features/availability/components/WeekAvailabilityEditor.module.css @@ -1,5 +1,5 @@ -.container { - container: editor / inline-size; +.paintable { + cursor: crosshair; } .editor { @@ -8,121 +8,6 @@ gap: var(--s-3); } -.tracks { - display: none; -} - -.list { - display: flex; - flex-direction: column; -} - -@container editor (min-width: 40rem) { - .tracks { - display: grid; - grid-template-columns: max-content minmax(0, 1fr) max-content; - column-gap: var(--s-3); - row-gap: var(--s-2); - align-items: center; - } - - .list { - display: none; - } -} - -.axisToggle { - display: flex; - align-items: center; - gap: 2px; - align-self: end; - padding: 0; - background: none; - border: none; - font-size: var(--font-3xs); - line-height: 1rem; - color: var(--color-text-high); - white-space: nowrap; - cursor: pointer; - - &:hover { - color: var(--color-text); - } - - &:focus-visible { - outline: var(--focus-ring); - } -} - -.axisLead { - justify-self: start; -} - -.axisTrail { - justify-self: end; -} - -.axis { - position: relative; - height: 1rem; - align-self: end; - - & .axisLabel { - position: absolute; - top: 0; - transform: translateX(-50%); - font-size: var(--font-3xs); - line-height: 1rem; - color: var(--color-text-high); - white-space: nowrap; - - &.axisLabelFirst { - transform: translateX(0); - } - - &.axisLabelLast { - transform: translateX(-100%); - } - } -} - -.dayLabel { - display: flex; - align-items: center; - gap: var(--s-1); - font-size: var(--font-xs); - font-weight: var(--weight-semi); - white-space: nowrap; -} - -.noteFlag { - color: var(--color-text-accent); - flex-shrink: 0; -} - -.track { - position: relative; - height: 32px; - background-color: var(--color-bg-high); - border-radius: var(--radius-field); - touch-action: none; - cursor: crosshair; - user-select: none; -} - -.tick { - position: absolute; - top: 0; - bottom: 0; - width: 1px; - background-color: var(--color-bg-higher); - pointer-events: none; - - &.tickMidnight { - background-color: var(--color-border); - } -} - .bar { container: bar / inline-size; position: absolute; @@ -211,37 +96,6 @@ } } -.commitment { - position: absolute; - top: 3px; - bottom: 3px; - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - background: repeating-linear-gradient( - -45deg, - var(--color-bg-higher) 0 5px, - transparent 5px 10px - ); - border: 1px dashed var(--color-border-high); - border-radius: var(--radius-field); - pointer-events: none; - z-index: 2; -} - -.commitmentName { - 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); -} - .liveLabel { position: absolute; bottom: calc(100% + 4px); @@ -320,19 +174,6 @@ } } -.commitmentChip { - padding: var(--s-0-5) var(--s-2); - background: repeating-linear-gradient( - -45deg, - var(--color-bg-higher) 0 5px, - transparent 5px 10px - ); - border: 1px dashed var(--color-border-high); - border-radius: var(--radius-full); - font-size: var(--font-xs); - color: var(--color-text-high); -} - .addChip { display: inline-flex; align-items: center; diff --git a/app/features/availability/components/WeekAvailabilityEditor.tsx b/app/features/availability/components/WeekAvailabilityEditor.tsx index e45fb4f30..972cc6e6b 100644 --- a/app/features/availability/components/WeekAvailabilityEditor.tsx +++ b/app/features/availability/components/WeekAvailabilityEditor.tsx @@ -1,12 +1,5 @@ import clsx from "clsx"; -import { - ChevronLeft, - ChevronRight, - Flag, - Plus, - SquarePen, - Trash, -} from "lucide-react"; +import { Flag, Plus, SquarePen, Trash } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { SendouButton } from "~/components/elements/Button"; @@ -23,10 +16,16 @@ import type { EditorCommitment, } from "../availability-types"; import * as Availability from "../core/Availability"; +import { + ClockAxis, + TrackCommitment, + TrackTicks, + useClockWindow, +} from "./ScheduleTracks"; +import trackStyles from "./ScheduleTracks.module.css"; import styles from "./WeekAvailabilityEditor.module.css"; const MOVE_THRESHOLD_PX = 4; -const AXIS_LABEL_EVERY_HOURS = 2; type Gesture = | { @@ -90,16 +89,15 @@ export function WeekAvailabilityEditor({ weekday: "short", day: "numeric", }); - const { formatter: hourFormatter } = useDateTimeFormat({ hour: "numeric" }); const { formatter: timeFormatter } = useDateTimeFormat({ hour: "numeric", minute: "2-digit", }); + const clockWindow = useClockWindow(); + const { trackStart, trackEnd, pct, barStyle } = clockWindow; const [gesture, setGesture] = React.useState(null); const gestureRef = React.useRef(null); - const [earlierShown, setEarlierShown] = React.useState(false); - const [laterShown, setLaterShown] = React.useState(false); const [openDayDate, setOpenDayDate] = React.useState(null); const [openDayAddRow, setOpenDayAddRow] = React.useState(false); const popoverAnchorRef = React.useRef(null); @@ -107,28 +105,11 @@ export function WeekAvailabilityEditor({ const trackRefs = React.useRef>([]); const suppressClickRef = React.useRef(false); - const trackStart = earlierShown - ? AVAILABILITY.TRACK_EARLIER_START_MINUTES - : AVAILABILITY.TRACK_START_MINUTES; - const trackEnd = laterShown - ? AVAILABILITY.TRACK_LATER_END_MINUTES - : AVAILABILITY.TRACK_END_MINUTES; - const wallsOf = (date: string) => commitments .filter((commitment) => commitment.date === date) .map((commitment) => commitment.range); - const pct = (minutes: number) => - ((Math.min(Math.max(minutes, trackStart), trackEnd) - trackStart) / - (trackEnd - trackStart)) * - 100; - - const barStyle = (range: DayTimeRange) => ({ - left: `${pct(range.start)}%`, - width: `${pct(range.end) - pct(range.start)}%`, - }); - const dateAt = (date: string, minutes: number) => { const [year, month, day] = date.split("-").map(Number); @@ -427,15 +408,6 @@ export function WeekAvailabilityEditor({ openDayEditor(date, event.currentTarget); }; - const axisHours: Array = []; - for ( - let hour = trackStart / 60; - hour <= trackEnd / 60; - hour += AXIS_LABEL_EVERY_HOURS - ) { - axisHours.push(hour); - } - const openDay = value.find((day) => day.date === openDayDate); const dayRow = (day: AvailabilityEditorDay, dayIndex: number) => { @@ -461,43 +433,30 @@ export function WeekAvailabilityEditor({ return ( -
+
{dayLabelText(day)} {day.note ? ( - + ) : null}
{ trackRefs.current[dayIndex] = element; }} - className={styles.track} + className={clsx(trackStyles.track, styles.paintable)} onPointerDown={handleTrackPointerDown(dayIndex)} onPointerMove={handleGestureMove} onPointerUp={handleGestureEnd} onPointerCancel={handleGestureCancel} > - {axisHours - .filter((hour) => hour * 60 > trackStart && hour * 60 < trackEnd) - .map((hour) => ( -
- ))} + {dayCommitments.map((commitment) => ( -
- {commitment.name} -
+ clockWindow={clockWindow} + range={commitment.range} + name={commitment.name} + /> ))} {day.ranges.map((range) => { const isDragged = @@ -586,50 +545,16 @@ export function WeekAvailabilityEditor({ }; return ( -
+
-
- -
- {axisHours.map((hour) => ( - - {hourFormatter.format(dateAt(value[0].date, hour * 60))} - - ))} -
- +
+ {value.map((day, dayIndex) => dayRow(day, dayIndex))}
-
+
{value.map((day) => { const dayCommitments = commitments.filter( (commitment) => commitment.date === day.date, @@ -654,7 +579,7 @@ export function WeekAvailabilityEditor({ {dayCommitments.map((commitment) => ( {commitment.name} ·{" "} {rangeText(day.date, commitment.range)} @@ -673,7 +598,11 @@ export function WeekAvailabilityEditor({
{day.note ? (
- + {day.note}
) : null} diff --git a/app/features/availability/core/RosterSchedule.server.test.ts b/app/features/availability/core/RosterSchedule.server.test.ts new file mode 100644 index 000000000..73b12494d --- /dev/null +++ b/app/features/availability/core/RosterSchedule.server.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import * as AvailabilityWeekFactory from "~/db/seed/factories/AvailabilityWeekFactory"; +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 * as Availability from "./Availability"; +import * as RosterSchedule from "./RosterSchedule.server"; + +const users = UserFactory.pool(); +const memberId = () => users.id(1); +const teammateId = () => users.id(2); + +const TIMEZONE = "Europe/Helsinki"; +const HOUR = 60 * 60; + +const currentWeekStartsAt = () => + Availability.weekStartsAt(new Date(), TIMEZONE); + +const dataOf = (userIds: Array) => + RosterSchedule.rosterScheduleData({ userIds, timezone: TIMEZONE }); + +const memberOf = async (userId: number) => + (await dataOf([userId])).members.find((member) => member.userId === userId); + +describe("RosterSchedule.rosterScheduleData", () => { + beforeEach(async () => { + await users.create(2); + }); + + test("lays out the current and the next week as seven days each", async () => { + const { weeks } = await dataOf([memberId()]); + + expect(weeks).toHaveLength(2); + expect(weeks[0].startsAt).toBe(currentWeekStartsAt()); + expect(weeks[1].startsAt).toBe(weeks[0].endsAt); + + for (const week of weeks) { + expect(week.days).toHaveLength(7); + expect(week.days[0].startsAt).toBe(week.startsAt); + expect(week.days[6].endsAt).toBe(week.endsAt); + } + }); + + test("reports which of the weeks the member has filled in", async () => { + await AvailabilityWeekFactory.create({ + userId: memberId(), + weekStartsAt: currentWeekStartsAt(), + timezone: TIMEZONE, + }); + + expect((await memberOf(memberId()))?.reportedWeekStarts).toEqual([ + currentWeekStartsAt(), + ]); + }); + + test("cuts a commitment out of the reported availability", async () => { + const slot = { + startsAt: currentWeekStartsAt() + 18 * HOUR, + endsAt: currentWeekStartsAt() + 22 * HOUR, + }; + await AvailabilityWeekFactory.create({ + userId: memberId(), + weekStartsAt: currentWeekStartsAt(), + timezone: TIMEZONE, + slots: [slot], + }); + const team = await TeamFactory.create({ + memberUserIds: [memberId(), teammateId()], + }); + await TeamEventFactory.create({ + teamId: team.id, + authorId: memberId(), + name: "VoD review", + startsAt: slot.startsAt + HOUR, + endsAt: slot.startsAt + 2 * HOUR, + }); + + const member = await memberOf(memberId()); + + expect(member?.ranges).toEqual([ + { startsAt: slot.startsAt, endsAt: slot.startsAt + HOUR }, + { startsAt: slot.startsAt + 2 * HOUR, endsAt: slot.endsAt }, + ]); + }); + + test("returns a member with nothing reported as an empty week", async () => { + expect(await memberOf(memberId())).toEqual({ + userId: memberId(), + reportedWeekStarts: [], + ranges: [], + }); + }); +}); diff --git a/app/features/availability/core/RosterSchedule.server.ts b/app/features/availability/core/RosterSchedule.server.ts new file mode 100644 index 000000000..9a5124840 --- /dev/null +++ b/app/features/availability/core/RosterSchedule.server.ts @@ -0,0 +1,117 @@ +import { addWeeks } from "date-fns"; +import * as R from "remeda"; +import { 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 * as Availability from "./Availability"; +import * as Commitments from "./Commitments.server"; + +const DAY_SECONDS = 24 * 60 * 60; + +export type RosterScheduleData = SerializeFrom< + Awaited> +>; + +/** + * Effective availability of the given users over the reportable horizon, laid + * out as the viewer-local weeks and days the schedule surfaces render on. + * + * Which of these users make up a roster is only known in the browser (the + * scrim post form's team select, its pick-up member search), so the roster's + * shared free time is not resolved here — the members come out one by one and + * {@link Availability.playableWindows} merges the picked ones client side. + */ +export async function rosterScheduleData({ + userIds, + timezone, +}: { + userIds: Array; + timezone: string; +}) { + const now = new Date(); + const horizon = { + startsAt: Availability.weekRange(now, timezone).startsAt, + endsAt: Availability.weekRange( + addWeeks(now, AVAILABILITY.WEEK_HORIZON - 1), + timezone, + ).endsAt, + }; + + const [reportedWeeks, busyByUserId] = await Promise.all([ + AvailabilityRepository.findAllWeeksByUserIds({ userIds, ...horizon }), + Commitments.busyBlocksByUserIds({ userIds, ...horizon }), + ]); + + const weeks = R.range(0, AVAILABILITY.WEEK_HORIZON).map((weekOffset) => + weekView({ + range: Availability.weekRange(addWeeks(now, weekOffset), timezone), + timezone, + }), + ); + + return { + /** Server clock, so that the picker's cutoff of past windows renders the same before and after hydration. */ + now: dateToDatabaseTimestamp(now), + weeks, + members: userIds.map((userId) => { + const memberWeeks = reportedWeeks.filter( + (week) => week.userId === userId, + ); + const busy = busyByUserId.get(userId) ?? []; + + return { + userId, + reportedWeekStarts: weeks + .filter((week) => + memberWeeks.some( + (memberWeek) => + Math.abs(memberWeek.weekStartsAt - week.startsAt) < + AVAILABILITY.WEEK_MATCH_MAX_DISTANCE_SECONDS, + ), + ) + .map((week) => week.startsAt), + ranges: Availability.subtract( + Availability.clip( + memberWeeks.flatMap((week) => week.slots), + horizon, + ), + busy, + ), + }; + }), + }; +} + +function weekView({ range, timezone }: { range: TimeRange; timezone: string }) { + const dayStartsAt = (dayIndex: number) => + dayIndex === 7 + ? range.endsAt + : Availability.localToTimestamp({ + date: Availability.dateInTimezone( + range.startsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2, + timezone, + ), + time: "00:00", + timezone, + }); + + return { + startsAt: range.startsAt, + endsAt: range.endsAt, + weekNumber: Availability.isoWeekNumber( + range.startsAt + DAY_SECONDS / 2, + timezone, + ), + days: R.range(0, 7).map((dayIndex) => { + const startsAt = dayStartsAt(dayIndex); + + return { + startsAt, + endsAt: dayStartsAt(dayIndex + 1), + noonAt: startsAt + DAY_SECONDS / 2, + }; + }), + }; +} diff --git a/app/features/scrims/actions/scrims.new.server.ts b/app/features/scrims/actions/scrims.new.server.ts index 44243c0ce..c49faf939 100644 --- a/app/features/scrims/actions/scrims.new.server.ts +++ b/app/features/scrims/actions/scrims.new.server.ts @@ -9,19 +9,19 @@ import { dateToDatabaseTimestamp } from "~/utils/dates"; import invariant from "~/utils/invariant"; import { errorToast, errorToastIfFalsy } from "~/utils/remix.server"; import { toDBBoolean } from "~/utils/sql"; -import { assertUnreachable } from "~/utils/types"; import { scrimsPage } from "~/utils/urls"; import * as SQGroupRepository from "../../sendouq/SQGroupRepository.server"; import * as TeamRepository from "../../team/TeamRepository.server"; import { getMemberRoleType } from "../../team/team-utils"; import * as ScrimPickupRosterRepository from "../ScrimPickupRosterRepository.server"; import * as ScrimPostRepository from "../ScrimPostRepository.server"; -import { LUTI_DIVS, SCRIM } from "../scrims-constants"; import { - type fromSchema, - type RANGE_END_OPTIONS, - scrimsNewFormSchema, -} from "../scrims-schemas"; + LUTI_DIVS, + RANGE_END_MINUTES, + type RangeEndOption, + SCRIM, +} from "../scrims-constants"; +import { type fromSchema, scrimsNewFormSchema } from "../scrims-schemas"; import type { LutiDiv } from "../scrims-types"; import { serializeLutiDiv } from "../scrims-utils"; @@ -193,25 +193,9 @@ async function validatePickupAllUnbanned(userIds: number[]) { function resolveRangeEndToDate( startDate: Date, - rangeEnd: (typeof RANGE_END_OPTIONS)[number], + rangeEnd: RangeEndOption, ): Date { - switch (rangeEnd) { - case "+30min": - return add(startDate, { minutes: 30 }); - case "+1hour": - return add(startDate, { hours: 1 }); - case "+1.5hours": - return add(startDate, { hours: 1, minutes: 30 }); - case "+2hours": - return add(startDate, { hours: 2 }); - case "+2.5hours": - return add(startDate, { hours: 2, minutes: 30 }); - case "+3hours": - return add(startDate, { hours: 3 }); - default: { - assertUnreachable(rangeEnd); - } - } + return add(startDate, { minutes: RANGE_END_MINUTES[rangeEnd] }); } function resolveDivs( diff --git a/app/features/scrims/components/ScrimSchedulePicker.module.css b/app/features/scrims/components/ScrimSchedulePicker.module.css new file mode 100644 index 000000000..481b7ed3d --- /dev/null +++ b/app/features/scrims/components/ScrimSchedulePicker.module.css @@ -0,0 +1,164 @@ +/* The tracks need more room than the form column gives them, so the picker + sizes against the whole page container and centers back under the column. */ +.picker { + display: flex; + flex-direction: column; + gap: var(--s-3); + width: min(100cqw, 48rem); + margin-inline: calc(50% - min(50cqw, 24rem)); +} + +.header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--s-2); +} + +.heading { + font-size: var(--font-sm); + font-weight: var(--weight-bold); +} + +.slotBar { + container: bar / inline-size; + position: absolute; + top: 3px; + bottom: 3px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 0; + background-color: var(--color-success); + border: 1px solid var(--color-success); + border-radius: var(--radius-field); + cursor: pointer; + z-index: 1; + + &:focus-visible { + outline: var(--focus-ring); + } + + &.oneShort { + background-color: var(--color-success-low); + } + + &.picked { + box-shadow: + 0 0 0 2px var(--color-bg), + 0 0 0 4px var(--color-text); + } +} + +/* inside a slot the team is only partly complete for, the part it is: where picking it starts */ +.slotFull { + position: absolute; + top: 0; + bottom: 0; + background-color: var(--color-success); + pointer-events: none; +} + +/* the pill keeps the times legible whichever tier's green is under them */ +.slotLabel { + display: none; + max-width: 100%; + padding-inline: var(--s-1); + background-color: var(--color-bg); + border-radius: var(--radius-full); + font-size: var(--font-3xs); + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; + z-index: 1; +} + +@container bar (min-width: 9rem) { + .slotLabel { + display: block; + } +} + +.legend { + display: flex; + flex-wrap: wrap; + gap: var(--s-3); + font-size: var(--font-3xs); + color: var(--color-text-high); +} + +.legendItem { + display: flex; + align-items: center; + gap: var(--s-1); +} + +.slotSwatch { + width: 14px; + height: 10px; + background-color: var(--color-success); + border: 1px solid var(--color-success); + border-radius: var(--radius-field); + + &.oneShort { + background-color: var(--color-success-low); + } +} + +.unknown { + font-size: var(--font-2xs); + color: var(--color-text-high); +} + +.listDay { + display: flex; + flex-direction: column; + gap: var(--s-1-5); + padding-block: var(--s-2); + border-bottom: 1px solid var(--color-bg-higher); + + &:last-child { + border-bottom: none; + } +} + +.listDayHeader { + font-size: var(--font-xs); + font-weight: var(--weight-semi); +} + +.listDayBody { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--s-1-5); +} + +.slotChip { + padding: var(--s-0-5) var(--s-2); + background-color: var(--color-success-low); + border: 1px solid var(--color-success); + border-radius: var(--radius-full); + font-size: var(--font-xs); + color: var(--color-text); + cursor: pointer; + + &:focus-visible { + outline: var(--focus-ring); + } + + &.oneShort { + border-style: dashed; + opacity: 0.85; + } + + &.picked { + box-shadow: + 0 0 0 2px var(--color-bg), + 0 0 0 4px var(--color-text); + } +} diff --git a/app/features/scrims/components/ScrimSchedulePicker.tsx b/app/features/scrims/components/ScrimSchedulePicker.tsx new file mode 100644 index 000000000..fdbe87c7b --- /dev/null +++ b/app/features/scrims/components/ScrimSchedulePicker.tsx @@ -0,0 +1,416 @@ +import clsx from "clsx"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import * as R from "remeda"; +import { + SendouChipRadio, + SendouChipRadioGroup, +} from "~/components/elements/ChipRadio"; +import { useUser } from "~/features/auth/core/user"; +import type { + DayTimeRange, + TimeRange, +} from "~/features/availability/availability-types"; +import { + ClockAxis, + type ClockWindow, + TrackTicks, + useClockWindow, +} from "~/features/availability/components/ScheduleTracks"; +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, + dateToDatabaseTimestamp, +} from "~/utils/dates"; +import * as Scrim from "../core/Scrim"; +import type { ScrimsNewLoaderData } from "../loaders/scrims.new.server"; +import { SCRIM } from "../scrims-constants"; +import styles from "./ScrimSchedulePicker.module.css"; + +const MINUTE_IN_SECONDS = 60; + +type Week = RosterScheduleData["weeks"][number]; +type Day = Week["days"][number]; +/** The "With" field as the form holds it while it is being filled in. */ +type FromValue = + | { mode: "TEAM"; teamId: number } + | { mode: "PICKUP"; users: Array }; + +interface DaySlot extends Scrim.PickableSlot { + /** The slot on its day's track, in minutes from that day's midnight. */ + range: DayTimeRange; + /** The part of it the whole team is free for, on the same track. */ + fullRange: DayTimeRange | null; +} + +/** + * The roster's merged free time as a week of day tracks, one click on which + * fills in the post's start and start-time flexibility. Which roster is merged + * follows the "With" field, so this only appears once a team or a full pick-up + * has been picked. + * + * Only ever a prefill: the start inputs stay authoritative, and a start the + * schedules do not cover is warned about, never blocked. + */ +export function ScrimSchedulePicker({ + schedule, + scheduleUsers, + teams, + from, + at, + onPick, +}: { + schedule: RosterScheduleData; + scheduleUsers: ScrimsNewLoaderData["scheduleUsers"]; + teams: ScrimsNewLoaderData["teams"]; + from: FromValue; + at: Date | undefined; + onPick: (pick: { at: Date; rangeEnd: string | null }) => void; +}) { + const user = useUser(); + + const roster = rosterUserIds({ from, teams, viewerId: user?.id }); + if (roster.length < SCRIM.MIN_MEMBERS_PER_TEAM) return null; + + return ( + team.members), + ...scheduleUsers, + ...(user ? [user] : []), + ]} + roster={roster} + at={at} + onPick={onPick} + /> + ); +} + +function RosterTimeline({ + schedule, + names, + roster, + at, + onPick, +}: { + schedule: RosterScheduleData; + /** Everyone whose name the timeline may need, the viewer included. */ + names: Array<{ id: number; username: string }>; + roster: Array; + at: Date | undefined; + onPick: (pick: { at: Date; rangeEnd: string | null }) => void; +}) { + const { t } = useTranslation(["schedule"]); + const [weekIndex, setWeekIndex] = React.useState(0); + const { formatter: dayFormatter } = useDateTimeFormat({ + weekday: "short", + day: "numeric", + }); + const { formatter: timeFormatter } = useDateTimeFormat({ + hour: "numeric", + minute: "2-digit", + }); + + const week = schedule.weeks[weekIndex]; + const memberById = new Map( + schedule.members.map((member) => [member.userId, member]), + ); + const minPlayers = Math.min(SCRIM.MIN_MEMBERS_PER_TEAM, roster.length); + + // only what can still be posted for: a start earlier today, let alone + // earlier this week, is not a start the form would accept + const pickableWeek = { + startsAt: Math.max(week.startsAt, schedule.now), + endsAt: week.endsAt, + }; + const slots = Scrim.pickableSlots({ + members: roster.map((userId) => ({ + userId, + ranges: Availability.clip( + memberById.get(userId)?.ranges ?? [], + pickableWeek, + ), + })), + minPlayers, + }); + + const dayRows = week.days.map((day) => ({ + day, + slots: slotsOfDay({ slots, day }), + })); + const clockWindow = useClockWindow({ + fitTo: dayRows.flatMap((row) => row.slots.map((slot) => slot.range)), + }); + + const nameById = new Map(names.map((member) => [member.id, member.username])); + const freeNames = (userIds: Array) => + userIds + .flatMap((userId) => { + const username = nameById.get(userId); + + return username ? [username] : []; + }) + .join(", "); + const unknownUserIds = roster.filter( + (userId) => + !memberById.get(userId)?.reportedWeekStarts.includes(week.startsAt), + ); + const unknownNamed = unknownUserIds.flatMap((userId) => { + const username = nameById.get(userId); + + return username ? [username] : []; + }); + const unknownUnnamed = unknownUserIds.length - unknownNamed.length; + + const pickedAt = at ? dateToDatabaseTimestamp(at) : null; + + const pick = (slot: Scrim.PickableSlot) => + onPick({ + at: databaseTimestampToDate(slot.pick.startsAt), + rangeEnd: slot.pick.rangeEnd, + }); + + const rangeText = (range: TimeRange) => + `${timeFormatter.format(range.startsAt)} – ${timeFormatter.format(range.endsAt)}`; + + const dayRow = ({ day, slots: daySlots }: (typeof dayRows)[number]) => { + return ( + +
+ {dayFormatter.format(day.noonAt)} +
+
+ + {daySlots.map((slot) => ( + pick(slot)} + /> + ))} +
+ {/* keeps the day rows in step with the axis row's "later" expander */} +
+ + ); + }; + + return ( +
+
+

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

+ + setWeekIndex(0)} + > + {t("schedule:team.currentWeek")} + + setWeekIndex(1)} + > + {t("schedule:team.nextWeek")} + + +
+
+
+ + {dayRows.map(dayRow)} +
+
+ {dayRows.map(({ day, slots: daySlots }) => { + return ( +
+
+ {dayFormatter.format(day.noonAt)} +
+ {daySlots.length === 0 ? ( + + ) : ( +
+ {daySlots.map((slot) => ( + + ))} +
+ )} +
+ ); + })} +
+
+ + {unknownUserIds.length > 0 ? ( +
+ {t("schedule:picker.noSchedule", { + users: [ + ...unknownNamed, + ...(unknownUnnamed > 0 + ? [t("schedule:picker.andOthers", { amount: unknownUnnamed })] + : []), + ].join(", "), + })} +
+ ) : null} +
+ ); +} + +function SlotBar({ + clockWindow, + slot, + label, + members, + isPicked, + onPick, +}: { + clockWindow: ClockWindow; + slot: DaySlot; + label: string; + /** Who is free for the whole slot, named on hover. */ + members: string; + isPicked: boolean; + onPick: () => void; +}) { + const visibleStart = Math.max(slot.range.start, clockWindow.trackStart); + const visibleEnd = Math.min(slot.range.end, clockWindow.trackEnd); + if (visibleEnd <= visibleStart) return null; + + const withinBar = (minutes: number) => + ((Math.min(Math.max(minutes, visibleStart), visibleEnd) - visibleStart) / + (visibleEnd - visibleStart)) * + 100; + return ( + + ); +} + +function Legend({ minPlayers }: { minPlayers: number }) { + const { t } = useTranslation(["schedule"]); + + return ( +
+ + + {t("schedule:picker.legend.full", { players: minPlayers })} + + {minPlayers > 1 ? ( + + + {t("schedule:picker.legend.oneShort", { players: minPlayers - 1 })} + + ) : null} +
+ ); +} + +function rosterUserIds({ + from, + teams, + viewerId, +}: { + from: FromValue; + teams: ScrimsNewLoaderData["teams"]; + viewerId?: number; +}): Array { + if (!viewerId) return []; + + if (from.mode === "PICKUP") { + return R.unique([ + viewerId, + ...from.users.filter((userId) => typeof userId === "number"), + ]); + } + + 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)]); +} + +function slotsOfDay({ + slots, + day, +}: { + slots: Array; + day: Day; +}): Array { + return slots + .filter((slot) => withinDay(slot.startsAt, day)) + .map((slot) => ({ + ...slot, + range: dayRange(slot, day), + fullRange: slot.fullSpan ? dayRange(slot.fullSpan, day) : null, + })); +} + +const withinDay = (timestamp: number, day: Day) => + timestamp >= day.startsAt && timestamp < day.endsAt; + +const dayRange = (range: TimeRange, day: Day) => ({ + start: (range.startsAt - day.startsAt) / MINUTE_IN_SECONDS, + end: (range.endsAt - day.startsAt) / MINUTE_IN_SECONDS, +}); diff --git a/app/features/scrims/core/Scrim.test.ts b/app/features/scrims/core/Scrim.test.ts index 4f81d4622..c1f733411 100644 --- a/app/features/scrims/core/Scrim.test.ts +++ b/app/features/scrims/core/Scrim.test.ts @@ -6,10 +6,13 @@ import { applyFilters, isTrackingLocked, participantIdsListFromAccepted, + pickableSlots, sideDisplayName, sideOfUser, } from "./Scrim"; +const HOUR = 60 * 60; + type MockUser = { id: number }; type MockRequest = { isAccepted: boolean; users: MockUser[] }; @@ -575,3 +578,86 @@ describe("isTrackingLocked", () => { ).toBe(false); }); }); + +const freeFrom = (userId: number, startsAt: number, endsAt: number) => ({ + userId, + ranges: [{ startsAt, endsAt }], +}); + +describe("pickableSlots", () => { + const evening = (hours: number) => hours * HOUR; + + test("starts a slot the whole team is free for at its own start", () => { + const members = [1, 2, 3, 4].map((userId) => + freeFrom(userId, evening(18), evening(23)), + ); + + expect(pickableSlots({ members, minPlayers: 4 })).toEqual([ + { + startsAt: evening(18), + endsAt: evening(23), + userIds: [1, 2, 3, 4], + tier: "FULL", + fullSpan: null, + pick: { startsAt: evening(18), rangeEnd: "+3hours" }, + }, + ]); + }); + + test("starts a mixed slot where the whole team becomes free", () => { + const members = [ + freeFrom(1, evening(18), evening(23)), + freeFrom(2, evening(18), evening(23)), + freeFrom(3, evening(18), evening(23)), + freeFrom(4, evening(20), evening(23)), + ]; + + expect(pickableSlots({ members, minPlayers: 4 })).toEqual([ + { + startsAt: evening(18), + endsAt: evening(23), + userIds: [1, 2, 3], + tier: "ONE_SHORT", + fullSpan: { + startsAt: evening(20), + endsAt: evening(23), + tier: "FULL", + userIds: [1, 2, 3, 4], + }, + pick: { startsAt: evening(20), rangeEnd: "+2hours" }, + }, + ]); + }); + + test("leaves an hour of the slot to play, capped at the longest flexibility", () => { + const twoHours = [1, 2, 3, 4].map((userId) => + freeFrom(userId, evening(18), evening(20)), + ); + + expect(pickableSlots({ members: twoHours, minPlayers: 4 })[0].pick).toEqual( + { startsAt: evening(18), rangeEnd: "+1hour" }, + ); + }); + + test("gives an hour long slot no flexibility at all", () => { + const oneHour = [1, 2, 3, 4].map((userId) => + freeFrom(userId, evening(18), evening(19)), + ); + + expect(pickableSlots({ members: oneHour, minPlayers: 4 })[0].pick).toEqual({ + startsAt: evening(18), + rangeEnd: null, + }); + }); + + test("has no slots when the team is more than one player short", () => { + const members = [ + freeFrom(1, evening(18), evening(21)), + freeFrom(2, evening(18), evening(21)), + freeFrom(3, evening(21), evening(23)), + freeFrom(4, evening(21), evening(23)), + ]; + + expect(pickableSlots({ members, minPlayers: 4 })).toEqual([]); + }); +}); diff --git a/app/features/scrims/core/Scrim.ts b/app/features/scrims/core/Scrim.ts index e800487ed..936a1b449 100644 --- a/app/features/scrims/core/Scrim.ts +++ b/app/features/scrims/core/Scrim.ts @@ -1,9 +1,22 @@ import { format, isWeekend } from "date-fns"; import * as R from "remeda"; import type { Tables } from "~/db/tables"; +import { AVAILABILITY } from "~/features/availability/availability-constants"; +import type { + MemberAvailability, + PlayableWindowTier, + TimeRange, +} from "~/features/availability/availability-types"; +import * as Availability from "~/features/availability/core/Availability"; import { databaseTimestampToDate } from "~/utils/dates"; import { logger } from "~/utils/logger"; -import { LUTI_DIVS, SCRIM_TRACKING_AUTO_LOCK_HOURS } from "../scrims-constants"; +import { + LUTI_DIVS, + RANGE_END_MINUTES, + type RangeEndOption, + SCRIM, + SCRIM_TRACKING_AUTO_LOCK_HOURS, +} from "../scrims-constants"; import type { ScrimFilters, ScrimPost, ScrimSide } from "../scrims-types"; /** Returns true if the original poster has accepted any of the requests. */ @@ -204,6 +217,55 @@ export function lastReportedMap< ); } +export interface PickableSlot extends TimeRange { + tier: PlayableWindowTier; + /** Members free for the whole slot. */ + userIds: Array; + /** The part of the slot the whole team is free for, when that is only part of it. */ + fullSpan: TimeRange | null; + /** What picking the slot fills the post's start and start-time flexibility with. */ + pick: { startsAt: number; rangeEnd: RangeEndOption | null }; +} + +/** + * The roster's shared free time as the slots a scrim post can be picked from: + * maximal spans where the team is at most one player short, the `ONE_SHORT` + * ones being the "grab a sub" case. + * be given. + */ +export function pickableSlots({ + members, + minPlayers, +}: { + members: Array; + minPlayers: number; +}): Array { + const spansFreeFor = (playerCount: number) => + Availability.playableWindows({ + members, + minPlayers: playerCount, + }).filter((window) => window.tier === "FULL"); + + const fullSpans = spansFreeFor(minPlayers); + + return spansFreeFor(Math.max(1, minPlayers - 1)).map((slot) => { + const fullSpan = fullSpans.find( + (span) => span.startsAt >= slot.startsAt && span.endsAt <= slot.endsAt, + ); + const wholeSlotIsFull = + fullSpan?.startsAt === slot.startsAt && fullSpan?.endsAt === slot.endsAt; + + return { + startsAt: slot.startsAt, + endsAt: slot.endsAt, + userIds: slot.userIds, + tier: wholeSlotIsFull ? "FULL" : "ONE_SHORT", + fullSpan: wholeSlotIsFull ? null : (fullSpan ?? null), + pick: startPick({ slot, at: fullSpan?.startsAt ?? slot.startsAt }), + }; + }); +} + /** 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 @@ -213,3 +275,26 @@ function timeRangeToSegments(start: string, end: string) { ] : [{ start, end }]; } + +function startPick({ slot, at }: { slot: TimeRange; at: number }) { + const lastStartsAt = slot.endsAt - AVAILABILITY.MIN_WINDOW_MINUTES * 60; + const startsAt = R.clamp(at, { + min: slot.startsAt, + max: Math.max(slot.startsAt, lastStartsAt), + }); + const flexMinutes = + Math.min(lastStartsAt - startsAt, SCRIM.MAX_TIME_RANGE_MS / 1000) / 60; + + return { startsAt, rangeEnd: longestRangeEndWithin(flexMinutes) }; +} + +function longestRangeEndWithin(minutes: number): RangeEndOption | null { + const fitting = R.entries(RANGE_END_MINUTES).filter( + ([, optionMinutes]) => optionMinutes <= minutes, + ); + + return ( + R.firstBy(fitting, [([, optionMinutes]) => optionMinutes, "desc"])?.[0] ?? + null + ); +} diff --git a/app/features/scrims/loaders/scrims.new.server.ts b/app/features/scrims/loaders/scrims.new.server.ts index b45ab1fa8..234ed720b 100644 --- a/app/features/scrims/loaders/scrims.new.server.ts +++ b/app/features/scrims/loaders/scrims.new.server.ts @@ -1,5 +1,9 @@ +import * as R from "remeda"; import * as AssociationRepository from "~/features/associations/AssociationRepository.server"; import { requireUser } from "~/features/auth/core/user.server"; +import * as RosterSchedule from "~/features/availability/core/RosterSchedule.server"; +import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; +import { getViewerTimezone } from "~/features/timezone/timezone-context.server"; import type { SerializeFrom } from "~/utils/remix"; import * as TeamRepository from "../../team/TeamRepository.server"; import * as ScrimPickupRosterRepository from "../ScrimPickupRosterRepository.server"; @@ -9,9 +13,34 @@ export type ScrimsNewLoaderData = SerializeFrom; export const loader = async () => { const user = requireUser(); + const [teams, friendsAndTeammates] = await Promise.all([ + TeamRepository.findAllByMemberUserId(user.id), + SQGroupRepository.findFriendsAndTeammates(user.id), + ]); + + // everyone the post could be made with whose schedule the author may see: + // their teams' rosters and their friends, the same visibility rule the rest + // of the schedule surfaces follow + const scheduleUserIds = R.unique([ + user.id, + ...teams.flatMap((team) => team.members.map((member) => member.id)), + ...friendsAndTeammates.friends.map((friend) => friend.id), + ]); + return { - teams: await TeamRepository.findAllByMemberUserId(user.id), + teams, associations: await AssociationRepository.findByMemberUserId(user.id), recentPickupRosters: await ScrimPickupRosterRepository.findAllOwnRecent(), + schedule: await RosterSchedule.rosterScheduleData({ + userIds: scheduleUserIds, + timezone: getViewerTimezone() ?? "UTC", + }), + scheduleUsers: R.uniqueBy( + friendsAndTeammates.friends.map((friend) => ({ + id: friend.id, + username: friend.username, + })), + (friend) => friend.id, + ), }; }; diff --git a/app/features/scrims/routes/scrims.new.tsx b/app/features/scrims/routes/scrims.new.tsx index f5bc0eb5b..0dd2faebe 100644 --- a/app/features/scrims/routes/scrims.new.tsx +++ b/app/features/scrims/routes/scrims.new.tsx @@ -5,6 +5,7 @@ import { useLoaderData } from "react-router"; import type * as v from "valibot"; import { SendouDatePicker } from "~/components/elements/DatePicker"; import { Label } from "~/components/Label"; +import { timezoneMiddleware } from "~/features/timezone/timezone-middleware.server"; import type { CustomFieldRenderProps } from "~/form"; import { FormField } from "~/form/FormField"; import { FormFieldWrapper } from "~/form/fields/FormFieldWrapper"; @@ -16,16 +17,21 @@ import type { SendouRouteHandle } from "~/utils/remix.server"; import { FormMessage } from "../../../components/FormMessage"; import { Main } from "../../../components/Main"; import { action } from "../actions/scrims.new.server"; +import { ScrimSchedulePicker } from "../components/ScrimSchedulePicker"; import { WithFormField } from "../components/WithFormField"; import { loader, type ScrimsNewLoaderData } from "../loaders/scrims.new.server"; import { SCRIM } from "../scrims-constants"; import { scrimsNewFormSchema } from "../scrims-schemas"; -import styles from "./scrims.new.module.css"; export { action, loader }; +import type { Route } from "./+types/scrims.new"; +import styles from "./scrims.new.module.css"; + +export const middleware: Route.MiddlewareFunction[] = [timezoneMiddleware]; + export const handle: SendouRouteHandle = { - i18n: "scrims", + i18n: ["scrims", "schedule"], }; type FormFields = v.InferOutput; @@ -76,6 +82,8 @@ export default function NewScrimPage() { )} + + @@ -106,6 +114,28 @@ export default function NewScrimPage() { ); } +function SchedulePicker() { + const data = useLoaderData(); + const { values, setValue } = useFormFieldContext(); + + const from = values.from as FormFields["from"] | null; + if (!from) return null; + + return ( + { + setValue("at", at); + setValue("rangeEnd", rangeEnd); + }} + /> + ); +} + function BaseVisibilityFormField({ associations, name, diff --git a/app/features/scrims/scrims-constants.ts b/app/features/scrims/scrims-constants.ts index d3ca410b5..a84b0bfbf 100644 --- a/app/features/scrims/scrims-constants.ts +++ b/app/features/scrims/scrims-constants.ts @@ -13,6 +13,18 @@ export const LUTI_DIVS = [ "11", ] as const; +/** Start-time flexibility a scrim post can be given, as minutes added to its start. */ +export const RANGE_END_MINUTES = { + "+30min": 30, + "+1hour": 60, + "+1.5hours": 90, + "+2hours": 120, + "+2.5hours": 150, + "+3hours": 180, +} as const; + +export type RangeEndOption = keyof typeof RANGE_END_MINUTES; + export const SCRIM = { MAX_PICKUP_SIZE_EXCLUDING_OWNER: 5, MAX_SAVED_PICKUP_ROSTERS: 5, diff --git a/app/features/scrims/scrims-schemas.ts b/app/features/scrims/scrims-schemas.ts index ea9efdc93..ca6d2d7f3 100644 --- a/app/features/scrims/scrims-schemas.ts +++ b/app/features/scrims/scrims-schemas.ts @@ -271,15 +271,6 @@ export const scrimIdActionSchema = v.union([ const MAX_SCRIM_POST_TEXT_LENGTH = 500; -export const RANGE_END_OPTIONS = [ - "+30min", - "+1hour", - "+1.5hours", - "+2hours", - "+2.5hours", - "+3hours", -] as const; - export const scrimRequestFormSchema = v.object({ _action: stringConstant("NEW_REQUEST"), scrimPostId: idConstant(), diff --git a/e2e/pages/scrims/new-scrim-post-page.ts b/e2e/pages/scrims/new-scrim-post-page.ts index 9b94d407c..ba42a19e2 100644 --- a/e2e/pages/scrims/new-scrim-post-page.ts +++ b/e2e/pages/scrims/new-scrim-post-page.ts @@ -12,10 +12,21 @@ import { createFormHelpers } from "../../helpers/playwright-form"; export class NewScrimPostPage { private readonly page: Page; readonly form; + readonly locators; constructor(page: Page) { this.page = page; this.form = createFormHelpers(page, scrimsNewFormSchema); + this.locators = { + schedulePicker: page.getByTestId("scrim-schedule-picker"), + scheduleSlots: page.getByTestId("scrim-schedule-slot"), + scheduleUnknown: page.getByTestId("scrim-schedule-unknown"), + flexibility: page.getByLabel("Start time flexibility"), + // the chip radio input is visually hidden, so the label is what clicks + nextWeekToggle: page.locator( + 'label[for="chip-radio-scrim-schedule-week-next"]', + ), + }; } async goto() { @@ -52,6 +63,13 @@ export class NewScrimPostPage { return this.page.getByLabel(`User ${nth}`); } + /** One segment of the Start date picker, e.g. `"hour"` or `"day"`. */ + startSegment(segmentName: string) { + return this.page.getByRole("spinbutton", { + name: new RegExp(`^${segmentName}, Start`), + }); + } + /** Limits who sees the post to one of the author's associations. */ async selectVisibility(associationName: string) { await this.page diff --git a/e2e/scrims.spec.ts b/e2e/scrims.spec.ts index 718bcedba..3d8237f6f 100644 --- a/e2e/scrims.spec.ts +++ b/e2e/scrims.spec.ts @@ -1,6 +1,14 @@ -import { addDays, addHours, setHours, setMinutes, startOfHour } from "date-fns"; +import { + addDays, + addHours, + addWeeks, + setHours, + setMinutes, + startOfHour, +} from "date-fns"; import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; +import * as Availability from "~/features/availability/core/Availability"; import { serializeLutiDiv } from "~/features/scrims/scrims-utils"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { dateToDatabaseTimestamp } from "~/utils/dates"; @@ -10,7 +18,9 @@ import { expect, impersonate, isNotVisible, + MACHINE_TIMEZONE, navigate, + setTimezoneCookie, test, } from "./helpers/playwright"; import { AnythingAdder } from "./pages/layout/anything-adder"; @@ -24,6 +34,8 @@ const TOURNAMENT_NAME = "Swim or Sink"; const ASSOCIATION_NAME = "Inkling Alliance"; const PICKUP_NAMES = ["Pickup One", "Pickup Two", "Pickup Three"]; const GROUP_SIZE = 4; +const DAY_SECONDS = 24 * 60 * 60; +const WEDNESDAY = 2; const TOURNAMENT_MAP_POOL: Array<{ mode: ModeShort; stageId: StageId }> = [ { mode: "SZ", stageId: 1 }, { mode: "TC", stageId: 2 }, @@ -422,6 +434,49 @@ function createNamedUsers(factories: Factories, names: string[]) { })); } +test.describe("Scrim schedule picker", () => { + test("picks a start and its flexibility from the roster's shared free time", async ({ + page, + factories, + }) => { + const { memberUserIds } = await createTeamFor(factories, NZAP_TEST_ID); + const evening = nextWeekSlot(WEDNESDAY, "18:00", "23:00"); + + for (const userId of memberUserIds.slice(0, memberUserIds.length - 1)) { + await factories.AvailabilityWeekFactory.create({ + userId, + weekStartsAt: nextWeek().startsAt, + timezone: MACHINE_TIMEZONE, + slots: [evening], + }); + } + + await impersonate(page, NZAP_TEST_ID); + await setTimezoneCookie(page); + + const newPost = new NewScrimPostPage(page); + await newPost.goto(); + await newPost.locators.nextWeekToggle.click(); + + // the roster's last member never filled the week in, so the shared + // evening is one player short of a full team + await expect(newPost.locators.scheduleUnknown).toBeVisible(); + const slot = newPost.locators.scheduleSlots; + await expect(slot).toHaveCount(1); + await expect(slot).toHaveAttribute("data-tier", "ONE_SHORT"); + + await slot.click(); + + // 18:00, with the flexibility that still leaves an hour of the window + // to play whichever start is settled on, capped at the longest option + await expect(newPost.startSegment("hour")).toHaveText("6"); + await expect(newPost.startSegment("minute")).toHaveText("00"); + await expect(newPost.startSegment("AM/PM")).toHaveText("PM"); + await expect(newPost.locators.flexibility).toHaveValue("+3hours"); + await expect(slot).toHaveAttribute("data-picked", "true"); + }); +}); + async function createTeamFor(factories: Factories, userId: number) { const teammates = await factories.UserFactory.createMany(GROUP_SIZE - 1); @@ -430,6 +485,22 @@ async function createTeamFor(factories: Factories, userId: number) { }); } +function nextWeek() { + return Availability.weekRange(addWeeks(new Date(), 1), MACHINE_TIMEZONE); +} + +/** Wall-clock range on a day of next week, so it is always ahead of "now". */ +function nextWeekSlot(dayIndex: number, start: string, end: string) { + const date = Availability.dateInTimezone( + nextWeek().startsAt + dayIndex * DAY_SECONDS + DAY_SECONDS / 2, + MACHINE_TIMEZONE, + ); + const at = (time: string) => + Availability.localToTimestamp({ date, time, timezone: MACHINE_TIMEZONE }); + + return { startsAt: at(start), endsAt: at(end) }; +} + /** A pick-up sized group of users, `userId` its owner if one is given. */ async function createGroup(factories: Factories, userId?: number) { const others = await factories.UserFactory.createMany( diff --git a/locales/da/schedule.json b/locales/da/schedule.json index 9dcbc1fc3..b52219448 100644 --- a/locales/da/schedule.json +++ b/locales/da/schedule.json @@ -36,5 +36,11 @@ "team.notAvailable": "", "team.noWindows": "", "team.weekHeading": "", - "team.withSub": "" + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "" } diff --git a/locales/de/schedule.json b/locales/de/schedule.json index 9dcbc1fc3..b52219448 100644 --- a/locales/de/schedule.json +++ b/locales/de/schedule.json @@ -36,5 +36,11 @@ "team.notAvailable": "", "team.noWindows": "", "team.weekHeading": "", - "team.withSub": "" + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "" } diff --git a/locales/en/schedule.json b/locales/en/schedule.json index 6aec15084..f5f9127d4 100644 --- a/locales/en/schedule.json +++ b/locales/en/schedule.json @@ -36,5 +36,11 @@ "team.notAvailable": "Not available", "team.noWindows": "No shared free time", "team.weekHeading": "Week {{week}}", - "team.withSub": "With a sub ({{players}})" + "team.withSub": "With a sub ({{players}})", + "picker.title": "Pick a start from your schedule", + "picker.free": "{{amount}} free", + "picker.noSchedule": "No schedule this week: {{users}}", + "picker.andOthers": "{{amount}} more", + "picker.legend.full": "{{players}}+ free", + "picker.legend.oneShort": "{{players}} free (sub?)" } diff --git a/locales/es-ES/schedule.json b/locales/es-ES/schedule.json index 9dcbc1fc3..b52219448 100644 --- a/locales/es-ES/schedule.json +++ b/locales/es-ES/schedule.json @@ -36,5 +36,11 @@ "team.notAvailable": "", "team.noWindows": "", "team.weekHeading": "", - "team.withSub": "" + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "" } diff --git a/locales/es-US/schedule.json b/locales/es-US/schedule.json index 9dcbc1fc3..b52219448 100644 --- a/locales/es-US/schedule.json +++ b/locales/es-US/schedule.json @@ -36,5 +36,11 @@ "team.notAvailable": "", "team.noWindows": "", "team.weekHeading": "", - "team.withSub": "" + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "" } diff --git a/locales/fr-CA/schedule.json b/locales/fr-CA/schedule.json index 9dcbc1fc3..b52219448 100644 --- a/locales/fr-CA/schedule.json +++ b/locales/fr-CA/schedule.json @@ -36,5 +36,11 @@ "team.notAvailable": "", "team.noWindows": "", "team.weekHeading": "", - "team.withSub": "" + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "" } diff --git a/locales/fr-EU/schedule.json b/locales/fr-EU/schedule.json index 9dcbc1fc3..b52219448 100644 --- a/locales/fr-EU/schedule.json +++ b/locales/fr-EU/schedule.json @@ -36,5 +36,11 @@ "team.notAvailable": "", "team.noWindows": "", "team.weekHeading": "", - "team.withSub": "" + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "" } diff --git a/locales/he/schedule.json b/locales/he/schedule.json index 9dcbc1fc3..b52219448 100644 --- a/locales/he/schedule.json +++ b/locales/he/schedule.json @@ -36,5 +36,11 @@ "team.notAvailable": "", "team.noWindows": "", "team.weekHeading": "", - "team.withSub": "" + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "" } diff --git a/locales/it/schedule.json b/locales/it/schedule.json index 9dcbc1fc3..b52219448 100644 --- a/locales/it/schedule.json +++ b/locales/it/schedule.json @@ -36,5 +36,11 @@ "team.notAvailable": "", "team.noWindows": "", "team.weekHeading": "", - "team.withSub": "" + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "" } diff --git a/locales/ja/schedule.json b/locales/ja/schedule.json index 9dcbc1fc3..b52219448 100644 --- a/locales/ja/schedule.json +++ b/locales/ja/schedule.json @@ -36,5 +36,11 @@ "team.notAvailable": "", "team.noWindows": "", "team.weekHeading": "", - "team.withSub": "" + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "" } diff --git a/locales/ko/schedule.json b/locales/ko/schedule.json index 9dcbc1fc3..b52219448 100644 --- a/locales/ko/schedule.json +++ b/locales/ko/schedule.json @@ -36,5 +36,11 @@ "team.notAvailable": "", "team.noWindows": "", "team.weekHeading": "", - "team.withSub": "" + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "" } diff --git a/locales/nl/schedule.json b/locales/nl/schedule.json index 9dcbc1fc3..b52219448 100644 --- a/locales/nl/schedule.json +++ b/locales/nl/schedule.json @@ -36,5 +36,11 @@ "team.notAvailable": "", "team.noWindows": "", "team.weekHeading": "", - "team.withSub": "" + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "" } diff --git a/locales/pl/schedule.json b/locales/pl/schedule.json index 9dcbc1fc3..b52219448 100644 --- a/locales/pl/schedule.json +++ b/locales/pl/schedule.json @@ -36,5 +36,11 @@ "team.notAvailable": "", "team.noWindows": "", "team.weekHeading": "", - "team.withSub": "" + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "" } diff --git a/locales/pt-BR/schedule.json b/locales/pt-BR/schedule.json index 9dcbc1fc3..b52219448 100644 --- a/locales/pt-BR/schedule.json +++ b/locales/pt-BR/schedule.json @@ -36,5 +36,11 @@ "team.notAvailable": "", "team.noWindows": "", "team.weekHeading": "", - "team.withSub": "" + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "" } diff --git a/locales/ru/schedule.json b/locales/ru/schedule.json index 9dcbc1fc3..b52219448 100644 --- a/locales/ru/schedule.json +++ b/locales/ru/schedule.json @@ -36,5 +36,11 @@ "team.notAvailable": "", "team.noWindows": "", "team.weekHeading": "", - "team.withSub": "" + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "" } diff --git a/locales/zh/schedule.json b/locales/zh/schedule.json index 9dcbc1fc3..b52219448 100644 --- a/locales/zh/schedule.json +++ b/locales/zh/schedule.json @@ -36,5 +36,11 @@ "team.notAvailable": "", "team.noWindows": "", "team.weekHeading": "", - "team.withSub": "" + "team.withSub": "", + "picker.title": "", + "picker.free": "", + "picker.noSchedule": "", + "picker.andOthers": "", + "picker.legend.full": "", + "picker.legend.oneShort": "" }