Availability

PreviousNext

A weekly availability designer with draggable and resizable time slots.

Docs
abuiblock

Preview

Loading preview…
registry/abui/ui/availability.tsx
"use client"

import * as React from "react"
import { Clock, Settings, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
  DndContext,
  DragOverlay,
  useSensor,
  useSensors,
  PointerSensor,
  useDraggable,
  useDroppable,
  DragStartEvent,
  DragEndEvent,
  DragMoveEvent,
  DragOverEvent,
} from "@dnd-kit/core"
import tunnel from "tunnel-rat"
import { nanoid } from "nanoid"

// --- Types ---

export interface TimeSpan {
  active?: boolean
  id: string
  week_day: number // 0-6 - Sunday-Saturday
  start_time: string // "HH:mm"
  end_time: string // "HH:mm"
}

interface AvailabilityProps {
  value?: TimeSpan[]
  onValueChange?: (value: TimeSpan[]) => void
  disabled?: TimeSpan[] // Disabled regions where no events can exist
  days?: number[] // 0-6
  showAllDays?: boolean // If true, renders all 7 days, but disables interactions on days not in 'days' array
  timeIncrements?: number // minutes, default 30
  startTime?: number // hour 0-23, default 7
  endTime?: number // hour 0-23, default 23
  useAmPm?: boolean
  mergeAdjacent?: boolean // default true - merge spans that touch end-to-end
  slotClassName?: string // className for time slot items (default: "bg-muted")
  className?: string
}

// --- Utils ---

const timeToMinutes = (time: string) => {
  const [h, m] = time.split(":").map(Number)
  return h * 60 + m
}

