From f45ecf648cbde683c27d5773f35d0a76c72aecdc Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:45:22 +0300 Subject: [PATCH] WeekAvailabilityEditor --- .../availability/availability-constants.ts | 8 + .../availability/availability-types.ts | 27 + .../WeekAvailabilityEditor.module.css | 381 +++++++++ .../components/WeekAvailabilityEditor.tsx | 794 ++++++++++++++++++ .../availability/core/Availability.test.ts | 183 ++++ .../availability/core/Availability.ts | 169 ++++ .../components-showcase.module.css | 4 + .../components-showcase/routes/components.tsx | 87 +- app/modules/i18n/resources.browser.ts | 2 + app/modules/i18n/resources.server.ts | 32 + app/utils/i18n.ts | 1 + locales/da/schedule.json | 9 + locales/de/schedule.json | 9 + locales/en/schedule.json | 9 + locales/es-ES/schedule.json | 9 + locales/es-US/schedule.json | 9 + locales/fr-CA/schedule.json | 9 + locales/fr-EU/schedule.json | 9 + locales/he/schedule.json | 9 + locales/it/schedule.json | 9 + locales/ja/schedule.json | 9 + locales/ko/schedule.json | 9 + locales/nl/schedule.json | 9 + locales/pl/schedule.json | 9 + locales/pt-BR/schedule.json | 9 + locales/ru/schedule.json | 9 + locales/zh/schedule.json | 9 + 27 files changed, 1831 insertions(+), 1 deletion(-) create mode 100644 app/features/availability/components/WeekAvailabilityEditor.module.css create mode 100644 app/features/availability/components/WeekAvailabilityEditor.tsx create mode 100644 locales/da/schedule.json create mode 100644 locales/de/schedule.json create mode 100644 locales/en/schedule.json create mode 100644 locales/es-ES/schedule.json create mode 100644 locales/es-US/schedule.json create mode 100644 locales/fr-CA/schedule.json create mode 100644 locales/fr-EU/schedule.json create mode 100644 locales/he/schedule.json create mode 100644 locales/it/schedule.json create mode 100644 locales/ja/schedule.json create mode 100644 locales/ko/schedule.json create mode 100644 locales/nl/schedule.json create mode 100644 locales/pl/schedule.json create mode 100644 locales/pt-BR/schedule.json create mode 100644 locales/ru/schedule.json create mode 100644 locales/zh/schedule.json diff --git a/app/features/availability/availability-constants.ts b/app/features/availability/availability-constants.ts index 1fad48f47..da489fa12 100644 --- a/app/features/availability/availability-constants.ts +++ b/app/features/availability/availability-constants.ts @@ -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; diff --git a/app/features/availability/availability-types.ts b/app/features/availability/availability-types.ts index f93c24dd2..c96c7d662 100644 --- a/app/features/availability/availability-types.ts +++ b/app/features/availability/availability-types.ts @@ -22,3 +22,30 @@ export interface PlayableWindow extends TimeRange { /** Members free for the whole window, in the order they were given. */ userIds: Array; } + +/** + * 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; + note: string; +} + +/** The schedule editor's value: the seven days of one week, Monday first. */ +export type AvailabilityEditorWeek = Array; + +/** A commitment shown on the editor as a locked block that cannot be painted over. */ +export interface EditorCommitment { + date: string; + range: DayTimeRange; + name: string; +} diff --git a/app/features/availability/components/WeekAvailabilityEditor.module.css b/app/features/availability/components/WeekAvailabilityEditor.module.css new file mode 100644 index 000000000..a08c8f9de --- /dev/null +++ b/app/features/availability/components/WeekAvailabilityEditor.module.css @@ -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; +} diff --git a/app/features/availability/components/WeekAvailabilityEditor.tsx b/app/features/availability/components/WeekAvailabilityEditor.tsx new file mode 100644 index 000000000..d7a7ff44b --- /dev/null +++ b/app/features/availability/components/WeekAvailabilityEditor.tsx @@ -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; + 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; +}) { + 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(null); + const gestureRef = React.useRef(null); + const [earlierShown, setEarlierShown] = React.useState(false); + const [laterShown, setLaterShown] = React.useState(false); + const [openDayDate, setOpenDayDate] = React.useState(null); + const [openDayAddRow, setOpenDayAddRow] = React.useState(false); + const popoverAnchorRef = React.useRef(null); + const dayDraftRef = React.useRef(null); + const trackRefs = React.useRef>([]); + const suppressClickRef = React.useRef(false); + + const trackStart = earlierShown + ? AVAILABILITY.TRACK_EARLIER_START_MINUTES + : AVAILABILITY.TRACK_START_MINUTES; + const trackEnd = laterShown + ? AVAILABILITY.TRACK_LATER_END_MINUTES + : AVAILABILITY.TRACK_END_MINUTES; + + const wallsOf = (date: string) => + commitments + .filter((commitment) => commitment.date === date) + .map((commitment) => commitment.range); + + const pct = (minutes: number) => + ((Math.min(Math.max(minutes, trackStart), trackEnd) - trackStart) / + (trackEnd - trackStart)) * + 100; + + const barStyle = (range: DayTimeRange) => ({ + left: `${pct(range.start)}%`, + width: `${pct(range.end) - pct(range.start)}%`, + }); + + const dateAt = (date: string, minutes: number) => { + const [year, month, day] = date.split("-").map(Number); + + 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) => { + onChange( + value.map((day, index) => + index === dayIndex ? { ...day, ranges } : day, + ), + ); + }; + + const handleTrackPointerDown = + (dayIndex: number) => (event: React.PointerEvent) => { + 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) => { + 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) => { + 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) => { + 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, + ) => { + if (suppressClickRef.current) { + suppressClickRef.current = false; + return; + } + + openDayEditor(date, event.currentTarget); + }; + + const axisHours: Array = []; + for ( + let hour = trackStart / 60; + hour <= trackEnd / 60; + hour += AXIS_LABEL_EVERY_HOURS + ) { + axisHours.push(hour); + } + + const openDay = value.find((day) => day.date === openDayDate); + + const dayRow = (day: AvailabilityEditorDay, dayIndex: number) => { + 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 ( + +
+ {dayLabelText(day)} + {day.note ? ( + + ) : null} +
+
{ + 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) => ( +
+ ))} + {dayCommitments.map((commitment) => ( +
+ {commitment.name} +
+ ))} + {day.ranges.map((range) => { + const isDragged = + (dayGesture?.type === "move" || dayGesture?.type === "resize") && + sameRange(range, dayGesture.original); + const shown = isDragged && dayGesture ? dayGesture.range : range; + + return ( + + ); + })} + {gesture?.type === "paint" && + gesture.dayIndex === dayIndex && + gesture.range ? ( +
+ ) : null} + {fillPreview.map((piece) => ( +
+ ))} + {liveRange ? ( + + {rangeText(day.date, liveRange)} + + ) : null} +
+ + + ); + }; + + return ( +
+
+
+ +
+ {axisHours.map((hour) => ( + + {hourFormatter.format(dateAt(value[0].date, hour * 60))} + + ))} +
+ + {value.map((day, dayIndex) => dayRow(day, dayIndex))} +
+
+ {value.map((day) => { + const dayCommitments = commitments.filter( + (commitment) => commitment.date === day.date, + ); + + return ( +
+
{dayLabelText(day)}
+
+ {day.ranges.map((range) => ( + + ))} + {dayCommitments.map((commitment) => ( + + {commitment.name} ·{" "} + {rangeText(day.date, commitment.range)} + + ))} + +
+ {day.note ? ( +
+ + {day.note} +
+ ) : null} +
+ ); + })} +
+

+ {t("schedule:editor.timesInYourTimezone")} ·{" "} + {t("schedule:editor.visibility")} +

+
+ {openDay ? ( + { + if (!isOpen) closeDayEditor(); + }} + triggerRef={popoverAnchorRef} + > + { + dayDraftRef.current = draft; + }} + /> + + ) : null} +
+ ); +} + +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>(() => { + 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, nextNote: string) => { + setRanges(nextRanges); + setNote(nextNote); + onDraftChange({ ranges: nextRanges, note: nextNote }); + }; + + return ( +
+
{dayLabel}
+ {ranges.map((range) => ( +
+ + 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")} + /> + } + variant="minimal-destructive" + size="small" + aria-label={t("common:actions.delete")} + onPress={() => + update( + ranges.filter((other) => other.id !== range.id), + note, + ) + } + /> +
+ ))} + } + 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")} + +
+ + update(ranges, event.target.value)} + /> +
+
+ ); +} + +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); diff --git a/app/features/availability/core/Availability.test.ts b/app/features/availability/core/Availability.test.ts index 98b702bf8..81124e871 100644 --- a/app/features/availability/core/Availability.test.ts +++ b/app/features/availability/core/Availability.test.ts @@ -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)); + }); +}); diff --git a/app/features/availability/core/Availability.ts b/app/features/availability/core/Availability.ts index 51acf5cdd..5cdfe399e 100644 --- a/app/features/availability/core/Availability.ts +++ b/app/features/availability/core/Availability.ts @@ -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, +): Array { + 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 | 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) => + mergedDayRanges(walls).some( + (wall) => wall.start <= point && point < wall.end, + ); diff --git a/app/features/components-showcase/components-showcase.module.css b/app/features/components-showcase/components-showcase.module.css index ada31b8d2..eeb0e8674 100644 --- a/app/features/components-showcase/components-showcase.module.css +++ b/app/features/components-showcase/components-showcase.module.css @@ -35,3 +35,7 @@ .trophyExampleLarge { width: 200px; } + +.scheduleNarrow { + max-width: 360px; +} diff --git a/app/features/components-showcase/routes/components.tsx b/app/features/components-showcase/routes/components.tsx index 14c673891..5b1f3ef86 100644 --- a/app/features/components-showcase/routes/components.tsx +++ b/app/features/components-showcase/routes/components.tsx @@ -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 = [ + { + 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 ( +
+ Schedule + +
+
+
Week availability editor
+ +
+ setWeek(SCHEDULE_EXAMPLE_WEEK)} + > + Reset + + + toastQueue.add({ + message: `Saved week with ${rangeCount} time ranges`, + variant: "success", + }) + } + > + Save week + +
+
+ + +
+ +
+
+
+
+ ); +} + function MiscSection({ id }: { id: string }) { const [rangeValue, setRangeValue] = useState(50); const [colorValue, setColorValue] = useState("#3b82f6"); diff --git a/app/modules/i18n/resources.browser.ts b/app/modules/i18n/resources.browser.ts index a62c5f313..67165ace4 100644 --- a/app/modules/i18n/resources.browser.ts +++ b/app/modules/i18n/resources.browser.ts @@ -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, diff --git a/app/modules/i18n/resources.server.ts b/app/modules/i18n/resources.server.ts index faf9546f5..ee6a938c1 100644 --- a/app/modules/i18n/resources.server.ts +++ b/app/modules/i18n/resources.server.ts @@ -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, diff --git a/app/utils/i18n.ts b/app/utils/i18n.ts index 9908fc525..a83f44e9f 100644 --- a/app/utils/i18n.ts +++ b/app/utils/i18n.ts @@ -19,6 +19,7 @@ const ALL_NAMESPACES = [ "user", "weapons", "scrims", + "schedule", "tournament", "team", "tier-list-maker", diff --git a/locales/da/schedule.json b/locales/da/schedule.json new file mode 100644 index 000000000..bbb140d5c --- /dev/null +++ b/locales/da/schedule.json @@ -0,0 +1,9 @@ +{ + "editor.addTime": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.note": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "" +} diff --git a/locales/de/schedule.json b/locales/de/schedule.json new file mode 100644 index 000000000..bbb140d5c --- /dev/null +++ b/locales/de/schedule.json @@ -0,0 +1,9 @@ +{ + "editor.addTime": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.note": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "" +} diff --git a/locales/en/schedule.json b/locales/en/schedule.json new file mode 100644 index 000000000..f930b1bff --- /dev/null +++ b/locales/en/schedule.json @@ -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" +} diff --git a/locales/es-ES/schedule.json b/locales/es-ES/schedule.json new file mode 100644 index 000000000..bbb140d5c --- /dev/null +++ b/locales/es-ES/schedule.json @@ -0,0 +1,9 @@ +{ + "editor.addTime": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.note": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "" +} diff --git a/locales/es-US/schedule.json b/locales/es-US/schedule.json new file mode 100644 index 000000000..bbb140d5c --- /dev/null +++ b/locales/es-US/schedule.json @@ -0,0 +1,9 @@ +{ + "editor.addTime": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.note": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "" +} diff --git a/locales/fr-CA/schedule.json b/locales/fr-CA/schedule.json new file mode 100644 index 000000000..bbb140d5c --- /dev/null +++ b/locales/fr-CA/schedule.json @@ -0,0 +1,9 @@ +{ + "editor.addTime": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.note": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "" +} diff --git a/locales/fr-EU/schedule.json b/locales/fr-EU/schedule.json new file mode 100644 index 000000000..bbb140d5c --- /dev/null +++ b/locales/fr-EU/schedule.json @@ -0,0 +1,9 @@ +{ + "editor.addTime": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.note": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "" +} diff --git a/locales/he/schedule.json b/locales/he/schedule.json new file mode 100644 index 000000000..bbb140d5c --- /dev/null +++ b/locales/he/schedule.json @@ -0,0 +1,9 @@ +{ + "editor.addTime": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.note": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "" +} diff --git a/locales/it/schedule.json b/locales/it/schedule.json new file mode 100644 index 000000000..bbb140d5c --- /dev/null +++ b/locales/it/schedule.json @@ -0,0 +1,9 @@ +{ + "editor.addTime": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.note": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "" +} diff --git a/locales/ja/schedule.json b/locales/ja/schedule.json new file mode 100644 index 000000000..bbb140d5c --- /dev/null +++ b/locales/ja/schedule.json @@ -0,0 +1,9 @@ +{ + "editor.addTime": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.note": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "" +} diff --git a/locales/ko/schedule.json b/locales/ko/schedule.json new file mode 100644 index 000000000..bbb140d5c --- /dev/null +++ b/locales/ko/schedule.json @@ -0,0 +1,9 @@ +{ + "editor.addTime": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.note": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "" +} diff --git a/locales/nl/schedule.json b/locales/nl/schedule.json new file mode 100644 index 000000000..bbb140d5c --- /dev/null +++ b/locales/nl/schedule.json @@ -0,0 +1,9 @@ +{ + "editor.addTime": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.note": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "" +} diff --git a/locales/pl/schedule.json b/locales/pl/schedule.json new file mode 100644 index 000000000..bbb140d5c --- /dev/null +++ b/locales/pl/schedule.json @@ -0,0 +1,9 @@ +{ + "editor.addTime": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.note": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "" +} diff --git a/locales/pt-BR/schedule.json b/locales/pt-BR/schedule.json new file mode 100644 index 000000000..bbb140d5c --- /dev/null +++ b/locales/pt-BR/schedule.json @@ -0,0 +1,9 @@ +{ + "editor.addTime": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.note": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "" +} diff --git a/locales/ru/schedule.json b/locales/ru/schedule.json new file mode 100644 index 000000000..bbb140d5c --- /dev/null +++ b/locales/ru/schedule.json @@ -0,0 +1,9 @@ +{ + "editor.addTime": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.note": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "" +} diff --git a/locales/zh/schedule.json b/locales/zh/schedule.json new file mode 100644 index 000000000..bbb140d5c --- /dev/null +++ b/locales/zh/schedule.json @@ -0,0 +1,9 @@ +{ + "editor.addTime": "", + "editor.earlier": "", + "editor.editDay": "", + "editor.later": "", + "editor.note": "", + "editor.timesInYourTimezone": "", + "editor.visibility": "" +}