import clsx from "clsx"; import { Flag, Plus, SquarePen, Trash } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import * as R from "remeda"; 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 { ClockAxis, TrackCommitment, TrackTicks, useClockWindow, } from "./ScheduleTracks"; import trackStyles from "./ScheduleTracks.module.css"; import styles from "./WeekAvailabilityEditor.module.css"; const MOVE_THRESHOLD_PX = 4; 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: on wide containers a track per day where ranges are * painted, moved, resized and drag-filled; on narrow ones a stacked per-day list. Both share the * popover with exact time inputs and the day note, also the keyboard path. Commitments are locked * blocks: gestures may cross them, but a new range cannot start on one. */ export function WeekAvailabilityEditor({ value, onChange, commitments = [], notSharedWith = [], onPendingDraftChange, }: { value: AvailabilityEditorWeek; onChange: (value: AvailabilityEditorWeek) => void; commitments?: Array; /** Named friends and teams the week is kept from, called out under the tracks. */ notSharedWith?: Array; /** Reports edits typed in the day popover but not yet committed into `value`, which an unsaved changes guard would otherwise miss. */ onPendingDraftChange?: (hasPendingDraft: boolean) => void; }) { const { t, i18n } = useTranslation(["schedule", "common"]); const { formatter: dayFormatter } = useDateTimeFormat({ weekday: "short", day: "numeric", }); const { formatter: timeFormatter } = useDateTimeFormat({ hour: "numeric", minute: "2-digit", }); const clockWindow = useClockWindow({ expandTo: [ ...value.flatMap((day) => day.ranges), ...commitments.map((commitment) => commitment.range), ], }); const { trackStart, trackEnd, pct, barStyle } = clockWindow; const gestureWindow = { trackStart: Math.min(trackStart, AVAILABILITY.TRACK_EARLIER_START_MINUTES), trackEnd: Math.max(trackEnd, AVAILABILITY.TRACK_LATER_END_MINUTES), }; const [gesture, setGesture] = React.useState(null); const gestureRef = React.useRef(null); 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 pressPointRef = React.useRef<{ x: number; y: number } | null>(null); const wallsOf = (date: string) => commitments .filter((commitment) => commitment.date === date) .map((commitment) => commitment.range); 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 = (clientX - rect.left) / rect.width; return R.clamp(trackStart + fraction * (trackEnd - trackStart), { min: gestureWindow.trackStart, max: gestureWindow.trackEnd, }); }; 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 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); pressPointRef.current = { x: event.clientX, y: event.clientY }; 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); pressPointRef.current = { x: event.clientX, y: event.clientY }; 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); pressPointRef.current = { x: event.clientX, y: event.clientY }; 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), ...gestureWindow, }), }); 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, ), ...gestureWindow, }), }); break; } case "resize": { applyGesture({ ...current, range: Availability.resizedRange({ range: current.original, edge: current.edge, cursor: minutesAt(current.dayIndex, event.clientX), ...gestureWindow, }), }); 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" ) { replaceDayRanges( current.dayIndex, Availability.mergedDayRanges([ ...value[current.dayIndex].ranges.filter( (range) => !sameRange(range, current.original), ), current.range, ]), ); } else if (current.type === "fill") { 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; onPendingDraftChange?.(false); setOpenDayDate(date); setOpenDayAddRow(addRow); }; const dayFromDraft = (day: AvailabilityEditorDay, draft: DayDraft) => ({ ...day, ranges: Availability.mergedDayRanges( draft.ranges .filter((range) => range.start && range.end) .map((range) => Availability.dayRangeFromTimes(range.start, range.end)), ), note: draft.note.trim(), }); const applyDayDraft = (date: string, draft: DayDraft) => { onChange( value.map((day) => (day.date === date ? dayFromDraft(day, draft) : day)), ); onPendingDraftChange?.(false); }; const closeDayEditor = () => { const draft = dayDraftRef.current; if (draft && openDayDate) { applyDayDraft(openDayDate, draft); } dayDraftRef.current = null; onPendingDraftChange?.(false); setOpenDayDate(null); }; // deleting commits right away so the bar disappears as pressed; with no ranges left the popover closes too const handleRangeDelete = (draft: DayDraft) => { if (!openDayDate) return; applyDayDraft(openDayDate, draft); if (draft.ranges.every((range) => !range.start || !range.end)) { dayDraftRef.current = null; setOpenDayDate(null); } else { dayDraftRef.current = draft; } }; const handleBarClick = ( date: string, event: React.MouseEvent, ) => { const pressedAt = pressPointRef.current; pressPointRef.current = null; if ( pressedAt && event.detail > 0 && Math.hypot(event.clientX - pressedAt.x, event.clientY - pressedAt.y) > MOVE_THRESHOLD_PX ) { return; } openDayEditor(date, event.currentTarget); }; 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; // a plain click on a bar starts a move gesture too; the live time label belongs only to an actual drag const liveRange = dayGesture?.type === "move" && !dayGesture.moved ? null : (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={clsx(trackStyles.track, styles.paintable)} data-testid={`availability-track-${dayIndex}`} onPointerDown={handleTrackPointerDown(dayIndex)} onPointerMove={handleGestureMove} onPointerUp={handleGestureEnd} onPointerCancel={handleGestureCancel} > {dayCommitments.map((commitment, index) => ( ))} {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 (
{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, index) => ( {commitment.name} ·{" "} {rangeText(day.date, commitment.range)} ))}
{day.note ? (
{day.note}
) : null}
); })}

{t("schedule:editor.timesInYourTimezone")} {notSharedWith.length > 0 ? ( {" · "} {t("schedule:editor.notSharedWith", { audiences: new Intl.ListFormat(i18n.language).format( notSharedWith, ), })} ) : null}

{openDay ? ( { if (!isOpen) closeDayEditor(); }} triggerRef={popoverAnchorRef} aria-label={t("schedule:editor.editDay", { day: dayLabelText(openDay), })} > { dayDraftRef.current = draft; onPendingDraftChange?.( !R.isDeepEqual(dayFromDraft(openDay, draft), openDay), ); }} onRangeDelete={handleRangeDelete} /> ) : null}
); } function DayEditor({ day, dayLabel, startWithNewRow, onDraftChange, onRangeDelete, }: { day: AvailabilityEditorDay; dayLabel: string; /** Opens with an empty row already appended, for an "add time" entry point. */ startWithNewRow: boolean; onDraftChange: (draft: DayDraft) => void; /** Called with the remaining draft after a range row is deleted; deletes commit instantly. */ onRangeDelete: (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")} onClick={() => { const remaining = ranges.filter((other) => other.id !== range.id); update(remaining, note); onRangeDelete({ ranges: remaining, note }); }} />
))} } variant="minimal" size="small" className={styles.dayEditorAdd} onClick={() => { 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);