const minutesToTime = (minutes: number) => {
  const h = Math.floor(minutes / 60)
  const m = minutes % 60
  return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`
}

const formatDisplayTime = (time: string, useAmPm: boolean) => {
  if (!useAmPm) return time
  const [h, m] = time.split(":").map(Number)
  const ampm = h >= 12 ? "PM" : "AM"
  const h12 = h % 12 || 12
  return `${h12}:${m.toString().padStart(2, "0")} ${ampm}`
}

// Helper to generate a simple unique ID (not crypto secure but sufficient for UI)
const generateId = () => nanoid()

const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]

/**
 * Merges adjacent (contiguous) time spans on the same day.
 * Adjacent means one span's end_time equals another's start_time.
 * The merged span keeps the id of the earliest span.
 */
const mergeAdjacentSpans = (spans: TimeSpan[]): TimeSpan[] => {
  if (spans.length === 0) return spans

  // Group by day
  const byDay = new Map<number, TimeSpan[]>()
  spans.forEach(span => {
    const daySpans = byDay.get(span.week_day) || []
    daySpans.push(span)
    byDay.set(span.week_day, daySpans)
  })

  const merged: TimeSpan[] = []

  // Process each day
  byDay.forEach(daySpans => {
    // Sort by start time
    const sorted = [...daySpans].sort((a, b) => timeToMinutes(a.start_time) - timeToMinutes(b.start_time))

    let current = sorted[0]

    for (let i = 1; i < sorted.length; i++) {
      const next = sorted[i]

      // Check if current and next are adjacent (touching)
      if (current.end_time === next.start_time) {
        // Merge: extend current to include next
        current = {
          ...current,
          end_time: next.end_time,
          // Keep the earliest span's id (current is already earlier due to sorting)
        }
      } else {
        // Not adjacent, push current and move to next
        merged.push(current)
        current = next
      }
    }

    // Push the last span
    merged.push(current)
  })

  return merged
}

// --- Hooks ---

/**
 * Hook to handle creation dragging logic on a day column.
 */
function useCalendarCreation({
  containerRef,
  timeIncrements,
  startTime,
  endTime,
  events,
  disabledEvents = [],
  onCreate,
  colIndex,
  isDayDisabled = false,
}: {
  containerRef: React.RefObject<HTMLDivElement | null>
  timeIncrements: number
  startTime: number
  endTime: number
  events: TimeSpan[]
  disabledEvents?: TimeSpan[]
  onCreate: (dayIndex: number, start: number, end: number) => void
  colIndex: number
  isDayDisabled?: boolean
}) {
  const [isCreating, setIsCreating] = React.useState(false)
  const [creationStart, setCreationStart] = React.useState<number | null>(null)
  const [currentMouseY, setCurrentMouseY] = React.useState<number | null>(null)

  const totalMinutes = (endTime - startTime) * 60
  const startOffset = startTime * 60

  // Combine events and disabled regions for constraints
  // Sort all items by start time to determine safe zones
  const sortedConstraints = React.useMemo(() => {
    return [...events, ...disabledEvents].sort((a, b) => timeToMinutes(a.start_time) - timeToMinutes(b.start_time))
  }, [events, disabledEvents])

  const getMinutesFromY = (y: number) => {
    if (!containerRef.current) return 0
    const rect = containerRef.current.getBoundingClientRect()
    const relativeY = y - rect.top
    const percentage = Math.max(0, Math.min(1, relativeY / rect.height))
    const minutes = percentage * totalMinutes + startOffset
    return Math.round(minutes / timeIncrements) * timeIncrements
  }

  const handlePointerDown = (e: React.PointerEvent) => {
    if (isDayDisabled) return // No interaction if day is fully disabled
    if (e.target !== e.currentTarget) return // Only trigger on empty space
    // Prevent default drag behavior and text selection, but allow touch scrolling if not handled
    e.preventDefault()

    // Capture pointer to track movement even if it leaves the element
    containerRef.current?.setPointerCapture(e.pointerId)

    const startMins = getMinutesFromY(e.clientY)

    // Check strict overlap at start point (cannot start creation inside an event or disabled region)
    const isOverlapping = sortedConstraints.some(ev => {
      const s = timeToMinutes(ev.start_time)
      const e = timeToMinutes(ev.end_time)
      return startMins >= s && startMins < e
    })
    if (isOverlapping) return

    // Find constraints
    const prevEvent = sortedConstraints.filter(ev => timeToMinutes(ev.end_time) <= startMins).pop()
    const nextEvent = sortedConstraints.find(ev => timeToMinutes(ev.start_time) >= startMins)

    const minStartMins = prevEvent ? timeToMinutes(prevEvent.end_time) : startOffset
    const maxEndMins = nextEvent ? timeToMinutes(nextEvent.start_time) : endTime * 60

    setCreationStart(startMins)
    setCurrentMouseY(startMins)
    setIsCreating(true)

    const handlePointerMove = (ev: PointerEvent) => {
      const currentMins = getMinutesFromY(ev.clientY)
      // Clamp to constraints
      const clampedMins = Math.max(minStartMins, Math.min(currentMins, maxEndMins))
      setCurrentMouseY(clampedMins)
    }

    const handlePointerUp = (ev: PointerEvent) => {
      const currentMins = getMinutesFromY(ev.clientY)

      let finalStart = Math.min(startMins, currentMins)
      let finalEnd = Math.max(startMins, currentMins)

      // Clamp to constraints
      finalStart = Math.max(minStartMins, finalStart)
      finalEnd = Math.min(maxEndMins, finalEnd)

      // Ensure minimum size
      if (finalEnd - finalStart < timeIncrements) {
        finalEnd = Math.min(finalStart + timeIncrements, maxEndMins)
      }

      // Click-to-create logic (if essentially no drag occurred, try to make a 1-hour slot)
      if (finalEnd - finalStart <= timeIncrements) {
        const oneHourEnd = finalStart + 60
        finalEnd = Math.min(oneHourEnd, maxEndMins)
      }

      if (finalEnd > finalStart) {
        onCreate(colIndex, finalStart, finalEnd)
      }

      setIsCreating(false)
      setCreationStart(null)
      setCurrentMouseY(null)

      // Release pointer capture
      containerRef.current?.releasePointerCapture(ev.pointerId)

      // Cleanup listeners (though using setPointerCapture implicitly handles some of this, explicit cleanup is safe)
      window.removeEventListener("pointermove", handlePointerMove)
      window.removeEventListener("pointerup", handlePointerUp)
    }

    window.addEventListener("pointermove", handlePointerMove)
    window.addEventListener("pointerup", handlePointerUp)
  }

  return {
    isCreating,
    creationStart,
    currentMouseY,
    totalMinutes,
    startOffset,
    sortedConstraints,
    handlePointerDown,
  }
}

// --- Components ---

// Context to share dragging state across components
const AvailabilityDragContext = React.createContext<{
  dragPreviewTunnel: ReturnType<typeof tunnel>
  activeId: string | null
  activeSpan: TimeSpan | null
  overDayIndex: number | null
  deltaY: number
  timeIncrements: number
  isDropValid: boolean
} | null>(null)

export function Availability({
  value = [],
  onValueChange,
  disabled = [],
  days = [0, 1, 2, 3, 4, 5, 6],
  showAllDays = true,
  timeIncrements = 30,
  startTime = 7,
  endTime = 23,
  useAmPm = false,
  mergeAdjacent = true,
  slotClassName = "bg-muted",
  className,
}: AvailabilityProps) {
  const [internalValue, setInternalValue] = React.useState<TimeSpan[]>(value)

  // Drag state
  const dragPreviewTunnel = React.useMemo(() => tunnel(), [])
  const [activeId, setActiveId] = React.useState<string | null>(null)
  const [overDayIndex, setOverDayIndex] = React.useState<number | null>(null)
  const [deltaY, setDeltaY] = React.useState(0)
  const [isDropValid, setIsDropValid] = React.useState(true)

  const mainContainerRef = React.useRef<HTMLDivElement>(null)

  // Determine which days to render
  const renderedDays = React.useMemo(() => {
    if (showAllDays) {
      return [0, 1, 2, 3, 4, 5, 6]
    }
    return days
  }, [days, showAllDays])

  React.useEffect(() => {
    setInternalValue(value)
  }, [value])

  const updateValue = (newValue: TimeSpan[], shouldMerge = false) => {
    const finalValue = shouldMerge && mergeAdjacent ? mergeAdjacentSpans(newValue) : newValue
    setInternalValue(finalValue)
    onValueChange?.(finalValue)
  }

  const handleResize = (id: string, newStart: string, newEnd: string, isComplete = false) => {
    const newValue = internalValue.map(span => {
      if (span.id === id) {
        return { ...span, start_time: newStart, end_time: newEnd }
      }
      return span
    })
    updateValue(newValue, isComplete)
  }

  const handleCreate = (dayIndex: number, startMinutes: number, endMinutes: number) => {
    const newSpan: TimeSpan = {
      id: generateId(),
      week_day: dayIndex, // Directly use the dayIndex (0-6)
      start_time: minutesToTime(startMinutes),
      end_time: minutesToTime(endMinutes),
      active: true,
    }
    updateValue([...internalValue, newSpan], true)
  }

  const handleDelete = (id: string) => {
    updateValue(
      internalValue.filter(s => s.id !== id),
      true,
    )
  }

  const handleMove = (id: string, newStart: string, newEnd: string, newDayIndex: number) => {
    const newValue = internalValue.map(span => {
      if (span.id === id) {
        return { ...span, start_time: newStart, end_time: newEnd, week_day: newDayIndex }
      }
      return span
    })
    updateValue(newValue, true)
  }

  // Validation helper
  const validatePlacement = (
    span: TimeSpan,
    targetDayIndex: number,
    deltaY: number,
    containerHeight: number,
  ): { isValid: boolean; newStart: number; duration: number } => {
    const totalMinutes = (endTime - startTime) * 60
    const pixelsPerMinute = containerHeight / totalMinutes
    const deltaMinutesRaw = deltaY / pixelsPerMinute
    const deltaMinutes = Math.round(deltaMinutesRaw / timeIncrements) * timeIncrements

    const originalStart = timeToMinutes(span.start_time)
    const duration = timeToMinutes(span.end_time) - originalStart

    const newStart = originalStart + deltaMinutes
    const newEnd = newStart + duration

    // Check bounds
    const dayStartMins = startTime * 60
    const dayEndMins = endTime * 60

    if (newStart < dayStartMins || newEnd > dayEndMins) {
      return { isValid: false, newStart, duration }
    }

    // Check if day is active (in allowed days list)
    if (!days.includes(targetDayIndex)) {
      return { isValid: false, newStart, duration }
    }

    // Check collisions with active events
    const dayEvents = internalValue.filter(e => e.week_day === targetDayIndex && e.id !== span.id)
    const hasEventOverlap = dayEvents.some(e => {
      const eStart = timeToMinutes(e.start_time)
      const eEnd = timeToMinutes(e.end_time)
      return newStart < eEnd && newEnd > eStart
    })

    if (hasEventOverlap) {
      return { isValid: false, newStart, duration }
    }

    // Check collisions with disabled regions
    const dayDisabled = disabled.filter(e => e.week_day === targetDayIndex)
    const hasDisabledOverlap = dayDisabled.some(e => {
      const eStart = timeToMinutes(e.start_time)
      const eEnd = timeToMinutes(e.end_time)
      return newStart < eEnd && newEnd > eStart
    })

    if (hasDisabledOverlap) {
      return { isValid: false, newStart, duration }
    }

    return { isValid: true, newStart, duration }
  }

  // DnD Handlers
  const sensors = useSensors(
    useSensor(PointerSensor, {
      activationConstraint: {
        distance: 8,
      },
    }),
  )

  const handleDragStart = (event: DragStartEvent) => {
    setActiveId(event.active.id as string)
    setDeltaY(0)
    setOverDayIndex(null)
    setIsDropValid(true)
  }

  const handleDragMove = (event: DragMoveEvent) => {
    setDeltaY(event.delta.y)
    checkValidity(event.active.id as string, event.over?.id, event.delta.y)
  }

  const handleDragOver = (event: DragOverEvent) => {
    if (event.over) {
      const dayIndex = parseInt(event.over.id.toString().replace("day-", ""), 10)
      if (!isNaN(dayIndex)) {
        setOverDayIndex(dayIndex)
      }
    } else {
      setOverDayIndex(null)
    }
    checkValidity(event.active.id as string, event.over?.id, event.delta.y)
  }

  const checkValidity = (activeId: string, overId: string | number | undefined, currentDeltaY: number) => {
    if (!mainContainerRef.current || !overId) {
      setIsDropValid(false)
      return
    }

    const span = internalValue.find(s => s.id === activeId)
    if (!span) return

    const targetDayIndex = parseInt(overId.toString().replace("day-", ""), 10)
    if (isNaN(targetDayIndex)) {
      setIsDropValid(false)
      return
    }

    const result = validatePlacement(span, targetDayIndex, currentDeltaY, mainContainerRef.current.clientHeight)
    setIsDropValid(result.isValid)
  }

  const handleDragCancel = () => {
    setActiveId(null)
    setOverDayIndex(null)
    setDeltaY(0)
    setIsDropValid(true)
  }

  const handleDragEnd = (event: DragEndEvent) => {
    const { active, delta, over } = event
    setActiveId(null)
    setOverDayIndex(null)
    setDeltaY(0)
    setIsDropValid(true)

    const span = internalValue.find(s => s.id === active.id)
    if (!span || !mainContainerRef.current || !over) return

    const targetDayIndex = parseInt(over.id.toString().replace("day-", ""), 10)
    if (isNaN(targetDayIndex)) return

    // Final validation before commit
    const { isValid, newStart, duration } = validatePlacement(
      span,
      targetDayIndex,
      delta.y,
      mainContainerRef.current.clientHeight,
    )

    if (!isValid) {
      // Invalid drop, do nothing (snaps back)
      return
    }

    const newEndVal = newStart + duration
    handleMove(span.id, minutesToTime(newStart), minutesToTime(newEndVal), targetDayIndex)
  }

  const activeSpan = React.useMemo(() => internalValue.find(s => s.id === activeId) || null, [activeId, internalValue])

  return (
    <DndContext
      sensors={sensors}
      onDragStart={handleDragStart}
      onDragMove={handleDragMove}
      onDragOver={handleDragOver}
      onDragEnd={handleDragEnd}
      onDragCancel={handleDragCancel}
    >
      <AvailabilityDragContext.Provider
        value={{
          dragPreviewTunnel,
          activeId,
          activeSpan,
          overDayIndex,
          deltaY,
          timeIncrements,
          isDropValid,
        }}
      >
        <div
          suppressHydrationWarning
          className={cn(
            "flex h-[600px] w-full flex-col overflow-hidden rounded-md border bg-background select-none touch-none",
            className,
          )}
        >
          {/* Header */}
          <div className="flex w-full border-b bg-muted/40">
            <div className="w-16 flex-shrink-0 border-r p-2 text-xs font-medium text-muted-foreground" />
            <div className="flex flex-1">
              {renderedDays.map(dayIndex => {
                const isActive = days.includes(dayIndex)
                return (
                  <div
                    key={dayIndex}
                    className={cn(
                      "flex-1 border-r px-2 py-3 text-center text-sm font-medium last:border-r-0",
                      !isActive && "bg-muted/30 text-muted-foreground",
                    )}
                  >
                    {DAYS[dayIndex]}
                  </div>
                )
              })}
            </div>
          </div>

          {/* Body */}
          <div className="flex flex-1 overflow-y-auto relative" ref={mainContainerRef}>
            {/* Time Labels */}
            <div className="w-16 flex-shrink-0 border-r bg-muted/10 flex flex-col">
              {Array.from({ length: endTime - startTime }).map((_, i) => {
                const hour = startTime + i
                return (
                  <div
                    key={hour}
                    className="flex-1 border-b border-dashed border-muted-foreground/20 relative flex items-center justify-start pl-3"
                  >
                    <span className="text-xs text-muted-foreground">{formatDisplayTime(`${hour}:00`, useAmPm)}</span>
                  </div>
                )
              })}
            </div>

            {/* Days Grid */}
            <div className="flex flex-1 relative">
              <div className="absolute inset-0 pointer-events-none flex flex-col">
                {Array.from({ length: endTime - startTime }).map((_, i) => (
                  <div
                    key={i}
                    className="flex-1 border-b border-dashed border-foreground/10 dark:border-muted/60 w-full relative"
                  />
                ))}
              </div>

              {renderedDays.map((dayIndex, i) => {
                // Check if this day is in the active list
                const isActive = days.includes(dayIndex)

                return (
                  <DayColumn
                    key={dayIndex}
                    dayIndex={dayIndex}
                    colIndex={i}
                    startTime={startTime}
                    endTime={endTime}
                    timeIncrements={timeIncrements}
                    events={internalValue.filter(e => e.week_day === dayIndex)}
                    disabledEvents={disabled.filter(e => e.week_day === dayIndex)}
                    onCreate={handleCreate}
                    onResize={handleResize}
                    onDelete={handleDelete}
                    useAmPm={useAmPm}
                    isDayDisabled={!isActive}
                    slotClassName={slotClassName}
                  />
                )
              })}
            </div>
          </div>

          {/* Drag Overlay */}
          <DragOverlay>
            {activeSpan && (
              <div className="w-full h-full cursor-grabbing relative opacity-80">
                <dragPreviewTunnel.Out />
              </div>
            )}
          </DragOverlay>
        </div>
      </AvailabilityDragContext.Provider>
    </DndContext>
  )
}

interface DayColumnProps {
  dayIndex: number
  colIndex: number
  startTime: number
  endTime: number
  timeIncrements: number
  events: TimeSpan[]
  disabledEvents?: TimeSpan[]
  onCreate: (dayIndex: number, start: number, end: number) => void
  onResize: (id: string, start: string, end: string, isComplete?: boolean) => void
  onDelete: (id: string) => void
  useAmPm: boolean
  isDayDisabled?: boolean
  slotClassName?: string
}

function DayColumn({
  dayIndex,
  colIndex,
  startTime,
  endTime,
  timeIncrements,
  events,
  disabledEvents = [],
  onCreate,
  onResize,
  onDelete,
  useAmPm,
  isDayDisabled = false,
  slotClassName = "bg-muted",
}: DayColumnProps) {
  const containerRef = React.useRef<HTMLDivElement>(null)

  const context = React.useContext(AvailabilityDragContext)

  // Make the column a droppable zone for day detection
  // Only droppable if not disabled
  const { setNodeRef } = useDroppable({
    id: `day-${dayIndex}`,
    disabled: isDayDisabled,
  })

  const mergedRef = (node: HTMLDivElement | null) => {
    containerRef.current = node
    setNodeRef(node)
  }

  const { isCreating, creationStart, currentMouseY, totalMinutes, startOffset, sortedConstraints, handlePointerDown } =
    useCalendarCreation({
      containerRef,
      timeIncrements,
      startTime,
      endTime,
      events,
      disabledEvents,
      onCreate,
      colIndex,
      isDayDisabled,
    })

  // Calculate ghost position
  const showGhost = context?.activeId && context.overDayIndex === dayIndex && containerRef.current && !isDayDisabled

  const ghostStyle = React.useMemo(() => {
    if (!showGhost || !context?.activeSpan || !containerRef.current) return null

    const span = context.activeSpan
    const containerHeight = containerRef.current.clientHeight
    const pixelsPerMinute = containerHeight / totalMinutes

    const deltaMinutesRaw = context.deltaY / pixelsPerMinute
    const deltaMinutes = Math.round(deltaMinutesRaw / timeIncrements) * timeIncrements

    const originalStart = timeToMinutes(span.start_time)
    const duration = timeToMinutes(span.end_time) - originalStart

    // Calculate proposed start
    const newStart = originalStart + deltaMinutes

    // Determine visual feedback based on validity
    // If we are here, context.isDropValid has already determined if this position is valid
    // We just need to render the ghost at the proposed position (not clamped, so user sees why it fails)

    return {
      top: `${((newStart - startOffset) / totalMinutes) * 100}%`,
      height: `${(duration / totalMinutes) * 100}%`,
    }
  }, [
    context?.activeId,
    context?.deltaY,
    context?.activeSpan,
    context?.overDayIndex,
    timeIncrements,
    totalMinutes,
    startOffset,
    showGhost,
  ])

  return (
    <div
      ref={mergedRef}
      className={cn(
        "flex-1 relative border-r last:border-r-0 min-w-[100px] touch-none",
        isDayDisabled && "bg-muted/30",
        context?.activeId && "z-10", // Ensure z-index when dragging
      )}
      onPointerDown={handlePointerDown}
    >
      {/* Full Day Disabled Overlay */}
      {isDayDisabled && (
        <div
          className="absolute inset-0 bg-muted/10 pointer-events-none z-20"
          style={{
            backgroundImage: `repeating-linear-gradient(45deg, transparent, transparent 5px, rgba(128,128,128,0.15) 5px, rgba(128,128,128,0.15) 10px)`,
          }}
        />
      )}

      {/* Disabled Regions */}
      {disabledEvents.map((disabled, i) => {
        const startMins = timeToMinutes(disabled.start_time)
        const endMins = timeToMinutes(disabled.end_time)
        const duration = endMins - startMins

        return (
          <div
            key={`disabled-${i}`}
            className="absolute left-0 right-0 bg-muted/40 bg-stripes-muted pointer-events-none z-0"
            style={{
              top: `${((startMins - startOffset) / totalMinutes) * 100}%`,
              height: `${(duration / totalMinutes) * 100}%`,
              // Striped pattern using CSS gradient
              backgroundImage: `repeating-linear-gradient(45deg, transparent, transparent 5px, rgba(128,128,128,0.15) 5px, rgba(128,128,128,0.15) 10px)`,
            }}
          />
        )
      })}

      {/* Ghost Element */}
      {ghostStyle && (
        <div
          className={cn(
            "absolute left-1 right-1 rounded-md border z-0 pointer-events-none transition-all duration-100 ease-out",
            context?.isDropValid ? "bg-foreground/20 border-foreground/30" : "bg-destructive/20 border-destructive/50",
          )}
          style={ghostStyle}
        />
      )}

      {events.map((event, i) => {
        // Calculate neighbors considering BOTH events and disabled regions for resizing constraints
        // sortedConstraints contains both. We just need to find neighbors relative to THIS event in that list.

        // We need a clean list of constraints excluding the event itself
        const otherConstraints = sortedConstraints.filter(e => e.id !== event.id)

        const eventStart = timeToMinutes(event.start_time)
        const eventEnd = timeToMinutes(event.end_time)

        const prevItem = otherConstraints.filter(e => timeToMinutes(e.end_time) <= eventStart).pop()
        const nextItem = otherConstraints.find(e => timeToMinutes(e.start_time) >= eventEnd)

        const minStart = prevItem ? timeToMinutes(prevItem.end_time) : startOffset
        const maxEnd = nextItem ? timeToMinutes(nextItem.start_time) : endTime * 60

        const isDragging = context?.activeId === event.id

        return (
          <DraggableTimeSpan
            key={event.id}
            span={event}
            startTime={startTime}
            endTime={endTime}
            minStart={minStart}
            maxEnd={maxEnd}
            onResize={onResize}
            onDelete={onDelete}
            useAmPm={useAmPm}
            timeIncrements={timeIncrements}
            containerRef={containerRef}
            isDragging={isDragging}
            isLocked={isDayDisabled} // Pass lock state if day is disabled
            slotClassName={slotClassName}
          />
        )
      })}

      {isCreating && creationStart !== null && currentMouseY !== null && (
        <div
          className="absolute left-0 right-0 mx-1 rounded bg-primary/30 border border-primary z-20 pointer-events-none"
          style={{
            top: `${((Math.min(creationStart, currentMouseY) - startOffset) / totalMinutes) * 100}%`,
            height: `${(Math.abs(currentMouseY - creationStart) / totalMinutes) * 100}%`,
          }}
        />
      )}
    </div>
  )
}

interface DraggableTimeSpanProps {
  span: TimeSpan
  startTime: number
  endTime: number
  minStart: number
  maxEnd: number
  onResize: (id: string, start: string, end: string, isComplete?: boolean) => void
  onDelete: (id: string) => void
  useAmPm: boolean
  timeIncrements: number
  containerRef: React.RefObject<HTMLDivElement | null>
  isDragging?: boolean
  isLocked?: boolean
  slotClassName?: string
}

function DraggableTimeSpan({
  span,
  startTime,
  endTime,
  minStart,
  maxEnd,
  onResize,
  onDelete,
  useAmPm,
  timeIncrements,
  containerRef,
  isDragging,
  isLocked = false,
  slotClassName = "bg-muted",
}: DraggableTimeSpanProps) {
  const context = React.useContext(AvailabilityDragContext)
  const { attributes, listeners, setNodeRef } = useDraggable({
    id: span.id,
    data: span,
    disabled: isLocked, // Disable drag if locked
  })

  const startMinutes = timeToMinutes(span.start_time)
  const endMinutes = timeToMinutes(span.end_time)
  const totalMinutes = (endTime - startTime) * 60
  const startOffset = startTime * 60
  const durationMinutes = endMinutes - startMinutes

  // Apply transform if dragging
  const style: React.CSSProperties = {
    top: `${((startMinutes - startOffset) / totalMinutes) * 100}%`,
    height: `${(durationMinutes / totalMinutes) * 100}%`,
    // We hide the original element when dragging, but we render the tunnel content
    opacity: isDragging ? 0 : isLocked ? 0.6 : 1, // Fade if locked
  }

  const handleResizeStart = (e: React.PointerEvent, edge: "top" | "bottom") => {
    if (isLocked) return
    e.stopPropagation()
    e.preventDefault()

    // Capture pointer for resize drag
    const target = e.target as HTMLElement
    target.setPointerCapture(e.pointerId)

    const initialY = e.clientY
    const initialStart = startMinutes
    const initialEnd = endMinutes

    // Re-calculate local constraints for resize (using passed props or logic)
    // We can trust the parent passed correct minStart/maxEnd for neighbors

    const handlePointerMove = (ev: PointerEvent) => {
      if (!containerRef.current) return

      const containerHeight = containerRef.current.clientHeight
      const pixelsPerMinute = containerHeight / totalMinutes
      const deltaY = ev.clientY - initialY
      const deltaMinutes = Math.round(deltaY / pixelsPerMinute / timeIncrements) * timeIncrements

      if (deltaMinutes === 0) return

      let newStart = initialStart
      let newEnd = initialEnd

      if (edge === "top") {
        newStart += deltaMinutes
        // For resize we still want clamping to neighbors
        if (newStart < minStart) newStart = minStart
        if (newStart >= newEnd - timeIncrements) newStart = newEnd - timeIncrements
      } else {
        newEnd += deltaMinutes
        if (newEnd > maxEnd) newEnd = maxEnd
        if (newEnd <= newStart + timeIncrements) newEnd = newStart + timeIncrements
      }

      // During drag: don't merge (isComplete = false)
      onResize(span.id, minutesToTime(newStart), minutesToTime(newEnd), false)
    }

    const handlePointerUp = (ev: PointerEvent) => {
      target.releasePointerCapture(ev.pointerId)

      // Final commit with clamping logic repeated
      if (containerRef.current) {
        const containerHeight = containerRef.current.clientHeight
        const pixelsPerMinute = containerHeight / totalMinutes
        const deltaY = ev.clientY - initialY
        const deltaMinutes = Math.round(deltaY / pixelsPerMinute / timeIncrements) * timeIncrements

        let newStart = initialStart
        let newEnd = initialEnd

        if (edge === "top") {
          newStart += deltaMinutes
          if (newStart < minStart) newStart = minStart
          if (newStart >= newEnd - timeIncrements) newStart = newEnd - timeIncrements
        } else {
          newEnd += deltaMinutes
          if (newEnd > maxEnd) newEnd = maxEnd
          if (newEnd <= newStart + timeIncrements) newEnd = newStart + timeIncrements
        }

        onResize(span.id, minutesToTime(newStart), minutesToTime(newEnd), true)
      }

      window.removeEventListener("pointermove", handlePointerMove)
      window.removeEventListener("pointerup", handlePointerUp)
    }

    window.addEventListener("pointermove", handlePointerMove)
    window.addEventListener("pointerup", handlePointerUp)
  }

  const canResize = !isLocked

  const content = (
    <>
      {/* Resize Handle Top - Increased hit area */}
      {canResize && (
        <div
          className="absolute top-0 left-0 right-0 h-4 -mt-2 cursor-row-resize z-10"
          onPointerDown={e => handleResizeStart(e, "top")}
        />
      )}
      {/* Visual Top Handle */}
      <div className="absolute top-0 left-1 right-1 h-1 bg-transparent group-hover:bg-foreground/20 rounded-t-sm" />

      {/* Drag Handle Area (middle) - Use dnd-kit listeners here */}
      <div
        className={cn(
          "absolute inset-0 top-2 bottom-2 z-0",
          canResize ? "cursor-grab active:cursor-grabbing" : "cursor-default",
        )}
        {...listeners}
        {...attributes}
      />

      <TimeSpanCard
        span={span}
        useAmPm={useAmPm}
        duration={durationMinutes / 60}
        onDelete={canResize ? () => onDelete(span.id) : undefined}
      />

      {/* Resize Handle Bottom - Increased hit area */}
      {canResize && (
        <div
          className="absolute bottom-0 left-0 right-0 h-4 -mb-2 cursor-row-resize z-10"
          onPointerDown={e => handleResizeStart(e, "bottom")}
        />
      )}
      {/* Visual Bottom Handle */}
      <div className="absolute bottom-0 left-1 right-1 h-1 bg-transparent group-hover:bg-foreground/20 rounded-b-sm" />
    </>
  )

  return (
    <>
      <div
        ref={setNodeRef}
        style={style}
        className={cn(
          "absolute left-1 right-1 rounded border p-3 shadow-sm text-xs group overflow-hidden touch-none",
          slotClassName,
          isDragging && "opacity-0", // Hide original while dragging
          isLocked && "border-dashed opacity-60 cursor-default bg-muted/50",
        )}
      >
        {content}
      </div>

      {/* Tunnel visual content to overlay if dragging */}
      {isDragging && context && (
        <context.dragPreviewTunnel.In>
          <div
            className={cn(
              "absolute left-0 right-0 rounded-md border p-3 shadow-lg text-xs overflow-hidden h-full w-full",
              context.isDropValid ? "border-foreground/50 bg-foreground/10" : "border-destructive/50 bg-destructive/20",
            )}
          >
            {/* Render content without interactive handlers for the preview */}
            <div className="absolute top-0 left-1 right-1 h-1 bg-transparent group-hover:bg-foreground/20 rounded-t-sm" />
            <TimeSpanCard
              span={span}
              useAmPm={useAmPm}
              duration={durationMinutes / 60}
              // No delete button in preview
            />
            <div className="absolute bottom-0 left-1 right-1 h-1 bg-transparent group-hover:bg-foreground/20 rounded-b-sm" />
          </div>
        </context.dragPreviewTunnel.In>
      )}
    </>
  )
}

function TimeSpanCard({
  span,
  useAmPm,
  duration,
  onDelete,
}: {
  span: TimeSpan
  useAmPm: boolean
  duration?: number
  onDelete?: () => void
}) {
  const calculatedDuration = duration || (timeToMinutes(span.end_time) - timeToMinutes(span.start_time)) / 60

  return (
    <div className="h-full flex flex-col relative items-between text-foreground timespan-inner-area pointer-events-none">
      <div className="flex flex-col gap-0.5 text-inherit">
        <p className="font-semibold leading-none">{formatDisplayTime(span.start_time, useAmPm)}</p>
        <div className="flex items-center gap-0.5">
          <Clock className="h-2 w-2" />{" "}
          <p className="text-[10px] opacity-80">{calculatedDuration.toFixed(1).replace(".0", "")}h</p>
        </div>
      </div>
      {onDelete && (
        <Button
          variant="ghost"
          size="icon"
          className="h-5 w-5 hover:bg-foreground/5 dark:hover:bg-foreground/10 -mt-1 -mr-1 absolute top-0 right-0 z-20 pointer-events-auto"
          onPointerDown={e => {
            e.stopPropagation() // Prevent drag/resize from card
          }}
          onClick={e => {
            e.stopPropagation()
            onDelete()
          }}
        >
          <X className="h-3 w-3" />
        </Button>
      )}
      <div className="flex flex-col gap-1 mt-auto text-inherit">
        {!onDelete && <Settings className="h-3 w-3 opacity-50" />}
        <p className="font-semibold leading-none !text-inherit">{formatDisplayTime(span.end_time, useAmPm)}</p>
      </div>
    </div>
  )
}

Installation

npx shadcn@latest add @abui/availability

Usage

import { Availability } from "@/components/availability"
<Availability />