Created
May 12, 2026 21:50
-
-
Save ryanflorence/9fd7f28dc43dae93960cc7b8c1b51b29 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { | |
| clientEntry, | |
| css, | |
| on, | |
| ref, | |
| type Handle, | |
| type SerializableValue, | |
| } from "remix/ui" | |
| import { theme } from "remix/ui/theme" | |
| import { | |
| previewCopyBlockAcrossDays, | |
| previewDeleteBlock, | |
| previewMoveBlock, | |
| previewResizeBlockTime, | |
| type ScheduleLayoutBlock, | |
| type ScheduleLayoutResult, | |
| } from "./schedule-layout.ts" | |
| type GridBlockDocument = ScheduleLayoutBlock & { | |
| [key: string]: SerializableValue | |
| } | |
| export type GridScheduleDocument = { | |
| [key: string]: SerializableValue | |
| blocks: GridBlockDocument[] | |
| id: number | |
| name: string | |
| revision: number | |
| updatedAt: number | |
| } | |
| type GridInputId = string | |
| type DraftBlock = GridBlockDocument & { | |
| id: string | |
| } | |
| type DragState = { | |
| active: boolean | |
| blockId: GridInputId | |
| duration: number | |
| grid: GridMeasurement | |
| offsetX: number | |
| offsetY: number | |
| originalBlocks: ScheduleLayoutBlock[] | |
| pointerId: number | |
| startX: number | |
| startY: number | |
| } | |
| type ResizeEdge = "start" | "end" | |
| type ResizeState = { | |
| active: boolean | |
| blockId: GridInputId | |
| edge: ResizeEdge | |
| grid: GridMeasurement | |
| offsetY: number | |
| originalBlock: GridBlockDocument | |
| originalBlocks: ScheduleLayoutBlock[] | |
| pointerId: number | |
| startY: number | |
| } | |
| type HorizontalResizeEdge = "dayStart" | "dayEnd" | |
| type HorizontalResizeState = { | |
| active: boolean | |
| blockId: GridInputId | |
| edge: HorizontalResizeEdge | |
| grid: GridMeasurement | |
| idPrefix: string | |
| offsetX: number | |
| originalBlock: GridBlockDocument | |
| originalBlocks: ScheduleLayoutBlock[] | |
| pointerId: number | |
| startX: number | |
| } | |
| type GestureKind = "drag" | "horizontal-resize" | "resize" | |
| type GridMeasurement = { | |
| dayWidth: number | |
| labelWidth: number | |
| left: number | |
| rowHeight: number | |
| top: number | |
| } | |
| type BlockPlacement = { | |
| dayOfWeek: number | |
| startMinute: number | |
| } | |
| export const ScheduleGrid = clientEntry( | |
| import.meta.url, | |
| function ScheduleGrid( | |
| handle: Handle<{ | |
| csrfToken: string | |
| schedule: GridScheduleDocument | |
| }>, | |
| ) { | |
| let schedule = handle.props.schedule | |
| let draftBlock: DraftBlock | null = null | |
| let dragState: DragState | null = null | |
| let horizontalResizeState: HorizontalResizeState | null = null | |
| let preview: ScheduleLayoutResult | null = null | |
| let resizeState: ResizeState | null = null | |
| let gridElement: HTMLDivElement | null = null | |
| let activeGesture: GestureKind | null = null | |
| let horizontalResizeSequence = 0 | |
| let saveSequence = 0 | |
| let latestAppliedSaveSequence = 0 | |
| return () => { | |
| if (handle.props.schedule.id !== schedule.id) { | |
| schedule = handle.props.schedule | |
| draftBlock = null | |
| dragState = null | |
| horizontalResizeState = null | |
| preview = null | |
| resizeState = null | |
| activeGesture = null | |
| horizontalResizeSequence = 0 | |
| saveSequence = 0 | |
| latestAppliedSaveSequence = 0 | |
| } | |
| let visibleBlocks = (preview?.blocks ?? schedule.blocks) as GridBlockDocument[] | |
| return ( | |
| <section aria-label="Weekly schedule" mix={weekScheduleStyle}> | |
| <div mix={calendarTitleStyle}>{schedule.name}</div> | |
| <div mix={dayHeaderGridStyle}> | |
| <div aria-hidden="true" /> | |
| {weekDays.map((day) => ( | |
| <div key={day} mix={dayHeaderStyle}> | |
| {day} | |
| </div> | |
| ))} | |
| </div> | |
| <div mix={timeGridScrollerStyle}> | |
| <div | |
| mix={[ | |
| timeGridStyle, | |
| ref((node, signal) => { | |
| gridElement = node | |
| signal.addEventListener("abort", () => { | |
| if (gridElement === node) gridElement = null | |
| }) | |
| }), | |
| ]} | |
| > | |
| <div mix={timeRowsStyle}> | |
| <div aria-hidden="true" mix={timeRowStyle}> | |
| <div /> | |
| {weekDays.map((day) => ( | |
| <div key={`spacer-${day}`} mix={spacerTimeCellStyle} /> | |
| ))} | |
| </div> | |
| {timeSlots.map((time) => ( | |
| <div key={time} mix={timeRowStyle}> | |
| <div | |
| mix={[ | |
| timeLabelStyle, | |
| isHourSlot(time) ? hourTimeLabelStyle : undefined, | |
| ]} | |
| > | |
| {time} | |
| </div> | |
| {weekDays.map((day) => ( | |
| <div | |
| aria-label={`${day} ${time}`} | |
| key={`${day}-${time}`} | |
| mix={[ | |
| timeCellStyle, | |
| on("click", () => { | |
| startDraft(day, time) | |
| }), | |
| isHourSlot(time) ? hourTimeCellStyle : undefined, | |
| isHalfHourSlot(time) | |
| ? halfHourTimeCellStyle | |
| : undefined, | |
| ]} | |
| /> | |
| ))} | |
| </div> | |
| ))} | |
| </div> | |
| <div aria-label="Scheduled blocks" mix={blockLayerStyle}> | |
| {visibleBlocks.map((block) => ( | |
| <ScheduleBlock | |
| block={block} | |
| isDragging={dragState?.active === true && dragState.blockId === block.id} | |
| isDraft={draftBlock?.id === block.id} | |
| isHorizontalResizing={horizontalResizeState?.active === true && horizontalResizeState.blockId === block.id} | |
| isResizing={resizeState?.active === true && resizeState.blockId === block.id} | |
| key={block.id} | |
| onCancelDraft={cancelDraft} | |
| onCommit={commitBlock} | |
| onDelete={deleteBlock} | |
| onDragEnd={endDrag} | |
| onDragMove={moveDrag} | |
| onDragStart={startDrag} | |
| onHorizontalResizeEnd={endHorizontalResize} | |
| onHorizontalResizeMove={moveHorizontalResize} | |
| onHorizontalResizeStart={startHorizontalResize} | |
| onResizeEnd={endResize} | |
| onResizeMove={moveResize} | |
| onResizeStart={startResize} | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| ) | |
| } | |
| function startDraft(day: string, time: string) { | |
| if (draftBlock) return | |
| let startMinute = timeToMinute(time) | |
| draftBlock = { | |
| color: null, | |
| dayOfWeek: weekDays.indexOf(day), | |
| endMinute: startMinute + slotMinutes, | |
| id: `block-${Date.now()}`, | |
| name: "", | |
| startMinute, | |
| } | |
| schedule.blocks.push(draftBlock) | |
| handle.update() | |
| } | |
| function cancelDraft() { | |
| if (draftBlock) { | |
| schedule.blocks = schedule.blocks.filter( | |
| (block) => block.id !== draftBlock?.id, | |
| ) | |
| } | |
| draftBlock = null | |
| handle.update() | |
| } | |
| function commitBlock(block: GridBlockDocument) { | |
| let name = block.name.trim() | |
| if (!name) { | |
| if (draftBlock?.id === block.id) cancelDraft() | |
| return | |
| } | |
| block.name = name | |
| if (draftBlock?.id === block.id) draftBlock = null | |
| handle.update() | |
| saveSchedule() | |
| } | |
| function deleteBlock(block: GridBlockDocument) { | |
| if (draftBlock?.id === block.id) { | |
| cancelDraft() | |
| return | |
| } | |
| let result = previewDeleteBlock(schedule.blocks, block.id) | |
| schedule.blocks = result.blocks as GridBlockDocument[] | |
| handle.update() | |
| saveSchedule() | |
| } | |
| function startDrag(block: GridBlockDocument, event: PointerEvent) { | |
| if ( | |
| draftBlock || | |
| activeGesture || | |
| horizontalResizeState || | |
| resizeState || | |
| event.button !== 0 || | |
| !gridElement | |
| ) { | |
| return | |
| } | |
| let grid = measureGrid(gridElement) | |
| let blockLeft = grid.left + grid.labelWidth + block.dayOfWeek * grid.dayWidth | |
| let blockTop = | |
| grid.top + (startMinuteToSlotIndex(block.startMinute) + 1) * grid.rowHeight | |
| dragState = { | |
| active: false, | |
| blockId: block.id, | |
| duration: block.endMinute - block.startMinute, | |
| grid, | |
| offsetX: event.clientX - blockLeft, | |
| offsetY: event.clientY - blockTop, | |
| originalBlocks: schedule.blocks.map(copyBlock), | |
| pointerId: event.pointerId, | |
| startX: event.clientX, | |
| startY: event.clientY, | |
| } | |
| bindGesture("drag") | |
| } | |
| function moveDrag(event: PointerEvent) { | |
| if (!dragState || dragState.pointerId !== event.pointerId) return | |
| let distance = Math.hypot( | |
| event.clientX - dragState.startX, | |
| event.clientY - dragState.startY, | |
| ) | |
| if (!dragState.active && distance < dragThreshold) return | |
| dragState.active = true | |
| event.preventDefault() | |
| let placement = pointerToPlacement(event, dragState) | |
| updatePreview( | |
| previewMoveBlock(dragState.originalBlocks, dragState.blockId, placement), | |
| ) | |
| } | |
| function endDrag(event: PointerEvent) { | |
| if (!dragState || dragState.pointerId !== event.pointerId) return | |
| unbindGesture() | |
| let finalPreview = dragState.active && preview && !preview.unresolved | |
| ? preview | |
| : null | |
| dragState = null | |
| if (finalPreview) { | |
| event.preventDefault() | |
| schedule.blocks = finalPreview.blocks as GridBlockDocument[] | |
| preview = null | |
| saveSchedule() | |
| handle.update() | |
| return | |
| } | |
| if (preview) { | |
| preview = null | |
| handle.update() | |
| } | |
| } | |
| function startResize( | |
| block: GridBlockDocument, | |
| edge: ResizeEdge, | |
| event: PointerEvent, | |
| ) { | |
| if ( | |
| draftBlock || | |
| dragState || | |
| horizontalResizeState || | |
| event.button !== 0 || | |
| !gridElement | |
| ) { | |
| return | |
| } | |
| let grid = measureGrid(gridElement) | |
| let edgeMinute = edge === "start" ? block.startMinute : block.endMinute | |
| let edgeTop = | |
| grid.top + (startMinuteToSlotIndex(edgeMinute) + 1) * grid.rowHeight | |
| resizeState = { | |
| active: false, | |
| blockId: block.id, | |
| edge, | |
| grid, | |
| offsetY: event.clientY - edgeTop, | |
| originalBlock: copyBlock(block), | |
| originalBlocks: schedule.blocks.map(copyBlock), | |
| pointerId: event.pointerId, | |
| startY: event.clientY, | |
| } | |
| bindGesture("resize") | |
| } | |
| function moveResize(event: PointerEvent) { | |
| if (!resizeState || resizeState.pointerId !== event.pointerId) return | |
| let distance = Math.abs(event.clientY - resizeState.startY) | |
| if (!resizeState.active && distance < dragThreshold) return | |
| resizeState.active = true | |
| event.preventDefault() | |
| updatePreview( | |
| previewResizeBlockTime(resizeState.originalBlocks, resizeState.blockId, { | |
| edge: resizeState.edge, | |
| minute: pointerToResizeMinute(event, resizeState), | |
| }), | |
| ) | |
| } | |
| function endResize(event: PointerEvent) { | |
| if (!resizeState || resizeState.pointerId !== event.pointerId) return | |
| unbindGesture() | |
| let finalPreview = resizeState.active && preview && !preview.unresolved | |
| ? preview | |
| : null | |
| resizeState = null | |
| if (finalPreview) { | |
| event.preventDefault() | |
| schedule.blocks = finalPreview.blocks as GridBlockDocument[] | |
| preview = null | |
| saveSchedule() | |
| handle.update() | |
| return | |
| } | |
| if (preview) { | |
| preview = null | |
| handle.update() | |
| } | |
| } | |
| function startHorizontalResize( | |
| block: GridBlockDocument, | |
| edge: HorizontalResizeEdge, | |
| event: PointerEvent, | |
| ) { | |
| if ( | |
| draftBlock || | |
| dragState || | |
| horizontalResizeState || | |
| resizeState || | |
| event.button !== 0 || | |
| !gridElement | |
| ) { | |
| return | |
| } | |
| let grid = measureGrid(gridElement) | |
| let edgeColumn = edge === "dayStart" ? block.dayOfWeek : block.dayOfWeek + 1 | |
| let edgeLeft = grid.left + grid.labelWidth + edgeColumn * grid.dayWidth | |
| horizontalResizeState = { | |
| active: false, | |
| blockId: block.id, | |
| edge, | |
| grid, | |
| idPrefix: `repeat-${++horizontalResizeSequence}-${Date.now().toString(36)}`, | |
| offsetX: event.clientX - edgeLeft, | |
| originalBlock: copyBlock(block), | |
| originalBlocks: schedule.blocks.map(copyBlock), | |
| pointerId: event.pointerId, | |
| startX: event.clientX, | |
| } | |
| bindGesture("horizontal-resize") | |
| } | |
| function moveHorizontalResize(event: PointerEvent) { | |
| if ( | |
| !horizontalResizeState || | |
| horizontalResizeState.pointerId !== event.pointerId | |
| ) { | |
| return | |
| } | |
| let state = horizontalResizeState | |
| let distance = Math.abs(event.clientX - state.startX) | |
| if (!state.active && distance < dragThreshold) return | |
| state.active = true | |
| event.preventDefault() | |
| updatePreview( | |
| previewCopyBlockAcrossDays( | |
| state.originalBlocks, | |
| state.blockId, | |
| { | |
| createId: (_source, dayOfWeek) => `${state.idPrefix}-${dayOfWeek}`, | |
| firstDay: | |
| state.edge === "dayStart" | |
| ? pointerToResizeDay(event, state) | |
| : state.originalBlock.dayOfWeek, | |
| lastDay: | |
| state.edge === "dayEnd" | |
| ? pointerToResizeDay(event, state) | |
| : state.originalBlock.dayOfWeek, | |
| }, | |
| ), | |
| ) | |
| } | |
| function endHorizontalResize(event: PointerEvent) { | |
| if ( | |
| !horizontalResizeState || | |
| horizontalResizeState.pointerId !== event.pointerId | |
| ) { | |
| return | |
| } | |
| unbindGesture() | |
| let finalPreview = | |
| horizontalResizeState.active && preview && !preview.unresolved | |
| ? preview | |
| : null | |
| horizontalResizeState = null | |
| if (finalPreview) { | |
| event.preventDefault() | |
| schedule.blocks = finalPreview.blocks as GridBlockDocument[] | |
| preview = null | |
| saveSchedule() | |
| handle.update() | |
| return | |
| } | |
| if (preview) { | |
| preview = null | |
| handle.update() | |
| } | |
| } | |
| function bindGesture(kind: GestureKind) { | |
| activeGesture = kind | |
| window.addEventListener("pointermove", handleWindowPointerMove) | |
| window.addEventListener("pointerup", handleWindowPointerEnd) | |
| window.addEventListener("pointercancel", handleWindowPointerEnd) | |
| } | |
| function unbindGesture() { | |
| window.removeEventListener("pointermove", handleWindowPointerMove) | |
| window.removeEventListener("pointerup", handleWindowPointerEnd) | |
| window.removeEventListener("pointercancel", handleWindowPointerEnd) | |
| activeGesture = null | |
| } | |
| function handleWindowPointerMove(event: PointerEvent) { | |
| if (activeGesture === "drag") { | |
| moveDrag(event) | |
| return | |
| } | |
| if (activeGesture === "resize") { | |
| moveResize(event) | |
| return | |
| } | |
| if (activeGesture === "horizontal-resize") { | |
| moveHorizontalResize(event) | |
| } | |
| } | |
| function handleWindowPointerEnd(event: PointerEvent) { | |
| if (activeGesture === "drag") { | |
| endDrag(event) | |
| return | |
| } | |
| if (activeGesture === "resize") { | |
| endResize(event) | |
| return | |
| } | |
| if (activeGesture === "horizontal-resize") { | |
| endHorizontalResize(event) | |
| } | |
| } | |
| function updatePreview(nextPreview: ScheduleLayoutResult) { | |
| if (sameBlocks(preview?.blocks ?? schedule.blocks, nextPreview.blocks)) return | |
| preview = nextPreview | |
| handle.update() | |
| } | |
| async function saveSchedule() { | |
| let sequence = ++saveSequence | |
| let scheduleId = schedule.id | |
| let response = await fetch(`/schedules/${scheduleId}`, { | |
| body: JSON.stringify({ | |
| baseRevision: schedule.revision, | |
| blocks: schedule.blocks, | |
| name: schedule.name, | |
| }), | |
| headers: { | |
| "content-type": "application/json", | |
| "x-csrf-token": handle.props.csrfToken, | |
| }, | |
| method: "PUT", | |
| }) | |
| if (!response.ok || sequence < latestAppliedSaveSequence) return | |
| let json = (await response.json()) as { schedule: GridScheduleDocument } | |
| if (schedule.id !== scheduleId || sequence < saveSequence) return | |
| latestAppliedSaveSequence = sequence | |
| schedule = json.schedule | |
| handle.update() | |
| } | |
| }, | |
| ) | |
| function ScheduleBlock( | |
| handle: Handle<{ | |
| block: GridBlockDocument | |
| isDragging: boolean | |
| isDraft: boolean | |
| isHorizontalResizing: boolean | |
| isResizing: boolean | |
| onCancelDraft: () => void | |
| onCommit: (block: GridBlockDocument) => void | |
| onDelete: (block: GridBlockDocument) => void | |
| onDragEnd: (event: PointerEvent) => void | |
| onDragMove: (event: PointerEvent) => void | |
| onDragStart: (block: GridBlockDocument, event: PointerEvent) => void | |
| onHorizontalResizeEnd: (event: PointerEvent) => void | |
| onHorizontalResizeMove: (event: PointerEvent) => void | |
| onHorizontalResizeStart: ( | |
| block: GridBlockDocument, | |
| edge: HorizontalResizeEdge, | |
| event: PointerEvent, | |
| ) => void | |
| onResizeEnd: (event: PointerEvent) => void | |
| onResizeMove: (event: PointerEvent) => void | |
| onResizeStart: ( | |
| block: GridBlockDocument, | |
| edge: ResizeEdge, | |
| event: PointerEvent, | |
| ) => void | |
| }>, | |
| ) { | |
| let block = handle.props.block | |
| let name = block.name | |
| let lastCommittedName = block.name | |
| return () => { | |
| if (handle.props.block.id !== block.id) { | |
| name = handle.props.block.name | |
| lastCommittedName = handle.props.block.name | |
| } | |
| block = handle.props.block | |
| let label = name || "Untitled" | |
| return ( | |
| <div | |
| aria-label={label} | |
| mix={[ | |
| blockBoxStyle, | |
| handle.props.isDragging || | |
| handle.props.isHorizontalResizing || | |
| handle.props.isResizing | |
| ? draggingBlockBoxStyle | |
| : undefined, | |
| on("click", (event) => { | |
| if (event.target === event.currentTarget) { | |
| event.currentTarget.focus() | |
| } | |
| }), | |
| on("keydown", (event) => { | |
| if (event.target !== event.currentTarget) return | |
| if (event.key === "Backspace" || event.key === "Delete") { | |
| event.preventDefault() | |
| handle.props.onDelete(block) | |
| } | |
| }), | |
| on("pointerdown", (event) => { | |
| if (event.target instanceof HTMLInputElement) return | |
| if (event.target === event.currentTarget) { | |
| event.currentTarget.focus() | |
| } | |
| handle.props.onDragStart(block, event) | |
| event.currentTarget.setPointerCapture(event.pointerId) | |
| }), | |
| on("pointermove", (event) => { | |
| if (event.currentTarget.hasPointerCapture(event.pointerId)) { | |
| handle.props.onDragMove(event) | |
| } | |
| }), | |
| on("pointerup", (event) => { | |
| if (event.currentTarget.hasPointerCapture(event.pointerId)) { | |
| handle.props.onDragEnd(event) | |
| } | |
| if (event.currentTarget.hasPointerCapture(event.pointerId)) { | |
| event.currentTarget.releasePointerCapture(event.pointerId) | |
| } | |
| }), | |
| on("pointercancel", (event) => { | |
| if (event.currentTarget.hasPointerCapture(event.pointerId)) { | |
| handle.props.onDragEnd(event) | |
| } | |
| if (event.currentTarget.hasPointerCapture(event.pointerId)) { | |
| event.currentTarget.releasePointerCapture(event.pointerId) | |
| } | |
| }), | |
| ]} | |
| tabIndex={0} | |
| style={{ | |
| backgroundColor: blockBackgroundColor(block), | |
| gridColumn: block.dayOfWeek + 2, | |
| gridRow: `${startMinuteToSlotIndex(block.startMinute) + 2} / span ${durationToSlotSpan( | |
| block.startMinute, | |
| block.endMinute, | |
| )}`, | |
| }} | |
| > | |
| <input | |
| aria-label={handle.props.isDraft ? "New block name" : `${label} name`} | |
| mix={[ | |
| blockInputStyle, | |
| ref((node) => { | |
| if (handle.props.isDraft) node.focus() | |
| }), | |
| on("input", (event) => { | |
| name = event.currentTarget.value | |
| event.currentTarget.size = inputSize(name) | |
| }), | |
| on("keydown", (event) => { | |
| if (event.key === "Escape" && handle.props.isDraft) { | |
| event.preventDefault() | |
| handle.props.onCancelDraft() | |
| } | |
| if (event.key === "Enter") { | |
| event.preventDefault() | |
| event.currentTarget.blur() | |
| } | |
| }), | |
| on("blur", commit), | |
| ]} | |
| defaultValue={name} | |
| size={inputSize(name)} | |
| type="text" | |
| /> | |
| <ResizeHandle | |
| block={block} | |
| edge="start" | |
| onResizeEnd={handle.props.onResizeEnd} | |
| onResizeMove={handle.props.onResizeMove} | |
| onResizeStart={handle.props.onResizeStart} | |
| /> | |
| <ResizeHandle | |
| block={block} | |
| edge="end" | |
| onResizeEnd={handle.props.onResizeEnd} | |
| onResizeMove={handle.props.onResizeMove} | |
| onResizeStart={handle.props.onResizeStart} | |
| /> | |
| <HorizontalResizeHandle | |
| block={block} | |
| edge="dayStart" | |
| onResizeEnd={handle.props.onHorizontalResizeEnd} | |
| onResizeMove={handle.props.onHorizontalResizeMove} | |
| onResizeStart={handle.props.onHorizontalResizeStart} | |
| /> | |
| <HorizontalResizeHandle | |
| block={block} | |
| edge="dayEnd" | |
| onResizeEnd={handle.props.onHorizontalResizeEnd} | |
| onResizeMove={handle.props.onHorizontalResizeMove} | |
| onResizeStart={handle.props.onHorizontalResizeStart} | |
| /> | |
| </div> | |
| ) | |
| } | |
| function commit() { | |
| let trimmedName = name.trim() | |
| if (!handle.props.isDraft && trimmedName === lastCommittedName) return | |
| block.name = trimmedName | |
| lastCommittedName = trimmedName | |
| handle.props.onCommit(block) | |
| } | |
| } | |
| function ResizeHandle( | |
| handle: Handle<{ | |
| block: GridBlockDocument | |
| edge: ResizeEdge | |
| onResizeEnd: (event: PointerEvent) => void | |
| onResizeMove: (event: PointerEvent) => void | |
| onResizeStart: ( | |
| block: GridBlockDocument, | |
| edge: ResizeEdge, | |
| event: PointerEvent, | |
| ) => void | |
| }>, | |
| ) { | |
| return () => ( | |
| <div | |
| aria-label={`${handle.props.edge === "start" ? "Start" : "End"} resize handle`} | |
| className="resize-handle" | |
| mix={[ | |
| resizeHandleStyle, | |
| handle.props.edge === "start" ? startResizeHandleStyle : endResizeHandleStyle, | |
| on("pointerdown", (event) => { | |
| event.preventDefault() | |
| event.stopPropagation() | |
| handle.props.onResizeStart( | |
| handle.props.block, | |
| handle.props.edge, | |
| event, | |
| ) | |
| event.currentTarget.setPointerCapture(event.pointerId) | |
| }), | |
| on("pointermove", (event) => { | |
| if (event.currentTarget.hasPointerCapture(event.pointerId)) { | |
| handle.props.onResizeMove(event) | |
| } | |
| }), | |
| on("pointerup", (event) => { | |
| if (event.currentTarget.hasPointerCapture(event.pointerId)) { | |
| handle.props.onResizeEnd(event) | |
| } | |
| if (event.currentTarget.hasPointerCapture(event.pointerId)) { | |
| event.currentTarget.releasePointerCapture(event.pointerId) | |
| } | |
| }), | |
| on("pointercancel", (event) => { | |
| if (event.currentTarget.hasPointerCapture(event.pointerId)) { | |
| handle.props.onResizeEnd(event) | |
| } | |
| if (event.currentTarget.hasPointerCapture(event.pointerId)) { | |
| event.currentTarget.releasePointerCapture(event.pointerId) | |
| } | |
| }), | |
| ]} | |
| /> | |
| ) | |
| } | |
| function HorizontalResizeHandle( | |
| handle: Handle<{ | |
| block: GridBlockDocument | |
| edge: HorizontalResizeEdge | |
| onResizeEnd: (event: PointerEvent) => void | |
| onResizeMove: (event: PointerEvent) => void | |
| onResizeStart: ( | |
| block: GridBlockDocument, | |
| edge: HorizontalResizeEdge, | |
| event: PointerEvent, | |
| ) => void | |
| }>, | |
| ) { | |
| return () => ( | |
| <div | |
| aria-label={`${handle.props.edge === "dayStart" ? "First day" : "Last day"} resize handle`} | |
| className="resize-handle" | |
| mix={[ | |
| horizontalResizeHandleStyle, | |
| handle.props.edge === "dayStart" | |
| ? dayStartResizeHandleStyle | |
| : dayEndResizeHandleStyle, | |
| on("pointerdown", (event) => { | |
| event.preventDefault() | |
| event.stopPropagation() | |
| handle.props.onResizeStart( | |
| handle.props.block, | |
| handle.props.edge, | |
| event, | |
| ) | |
| event.currentTarget.setPointerCapture(event.pointerId) | |
| }), | |
| on("pointermove", (event) => { | |
| if (event.currentTarget.hasPointerCapture(event.pointerId)) { | |
| handle.props.onResizeMove(event) | |
| } | |
| }), | |
| on("pointerup", (event) => { | |
| if (event.currentTarget.hasPointerCapture(event.pointerId)) { | |
| handle.props.onResizeEnd(event) | |
| } | |
| if (event.currentTarget.hasPointerCapture(event.pointerId)) { | |
| event.currentTarget.releasePointerCapture(event.pointerId) | |
| } | |
| }), | |
| on("pointercancel", (event) => { | |
| if (event.currentTarget.hasPointerCapture(event.pointerId)) { | |
| handle.props.onResizeEnd(event) | |
| } | |
| if (event.currentTarget.hasPointerCapture(event.pointerId)) { | |
| event.currentTarget.releasePointerCapture(event.pointerId) | |
| } | |
| }), | |
| ]} | |
| /> | |
| ) | |
| } | |
| const weekDays = [ | |
| "Monday", | |
| "Tuesday", | |
| "Wednesday", | |
| "Thursday", | |
| "Friday", | |
| "Saturday", | |
| "Sunday", | |
| ] | |
| const timeSlots = [ | |
| "6:30 am", | |
| "6:45 am", | |
| "7:00am", | |
| "7:15am", | |
| "7:30am", | |
| "7:45am", | |
| "8:00am", | |
| "8:15am", | |
| "8:30am", | |
| "8:45am", | |
| "9:00am", | |
| "9:15am", | |
| "9:30am", | |
| "9:45am", | |
| "10:00am", | |
| "10:15am", | |
| "10:30am", | |
| "10:45am", | |
| "11:00am", | |
| "11:15am", | |
| "11:30am", | |
| "11:45am", | |
| "12:00pm", | |
| "12:15pm", | |
| "12:30pm", | |
| "12:45pm", | |
| "1:00pm", | |
| "1:15pm", | |
| "1:30pm", | |
| "1:45pm", | |
| "2:00pm", | |
| "2:15pm", | |
| "2:30pm", | |
| "2:45pm", | |
| "3:00pm", | |
| "3:15pm", | |
| "3:30pm", | |
| "3:45pm", | |
| "4:00pm", | |
| "4:15pm", | |
| "4:30pm", | |
| "4:45pm", | |
| "5:00pm", | |
| "5:15pm", | |
| "5:30pm", | |
| "5:45pm", | |
| "6:00pm", | |
| "6:15pm", | |
| "6:30pm", | |
| "6:45pm", | |
| "7:00pm", | |
| "7:15pm", | |
| "7:30pm", | |
| "7:45pm", | |
| "8:00pm", | |
| "8:15pm", | |
| "8:30pm", | |
| "8:45pm", | |
| "9:00pm", | |
| "9:15pm", | |
| "9:30pm", | |
| "9:45pm", | |
| "10:00pm", | |
| "10:15pm", | |
| "10:30pm", | |
| ] | |
| function isHourSlot(time: string) { | |
| return time.includes(":00") | |
| } | |
| function isHalfHourSlot(time: string) { | |
| return time.includes(":30") | |
| } | |
| function timeToMinute(time: string) { | |
| let match = /^(\d{1,2}):(\d{2})\s*(am|pm)?$/i.exec(time) | |
| if (!match) return firstSlotMinute | |
| let hour = Number(match[1]) | |
| let minute = Number(match[2]) | |
| let period = match[3]?.toLowerCase() | |
| if (period === "pm" && hour !== 12) hour += 12 | |
| if (period === "am" && hour === 12) hour = 0 | |
| return hour * 60 + minute | |
| } | |
| const firstSlotMinute = 390 | |
| const slotMinutes = 15 | |
| const lastSlotMinute = firstSlotMinute + timeSlots.length * slotMinutes | |
| const gridLabelWidth = 72 | |
| const gridRowHeight = 44 | |
| const dragThreshold = 4 | |
| const horizontalResizeThreshold = 0.15 | |
| function measureGrid(element: HTMLElement): GridMeasurement { | |
| let rect = element.getBoundingClientRect() | |
| let labelWidth = gridLabelWidth | |
| let rowHeight = Math.max( | |
| 1, | |
| rect.height > 0 ? rect.height / (timeSlots.length + 1) : gridRowHeight, | |
| ) | |
| return { | |
| dayWidth: Math.max(1, (rect.width - labelWidth) / weekDays.length), | |
| labelWidth, | |
| left: rect.left, | |
| rowHeight, | |
| top: rect.top, | |
| } | |
| } | |
| function pointerToPlacement( | |
| event: PointerEvent, | |
| dragState: DragState, | |
| ): BlockPlacement { | |
| let blockLeft = event.clientX - dragState.offsetX | |
| let blockTop = event.clientY - dragState.offsetY | |
| let rawDay = Math.round( | |
| (blockLeft - dragState.grid.left - dragState.grid.labelWidth) / | |
| dragState.grid.dayWidth, | |
| ) | |
| let rawStart = | |
| firstSlotMinute + | |
| Math.round( | |
| (blockTop - dragState.grid.top - dragState.grid.rowHeight) / | |
| dragState.grid.rowHeight, | |
| ) * | |
| slotMinutes | |
| return { | |
| dayOfWeek: clamp(rawDay, 0, weekDays.length - 1), | |
| startMinute: clampToSlot( | |
| rawStart, | |
| firstSlotMinute, | |
| lastSlotMinute - dragState.duration, | |
| ), | |
| } | |
| } | |
| function pointerToResizeMinute( | |
| event: PointerEvent, | |
| resizeState: ResizeState, | |
| ) { | |
| let edgeTop = event.clientY - resizeState.offsetY | |
| let rawMinute = | |
| firstSlotMinute + | |
| Math.round( | |
| (edgeTop - resizeState.grid.top - resizeState.grid.rowHeight) / | |
| resizeState.grid.rowHeight, | |
| ) * | |
| slotMinutes | |
| if (resizeState.edge === "start") { | |
| return clampToSlot( | |
| rawMinute, | |
| firstSlotMinute, | |
| resizeState.originalBlock.endMinute - slotMinutes, | |
| ) | |
| } | |
| return clampToSlot( | |
| rawMinute, | |
| resizeState.originalBlock.startMinute + slotMinutes, | |
| lastSlotMinute, | |
| ) | |
| } | |
| function pointerToResizeDay( | |
| event: PointerEvent, | |
| resizeState: HorizontalResizeState, | |
| ) { | |
| let edgeLeft = event.clientX - resizeState.offsetX | |
| let rawEdgeColumn = | |
| (edgeLeft - resizeState.grid.left - resizeState.grid.labelWidth) / | |
| resizeState.grid.dayWidth | |
| if (resizeState.edge === "dayStart") { | |
| return clamp( | |
| Math.floor(rawEdgeColumn + horizontalResizeThreshold), | |
| 0, | |
| resizeState.originalBlock.dayOfWeek, | |
| ) | |
| } | |
| return clamp( | |
| Math.ceil(rawEdgeColumn - horizontalResizeThreshold) - 1, | |
| resizeState.originalBlock.dayOfWeek, | |
| weekDays.length - 1, | |
| ) | |
| } | |
| function copyBlock(block: GridBlockDocument): GridBlockDocument { | |
| return { ...block } | |
| } | |
| function sameBlocks( | |
| leftBlocks: ReadonlyArray<ScheduleLayoutBlock>, | |
| rightBlocks: ReadonlyArray<ScheduleLayoutBlock>, | |
| ) { | |
| if (leftBlocks.length !== rightBlocks.length) return false | |
| for (let index = 0; index < leftBlocks.length; index++) { | |
| let left = leftBlocks[index]! | |
| let right = rightBlocks[index]! | |
| if ( | |
| left.color !== right.color || | |
| left.dayOfWeek !== right.dayOfWeek || | |
| left.endMinute !== right.endMinute || | |
| left.id !== right.id || | |
| left.name !== right.name || | |
| left.startMinute !== right.startMinute | |
| ) { | |
| return false | |
| } | |
| } | |
| return true | |
| } | |
| function clamp(value: number, min: number, max: number) { | |
| return Math.min(max, Math.max(min, value)) | |
| } | |
| function clampToSlot(value: number, min: number, max: number) { | |
| let snapped = Math.round(value / slotMinutes) * slotMinutes | |
| return clamp(snapped, min, Math.max(min, max)) | |
| } | |
| function startMinuteToSlotIndex(startMinute: number) { | |
| return Math.max(0, Math.round((startMinute - firstSlotMinute) / slotMinutes)) | |
| } | |
| function durationToSlotSpan(startMinute: number, endMinute: number) { | |
| return Math.max(1, Math.round((endMinute - startMinute) / slotMinutes)) | |
| } | |
| function inputSize(value: string) { | |
| return Math.max(1, value.length) | |
| } | |
| function blockBackgroundColor(block: GridBlockDocument) { | |
| let hue = hashString(block.name.trim().toLowerCase() || String(block.id)) % 360 | |
| return `hsl(${hue} 78% 88%)` | |
| } | |
| function hashString(value: string) { | |
| let hash = 0 | |
| for (let index = 0; index < value.length; index++) { | |
| hash = (hash * 31 + value.charCodeAt(index)) >>> 0 | |
| } | |
| return hash | |
| } | |
| const weekScheduleStyle = css({ | |
| display: "grid", | |
| gridTemplateRows: "72px 40px minmax(0, 1fr)", | |
| height: "100%", | |
| minHeight: 0, | |
| overflow: "hidden", | |
| }) | |
| const calendarTitleStyle = css({ | |
| alignItems: "center", | |
| color: theme.colors.text.primary, | |
| display: "flex", | |
| fontSize: theme.fontSize.xxl, | |
| fontWeight: theme.fontWeight.bold, | |
| justifyContent: "center", | |
| letterSpacing: theme.letterSpacing.tight, | |
| }) | |
| const dayHeaderGridStyle = css({ | |
| alignItems: "end", | |
| borderBottom: `1px solid ${theme.colors.border.strong}`, | |
| display: "grid", | |
| gridTemplateColumns: "72px repeat(7, minmax(88px, 1fr))", | |
| paddingRight: "12px", | |
| }) | |
| const dayHeaderStyle = css({ | |
| color: theme.colors.text.primary, | |
| fontSize: theme.fontSize.xs, | |
| fontWeight: theme.fontWeight.medium, | |
| padding: `${theme.space.xs} ${theme.space.sm}`, | |
| textAlign: "center", | |
| }) | |
| const timeGridScrollerStyle = css({ | |
| minHeight: 0, | |
| overflowY: "auto", | |
| }) | |
| const timeGridStyle = css({ | |
| minWidth: "760px", | |
| position: "relative", | |
| }) | |
| const timeRowsStyle = css({ | |
| position: "relative", | |
| }) | |
| const timeRowStyle = css({ | |
| display: "grid", | |
| gridTemplateColumns: "72px repeat(7, minmax(88px, 1fr))", | |
| minHeight: "44px", | |
| }) | |
| const timeLabelStyle = css({ | |
| color: theme.colors.text.secondary, | |
| fontSize: theme.fontSize.xs, | |
| padding: `0 ${theme.space.sm} 0 0`, | |
| textAlign: "right", | |
| transform: "translateY(-0.75em)", | |
| }) | |
| const hourTimeLabelStyle = css({ | |
| color: theme.colors.text.primary, | |
| fontWeight: theme.fontWeight.semibold, | |
| }) | |
| const timeCellStyle = css({ | |
| borderLeft: `1px dashed ${theme.colors.border.default}`, | |
| borderTop: `1px dashed ${theme.colors.border.default}`, | |
| minHeight: "44px", | |
| }) | |
| const hourTimeCellStyle = css({ | |
| borderTop: `1px solid ${theme.colors.border.strong}`, | |
| }) | |
| const halfHourTimeCellStyle = css({ | |
| borderTop: `1px dotted ${theme.colors.border.strong}`, | |
| }) | |
| const spacerTimeCellStyle = css({ | |
| borderLeft: `1px dashed ${theme.colors.border.default}`, | |
| minHeight: "44px", | |
| }) | |
| const blockLayerStyle = css({ | |
| display: "grid", | |
| gridTemplateColumns: "72px repeat(7, minmax(88px, 1fr))", | |
| gridAutoRows: "44px", | |
| inset: 0, | |
| pointerEvents: "none", | |
| position: "absolute", | |
| zIndex: 1, | |
| }) | |
| const blockBoxStyle = css({ | |
| alignItems: "center", | |
| backgroundColor: theme.surface.lvl1, | |
| border: `1px solid ${theme.colors.border.default}`, | |
| borderRadius: theme.radius.md, | |
| boxShadow: theme.shadow.xs, | |
| color: "#111111", | |
| cursor: "grab", | |
| display: "flex", | |
| fontSize: theme.fontSize.sm, | |
| fontWeight: theme.fontWeight.medium, | |
| justifyContent: "center", | |
| margin: "3px 6px", | |
| overflow: "hidden", | |
| padding: theme.space.sm, | |
| pointerEvents: "auto", | |
| position: "relative", | |
| textAlign: "center", | |
| touchAction: "none", | |
| transition: | |
| "background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease", | |
| userSelect: "none", | |
| "&:hover .resize-handle": { | |
| opacity: 1, | |
| }, | |
| "&:focus": { | |
| borderColor: theme.colors.focus.ring, | |
| boxShadow: theme.shadow.md, | |
| outline: `3px solid ${theme.colors.focus.ring}`, | |
| outlineOffset: "2px", | |
| }, | |
| }) | |
| const draggingBlockBoxStyle = css({ | |
| backgroundColor: theme.surface.lvl2, | |
| borderColor: theme.colors.focus.ring, | |
| boxShadow: theme.shadow.lg, | |
| cursor: "grabbing", | |
| outline: `3px solid ${theme.colors.focus.ring}`, | |
| outlineOffset: "2px", | |
| transform: "scale(1.02)", | |
| zIndex: 2, | |
| "& .resize-handle": { | |
| opacity: 1, | |
| }, | |
| }) | |
| const resizeHandleStyle = css({ | |
| cursor: "ns-resize", | |
| height: "14px", | |
| left: theme.space.sm, | |
| opacity: 0, | |
| position: "absolute", | |
| right: theme.space.sm, | |
| touchAction: "none", | |
| transition: "opacity 120ms ease", | |
| zIndex: 3, | |
| "&::before": { | |
| backgroundColor: theme.colors.focus.ring, | |
| borderRadius: "999px", | |
| content: '""', | |
| height: "3px", | |
| left: "50%", | |
| position: "absolute", | |
| top: "50%", | |
| transform: "translate(-50%, -50%)", | |
| width: "36px", | |
| }, | |
| "&:hover": { | |
| opacity: 1, | |
| }, | |
| }) | |
| const startResizeHandleStyle = css({ | |
| top: 0, | |
| transform: "translateY(-4px)", | |
| }) | |
| const endResizeHandleStyle = css({ | |
| bottom: 0, | |
| transform: "translateY(4px)", | |
| }) | |
| const horizontalResizeHandleStyle = css({ | |
| bottom: theme.space.lg, | |
| cursor: "ew-resize", | |
| opacity: 0, | |
| position: "absolute", | |
| top: theme.space.lg, | |
| touchAction: "none", | |
| transition: "opacity 120ms ease", | |
| width: "14px", | |
| zIndex: 3, | |
| "&::before": { | |
| backgroundColor: theme.colors.focus.ring, | |
| borderRadius: "999px", | |
| content: '""', | |
| height: "24px", | |
| left: "50%", | |
| position: "absolute", | |
| top: "50%", | |
| transform: "translate(-50%, -50%)", | |
| width: "3px", | |
| }, | |
| "&:hover": { | |
| opacity: 1, | |
| }, | |
| }) | |
| const dayStartResizeHandleStyle = css({ | |
| left: 0, | |
| transform: "translateX(-4px)", | |
| }) | |
| const dayEndResizeHandleStyle = css({ | |
| right: 0, | |
| transform: "translateX(4px)", | |
| }) | |
| const blockInputStyle = css({ | |
| backgroundColor: "transparent", | |
| border: 0, | |
| boxSizing: "content-box", | |
| color: "inherit", | |
| font: "inherit", | |
| fontWeight: "inherit", | |
| lineHeight: 1.2, | |
| maxWidth: "100%", | |
| minWidth: "1ch", | |
| outline: 0, | |
| padding: 0, | |
| textAlign: "center", | |
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| export interface ScheduleLayoutBlock { | |
| color: string | null | |
| dayOfWeek: number | |
| endMinute: number | |
| id: string | |
| name: string | |
| startMinute: number | |
| } | |
| export type ResizeEdge = "start" | "end" | |
| export type ReflowDirection = "down" | "up" | |
| export interface ScheduleLayoutPolicy { | |
| dayMinutes: number | |
| minimumDuration: number | |
| slotMinutes: number | |
| } | |
| export type ScheduleLayoutChangeKind = | |
| | "created" | |
| | "deleted" | |
| | "moved" | |
| | "resized" | |
| export interface ScheduleLayoutChange { | |
| after?: ScheduleLayoutBlock | |
| before?: ScheduleLayoutBlock | |
| id: string | |
| kind: ScheduleLayoutChangeKind | |
| } | |
| export interface ScheduleLayoutResult { | |
| blocks: ScheduleLayoutBlock[] | |
| changes: ScheduleLayoutChange[] | |
| unresolved: boolean | |
| } | |
| export interface CopyAcrossDaysOptions { | |
| createId?: (source: ScheduleLayoutBlock, dayOfWeek: number) => string | |
| firstDay: number | |
| lastDay: number | |
| } | |
| export const defaultScheduleLayoutPolicy: ScheduleLayoutPolicy = { | |
| dayMinutes: 1440, | |
| minimumDuration: 15, | |
| slotMinutes: 15, | |
| } | |
| export function previewDeleteBlock( | |
| sourceBlocks: ScheduleLayoutBlock[], | |
| blockId: string, | |
| ): ScheduleLayoutResult { | |
| let blocks = sourceBlocks.filter((block) => block.id !== blockId).map(copyBlock) | |
| return toResult(sourceBlocks, blocks, false) | |
| } | |
| export function previewMoveBlock( | |
| sourceBlocks: ScheduleLayoutBlock[], | |
| blockId: string, | |
| placement: { dayOfWeek: number; startMinute: number }, | |
| policyInput: Partial<ScheduleLayoutPolicy> = {}, | |
| ): ScheduleLayoutResult { | |
| let policy = createPolicy(policyInput) | |
| let originalBlocks = sourceBlocks.map(copyBlock) | |
| let movedBlock = requireBlock(originalBlocks, blockId) | |
| let duration = durationOf(movedBlock) | |
| let blocks = originalBlocks.filter((block) => block.id !== blockId) | |
| let movedCopy = copyBlock(movedBlock) | |
| movedCopy.dayOfWeek = clampDay(placement.dayOfWeek) | |
| movedCopy.startMinute = clampMinute( | |
| placement.startMinute, | |
| 0, | |
| policy.dayMinutes - duration, | |
| policy, | |
| ) | |
| movedCopy.endMinute = movedCopy.startMinute + duration | |
| let resolved = insertBlock(blocks, movedCopy, policy) | |
| return toResult(sourceBlocks, resolved ?? originalBlocks, resolved === null) | |
| } | |
| export function previewResizeBlockTime( | |
| sourceBlocks: ScheduleLayoutBlock[], | |
| blockId: string, | |
| resize: { edge: ResizeEdge; minute: number }, | |
| policyInput: Partial<ScheduleLayoutPolicy> = {}, | |
| ): ScheduleLayoutResult { | |
| let policy = createPolicy(policyInput) | |
| let originalBlocks = sourceBlocks.map(copyBlock) | |
| let blocks = originalBlocks.map(copyBlock) | |
| let resizedBlock = requireBlock(blocks, blockId) | |
| if (resize.edge === "start") { | |
| resizedBlock.startMinute = clampMinute( | |
| resize.minute, | |
| 0, | |
| resizedBlock.endMinute - policy.minimumDuration, | |
| policy, | |
| ) | |
| } else { | |
| resizedBlock.endMinute = clampMinute( | |
| resize.minute, | |
| resizedBlock.startMinute + policy.minimumDuration, | |
| policy.dayMinutes, | |
| policy, | |
| ) | |
| } | |
| let direction: ReflowDirection = resize.edge === "start" ? "up" : "down" | |
| let resolved = resolvePush(blocks, resizedBlock, direction, policy) | |
| return toResult(sourceBlocks, resolved ?? originalBlocks, resolved === null) | |
| } | |
| export function previewCopyBlockAcrossDays( | |
| sourceBlocks: ScheduleLayoutBlock[], | |
| blockId: string, | |
| options: CopyAcrossDaysOptions, | |
| policyInput: Partial<ScheduleLayoutPolicy> = {}, | |
| ): ScheduleLayoutResult { | |
| let policy = createPolicy(policyInput) | |
| let sourceBlock = requireBlock(sourceBlocks, blockId) | |
| let firstDay = clampDay(Math.min(options.firstDay, options.lastDay)) | |
| let lastDay = clampDay(Math.max(options.firstDay, options.lastDay)) | |
| let blocks = sourceBlocks.map(copyBlock) | |
| let unresolved = false | |
| for (let dayOfWeek = firstDay; dayOfWeek <= lastDay; dayOfWeek++) { | |
| if (dayOfWeek === sourceBlock.dayOfWeek) continue | |
| let duplicateBlock: ScheduleLayoutBlock = { | |
| ...copyBlock(sourceBlock), | |
| dayOfWeek, | |
| id: options.createId?.(sourceBlock, dayOfWeek) ?? `${sourceBlock.id}-${dayOfWeek}`, | |
| } | |
| let resolved = insertBlock(blocks, duplicateBlock, policy) | |
| if (resolved) { | |
| blocks = resolved | |
| } else { | |
| unresolved = true | |
| } | |
| } | |
| return toResult(sourceBlocks, blocks, unresolved) | |
| } | |
| function insertBlock( | |
| blocks: ScheduleLayoutBlock[], | |
| insertedBlock: ScheduleLayoutBlock, | |
| policy: ScheduleLayoutPolicy, | |
| ): ScheduleLayoutBlock[] | null { | |
| let withInserted = [...blocks.map(copyBlock), copyBlock(insertedBlock)] | |
| let inserted = requireBlock(withInserted, insertedBlock.id) | |
| let collisions = getCollisions(withInserted, inserted) | |
| if (collisions.length === 0) return isValidLayout(withInserted, policy) ? withInserted : null | |
| let primaryCollision = collisions.sort( | |
| (left, right) => | |
| overlapMinutes(right, inserted) - overlapMinutes(left, inserted), | |
| )[0]! | |
| return resolvePush(withInserted, inserted, moveDirection(inserted, primaryCollision), policy) | |
| } | |
| function resolvePush( | |
| blocks: ScheduleLayoutBlock[], | |
| anchorBlock: ScheduleLayoutBlock, | |
| direction: ReflowDirection, | |
| policy: ScheduleLayoutPolicy, | |
| ) { | |
| let candidate = blocks.map(copyBlock) | |
| let anchor = requireBlock(candidate, anchorBlock.id) | |
| let collisions = getCollisions(candidate, anchor) | |
| if (collisions.length === 0) return isValidLayout(candidate, policy) ? candidate : null | |
| let dayBlocks = candidate.filter( | |
| (block) => block.id !== anchor.id && block.dayOfWeek === anchor.dayOfWeek, | |
| ) | |
| if (direction === "down") { | |
| placeBlocksDown(anchor, dayBlocks) | |
| } else { | |
| placeBlocksUp(anchor, dayBlocks) | |
| } | |
| return isValidLayout(candidate, policy) ? candidate : null | |
| } | |
| function placeBlocksDown( | |
| anchorBlock: ScheduleLayoutBlock, | |
| dayBlocks: ScheduleLayoutBlock[], | |
| ) { | |
| let cursor = anchorBlock.endMinute | |
| for (let block of dayBlocks.sort((left, right) => left.startMinute - right.startMinute)) { | |
| if (block.endMinute <= anchorBlock.startMinute) continue | |
| if (block.startMinute < cursor) { | |
| moveBlockTo(block, cursor) | |
| cursor = block.endMinute | |
| } | |
| } | |
| } | |
| function placeBlocksUp( | |
| anchorBlock: ScheduleLayoutBlock, | |
| dayBlocks: ScheduleLayoutBlock[], | |
| ) { | |
| let cursor = anchorBlock.startMinute | |
| for (let block of dayBlocks.sort((left, right) => right.startMinute - left.startMinute)) { | |
| if (block.startMinute >= anchorBlock.endMinute) continue | |
| if (block.endMinute > cursor) { | |
| moveBlockTo(block, cursor - durationOf(block)) | |
| cursor = block.startMinute | |
| } | |
| } | |
| } | |
| function toResult( | |
| beforeBlocks: ScheduleLayoutBlock[], | |
| afterBlocks: ScheduleLayoutBlock[], | |
| unresolved: boolean, | |
| ): ScheduleLayoutResult { | |
| let blocks = sortBlocks(afterBlocks) | |
| return { | |
| blocks, | |
| changes: getChanges(beforeBlocks, blocks), | |
| unresolved, | |
| } | |
| } | |
| function getChanges( | |
| beforeBlocks: ScheduleLayoutBlock[], | |
| afterBlocks: ScheduleLayoutBlock[], | |
| ) { | |
| let beforeById = new Map(beforeBlocks.map((block) => [block.id, block])) | |
| let afterById = new Map(afterBlocks.map((block) => [block.id, block])) | |
| let changes: ScheduleLayoutChange[] = [] | |
| for (let before of beforeBlocks) { | |
| let after = afterById.get(before.id) | |
| if (!after) { | |
| changes.push({ before: copyBlock(before), id: before.id, kind: "deleted" }) | |
| continue | |
| } | |
| if (sameBlockPlacement(before, after)) continue | |
| changes.push({ | |
| after: copyBlock(after), | |
| before: copyBlock(before), | |
| id: before.id, | |
| kind: durationOf(before) === durationOf(after) ? "moved" : "resized", | |
| }) | |
| } | |
| for (let after of afterBlocks) { | |
| if (beforeById.has(after.id)) continue | |
| changes.push({ after: copyBlock(after), id: after.id, kind: "created" }) | |
| } | |
| return changes | |
| } | |
| function moveDirection( | |
| movedBlock: ScheduleLayoutBlock, | |
| primaryCollision: ScheduleLayoutBlock, | |
| ): ReflowDirection { | |
| if (movedBlock.startMinute <= primaryCollision.startMinute) return "down" | |
| if (movedBlock.endMinute >= primaryCollision.endMinute) return "up" | |
| return blockCenter(movedBlock) <= blockCenter(primaryCollision) ? "down" : "up" | |
| } | |
| function getCollisions(blocks: ScheduleLayoutBlock[], anchorBlock: ScheduleLayoutBlock) { | |
| return blocks.filter( | |
| (block) => | |
| block.id !== anchorBlock.id && | |
| block.dayOfWeek === anchorBlock.dayOfWeek && | |
| blocksOverlap(block, anchorBlock), | |
| ) | |
| } | |
| function isValidLayout(blocks: ScheduleLayoutBlock[], policy: ScheduleLayoutPolicy) { | |
| return blocks.every((block) => isValidBlock(block, policy)) && isNonOverlapping(blocks) | |
| } | |
| function isValidBlock(block: ScheduleLayoutBlock, policy: ScheduleLayoutPolicy) { | |
| return ( | |
| Number.isInteger(block.dayOfWeek) && | |
| block.dayOfWeek >= 0 && | |
| block.dayOfWeek <= 6 && | |
| Number.isInteger(block.startMinute) && | |
| Number.isInteger(block.endMinute) && | |
| block.startMinute >= 0 && | |
| block.endMinute <= policy.dayMinutes && | |
| durationOf(block) >= policy.minimumDuration | |
| ) | |
| } | |
| function isNonOverlapping(blocks: ScheduleLayoutBlock[]) { | |
| let byDay = new Map<number, ScheduleLayoutBlock[]>() | |
| for (let block of blocks) { | |
| let dayBlocks = byDay.get(block.dayOfWeek) ?? [] | |
| dayBlocks.push(block) | |
| byDay.set(block.dayOfWeek, dayBlocks) | |
| } | |
| for (let dayBlocks of byDay.values()) { | |
| let sorted = dayBlocks.sort((left, right) => left.startMinute - right.startMinute) | |
| for (let index = 0; index < sorted.length - 1; index++) { | |
| if (blocksOverlap(sorted[index]!, sorted[index + 1]!)) return false | |
| } | |
| } | |
| return true | |
| } | |
| function sameBlockPlacement(left: ScheduleLayoutBlock, right: ScheduleLayoutBlock) { | |
| return ( | |
| left.color === right.color && | |
| left.dayOfWeek === right.dayOfWeek && | |
| left.endMinute === right.endMinute && | |
| left.name === right.name && | |
| left.startMinute === right.startMinute | |
| ) | |
| } | |
| function blocksOverlap(left: ScheduleLayoutBlock, right: ScheduleLayoutBlock) { | |
| return left.startMinute < right.endMinute && left.endMinute > right.startMinute | |
| } | |
| function overlapMinutes(left: ScheduleLayoutBlock, right: ScheduleLayoutBlock) { | |
| return Math.max( | |
| 0, | |
| Math.min(left.endMinute, right.endMinute) - | |
| Math.max(left.startMinute, right.startMinute), | |
| ) | |
| } | |
| function moveBlockTo(block: ScheduleLayoutBlock, startMinute: number) { | |
| let duration = durationOf(block) | |
| block.startMinute = startMinute | |
| block.endMinute = startMinute + duration | |
| } | |
| function blockCenter(block: ScheduleLayoutBlock) { | |
| return block.startMinute + durationOf(block) / 2 | |
| } | |
| function durationOf(block: ScheduleLayoutBlock) { | |
| return block.endMinute - block.startMinute | |
| } | |
| function requireBlock(blocks: ScheduleLayoutBlock[], blockId: string) { | |
| let block = blocks.find((block) => block.id === blockId) | |
| if (!block) throw new Error(`Unknown schedule block: ${blockId}`) | |
| return block | |
| } | |
| function sortBlocks(blocks: ScheduleLayoutBlock[]) { | |
| return blocks | |
| .map(copyBlock) | |
| .sort( | |
| (left, right) => | |
| left.dayOfWeek - right.dayOfWeek || | |
| left.startMinute - right.startMinute || | |
| left.id.localeCompare(right.id), | |
| ) | |
| } | |
| function copyBlock(block: ScheduleLayoutBlock): ScheduleLayoutBlock { | |
| return { ...block } | |
| } | |
| function createPolicy(policy: Partial<ScheduleLayoutPolicy>) { | |
| return { ...defaultScheduleLayoutPolicy, ...policy } | |
| } | |
| function clampDay(value: number) { | |
| return clamp(Math.round(value), 0, 6) | |
| } | |
| function clampMinute( | |
| value: number, | |
| min: number, | |
| max: number, | |
| policy: ScheduleLayoutPolicy, | |
| ) { | |
| let snapped = Math.round(value / policy.slotMinutes) * policy.slotMinutes | |
| return clamp(snapped, min, Math.max(min, max)) | |
| } | |
| function clamp(value: number, min: number, max: number) { | |
| return Math.min(max, Math.max(min, value)) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment