Skip to content

Instantly share code, notes, and snippets.

@ryanflorence
Created May 12, 2026 21:50
Show Gist options
  • Select an option

  • Save ryanflorence/9fd7f28dc43dae93960cc7b8c1b51b29 to your computer and use it in GitHub Desktop.

Select an option

Save ryanflorence/9fd7f28dc43dae93960cc7b8c1b51b29 to your computer and use it in GitHub Desktop.
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",
})
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