Scrim new

This commit is contained in:
Kalle
2026-08-25 07:12:03 +03:00
parent 612400ab03
commit 16ea3510c1
33 changed files with 1640 additions and 318 deletions

View File

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

View File

@@ -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<typeof useClockWindow>;
/**
* 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<DayTimeRange>;
} = {}) {
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<number> = [];
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<DayTimeRange>) {
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 (
<>
<button
type="button"
className={clsx(styles.axisToggle, styles.axisLead)}
onClick={() => clockWindow.setEarlierShown(!clockWindow.earlierShown)}
>
{clockWindow.earlierShown ? (
<ChevronRight size={12} aria-hidden />
) : (
<ChevronLeft size={12} aria-hidden />
)}
{t("schedule:editor.earlier")}
</button>
<div className={styles.axis}>
{clockWindow.hours.map((hour) => (
<span
key={hour}
className={clsx(styles.axisLabel, {
[styles.axisLabelFirst]:
hour * MINUTES_IN_HOUR === clockWindow.trackStart,
[styles.axisLabelLast]:
hour * MINUTES_IN_HOUR === clockWindow.trackEnd,
})}
style={{ left: `${clockWindow.pct(hour * MINUTES_IN_HOUR)}%` }}
>
{formatter.format(hourAt(hour))}
</span>
))}
</div>
<button
type="button"
className={clsx(styles.axisToggle, styles.axisTrail)}
onClick={() => clockWindow.setLaterShown(!clockWindow.laterShown)}
>
{t("schedule:editor.later")}
{clockWindow.laterShown ? (
<ChevronLeft size={12} aria-hidden />
) : (
<ChevronRight size={12} aria-hidden />
)}
</button>
</>
);
}
/** 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) => (
<div
key={hour}
className={clsx(styles.tick, {
[styles.tickMidnight]: hour === 24,
})}
style={{ left: `${clockWindow.pct(hour * MINUTES_IN_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 (
<div
className={styles.commitment}
style={clockWindow.barStyle(range)}
title={name}
data-testid="availability-commitment"
>
<span className={styles.commitmentName}>{name}</span>
</div>
);
}

View File

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

View File

@@ -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<Gesture | null>(null);
const gestureRef = React.useRef<Gesture | null>(null);
const [earlierShown, setEarlierShown] = React.useState(false);
const [laterShown, setLaterShown] = React.useState(false);
const [openDayDate, setOpenDayDate] = React.useState<string | null>(null);
const [openDayAddRow, setOpenDayAddRow] = React.useState(false);
const popoverAnchorRef = React.useRef<HTMLElement | null>(null);
@@ -107,28 +105,11 @@ export function WeekAvailabilityEditor({
const trackRefs = React.useRef<Array<HTMLDivElement | null>>([]);
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<number> = [];
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 (
<React.Fragment key={day.date}>
<div className={styles.dayLabel}>
<div className={trackStyles.dayLabel}>
{dayLabelText(day)}
{day.note ? (
<Flag className={styles.noteFlag} size={12} aria-hidden />
<Flag className={trackStyles.noteFlag} size={12} aria-hidden />
) : null}
</div>
<div
ref={(element) => {
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) => (
<div
key={hour}
className={clsx(styles.tick, {
[styles.tickMidnight]: hour === 24,
})}
style={{ left: `${pct(hour * 60)}%` }}
/>
))}
<TrackTicks clockWindow={clockWindow} />
{dayCommitments.map((commitment) => (
<div
<TrackCommitment
key={`${commitment.range.start}-${commitment.name}`}
className={styles.commitment}
style={barStyle(commitment.range)}
title={commitment.name}
data-testid="availability-commitment"
>
<span className={styles.commitmentName}>{commitment.name}</span>
</div>
clockWindow={clockWindow}
range={commitment.range}
name={commitment.name}
/>
))}
{day.ranges.map((range) => {
const isDragged =
@@ -586,50 +545,16 @@ export function WeekAvailabilityEditor({
};
return (
<div className={styles.container}>
<div className={trackStyles.container}>
<div className={styles.editor}>
<div className={styles.tracks}>
<button
type="button"
className={clsx(styles.axisToggle, styles.axisLead)}
onClick={() => setEarlierShown(!earlierShown)}
>
{earlierShown ? (
<ChevronRight size={12} aria-hidden />
) : (
<ChevronLeft size={12} aria-hidden />
)}
{t("schedule:editor.earlier")}
</button>
<div className={styles.axis}>
{axisHours.map((hour) => (
<span
key={hour}
className={clsx(styles.axisLabel, {
[styles.axisLabelFirst]: hour * 60 === trackStart,
[styles.axisLabelLast]: hour * 60 === trackEnd,
})}
style={{ left: `${pct(hour * 60)}%` }}
>
{hourFormatter.format(dateAt(value[0].date, hour * 60))}
</span>
))}
</div>
<button
type="button"
className={clsx(styles.axisToggle, styles.axisTrail)}
onClick={() => setLaterShown(!laterShown)}
>
{t("schedule:editor.later")}
{laterShown ? (
<ChevronLeft size={12} aria-hidden />
) : (
<ChevronRight size={12} aria-hidden />
)}
</button>
<div className={trackStyles.tracks}>
<ClockAxis
clockWindow={clockWindow}
dayStartsAt={dateAt(value[0].date, 0)}
/>
{value.map((day, dayIndex) => dayRow(day, dayIndex))}
</div>
<div className={styles.list}>
<div className={trackStyles.list}>
{value.map((day) => {
const dayCommitments = commitments.filter(
(commitment) => commitment.date === day.date,
@@ -654,7 +579,7 @@ export function WeekAvailabilityEditor({
{dayCommitments.map((commitment) => (
<span
key={`${commitment.range.start}-${commitment.name}`}
className={styles.commitmentChip}
className={trackStyles.commitmentChip}
>
{commitment.name} ·{" "}
{rangeText(day.date, commitment.range)}
@@ -673,7 +598,11 @@ export function WeekAvailabilityEditor({
</div>
{day.note ? (
<div className={styles.listNote}>
<Flag size={12} aria-hidden className={styles.noteFlag} />
<Flag
size={12}
aria-hidden
className={trackStyles.noteFlag}
/>
{day.note}
</div>
) : null}

View File

@@ -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<number>) =>
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: [],
});
});
});

View File

@@ -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<ReturnType<typeof rosterScheduleData>>
>;
/**
* 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<number>;
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,
};
}),
};
}

View File

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

View File

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

View File

@@ -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<number | null | undefined> };
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 (
<RosterTimeline
schedule={schedule}
names={[
...teams.flatMap((team) => 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<number>;
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<number>) =>
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 (
<React.Fragment key={day.startsAt}>
<div className={trackStyles.dayLabel}>
{dayFormatter.format(day.noonAt)}
</div>
<div className={trackStyles.track}>
<TrackTicks clockWindow={clockWindow} />
{daySlots.map((slot) => (
<SlotBar
key={slot.startsAt}
clockWindow={clockWindow}
slot={slot}
label={`${rangeText(slot)} · ${t("schedule:picker.free", {
amount: slot.userIds.length,
})}`}
members={freeNames(slot.userIds)}
isPicked={slot.pick.startsAt === pickedAt}
onPick={() => pick(slot)}
/>
))}
</div>
{/* keeps the day rows in step with the axis row's "later" expander */}
<div />
</React.Fragment>
);
};
return (
<section className={styles.picker} data-testid="scrim-schedule-picker">
<div className={styles.header}>
<h3 className={styles.heading}>{t("schedule:picker.title")}</h3>
<SendouChipRadioGroup>
<SendouChipRadio
name="scrim-schedule-week"
value="current"
checked={weekIndex === 0}
onChange={() => setWeekIndex(0)}
>
{t("schedule:team.currentWeek")}
</SendouChipRadio>
<SendouChipRadio
name="scrim-schedule-week"
value="next"
checked={weekIndex === 1}
onChange={() => setWeekIndex(1)}
>
{t("schedule:team.nextWeek")}
</SendouChipRadio>
</SendouChipRadioGroup>
</div>
<div className={trackStyles.container}>
<div className={trackStyles.tracks}>
<ClockAxis
clockWindow={clockWindow}
dayStartsAt={databaseTimestampToDate(week.days[0].startsAt)}
/>
{dayRows.map(dayRow)}
</div>
<div className={trackStyles.list}>
{dayRows.map(({ day, slots: daySlots }) => {
return (
<div key={day.startsAt} className={styles.listDay}>
<div className={styles.listDayHeader}>
{dayFormatter.format(day.noonAt)}
</div>
{daySlots.length === 0 ? (
<span className="text-lighter text-xs"></span>
) : (
<div className={styles.listDayBody}>
{daySlots.map((slot) => (
<button
key={slot.startsAt}
type="button"
className={clsx(styles.slotChip, {
[styles.oneShort]: slot.tier === "ONE_SHORT",
[styles.picked]: slot.pick.startsAt === pickedAt,
})}
title={freeNames(slot.userIds)}
onClick={() => pick(slot)}
>
{rangeText(slot)} ·{" "}
{t("schedule:picker.free", {
amount: slot.userIds.length,
})}
</button>
))}
</div>
)}
</div>
);
})}
</div>
</div>
<Legend minPlayers={minPlayers} />
{unknownUserIds.length > 0 ? (
<div className={styles.unknown} data-testid="scrim-schedule-unknown">
{t("schedule:picker.noSchedule", {
users: [
...unknownNamed,
...(unknownUnnamed > 0
? [t("schedule:picker.andOthers", { amount: unknownUnnamed })]
: []),
].join(", "),
})}
</div>
) : null}
</section>
);
}
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 (
<button
type="button"
className={clsx(styles.slotBar, {
[styles.oneShort]: slot.tier === "ONE_SHORT",
[styles.picked]: isPicked,
})}
style={clockWindow.barStyle({ start: visibleStart, end: visibleEnd })}
title={members ? `${label} · ${members}` : label}
aria-label={label}
data-testid="scrim-schedule-slot"
data-tier={slot.tier}
data-picked={isPicked || undefined}
onClick={onPick}
>
{slot.fullRange ? (
<span
className={styles.slotFull}
style={{
left: `${withinBar(slot.fullRange.start)}%`,
right: `${100 - withinBar(slot.fullRange.end)}%`,
}}
/>
) : null}
<span className={styles.slotLabel}>{label}</span>
</button>
);
}
function Legend({ minPlayers }: { minPlayers: number }) {
const { t } = useTranslation(["schedule"]);
return (
<div className={styles.legend}>
<span className={styles.legendItem}>
<span className={styles.slotSwatch} />
{t("schedule:picker.legend.full", { players: minPlayers })}
</span>
{minPlayers > 1 ? (
<span className={styles.legendItem}>
<span className={clsx(styles.slotSwatch, styles.oneShort)} />
{t("schedule:picker.legend.oneShort", { players: minPlayers - 1 })}
</span>
) : null}
</div>
);
}
function rosterUserIds({
from,
teams,
viewerId,
}: {
from: FromValue;
teams: ScrimsNewLoaderData["teams"];
viewerId?: number;
}): Array<number> {
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<Scrim.PickableSlot>;
day: Day;
}): Array<DaySlot> {
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,
});

View File

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

View File

@@ -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<number>;
/** 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<MemberAvailability>;
minPlayers: number;
}): Array<PickableSlot> {
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
);
}

View File

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

View File

@@ -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<typeof scrimsNewFormSchema>;
@@ -76,6 +82,8 @@ export default function NewScrimPage() {
)}
</FormField>
<SchedulePicker />
<FormField name="at" />
<FormField name="rangeEnd" />
@@ -106,6 +114,28 @@ export default function NewScrimPage() {
);
}
function SchedulePicker() {
const data = useLoaderData<typeof loader>();
const { values, setValue } = useFormFieldContext();
const from = values.from as FormFields["from"] | null;
if (!from) return null;
return (
<ScrimSchedulePicker
schedule={data.schedule}
scheduleUsers={data.scheduleUsers}
teams={data.teams}
from={from}
at={values.at as Date | undefined}
onPick={({ at, rangeEnd }) => {
setValue("at", at);
setValue("rangeEnd", rangeEnd);
}}
/>
);
}
function BaseVisibilityFormField({
associations,
name,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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?)"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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