mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-08 20:26:08 -05:00
WeekAvailabilityEditor
This commit is contained in:
@@ -10,4 +10,12 @@ export const AVAILABILITY = {
|
||||
WEEK_HORIZON: 2,
|
||||
/** Weeks whose end is further in the past than this are deleted. */
|
||||
RETENTION_MONTHS: 3,
|
||||
/** Left edge of the editor's clock window (14:00) — evenings are when people play. */
|
||||
TRACK_START_MINUTES: 14 * 60,
|
||||
/** Left edge of the clock window with the earlier-hours expander open (06:00). */
|
||||
TRACK_EARLIER_START_MINUTES: 6 * 60,
|
||||
/** Right edge of the clock window, reaching past midnight (02:00). */
|
||||
TRACK_END_MINUTES: 26 * 60,
|
||||
/** Right edge of the clock window with the later-hours expander open (06:00 the next day). */
|
||||
TRACK_LATER_END_MINUTES: 30 * 60,
|
||||
} as const;
|
||||
|
||||
@@ -22,3 +22,30 @@ export interface PlayableWindow extends TimeRange {
|
||||
/** Members free for the whole window, in the order they were given. */
|
||||
userIds: Array<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A span within one day of the schedule editor, in minutes from that day's
|
||||
* midnight. `end` may pass 1440 for a range crossing midnight.
|
||||
*/
|
||||
export interface DayTimeRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** One day of the schedule editor: the ranges painted on its track plus its note. */
|
||||
export interface AvailabilityEditorDay {
|
||||
/** `YYYY-MM-DD` in the editing user's timezone */
|
||||
date: string;
|
||||
ranges: Array<DayTimeRange>;
|
||||
note: string;
|
||||
}
|
||||
|
||||
/** The schedule editor's value: the seven days of one week, Monday first. */
|
||||
export type AvailabilityEditorWeek = Array<AvailabilityEditorDay>;
|
||||
|
||||
/** A commitment shown on the editor as a locked block that cannot be painted over. */
|
||||
export interface EditorCommitment {
|
||||
date: string;
|
||||
range: DayTimeRange;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
.container {
|
||||
container: editor / inline-size;
|
||||
}
|
||||
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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;
|
||||
top: 3px;
|
||||
bottom: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background-color: var(--color-success-low);
|
||||
border: 1px solid var(--color-success);
|
||||
border-radius: var(--radius-field);
|
||||
cursor: grab;
|
||||
z-index: 1;
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
}
|
||||
}
|
||||
|
||||
.barPreview {
|
||||
border-style: dashed;
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.barTimes,
|
||||
.barTimesShort {
|
||||
display: none;
|
||||
max-width: 100%;
|
||||
padding-inline: var(--s-1);
|
||||
font-size: var(--font-3xs);
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@container bar (min-width: 3rem) {
|
||||
.barTimesShort {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@container bar (min-width: 7.5rem) {
|
||||
.barTimes {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.barTimesShort {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 8px;
|
||||
cursor: ew-resize;
|
||||
|
||||
&.handleStart {
|
||||
left: -2px;
|
||||
}
|
||||
|
||||
&.handleEnd {
|
||||
right: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.fillHandle {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: -5px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: var(--color-success);
|
||||
border: 2px solid var(--color-bg);
|
||||
border-radius: var(--radius-full);
|
||||
cursor: ns-resize;
|
||||
opacity: 0;
|
||||
|
||||
.bar:hover &,
|
||||
.bar:focus-visible & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
z-index: 3;
|
||||
padding: 0 var(--s-1-5);
|
||||
background-color: var(--color-bg-higher);
|
||||
border-radius: var(--radius-field);
|
||||
font-size: var(--font-2xs);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.editButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--s-1);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-field);
|
||||
color: var(--color-text-high);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
font-size: var(--font-xs);
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.listDayBody {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--s-1-5);
|
||||
}
|
||||
|
||||
.timeChip {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
gap: var(--s-0-5);
|
||||
padding: var(--s-0-5) var(--s-1);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-accent);
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
}
|
||||
}
|
||||
|
||||
.listNote {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.dayEditor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.dayEditorTitle {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.dayEditorRange {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.dayEditorAdd {
|
||||
align-self: start;
|
||||
}
|
||||
794
app/features/availability/components/WeekAvailabilityEditor.tsx
Normal file
794
app/features/availability/components/WeekAvailabilityEditor.tsx
Normal file
@@ -0,0 +1,794 @@
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Flag,
|
||||
Plus,
|
||||
SquarePen,
|
||||
Trash,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouAnchoredPopover } from "~/components/elements/Popover";
|
||||
import { Input } from "~/components/Input";
|
||||
import { Label } from "~/components/Label";
|
||||
import { TimeRangeFormField } from "~/form/fields/TimeRangeFormField";
|
||||
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
||||
import { AVAILABILITY } from "../availability-constants";
|
||||
import type {
|
||||
AvailabilityEditorDay,
|
||||
AvailabilityEditorWeek,
|
||||
DayTimeRange,
|
||||
EditorCommitment,
|
||||
} from "../availability-types";
|
||||
import * as Availability from "../core/Availability";
|
||||
import styles from "./WeekAvailabilityEditor.module.css";
|
||||
|
||||
const MOVE_THRESHOLD_PX = 4;
|
||||
const AXIS_LABEL_EVERY_HOURS = 2;
|
||||
|
||||
type Gesture =
|
||||
| {
|
||||
type: "paint";
|
||||
dayIndex: number;
|
||||
anchor: number;
|
||||
range: DayTimeRange | null;
|
||||
}
|
||||
| {
|
||||
type: "move";
|
||||
dayIndex: number;
|
||||
original: DayTimeRange;
|
||||
range: DayTimeRange;
|
||||
startClientX: number;
|
||||
moved: boolean;
|
||||
}
|
||||
| {
|
||||
type: "resize";
|
||||
dayIndex: number;
|
||||
original: DayTimeRange;
|
||||
edge: "start" | "end";
|
||||
range: DayTimeRange;
|
||||
}
|
||||
| {
|
||||
type: "fill";
|
||||
dayIndex: number;
|
||||
range: DayTimeRange;
|
||||
targetDayIndex: number;
|
||||
};
|
||||
|
||||
interface DraftRange {
|
||||
id: number;
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
interface DayDraft {
|
||||
ranges: Array<DraftRange>;
|
||||
note: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One week of the user's own availability as an editable timeline: on wide
|
||||
* containers each day is a track where ranges are painted, moved, resized and
|
||||
* drag-filled with the pointer; on narrow containers a stacked per-day list.
|
||||
* Both share the same popover with exact time inputs and the day note, which
|
||||
* is also the keyboard path. Commitments render as locked blocks on the
|
||||
* tracks; gestures may cross them, but a new range cannot start on one.
|
||||
*/
|
||||
export function WeekAvailabilityEditor({
|
||||
value,
|
||||
onChange,
|
||||
commitments = [],
|
||||
}: {
|
||||
value: AvailabilityEditorWeek;
|
||||
onChange: (value: AvailabilityEditorWeek) => void;
|
||||
commitments?: Array<EditorCommitment>;
|
||||
}) {
|
||||
const { t } = useTranslation(["schedule", "common"]);
|
||||
const { formatter: dayFormatter } = useDateTimeFormat({
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
const { formatter: hourFormatter } = useDateTimeFormat({ hour: "numeric" });
|
||||
const { formatter: timeFormatter } = useDateTimeFormat({
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
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);
|
||||
const dayDraftRef = React.useRef<DayDraft | null>(null);
|
||||
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);
|
||||
|
||||
return new Date(year, month - 1, day, 0, minutes);
|
||||
};
|
||||
|
||||
const dayLabelText = (day: AvailabilityEditorDay) =>
|
||||
dayFormatter.format(dateAt(day.date, 12 * 60));
|
||||
|
||||
const rangeText = (date: string, range: DayTimeRange) =>
|
||||
`${timeFormatter.format(dateAt(date, range.start))} – ${timeFormatter.format(dateAt(date, range.end))}`;
|
||||
|
||||
const minutesAt = (dayIndex: number, clientX: number) => {
|
||||
const track = trackRefs.current[dayIndex];
|
||||
if (!track) return trackStart;
|
||||
|
||||
const rect = track.getBoundingClientRect();
|
||||
const fraction = Math.min(
|
||||
Math.max((clientX - rect.left) / rect.width, 0),
|
||||
1,
|
||||
);
|
||||
|
||||
return trackStart + fraction * (trackEnd - trackStart);
|
||||
};
|
||||
|
||||
const pxToMinutes = (dayIndex: number, px: number) => {
|
||||
const track = trackRefs.current[dayIndex];
|
||||
if (!track) return 0;
|
||||
|
||||
return (px / track.getBoundingClientRect().width) * (trackEnd - trackStart);
|
||||
};
|
||||
|
||||
const dayIndexAt = (clientY: number) => {
|
||||
let closest = 0;
|
||||
let closestDistance = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const [index, track] of trackRefs.current.entries()) {
|
||||
if (!track) continue;
|
||||
|
||||
const rect = track.getBoundingClientRect();
|
||||
const center = rect.top + rect.height / 2;
|
||||
const distance = Math.abs(clientY - center);
|
||||
|
||||
if (distance < closestDistance) {
|
||||
closest = index;
|
||||
closestDistance = distance;
|
||||
}
|
||||
}
|
||||
|
||||
return closest;
|
||||
};
|
||||
|
||||
const applyGesture = (next: Gesture | null) => {
|
||||
gestureRef.current = next;
|
||||
setGesture(next);
|
||||
};
|
||||
|
||||
const trackArgs = { trackStart, trackEnd };
|
||||
|
||||
const replaceDayRanges = (dayIndex: number, ranges: Array<DayTimeRange>) => {
|
||||
onChange(
|
||||
value.map((day, index) =>
|
||||
index === dayIndex ? { ...day, ranges } : day,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleTrackPointerDown =
|
||||
(dayIndex: number) => (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (gestureRef.current) return;
|
||||
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
applyGesture({
|
||||
type: "paint",
|
||||
dayIndex,
|
||||
anchor: minutesAt(dayIndex, event.clientX),
|
||||
range: null,
|
||||
});
|
||||
};
|
||||
|
||||
const handleBarPointerDown =
|
||||
(dayIndex: number, range: DayTimeRange) =>
|
||||
(event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
if (gestureRef.current) return;
|
||||
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
applyGesture({
|
||||
type: "move",
|
||||
dayIndex,
|
||||
original: range,
|
||||
range,
|
||||
startClientX: event.clientX,
|
||||
moved: false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleResizePointerDown =
|
||||
(dayIndex: number, range: DayTimeRange, edge: "start" | "end") =>
|
||||
(event: React.PointerEvent<HTMLSpanElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
if (gestureRef.current) return;
|
||||
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
applyGesture({ type: "resize", dayIndex, original: range, edge, range });
|
||||
};
|
||||
|
||||
const handleFillPointerDown =
|
||||
(dayIndex: number, range: DayTimeRange) =>
|
||||
(event: React.PointerEvent<HTMLSpanElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
if (gestureRef.current) return;
|
||||
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
applyGesture({ type: "fill", dayIndex, range, targetDayIndex: dayIndex });
|
||||
};
|
||||
|
||||
const handleGestureMove = (event: React.PointerEvent) => {
|
||||
const current = gestureRef.current;
|
||||
if (!current) return;
|
||||
|
||||
switch (current.type) {
|
||||
case "paint": {
|
||||
applyGesture({
|
||||
...current,
|
||||
range: Availability.paintedRange({
|
||||
anchor: current.anchor,
|
||||
cursor: minutesAt(current.dayIndex, event.clientX),
|
||||
walls: wallsOf(value[current.dayIndex].date),
|
||||
...trackArgs,
|
||||
}),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "move": {
|
||||
const moved =
|
||||
current.moved ||
|
||||
Math.abs(event.clientX - current.startClientX) > MOVE_THRESHOLD_PX;
|
||||
if (!moved) return;
|
||||
|
||||
applyGesture({
|
||||
...current,
|
||||
moved,
|
||||
range: Availability.movedRange({
|
||||
range: current.original,
|
||||
delta: pxToMinutes(
|
||||
current.dayIndex,
|
||||
event.clientX - current.startClientX,
|
||||
),
|
||||
...trackArgs,
|
||||
}),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "resize": {
|
||||
applyGesture({
|
||||
...current,
|
||||
range: Availability.resizedRange({
|
||||
range: current.original,
|
||||
edge: current.edge,
|
||||
cursor: minutesAt(current.dayIndex, event.clientX),
|
||||
...trackArgs,
|
||||
}),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "fill": {
|
||||
applyGesture({ ...current, targetDayIndex: dayIndexAt(event.clientY) });
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleGestureEnd = () => {
|
||||
const current = gestureRef.current;
|
||||
if (!current) return;
|
||||
|
||||
if (current.type === "paint" && current.range) {
|
||||
const painted = current.range;
|
||||
replaceDayRanges(
|
||||
current.dayIndex,
|
||||
Availability.mergedDayRanges([
|
||||
...value[current.dayIndex].ranges,
|
||||
painted,
|
||||
]),
|
||||
);
|
||||
} else if (
|
||||
(current.type === "move" && current.moved) ||
|
||||
current.type === "resize"
|
||||
) {
|
||||
suppressClickRef.current = true;
|
||||
replaceDayRanges(
|
||||
current.dayIndex,
|
||||
Availability.mergedDayRanges([
|
||||
...value[current.dayIndex].ranges.filter(
|
||||
(range) => !sameRange(range, current.original),
|
||||
),
|
||||
current.range,
|
||||
]),
|
||||
);
|
||||
} else if (current.type === "fill") {
|
||||
suppressClickRef.current = true;
|
||||
onChange(
|
||||
value.map((day, index) => {
|
||||
if (
|
||||
index === current.dayIndex ||
|
||||
!isBetween(index, current.dayIndex, current.targetDayIndex)
|
||||
) {
|
||||
return day;
|
||||
}
|
||||
|
||||
return {
|
||||
...day,
|
||||
ranges: Availability.mergedDayRanges([
|
||||
...day.ranges,
|
||||
current.range,
|
||||
]),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
applyGesture(null);
|
||||
};
|
||||
|
||||
const handleGestureCancel = () => applyGesture(null);
|
||||
|
||||
const openDayEditor = (date: string, anchor: HTMLElement, addRow = false) => {
|
||||
popoverAnchorRef.current = anchor;
|
||||
dayDraftRef.current = null;
|
||||
setOpenDayDate(date);
|
||||
setOpenDayAddRow(addRow);
|
||||
};
|
||||
|
||||
const closeDayEditor = () => {
|
||||
const draft = dayDraftRef.current;
|
||||
|
||||
if (draft && openDayDate) {
|
||||
onChange(
|
||||
value.map((day) =>
|
||||
day.date === openDayDate
|
||||
? {
|
||||
...day,
|
||||
ranges: Availability.mergedDayRanges(
|
||||
draft.ranges
|
||||
.filter((range) => range.start && range.end)
|
||||
.map((range) =>
|
||||
Availability.dayRangeFromTimes(range.start, range.end),
|
||||
),
|
||||
),
|
||||
note: draft.note.trim(),
|
||||
}
|
||||
: day,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
dayDraftRef.current = null;
|
||||
setOpenDayDate(null);
|
||||
};
|
||||
|
||||
const handleBarClick = (
|
||||
date: string,
|
||||
event: React.MouseEvent<HTMLElement>,
|
||||
) => {
|
||||
if (suppressClickRef.current) {
|
||||
suppressClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
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) => {
|
||||
const dayCommitments = commitments.filter(
|
||||
(commitment) => commitment.date === day.date,
|
||||
);
|
||||
const dayGesture =
|
||||
gesture && gesture.type !== "fill" && gesture.dayIndex === dayIndex
|
||||
? gesture
|
||||
: null;
|
||||
const liveRange = dayGesture?.range ?? null;
|
||||
const fillPreview =
|
||||
gesture?.type === "fill" &&
|
||||
gesture.dayIndex !== dayIndex &&
|
||||
isBetween(dayIndex, gesture.dayIndex, gesture.targetDayIndex)
|
||||
? [gesture.range]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<React.Fragment key={day.date}>
|
||||
<div className={styles.dayLabel}>
|
||||
{dayLabelText(day)}
|
||||
{day.note ? (
|
||||
<Flag className={styles.noteFlag} size={12} aria-hidden />
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
ref={(element) => {
|
||||
trackRefs.current[dayIndex] = element;
|
||||
}}
|
||||
className={styles.track}
|
||||
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)}%` }}
|
||||
/>
|
||||
))}
|
||||
{dayCommitments.map((commitment) => (
|
||||
<div
|
||||
key={`${commitment.range.start}-${commitment.name}`}
|
||||
className={styles.commitment}
|
||||
style={barStyle(commitment.range)}
|
||||
title={commitment.name}
|
||||
>
|
||||
<span className={styles.commitmentName}>{commitment.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{day.ranges.map((range) => {
|
||||
const isDragged =
|
||||
(dayGesture?.type === "move" || dayGesture?.type === "resize") &&
|
||||
sameRange(range, dayGesture.original);
|
||||
const shown = isDragged && dayGesture ? dayGesture.range : range;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`${range.start}-${range.end}`}
|
||||
className={styles.bar}
|
||||
style={barStyle(shown)}
|
||||
aria-label={`${t("schedule:editor.editDay", {
|
||||
day: dayLabelText(day),
|
||||
})} (${rangeText(day.date, range)})`}
|
||||
title={rangeText(day.date, shown)}
|
||||
onPointerDown={handleBarPointerDown(dayIndex, range)}
|
||||
onClick={(event) => handleBarClick(day.date, event)}
|
||||
>
|
||||
<span className={styles.barTimes}>
|
||||
{rangeText(day.date, shown)}
|
||||
</span>
|
||||
<span className={styles.barTimesShort}>
|
||||
{timeFormatter.format(dateAt(day.date, shown.start))}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(styles.handle, styles.handleStart)}
|
||||
onPointerDown={handleResizePointerDown(
|
||||
dayIndex,
|
||||
range,
|
||||
"start",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={clsx(styles.handle, styles.handleEnd)}
|
||||
onPointerDown={handleResizePointerDown(
|
||||
dayIndex,
|
||||
range,
|
||||
"end",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={styles.fillHandle}
|
||||
onPointerDown={handleFillPointerDown(dayIndex, range)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{gesture?.type === "paint" &&
|
||||
gesture.dayIndex === dayIndex &&
|
||||
gesture.range ? (
|
||||
<div
|
||||
className={clsx(styles.bar, styles.barPreview)}
|
||||
style={barStyle(gesture.range)}
|
||||
/>
|
||||
) : null}
|
||||
{fillPreview.map((piece) => (
|
||||
<div
|
||||
key={`${piece.start}-${piece.end}`}
|
||||
className={clsx(styles.bar, styles.barPreview)}
|
||||
style={barStyle(piece)}
|
||||
/>
|
||||
))}
|
||||
{liveRange ? (
|
||||
<span
|
||||
className={styles.liveLabel}
|
||||
style={{ left: `${pct(liveRange.start)}%` }}
|
||||
>
|
||||
{rangeText(day.date, liveRange)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.editButton}
|
||||
aria-label={t("schedule:editor.editDay", { day: dayLabelText(day) })}
|
||||
onClick={(event) => openDayEditor(day.date, event.currentTarget)}
|
||||
>
|
||||
<SquarePen size={14} aria-hidden />
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.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>
|
||||
{value.map((day, dayIndex) => dayRow(day, dayIndex))}
|
||||
</div>
|
||||
<div className={styles.list}>
|
||||
{value.map((day) => {
|
||||
const dayCommitments = commitments.filter(
|
||||
(commitment) => commitment.date === day.date,
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={day.date} className={styles.listDay}>
|
||||
<div className={styles.listDayHeader}>{dayLabelText(day)}</div>
|
||||
<div className={styles.listDayBody}>
|
||||
{day.ranges.map((range) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`${range.start}-${range.end}`}
|
||||
className={styles.timeChip}
|
||||
onClick={(event) =>
|
||||
openDayEditor(day.date, event.currentTarget)
|
||||
}
|
||||
>
|
||||
{rangeText(day.date, range)}
|
||||
</button>
|
||||
))}
|
||||
{dayCommitments.map((commitment) => (
|
||||
<span
|
||||
key={`${commitment.range.start}-${commitment.name}`}
|
||||
className={styles.commitmentChip}
|
||||
>
|
||||
{commitment.name} ·{" "}
|
||||
{rangeText(day.date, commitment.range)}
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addChip}
|
||||
onClick={(event) =>
|
||||
openDayEditor(day.date, event.currentTarget, true)
|
||||
}
|
||||
>
|
||||
<Plus size={14} aria-hidden />
|
||||
{t("schedule:editor.addTime")}
|
||||
</button>
|
||||
</div>
|
||||
{day.note ? (
|
||||
<div className={styles.listNote}>
|
||||
<Flag size={12} aria-hidden className={styles.noteFlag} />
|
||||
{day.note}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className={styles.footer}>
|
||||
{t("schedule:editor.timesInYourTimezone")} ·{" "}
|
||||
{t("schedule:editor.visibility")}
|
||||
</p>
|
||||
</div>
|
||||
{openDay ? (
|
||||
<SendouAnchoredPopover
|
||||
isOpen
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) closeDayEditor();
|
||||
}}
|
||||
triggerRef={popoverAnchorRef}
|
||||
>
|
||||
<DayEditor
|
||||
day={openDay}
|
||||
dayLabel={dayLabelText(openDay)}
|
||||
startWithNewRow={openDayAddRow}
|
||||
onDraftChange={(draft) => {
|
||||
dayDraftRef.current = draft;
|
||||
}}
|
||||
/>
|
||||
</SendouAnchoredPopover>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DayEditor({
|
||||
day,
|
||||
dayLabel,
|
||||
startWithNewRow,
|
||||
onDraftChange,
|
||||
}: {
|
||||
day: AvailabilityEditorDay;
|
||||
dayLabel: string;
|
||||
/** Opens with an empty row already appended, for an "add time" entry point. */
|
||||
startWithNewRow: boolean;
|
||||
onDraftChange: (draft: DayDraft) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["schedule", "common", "forms"]);
|
||||
const noteId = React.useId();
|
||||
const nextIdRef = React.useRef(day.ranges.length + 1);
|
||||
const [ranges, setRanges] = React.useState<Array<DraftRange>>(() => {
|
||||
const existing = day.ranges.map((range, index) => ({
|
||||
id: index,
|
||||
start: Availability.minutesToTime(range.start),
|
||||
end: Availability.minutesToTime(range.end),
|
||||
}));
|
||||
|
||||
return startWithNewRow || existing.length === 0
|
||||
? [...existing, { id: existing.length, start: "", end: "" }]
|
||||
: existing;
|
||||
});
|
||||
const [note, setNote] = React.useState(day.note);
|
||||
|
||||
const update = (nextRanges: Array<DraftRange>, nextNote: string) => {
|
||||
setRanges(nextRanges);
|
||||
setNote(nextNote);
|
||||
onDraftChange({ ranges: nextRanges, note: nextNote });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.dayEditor}>
|
||||
<div className={styles.dayEditorTitle}>{dayLabel}</div>
|
||||
{ranges.map((range) => (
|
||||
<div key={range.id} className={styles.dayEditorRange}>
|
||||
<TimeRangeFormField
|
||||
name={`range-${range.id}`}
|
||||
value={{ start: range.start, end: range.end }}
|
||||
onChange={(next) =>
|
||||
update(
|
||||
ranges.map((other) =>
|
||||
other.id === range.id
|
||||
? {
|
||||
...other,
|
||||
start: next?.start ?? "",
|
||||
end: next?.end ?? "",
|
||||
}
|
||||
: other,
|
||||
),
|
||||
note,
|
||||
)
|
||||
}
|
||||
startLabel={t("forms:labels.start")}
|
||||
endLabel={t("forms:labels.end")}
|
||||
/>
|
||||
<SendouButton
|
||||
icon={<Trash />}
|
||||
variant="minimal-destructive"
|
||||
size="small"
|
||||
aria-label={t("common:actions.delete")}
|
||||
onPress={() =>
|
||||
update(
|
||||
ranges.filter((other) => other.id !== range.id),
|
||||
note,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<SendouButton
|
||||
icon={<Plus />}
|
||||
variant="minimal"
|
||||
size="small"
|
||||
className={styles.dayEditorAdd}
|
||||
onPress={() => {
|
||||
const id = nextIdRef.current;
|
||||
nextIdRef.current += 1;
|
||||
update([...ranges, { id, start: "", end: "" }], note);
|
||||
}}
|
||||
>
|
||||
{t("schedule:editor.addTime")}
|
||||
</SendouButton>
|
||||
<div>
|
||||
<Label
|
||||
htmlFor={noteId}
|
||||
valueLimits={{
|
||||
current: note.length,
|
||||
max: AVAILABILITY.DAY_NOTE_MAX_LENGTH,
|
||||
}}
|
||||
>
|
||||
{t("schedule:editor.note")}
|
||||
</Label>
|
||||
<Input
|
||||
id={noteId}
|
||||
value={note}
|
||||
maxLength={AVAILABILITY.DAY_NOTE_MAX_LENGTH}
|
||||
onChange={(event) => update(ranges, event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sameRange = (one: DayTimeRange, other: DayTimeRange) =>
|
||||
one.start === other.start && one.end === other.end;
|
||||
|
||||
const isBetween = (index: number, one: number, other: number) =>
|
||||
index >= Math.min(one, other) && index <= Math.max(one, other);
|
||||
@@ -361,3 +361,186 @@ describe("Availability.snapMinutes", () => {
|
||||
expect(Availability.snapMinutes(minutes)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
const TRACK = { trackStart: 14 * 60, trackEnd: 26 * 60 };
|
||||
const minuteRange = (start: number, end: number) => ({ start, end });
|
||||
|
||||
describe("Availability.timeToMinutes", () => {
|
||||
test.each([
|
||||
["00:00", 0],
|
||||
["09:30", 570],
|
||||
["23:59", 1439],
|
||||
])("resolves %s to %i minutes", (time, expected) => {
|
||||
expect(Availability.timeToMinutes(time)).toBe(expected);
|
||||
});
|
||||
|
||||
test("throws on a malformed time", () => {
|
||||
expect(() => Availability.timeToMinutes("half past six")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Availability.minutesToTime", () => {
|
||||
test.each([
|
||||
{ why: "midnight", minutes: 0, expected: "00:00" },
|
||||
{ why: "an evening time", minutes: 1380, expected: "23:00" },
|
||||
{ why: "a time past midnight", minutes: 1560, expected: "02:00" },
|
||||
])("prints $why as $expected", ({ minutes, expected }) => {
|
||||
expect(Availability.minutesToTime(minutes)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Availability.dayRangeFromTimes", () => {
|
||||
test("keeps a same-day range as entered", () => {
|
||||
expect(Availability.dayRangeFromTimes("18:00", "22:00")).toEqual(
|
||||
minuteRange(1080, 1320),
|
||||
);
|
||||
});
|
||||
|
||||
test("pushes an end earlier than the start past midnight", () => {
|
||||
expect(Availability.dayRangeFromTimes("22:00", "02:00")).toEqual(
|
||||
minuteRange(1320, 1560),
|
||||
);
|
||||
});
|
||||
|
||||
test("treats an end equal to the start as an empty range", () => {
|
||||
const result = Availability.dayRangeFromTimes("18:00", "18:00");
|
||||
|
||||
expect(Availability.mergedDayRanges([result])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Availability.mergedDayRanges", () => {
|
||||
test("merges overlapping and touching ranges", () => {
|
||||
expect(
|
||||
Availability.mergedDayRanges([
|
||||
minuteRange(1200, 1320),
|
||||
minuteRange(1080, 1230),
|
||||
minuteRange(1320, 1380),
|
||||
]),
|
||||
).toEqual([minuteRange(1080, 1380)]);
|
||||
});
|
||||
|
||||
test("keeps separated ranges apart and drops empty ones", () => {
|
||||
expect(
|
||||
Availability.mergedDayRanges([
|
||||
minuteRange(1260, 1380),
|
||||
minuteRange(1080, 1140),
|
||||
minuteRange(600, 600),
|
||||
]),
|
||||
).toEqual([minuteRange(1080, 1140), minuteRange(1260, 1380)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Availability.paintedRange", () => {
|
||||
test("snaps both ends and orders a backwards drag", () => {
|
||||
expect(
|
||||
Availability.paintedRange({
|
||||
anchor: 1307,
|
||||
cursor: 1114,
|
||||
walls: [],
|
||||
...TRACK,
|
||||
}),
|
||||
).toEqual(minuteRange(1110, 1320));
|
||||
});
|
||||
|
||||
test("grows a plain press to one step", () => {
|
||||
expect(
|
||||
Availability.paintedRange({
|
||||
anchor: 1085,
|
||||
cursor: 1085,
|
||||
walls: [],
|
||||
...TRACK,
|
||||
}),
|
||||
).toEqual(minuteRange(1080, 1110));
|
||||
});
|
||||
|
||||
test("extends across a wall", () => {
|
||||
expect(
|
||||
Availability.paintedRange({
|
||||
anchor: 1080,
|
||||
cursor: 1440,
|
||||
walls: [minuteRange(1200, 1290)],
|
||||
...TRACK,
|
||||
}),
|
||||
).toEqual(minuteRange(1080, 1440));
|
||||
});
|
||||
|
||||
test("returns null when the anchor is inside a wall", () => {
|
||||
expect(
|
||||
Availability.paintedRange({
|
||||
anchor: 1230,
|
||||
cursor: 1440,
|
||||
walls: [minuteRange(1200, 1290)],
|
||||
...TRACK,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("stays inside the track", () => {
|
||||
expect(
|
||||
Availability.paintedRange({
|
||||
anchor: 1500,
|
||||
cursor: 2000,
|
||||
walls: [],
|
||||
...TRACK,
|
||||
}),
|
||||
).toEqual(minuteRange(1500, 1560));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Availability.movedRange", () => {
|
||||
test("snaps the move to the entry step", () => {
|
||||
expect(
|
||||
Availability.movedRange({
|
||||
range: minuteRange(1080, 1200),
|
||||
delta: 44,
|
||||
...TRACK,
|
||||
}),
|
||||
).toEqual(minuteRange(1110, 1230));
|
||||
});
|
||||
|
||||
test("stops at the track edges", () => {
|
||||
expect(
|
||||
Availability.movedRange({
|
||||
range: minuteRange(1080, 1200),
|
||||
delta: -1000,
|
||||
...TRACK,
|
||||
}),
|
||||
).toEqual(minuteRange(840, 960));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Availability.resizedRange", () => {
|
||||
test("keeps at least one step when dragged past the other edge", () => {
|
||||
expect(
|
||||
Availability.resizedRange({
|
||||
range: minuteRange(1080, 1200),
|
||||
edge: "end",
|
||||
cursor: 900,
|
||||
...TRACK,
|
||||
}),
|
||||
).toEqual(minuteRange(1080, 1110));
|
||||
});
|
||||
|
||||
test("stops the dragged edge at the track edges", () => {
|
||||
expect(
|
||||
Availability.resizedRange({
|
||||
range: minuteRange(1320, 1440),
|
||||
edge: "start",
|
||||
cursor: 500,
|
||||
...TRACK,
|
||||
}),
|
||||
).toEqual(minuteRange(840, 1440));
|
||||
});
|
||||
|
||||
test("snaps the dragged edge", () => {
|
||||
expect(
|
||||
Availability.resizedRange({
|
||||
range: minuteRange(1080, 1200),
|
||||
edge: "end",
|
||||
cursor: 1307,
|
||||
...TRACK,
|
||||
}),
|
||||
).toEqual(minuteRange(1080, 1320));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import invariant from "~/utils/invariant";
|
||||
import { AVAILABILITY } from "../availability-constants";
|
||||
import type {
|
||||
DayTimeRange,
|
||||
MemberAvailability,
|
||||
PlayableWindow,
|
||||
TimeRange,
|
||||
@@ -276,3 +277,171 @@ function inTimezone(timestamp: number, timezone: string) {
|
||||
timezone,
|
||||
);
|
||||
}
|
||||
|
||||
/** Minutes from midnight of a `HH:mm` time string. */
|
||||
export function timeToMinutes(time: string) {
|
||||
const [hours, minutes] = time.split(":").map(Number);
|
||||
|
||||
invariant(
|
||||
Number.isFinite(hours) && Number.isFinite(minutes),
|
||||
`Malformed time: ${time}`,
|
||||
);
|
||||
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* `HH:mm` on the clock at the given minutes from midnight. Minutes past 24h
|
||||
* wrap around, so the end of a range crossing midnight prints as e.g. `02:00`.
|
||||
*/
|
||||
export function minutesToTime(minutes: number) {
|
||||
const onClock = ((minutes % DAY_MINUTES) + DAY_MINUTES) % DAY_MINUTES;
|
||||
|
||||
return `${String(Math.floor(onClock / 60)).padStart(2, "0")}:${String(
|
||||
onClock % 60,
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor day range of the given start and end times. An end earlier than the
|
||||
* start means the range crosses midnight; an end equal to the start is an
|
||||
* empty range (dropped by {@link mergedDayRanges}).
|
||||
*/
|
||||
export function dayRangeFromTimes(start: string, end: string): DayTimeRange {
|
||||
const startMinutes = timeToMinutes(start);
|
||||
const endMinutes = timeToMinutes(end);
|
||||
|
||||
return {
|
||||
start: startMinutes,
|
||||
end: endMinutes >= startMinutes ? endMinutes : endMinutes + DAY_MINUTES,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The ranges of one day track sorted and merged so that no two of them overlap
|
||||
* or touch. Empty ranges are dropped.
|
||||
*/
|
||||
export function mergedDayRanges(
|
||||
ranges: Array<DayTimeRange>,
|
||||
): Array<DayTimeRange> {
|
||||
return normalize(ranges.map(toTimeRange)).map(toDayRange);
|
||||
}
|
||||
|
||||
interface TrackWindowArgs {
|
||||
/** Left edge of the visible clock window, minutes from midnight. */
|
||||
trackStart: number;
|
||||
/** Right edge of the visible clock window, minutes from midnight. */
|
||||
trackEnd: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Range painted by dragging on an empty part of a day track from `anchor` to
|
||||
* `cursor` (both minutes from midnight): ends snapped to the entry step, at
|
||||
* least one step long and kept inside the track. Painting cannot start on a
|
||||
* wall (a commitment) but may extend across one — null when the anchor is
|
||||
* inside a wall.
|
||||
*/
|
||||
export function paintedRange({
|
||||
anchor,
|
||||
cursor,
|
||||
walls,
|
||||
trackStart,
|
||||
trackEnd,
|
||||
}: TrackWindowArgs & {
|
||||
anchor: number;
|
||||
cursor: number;
|
||||
/** Blocks a paint cannot start on, i.e. the day's commitments. */
|
||||
walls: Array<DayTimeRange>;
|
||||
}): DayTimeRange | null {
|
||||
if (insideWall(anchor, walls)) return null;
|
||||
|
||||
const track = { start: trackStart, end: trackEnd };
|
||||
const from = clampMinutes(snapMinutes(anchor), track);
|
||||
const to = clampMinutes(snapMinutes(cursor), track);
|
||||
|
||||
let start = Math.min(from, to);
|
||||
let end = Math.max(from, to);
|
||||
|
||||
if (end - start < AVAILABILITY.SLOT_STEP_MINUTES) {
|
||||
end = Math.min(start + AVAILABILITY.SLOT_STEP_MINUTES, trackEnd);
|
||||
start = end - AVAILABILITY.SLOT_STEP_MINUTES;
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/**
|
||||
* `range` moved by `delta` minutes: the move is snapped to the entry step and
|
||||
* stopped at the track edges.
|
||||
*/
|
||||
export function movedRange({
|
||||
range,
|
||||
delta,
|
||||
trackStart,
|
||||
trackEnd,
|
||||
}: TrackWindowArgs & {
|
||||
range: DayTimeRange;
|
||||
delta: number;
|
||||
}): DayTimeRange {
|
||||
const length = range.end - range.start;
|
||||
if (trackEnd - trackStart < length) return range;
|
||||
|
||||
const start = R.clamp(range.start + snapMinutes(delta), {
|
||||
min: trackStart,
|
||||
max: trackEnd - length,
|
||||
});
|
||||
|
||||
return { start, end: start + length };
|
||||
}
|
||||
|
||||
/**
|
||||
* `range` with one edge dragged to `cursor`: snapped to the entry step, kept
|
||||
* at least one step long and stopped at the track edges.
|
||||
*/
|
||||
export function resizedRange({
|
||||
range,
|
||||
edge,
|
||||
cursor,
|
||||
trackStart,
|
||||
trackEnd,
|
||||
}: TrackWindowArgs & {
|
||||
range: DayTimeRange;
|
||||
edge: "start" | "end";
|
||||
cursor: number;
|
||||
}): DayTimeRange {
|
||||
if (edge === "start") {
|
||||
const start = R.clamp(snapMinutes(cursor), {
|
||||
min: trackStart,
|
||||
max: range.end - AVAILABILITY.SLOT_STEP_MINUTES,
|
||||
});
|
||||
|
||||
return { start, end: range.end };
|
||||
}
|
||||
|
||||
const end = R.clamp(snapMinutes(cursor), {
|
||||
min: range.start + AVAILABILITY.SLOT_STEP_MINUTES,
|
||||
max: trackEnd,
|
||||
});
|
||||
|
||||
return { start: range.start, end };
|
||||
}
|
||||
|
||||
const DAY_MINUTES = 24 * 60;
|
||||
|
||||
const toTimeRange = (range: DayTimeRange): TimeRange => ({
|
||||
startsAt: range.start,
|
||||
endsAt: range.end,
|
||||
});
|
||||
|
||||
const toDayRange = (range: TimeRange): DayTimeRange => ({
|
||||
start: range.startsAt,
|
||||
end: range.endsAt,
|
||||
});
|
||||
|
||||
const clampMinutes = (minutes: number, range: DayTimeRange) =>
|
||||
R.clamp(minutes, { min: range.start, max: range.end });
|
||||
|
||||
const insideWall = (point: number, walls: Array<DayTimeRange>) =>
|
||||
mergedDayRanges(walls).some(
|
||||
(wall) => wall.start <= point && point < wall.end,
|
||||
);
|
||||
|
||||
@@ -35,3 +35,7 @@
|
||||
.trophyExampleLarge {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.scheduleNarrow {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,11 @@ import { SubNav, SubNavLink } from "~/components/SubNav";
|
||||
import { Table } from "~/components/Table";
|
||||
import { TierPill } from "~/components/TierPill";
|
||||
import { WeaponSelect } from "~/components/WeaponSelect";
|
||||
import type {
|
||||
AvailabilityEditorWeek,
|
||||
EditorCommitment,
|
||||
} from "~/features/availability/availability-types";
|
||||
import { WeekAvailabilityEditor } from "~/features/availability/components/WeekAvailabilityEditor";
|
||||
import {
|
||||
ChangelogGraphic,
|
||||
type ChangelogGraphicEntry,
|
||||
@@ -85,7 +90,7 @@ import { EXAMPLE_TROPHY_MODEL } from "../example-trophy-model";
|
||||
import { formFieldsShowcaseSchema } from "../form-examples-schema";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["user", "q", "calendar", "tournament"],
|
||||
i18n: ["user", "q", "calendar", "tournament", "schedule"],
|
||||
};
|
||||
|
||||
export const SECTIONS = [
|
||||
@@ -153,6 +158,7 @@ export const SECTIONS = [
|
||||
{ title: "Tier Pills", id: "tier-pills", component: TierPillSection },
|
||||
{ title: "Game Selects", id: "game-selects", component: GameSelectSection },
|
||||
{ title: "Form Fields", id: "form-fields", component: FormFieldsSection },
|
||||
{ title: "Schedule", id: "schedule", component: ScheduleSection },
|
||||
{ title: "Miscellaneous", id: "miscellaneous", component: MiscSection },
|
||||
] as const;
|
||||
|
||||
@@ -3012,6 +3018,85 @@ function FormFieldsSection({ id }: { id: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const SCHEDULE_EXAMPLE_WEEK: AvailabilityEditorWeek = [
|
||||
{ date: "2026-08-24", ranges: [{ start: 18 * 60, end: 22 * 60 }], note: "" },
|
||||
{ date: "2026-08-25", ranges: [], note: "" },
|
||||
{
|
||||
date: "2026-08-26",
|
||||
ranges: [{ start: 19 * 60, end: 23 * 60 }],
|
||||
note: "Have to stop earlier, work trip next morning",
|
||||
},
|
||||
{ date: "2026-08-27", ranges: [{ start: 18 * 60, end: 22 * 60 }], note: "" },
|
||||
{ date: "2026-08-28", ranges: [], note: "" },
|
||||
{ date: "2026-08-29", ranges: [{ start: 12 * 60, end: 26 * 60 }], note: "" },
|
||||
{ date: "2026-08-30", ranges: [{ start: 18 * 60, end: 22 * 60 }], note: "" },
|
||||
];
|
||||
|
||||
const SCHEDULE_EXAMPLE_COMMITMENTS: Array<EditorCommitment> = [
|
||||
{
|
||||
date: "2026-08-26",
|
||||
range: { start: 20 * 60, end: 21 * 60 + 30 },
|
||||
name: "VoD review vs. FTWin",
|
||||
},
|
||||
{
|
||||
date: "2026-08-30",
|
||||
range: { start: 12 * 60, end: 18 * 60 },
|
||||
name: "In The Zone 42",
|
||||
},
|
||||
];
|
||||
|
||||
function ScheduleSection({ id }: { id: string }) {
|
||||
const [week, setWeek] = useState(SCHEDULE_EXAMPLE_WEEK);
|
||||
const rangeCount = week.reduce((acc, day) => acc + day.ranges.length, 0);
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<SectionTitle id={id}>Schedule</SectionTitle>
|
||||
|
||||
<div className="stack md">
|
||||
<div className="stack sm">
|
||||
<div className={styles.componentLabel}>Week availability editor</div>
|
||||
<WeekAvailabilityEditor
|
||||
value={week}
|
||||
onChange={setWeek}
|
||||
commitments={SCHEDULE_EXAMPLE_COMMITMENTS}
|
||||
/>
|
||||
<div className="stack horizontal sm">
|
||||
<SendouButton
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onPress={() => setWeek(SCHEDULE_EXAMPLE_WEEK)}
|
||||
>
|
||||
Reset
|
||||
</SendouButton>
|
||||
<SendouButton
|
||||
size="small"
|
||||
onPress={() =>
|
||||
toastQueue.add({
|
||||
message: `Saved week with ${rangeCount} time ranges`,
|
||||
variant: "success",
|
||||
})
|
||||
}
|
||||
>
|
||||
Save week
|
||||
</SendouButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ComponentRow label="Narrow container (mobile layout, shares state with the editor above)">
|
||||
<div className={styles.scheduleNarrow}>
|
||||
<WeekAvailabilityEditor
|
||||
value={week}
|
||||
onChange={setWeek}
|
||||
commitments={SCHEDULE_EXAMPLE_COMMITMENTS}
|
||||
/>
|
||||
</div>
|
||||
</ComponentRow>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function MiscSection({ id }: { id: string }) {
|
||||
const [rangeValue, setRangeValue] = useState(50);
|
||||
const [colorValue, setColorValue] = useState("#3b82f6");
|
||||
|
||||
@@ -15,6 +15,7 @@ import lfg from "../../../locales/en/lfg.json";
|
||||
import org from "../../../locales/en/org.json";
|
||||
import params from "../../../locales/en/params.json";
|
||||
import q from "../../../locales/en/q.json";
|
||||
import schedule from "../../../locales/en/schedule.json";
|
||||
import scrims from "../../../locales/en/scrims.json";
|
||||
import settings from "../../../locales/en/settings.json";
|
||||
import team from "../../../locales/en/team.json";
|
||||
@@ -44,6 +45,7 @@ export const resources = {
|
||||
org,
|
||||
params,
|
||||
q,
|
||||
schedule,
|
||||
scrims,
|
||||
settings,
|
||||
team,
|
||||
|
||||
@@ -16,6 +16,7 @@ import lfgDa from "../../../locales/da/lfg.json";
|
||||
import orgDa from "../../../locales/da/org.json";
|
||||
import paramsDa from "../../../locales/da/params.json";
|
||||
import qDa from "../../../locales/da/q.json";
|
||||
import scheduleDa from "../../../locales/da/schedule.json";
|
||||
import scrimsDa from "../../../locales/da/scrims.json";
|
||||
import settingsDa from "../../../locales/da/settings.json";
|
||||
import teamDa from "../../../locales/da/team.json";
|
||||
@@ -44,6 +45,7 @@ import lfgDe from "../../../locales/de/lfg.json";
|
||||
import orgDe from "../../../locales/de/org.json";
|
||||
import paramsDe from "../../../locales/de/params.json";
|
||||
import qDe from "../../../locales/de/q.json";
|
||||
import scheduleDe from "../../../locales/de/schedule.json";
|
||||
import scrimsDe from "../../../locales/de/scrims.json";
|
||||
import settingsDe from "../../../locales/de/settings.json";
|
||||
import teamDe from "../../../locales/de/team.json";
|
||||
@@ -72,6 +74,7 @@ import lfg from "../../../locales/en/lfg.json";
|
||||
import org from "../../../locales/en/org.json";
|
||||
import params from "../../../locales/en/params.json";
|
||||
import q from "../../../locales/en/q.json";
|
||||
import scheduleEn from "../../../locales/en/schedule.json";
|
||||
import scrimsEn from "../../../locales/en/scrims.json";
|
||||
import settings from "../../../locales/en/settings.json";
|
||||
import team from "../../../locales/en/team.json";
|
||||
@@ -100,6 +103,7 @@ import lfgEsEs from "../../../locales/es-ES/lfg.json";
|
||||
import orgEsEs from "../../../locales/es-ES/org.json";
|
||||
import paramsEsEs from "../../../locales/es-ES/params.json";
|
||||
import qEsEs from "../../../locales/es-ES/q.json";
|
||||
import scheduleEsEs from "../../../locales/es-ES/schedule.json";
|
||||
import scrimsEsEs from "../../../locales/es-ES/scrims.json";
|
||||
import settingsEsEs from "../../../locales/es-ES/settings.json";
|
||||
import teamEsEs from "../../../locales/es-ES/team.json";
|
||||
@@ -128,6 +132,7 @@ import lfgEsUs from "../../../locales/es-US/lfg.json";
|
||||
import orgEsUs from "../../../locales/es-US/org.json";
|
||||
import paramsEsUs from "../../../locales/es-US/params.json";
|
||||
import qEsUs from "../../../locales/es-US/q.json";
|
||||
import scheduleEsUs from "../../../locales/es-US/schedule.json";
|
||||
import scrimsEsUs from "../../../locales/es-US/scrims.json";
|
||||
import settingsEsUs from "../../../locales/es-US/settings.json";
|
||||
import teamEsUs from "../../../locales/es-US/team.json";
|
||||
@@ -156,6 +161,7 @@ import lfgFrCa from "../../../locales/fr-CA/lfg.json";
|
||||
import orgFrCa from "../../../locales/fr-CA/org.json";
|
||||
import paramsFrCa from "../../../locales/fr-CA/params.json";
|
||||
import qFrCa from "../../../locales/fr-CA/q.json";
|
||||
import scheduleFrCa from "../../../locales/fr-CA/schedule.json";
|
||||
import scrimsFrCa from "../../../locales/fr-CA/scrims.json";
|
||||
import settingsFrCa from "../../../locales/fr-CA/settings.json";
|
||||
import teamFrCa from "../../../locales/fr-CA/team.json";
|
||||
@@ -184,6 +190,7 @@ import lfgFrEu from "../../../locales/fr-EU/lfg.json";
|
||||
import orgFrEu from "../../../locales/fr-EU/org.json";
|
||||
import paramsFrEu from "../../../locales/fr-EU/params.json";
|
||||
import qFrEu from "../../../locales/fr-EU/q.json";
|
||||
import scheduleFrEu from "../../../locales/fr-EU/schedule.json";
|
||||
import scrimsFrEu from "../../../locales/fr-EU/scrims.json";
|
||||
import settingsFrEu from "../../../locales/fr-EU/settings.json";
|
||||
import teamFrEu from "../../../locales/fr-EU/team.json";
|
||||
@@ -212,6 +219,7 @@ import lfgHe from "../../../locales/he/lfg.json";
|
||||
import orgHe from "../../../locales/he/org.json";
|
||||
import paramsHe from "../../../locales/he/params.json";
|
||||
import qHe from "../../../locales/he/q.json";
|
||||
import scheduleHe from "../../../locales/he/schedule.json";
|
||||
import scrimsHe from "../../../locales/he/scrims.json";
|
||||
import settingsHe from "../../../locales/he/settings.json";
|
||||
import teamHe from "../../../locales/he/team.json";
|
||||
@@ -240,6 +248,7 @@ import lfgIt from "../../../locales/it/lfg.json";
|
||||
import orgIt from "../../../locales/it/org.json";
|
||||
import paramsIt from "../../../locales/it/params.json";
|
||||
import qIt from "../../../locales/it/q.json";
|
||||
import scheduleIt from "../../../locales/it/schedule.json";
|
||||
import scrimsIt from "../../../locales/it/scrims.json";
|
||||
import settingsIt from "../../../locales/it/settings.json";
|
||||
import teamIt from "../../../locales/it/team.json";
|
||||
@@ -268,6 +277,7 @@ import lfgJa from "../../../locales/ja/lfg.json";
|
||||
import orgJa from "../../../locales/ja/org.json";
|
||||
import paramsJa from "../../../locales/ja/params.json";
|
||||
import qJa from "../../../locales/ja/q.json";
|
||||
import scheduleJa from "../../../locales/ja/schedule.json";
|
||||
import scrimsJa from "../../../locales/ja/scrims.json";
|
||||
import settingsJa from "../../../locales/ja/settings.json";
|
||||
import teamJa from "../../../locales/ja/team.json";
|
||||
@@ -296,6 +306,7 @@ import lfgKo from "../../../locales/ko/lfg.json";
|
||||
import orgKo from "../../../locales/ko/org.json";
|
||||
import paramsKo from "../../../locales/ko/params.json";
|
||||
import qKo from "../../../locales/ko/q.json";
|
||||
import scheduleKo from "../../../locales/ko/schedule.json";
|
||||
import scrimsKo from "../../../locales/ko/scrims.json";
|
||||
import settingsKo from "../../../locales/ko/settings.json";
|
||||
import teamKo from "../../../locales/ko/team.json";
|
||||
@@ -324,6 +335,7 @@ import lfgNl from "../../../locales/nl/lfg.json";
|
||||
import orgNl from "../../../locales/nl/org.json";
|
||||
import paramsNl from "../../../locales/nl/params.json";
|
||||
import qNl from "../../../locales/nl/q.json";
|
||||
import scheduleNl from "../../../locales/nl/schedule.json";
|
||||
import scrimsNl from "../../../locales/nl/scrims.json";
|
||||
import settingsNl from "../../../locales/nl/settings.json";
|
||||
import teamNl from "../../../locales/nl/team.json";
|
||||
@@ -352,6 +364,7 @@ import lfgPl from "../../../locales/pl/lfg.json";
|
||||
import orgPl from "../../../locales/pl/org.json";
|
||||
import paramsPl from "../../../locales/pl/params.json";
|
||||
import qPl from "../../../locales/pl/q.json";
|
||||
import schedulePl from "../../../locales/pl/schedule.json";
|
||||
import scrimsPl from "../../../locales/pl/scrims.json";
|
||||
import settingsPl from "../../../locales/pl/settings.json";
|
||||
import teamPl from "../../../locales/pl/team.json";
|
||||
@@ -380,6 +393,7 @@ import lfgPtBr from "../../../locales/pt-BR/lfg.json";
|
||||
import orgPtBr from "../../../locales/pt-BR/org.json";
|
||||
import paramsPtBr from "../../../locales/pt-BR/params.json";
|
||||
import qPtBr from "../../../locales/pt-BR/q.json";
|
||||
import schedulePtBr from "../../../locales/pt-BR/schedule.json";
|
||||
import scrimsPtBr from "../../../locales/pt-BR/scrims.json";
|
||||
import settingsPtBr from "../../../locales/pt-BR/settings.json";
|
||||
import teamPtBr from "../../../locales/pt-BR/team.json";
|
||||
@@ -408,6 +422,7 @@ import lfgRu from "../../../locales/ru/lfg.json";
|
||||
import orgRu from "../../../locales/ru/org.json";
|
||||
import paramsRu from "../../../locales/ru/params.json";
|
||||
import qRu from "../../../locales/ru/q.json";
|
||||
import scheduleRu from "../../../locales/ru/schedule.json";
|
||||
import scrimsRu from "../../../locales/ru/scrims.json";
|
||||
import settingsRu from "../../../locales/ru/settings.json";
|
||||
import teamRu from "../../../locales/ru/team.json";
|
||||
@@ -436,6 +451,7 @@ import lfgZh from "../../../locales/zh/lfg.json";
|
||||
import orgZh from "../../../locales/zh/org.json";
|
||||
import paramsZh from "../../../locales/zh/params.json";
|
||||
import qZh from "../../../locales/zh/q.json";
|
||||
import scheduleZh from "../../../locales/zh/schedule.json";
|
||||
import scrimsZh from "../../../locales/zh/scrims.json";
|
||||
import settingsZh from "../../../locales/zh/settings.json";
|
||||
import teamZh from "../../../locales/zh/team.json";
|
||||
@@ -454,6 +470,7 @@ export const resources = {
|
||||
forms: formsEsUs,
|
||||
friends: friendsEsUs,
|
||||
weapons: weaponsEsUs,
|
||||
schedule: scheduleEsUs,
|
||||
scrims: scrimsEsUs,
|
||||
settings: settingsEsUs,
|
||||
common: commonEsUs,
|
||||
@@ -484,6 +501,7 @@ export const resources = {
|
||||
forms: forms,
|
||||
friends: friends,
|
||||
weapons: weapons,
|
||||
schedule: scheduleEn,
|
||||
scrims: scrimsEn,
|
||||
settings: settings,
|
||||
common: common,
|
||||
@@ -514,6 +532,7 @@ export const resources = {
|
||||
forms: formsKo,
|
||||
friends: friendsKo,
|
||||
weapons: weaponsKo,
|
||||
schedule: scheduleKo,
|
||||
scrims: scrimsKo,
|
||||
settings: settingsKo,
|
||||
common: commonKo,
|
||||
@@ -544,6 +563,7 @@ export const resources = {
|
||||
forms: formsDe,
|
||||
friends: friendsDe,
|
||||
weapons: weaponsDe,
|
||||
schedule: scheduleDe,
|
||||
scrims: scrimsDe,
|
||||
settings: settingsDe,
|
||||
common: commonDe,
|
||||
@@ -574,6 +594,7 @@ export const resources = {
|
||||
forms: formsNl,
|
||||
friends: friendsNl,
|
||||
weapons: weaponsNl,
|
||||
schedule: scheduleNl,
|
||||
scrims: scrimsNl,
|
||||
settings: settingsNl,
|
||||
common: commonNl,
|
||||
@@ -604,6 +625,7 @@ export const resources = {
|
||||
forms: formsPtBr,
|
||||
friends: friendsPtBr,
|
||||
weapons: weaponsPtBr,
|
||||
schedule: schedulePtBr,
|
||||
scrims: scrimsPtBr,
|
||||
settings: settingsPtBr,
|
||||
common: commonPtBr,
|
||||
@@ -634,6 +656,7 @@ export const resources = {
|
||||
forms: formsZh,
|
||||
friends: friendsZh,
|
||||
weapons: weaponsZh,
|
||||
schedule: scheduleZh,
|
||||
scrims: scrimsZh,
|
||||
settings: settingsZh,
|
||||
common: commonZh,
|
||||
@@ -664,6 +687,7 @@ export const resources = {
|
||||
forms: formsFrCa,
|
||||
friends: friendsFrCa,
|
||||
weapons: weaponsFrCa,
|
||||
schedule: scheduleFrCa,
|
||||
scrims: scrimsFrCa,
|
||||
settings: settingsFrCa,
|
||||
common: commonFrCa,
|
||||
@@ -694,6 +718,7 @@ export const resources = {
|
||||
forms: formsRu,
|
||||
friends: friendsRu,
|
||||
weapons: weaponsRu,
|
||||
schedule: scheduleRu,
|
||||
scrims: scrimsRu,
|
||||
settings: settingsRu,
|
||||
common: commonRu,
|
||||
@@ -724,6 +749,7 @@ export const resources = {
|
||||
forms: formsIt,
|
||||
friends: friendsIt,
|
||||
weapons: weaponsIt,
|
||||
schedule: scheduleIt,
|
||||
scrims: scrimsIt,
|
||||
settings: settingsIt,
|
||||
common: commonIt,
|
||||
@@ -754,6 +780,7 @@ export const resources = {
|
||||
forms: formsJa,
|
||||
friends: friendsJa,
|
||||
weapons: weaponsJa,
|
||||
schedule: scheduleJa,
|
||||
scrims: scrimsJa,
|
||||
settings: settingsJa,
|
||||
common: commonJa,
|
||||
@@ -784,6 +811,7 @@ export const resources = {
|
||||
forms: formsDa,
|
||||
friends: friendsDa,
|
||||
weapons: weaponsDa,
|
||||
schedule: scheduleDa,
|
||||
scrims: scrimsDa,
|
||||
settings: settingsDa,
|
||||
common: commonDa,
|
||||
@@ -814,6 +842,7 @@ export const resources = {
|
||||
forms: formsEsEs,
|
||||
friends: friendsEsEs,
|
||||
weapons: weaponsEsEs,
|
||||
schedule: scheduleEsEs,
|
||||
scrims: scrimsEsEs,
|
||||
settings: settingsEsEs,
|
||||
common: commonEsEs,
|
||||
@@ -844,6 +873,7 @@ export const resources = {
|
||||
forms: formsHe,
|
||||
friends: friendsHe,
|
||||
weapons: weaponsHe,
|
||||
schedule: scheduleHe,
|
||||
scrims: scrimsHe,
|
||||
settings: settingsHe,
|
||||
common: commonHe,
|
||||
@@ -874,6 +904,7 @@ export const resources = {
|
||||
forms: formsFrEu,
|
||||
friends: friendsFrEu,
|
||||
weapons: weaponsFrEu,
|
||||
schedule: scheduleFrEu,
|
||||
scrims: scrimsFrEu,
|
||||
settings: settingsFrEu,
|
||||
common: commonFrEu,
|
||||
@@ -904,6 +935,7 @@ export const resources = {
|
||||
forms: formsPl,
|
||||
friends: friendsPl,
|
||||
weapons: weaponsPl,
|
||||
schedule: schedulePl,
|
||||
scrims: scrimsPl,
|
||||
settings: settingsPl,
|
||||
common: commonPl,
|
||||
|
||||
@@ -19,6 +19,7 @@ const ALL_NAMESPACES = [
|
||||
"user",
|
||||
"weapons",
|
||||
"scrims",
|
||||
"schedule",
|
||||
"tournament",
|
||||
"team",
|
||||
"tier-list-maker",
|
||||
|
||||
9
locales/da/schedule.json
Normal file
9
locales/da/schedule.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.note": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": ""
|
||||
}
|
||||
9
locales/de/schedule.json
Normal file
9
locales/de/schedule.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.note": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": ""
|
||||
}
|
||||
9
locales/en/schedule.json
Normal file
9
locales/en/schedule.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"editor.addTime": "Add time",
|
||||
"editor.earlier": "Earlier",
|
||||
"editor.editDay": "Edit {{day}}",
|
||||
"editor.later": "Later",
|
||||
"editor.note": "Note",
|
||||
"editor.timesInYourTimezone": "Times in your time zone",
|
||||
"editor.visibility": "Visible to your teammates and friends"
|
||||
}
|
||||
9
locales/es-ES/schedule.json
Normal file
9
locales/es-ES/schedule.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.note": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": ""
|
||||
}
|
||||
9
locales/es-US/schedule.json
Normal file
9
locales/es-US/schedule.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.note": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": ""
|
||||
}
|
||||
9
locales/fr-CA/schedule.json
Normal file
9
locales/fr-CA/schedule.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.note": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": ""
|
||||
}
|
||||
9
locales/fr-EU/schedule.json
Normal file
9
locales/fr-EU/schedule.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.note": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": ""
|
||||
}
|
||||
9
locales/he/schedule.json
Normal file
9
locales/he/schedule.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.note": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": ""
|
||||
}
|
||||
9
locales/it/schedule.json
Normal file
9
locales/it/schedule.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.note": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": ""
|
||||
}
|
||||
9
locales/ja/schedule.json
Normal file
9
locales/ja/schedule.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.note": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": ""
|
||||
}
|
||||
9
locales/ko/schedule.json
Normal file
9
locales/ko/schedule.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.note": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": ""
|
||||
}
|
||||
9
locales/nl/schedule.json
Normal file
9
locales/nl/schedule.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.note": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": ""
|
||||
}
|
||||
9
locales/pl/schedule.json
Normal file
9
locales/pl/schedule.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.note": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": ""
|
||||
}
|
||||
9
locales/pt-BR/schedule.json
Normal file
9
locales/pt-BR/schedule.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.note": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": ""
|
||||
}
|
||||
9
locales/ru/schedule.json
Normal file
9
locales/ru/schedule.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.note": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": ""
|
||||
}
|
||||
9
locales/zh/schedule.json
Normal file
9
locales/zh/schedule.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"editor.addTime": "",
|
||||
"editor.earlier": "",
|
||||
"editor.editDay": "",
|
||||
"editor.later": "",
|
||||
"editor.note": "",
|
||||
"editor.timesInYourTimezone": "",
|
||||
"editor.visibility": ""
|
||||
}
|
||||
Reference in New Issue
Block a user