WeekAvailabilityEditor

This commit is contained in:
Kalle
2026-08-22 10:45:22 +03:00
parent 6216b8957f
commit f45ecf648c
27 changed files with 1831 additions and 1 deletions

View File

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

View File

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

View File

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

View 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);

View File

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

View File

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

View File

@@ -35,3 +35,7 @@
.trophyExampleLarge {
width: 200px;
}
.scheduleNarrow {
max-width: 360px;
}

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ const ALL_NAMESPACES = [
"user",
"weapons",
"scrims",
"schedule",
"tournament",
"team",
"tier-list-maker",

9
locales/da/schedule.json Normal file
View 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
View 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
View 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"
}

View File

@@ -0,0 +1,9 @@
{
"editor.addTime": "",
"editor.earlier": "",
"editor.editDay": "",
"editor.later": "",
"editor.note": "",
"editor.timesInYourTimezone": "",
"editor.visibility": ""
}

View File

@@ -0,0 +1,9 @@
{
"editor.addTime": "",
"editor.earlier": "",
"editor.editDay": "",
"editor.later": "",
"editor.note": "",
"editor.timesInYourTimezone": "",
"editor.visibility": ""
}

View File

@@ -0,0 +1,9 @@
{
"editor.addTime": "",
"editor.earlier": "",
"editor.editDay": "",
"editor.later": "",
"editor.note": "",
"editor.timesInYourTimezone": "",
"editor.visibility": ""
}

View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,9 @@
{
"editor.addTime": "",
"editor.earlier": "",
"editor.editDay": "",
"editor.later": "",
"editor.note": "",
"editor.timesInYourTimezone": "",
"editor.visibility": ""
}

View 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
View 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
View File

@@ -0,0 +1,9 @@
{
"editor.addTime": "",
"editor.earlier": "",
"editor.editDay": "",
"editor.later": "",
"editor.note": "",
"editor.timesInYourTimezone": "",
"editor.visibility": ""
}