mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-28 14:18:04 -05:00
Scrim new
This commit is contained in:
161
app/features/availability/components/ScheduleTracks.module.css
Normal file
161
app/features/availability/components/ScheduleTracks.module.css
Normal 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);
|
||||
}
|
||||
199
app/features/availability/components/ScheduleTracks.tsx
Normal file
199
app/features/availability/components/ScheduleTracks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
93
app/features/availability/core/RosterSchedule.server.test.ts
Normal file
93
app/features/availability/core/RosterSchedule.server.test.ts
Normal 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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
117
app/features/availability/core/RosterSchedule.server.ts
Normal file
117
app/features/availability/core/RosterSchedule.server.ts
Normal 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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
164
app/features/scrims/components/ScrimSchedulePicker.module.css
Normal file
164
app/features/scrims/components/ScrimSchedulePicker.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
416
app/features/scrims/components/ScrimSchedulePicker.tsx
Normal file
416
app/features/scrims/components/ScrimSchedulePicker.tsx
Normal 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,
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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?)"
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user