calendar/calendar.js
2025-08-20 21:07:59 -06:00

725 lines
26 KiB
JavaScript

// calendar.js — Infinite scrolling week-by-week with overlay event rendering
import {
monthAbbr,
DAY_MS,
WEEK_MS,
isoWeekInfo,
toLocalString,
fromLocalString,
mondayIndex,
pad,
daysInclusive,
addDaysStr,
getLocalizedWeekdayNames,
getLocalizedMonthName,
formatDateRange,
lunarPhaseSymbol
} from './date-utils.js'
import { EventManager } from './event-manager.js'
class InfiniteCalendar {
constructor(config = {}) {
this.config = {
select_days: 0,
min_year: 1900,
max_year: 2100,
...config
}
this.weekend = [true, false, false, false, false, false, true]
// Initialize event manager
this.eventManager = new EventManager(this)
this.viewport = document.getElementById('calendar-viewport')
this.content = document.getElementById('calendar-content')
this.header = document.getElementById('calendar-header')
this.jogwheelViewport = document.getElementById('jogwheel-viewport')
this.jogwheelContent = document.getElementById('jogwheel-content')
this.selectedDateInput = document.getElementById('selected-date')
this.rowHeight = this.computeRowHeight()
this.visibleWeeks = new Map()
this.baseDate = new Date(2024, 0, 1) // 2024 begins with Monday
this.init()
} init() {
this.createHeader()
this.setupScrollListener()
this.setupJogwheel()
this.setupYearScroll()
this.setupSelectionInput()
this.setupCurrentDate()
this.setupInitialView()
}
setupInitialView() {
const minYearDate = new Date(this.config.min_year, 0, 1)
const maxYearLastDay = new Date(this.config.max_year, 11, 31)
const lastWeekMonday = new Date(maxYearLastDay)
lastWeekMonday.setDate(maxYearLastDay.getDate() - mondayIndex(maxYearLastDay))
this.minVirtualWeek = this.getWeekIndex(minYearDate)
this.maxVirtualWeek = this.getWeekIndex(lastWeekMonday)
this.totalVirtualWeeks = this.maxVirtualWeek - this.minVirtualWeek + 1
this.content.style.height = `${this.totalVirtualWeeks * this.rowHeight}px`
this.jogwheelContent.style.height = `${(this.totalVirtualWeeks * this.rowHeight) / 10}px`
const initial = this.config.initial || this.today
this.navigateTo(fromLocalString(initial))
}
setupCurrentDate() {
const updateDate = () => {
this.now = new Date()
const today = toLocalString(this.now)
if (this.today === today) return
this.today = today
for (const cell of this.content.querySelectorAll('.cell.today')) cell.classList.remove('today')
const cell = this.content.querySelector(`.cell[data-date="${this.today}"]`)
if (cell) cell.classList.add('today')
const todayDateElement = document.getElementById('today-date')
const t = this.now.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric'}).replace(/,? /, "\n")
todayDateElement.textContent = t.charAt(0).toUpperCase() + t.slice(1)
}
const todayDateElement = document.getElementById('today-date')
todayDateElement.addEventListener('click', () => this.goToToday())
// Day selection drag functionality is handled through cell event handlers in EventManager
updateDate()
setInterval(updateDate, 1000)
}
setupYearScroll() {
let throttled = false
const handleWheel = e => {
e.preventDefault()
e.stopPropagation()
const currentYear = parseInt(this.yearLabel.textContent)
const topDisplayIndex = Math.floor(this.viewport.scrollTop / this.rowHeight)
const currentWeekIndex = topDisplayIndex + this.minVirtualWeek
const sensitivity = 1/3
const delta = Math.round(e.deltaY * sensitivity)
if (!delta) return
const newYear = Math.max(this.config.min_year, Math.min(this.config.max_year, currentYear + delta))
if (newYear === currentYear || throttled) return
throttled = true
this.navigateToYear(newYear, currentWeekIndex)
setTimeout(() => throttled = false, 100)
}
this.yearLabel.addEventListener('wheel', handleWheel, { passive: false })
}
navigateTo(date) {
const targetWeekIndex = this.getWeekIndex(date) - 3
return this.scrollToTarget(targetWeekIndex)
}
navigateToYear(targetYear, weekIndex) {
const monday = this.getMondayForVirtualWeek(weekIndex)
const { week } = isoWeekInfo(monday)
const jan4 = new Date(targetYear, 0, 4)
const jan4Monday = new Date(jan4)
jan4Monday.setDate(jan4.getDate() - mondayIndex(jan4))
const targetMonday = new Date(jan4Monday)
targetMonday.setDate(jan4Monday.getDate() + (week - 1) * 7)
this.scrollToTarget(targetMonday)
}
scrollToTarget(target, options = {}) {
const { smooth = false, forceUpdate = true, allowFloating = false } = options
let targetWeekIndex
if (target instanceof Date) {
if (!this.isDateInAllowedRange(target)) return false
targetWeekIndex = this.getWeekIndex(target)
} else if (typeof target === 'number') {
targetWeekIndex = target
} else {
return false
}
if (allowFloating) {
if (targetWeekIndex < this.minVirtualWeek - 0.5 || targetWeekIndex > this.maxVirtualWeek + 0.5) return false
} else {
if (targetWeekIndex < this.minVirtualWeek || targetWeekIndex > this.maxVirtualWeek) return false
}
const targetScrollTop = (targetWeekIndex - this.minVirtualWeek) * this.rowHeight
if (smooth) this.viewport.scrollTo({ top: targetScrollTop, behavior: 'smooth' })
else this.viewport.scrollTop = targetScrollTop
const mainScrollable = Math.max(0, this.content.scrollHeight - this.viewport.clientHeight)
const jogScrollable = Math.max(0, this.jogwheelContent.scrollHeight - this.jogwheelViewport.clientHeight)
const jogwheelTarget = mainScrollable > 0 ? (targetScrollTop / mainScrollable) * jogScrollable : 0
if (smooth) this.jogwheelViewport.scrollTo({ top: jogwheelTarget, behavior: 'smooth' })
else this.jogwheelViewport.scrollTop = jogwheelTarget
if (forceUpdate) this.updateVisibleWeeks()
return true
}
computeRowHeight() {
const el = document.createElement('div')
el.style.position = 'absolute'
el.style.visibility = 'hidden'
el.style.height = 'var(--cell-h)'
document.body.appendChild(el)
const h = el.getBoundingClientRect().height || 64
el.remove()
return Math.round(h)
}
createHeader() {
this.yearLabel = document.createElement('div')
this.yearLabel.className = 'year-label'
this.yearLabel.textContent = isoWeekInfo(new Date()).year
this.header.appendChild(this.yearLabel)
const names = getLocalizedWeekdayNames()
names.forEach((name, i) => {
const c = document.createElement('div')
c.classList.add('dow')
const dayIdx = (i + 1) % 7
if (this.weekend[dayIdx]) c.classList.add('weekend')
c.textContent = name
this.header.appendChild(c)
})
const spacer = document.createElement('div')
spacer.className = 'overlay-header-spacer'
this.header.appendChild(spacer)
}
getWeekIndex(date) {
const monday = new Date(date)
monday.setDate(date.getDate() - mondayIndex(date))
return Math.floor((monday - this.baseDate) / WEEK_MS)
}
getMondayForVirtualWeek(virtualWeek) {
const monday = new Date(this.baseDate)
monday.setDate(monday.getDate() + virtualWeek * 7)
return monday
}
isDateInAllowedRange(date) {
const y = date.getFullYear()
return y >= this.config.min_year && y <= this.config.max_year
}
setupScrollListener() {
let t
this.viewport.addEventListener('scroll', () => {
this.updateVisibleWeeks()
clearTimeout(t)
t = setTimeout(() => this.updateVisibleWeeks(), 8)
})
}
updateVisibleWeeks() {
const scrollTop = this.viewport.scrollTop
const viewportH = this.viewport.clientHeight
this.updateYearLabel(scrollTop)
const buffer = 10
const startIdx = Math.floor((scrollTop - buffer * this.rowHeight) / this.rowHeight)
const endIdx = Math.ceil((scrollTop + viewportH + buffer * this.rowHeight) / this.rowHeight)
const startVW = Math.max(this.minVirtualWeek, startIdx + this.minVirtualWeek)
const endVW = Math.min(this.maxVirtualWeek, endIdx + this.minVirtualWeek)
for (const [vw, el] of this.visibleWeeks) {
if (vw < startVW || vw > endVW) {
el.remove()
this.visibleWeeks.delete(vw)
}
}
for (let vw = startVW; vw <= endVW; vw++) {
if (this.visibleWeeks.has(vw)) continue
const weekEl = this.createWeekElement(vw)
weekEl.style.position = 'absolute'
weekEl.style.left = '0'
const displayIndex = vw - this.minVirtualWeek
weekEl.style.top = `${displayIndex * this.rowHeight}px`
weekEl.style.width = '100%'
weekEl.style.height = `${this.rowHeight}px`
this.content.appendChild(weekEl)
this.visibleWeeks.set(vw, weekEl)
this.addEventsToWeek(weekEl, vw)
}
this.eventManager.applySelectionToVisible()
}
updateYearLabel(scrollTop) {
const topDisplayIndex = Math.floor(scrollTop / this.rowHeight)
const topVW = topDisplayIndex + this.minVirtualWeek
const monday = this.getMondayForVirtualWeek(topVW)
const { year } = isoWeekInfo(monday)
if (this.yearLabel.textContent !== String(year)) this.yearLabel.textContent = year
}
createWeekElement(virtualWeek) {
const weekDiv = document.createElement('div')
weekDiv.className = 'week-row'
const monday = this.getMondayForVirtualWeek(virtualWeek)
const wkLabel = document.createElement('div')
wkLabel.className = 'week-label'
wkLabel.textContent = `W${pad(isoWeekInfo(monday).week)}`
weekDiv.appendChild(wkLabel)
// days grid container to host cells and overlay
const daysGrid = document.createElement('div')
daysGrid.className = 'days-grid'
daysGrid.style.position = 'relative'
daysGrid.style.display = 'grid'
daysGrid.style.gridTemplateColumns = 'repeat(7, 1fr)'
daysGrid.style.gridAutoRows = '1fr'
daysGrid.style.height = '100%'
daysGrid.style.width = '100%'
weekDiv.appendChild(daysGrid)
// overlay positioned above cells, same 7-col grid
const overlay = document.createElement('div')
overlay.className = 'week-overlay'
overlay.style.position = 'absolute'
overlay.style.inset = '0'
overlay.style.pointerEvents = 'none'
overlay.style.display = 'grid'
overlay.style.gridTemplateColumns = 'repeat(7, 1fr)'
overlay.style.gridAutoRows = '1fr'
overlay.style.zIndex = '15'
daysGrid.appendChild(overlay)
weekDiv._overlay = overlay
weekDiv._daysGrid = daysGrid
const cur = new Date(monday)
let hasFirst = false
let monthToLabel = null
let labelYear = null
for (let i = 0; i < 7; i++) {
const cell = document.createElement('div')
cell.className = 'cell'
const dateStr = toLocalString(cur)
cell.setAttribute('data-date', dateStr)
const dow = cur.getDay()
if (this.weekend[dow]) cell.classList.add('weekend')
const m = cur.getMonth()
cell.classList.add(monthAbbr[m])
const isFirst = cur.getDate() === 1
if (isFirst) {
hasFirst = true
monthToLabel = m
labelYear = cur.getFullYear()
}
const day = document.createElement('h1')
day.textContent = String(cur.getDate())
const date = toLocalString(cur)
cell.dataset.date = date
if (this.today && date === this.today) cell.classList.add('today')
if (this.config.select_days > 0) {
cell.addEventListener('mousedown', e => {
e.preventDefault()
e.stopPropagation()
this.eventManager.startDrag(dateStr)
})
cell.addEventListener('touchstart', e => {
e.preventDefault()
e.stopPropagation()
this.eventManager.startDrag(dateStr)
})
cell.addEventListener('mouseenter', () => {
if (this.eventManager.isDragging) this.eventManager.updateDrag(dateStr)
})
cell.addEventListener('mouseup', e => {
e.stopPropagation()
if (this.eventManager.isDragging) this.eventManager.endDrag(dateStr)
})
cell.addEventListener('touchmove', e => {
if (this.eventManager.isDragging) {
e.preventDefault()
const touch = e.touches[0]
const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY)
if (elementBelow && elementBelow.closest('.cell[data-date]')) {
const cellBelow = elementBelow.closest('.cell[data-date]')
const touchDateStr = cellBelow.dataset.date
if (touchDateStr) this.eventManager.updateDrag(touchDateStr)
}
}
})
cell.addEventListener('touchend', e => {
e.stopPropagation()
if (this.eventManager.isDragging) this.eventManager.endDrag(dateStr)
})
}
if (isFirst) {
cell.classList.add('firstday')
day.textContent = cur.getMonth() ? monthAbbr[m].slice(0,3).toUpperCase() : cur.getFullYear()
}
cell.appendChild(day)
const luna = lunarPhaseSymbol(cur)
if (luna) {
const moon = document.createElement('span')
moon.className = 'lunar-phase'
moon.textContent = luna
cell.appendChild(moon)
}
daysGrid.appendChild(cell)
cur.setDate(cur.getDate() + 1)
}
if (hasFirst && monthToLabel !== null) {
if (labelYear && labelYear > this.config.max_year) return weekDiv
const overlayCell = document.createElement('div')
overlayCell.className = 'month-name-label'
let weeksSpan = 0
const d = new Date(cur)
d.setDate(cur.getDate() - 1)
for (let i = 0; i < 6; i++) {
d.setDate(cur.getDate() - 1 + i * 7)
if (d.getMonth() === monthToLabel) weeksSpan++
}
const remainingWeeks = Math.max(1, this.maxVirtualWeek - virtualWeek + 1)
weeksSpan = Math.max(1, Math.min(weeksSpan, remainingWeeks))
overlayCell.style.height = `${weeksSpan * this.rowHeight}px`
overlayCell.style.display = 'flex'
overlayCell.style.alignItems = 'center'
overlayCell.style.justifyContent = 'center'
overlayCell.style.zIndex = '15'
overlayCell.style.pointerEvents = 'none'
const label = document.createElement('span')
const year = String((labelYear ?? monday.getFullYear())).slice(-2)
label.textContent = `${getLocalizedMonthName(monthToLabel)} '${year}`
overlayCell.appendChild(label)
weekDiv.appendChild(overlayCell)
weekDiv.style.zIndex = '18'
}
return weekDiv
}
setupJogwheel() {
let lock = null
const sync = (fromEl, toEl, fromContent, toContent) => {
if (lock === toEl) return
lock = fromEl
const fromScrollable = Math.max(0, fromContent.scrollHeight - fromEl.clientHeight)
const toScrollable = Math.max(0, toContent.scrollHeight - toEl.clientHeight)
const ratio = fromScrollable > 0 ? fromEl.scrollTop / fromScrollable : 0
toEl.scrollTop = ratio * toScrollable
setTimeout(() => { if (lock === fromEl) lock = null }, 50)
}
this.jogwheelViewport.addEventListener('scroll', () =>
sync(this.jogwheelViewport, this.viewport, this.jogwheelContent, this.content)
)
this.viewport.addEventListener('scroll', () =>
sync(this.viewport, this.jogwheelViewport, this.content, this.jogwheelContent)
)
}
setupSelectionInput() {
if (this.config.select_days === 0) {
this.selectedDateInput.style.display = 'none'
} else {
this.selectedDateInput.style.display = 'block'
this.selectedDateInput.classList.add('clean-input')
}
}
goToToday() {
const top = new Date(this.now)
top.setDate(top.getDate() - 21)
this.scrollToTarget(top, { smooth: true })
}
// -------- Event Rendering (overlay-based) --------
refreshEvents() {
for (const [, weekEl] of this.visibleWeeks) {
this.addEventsToWeek(weekEl)
}
}
forceUpdateVisibleWeeks() {
// Force complete re-render of all visible weeks by clearing overlays first
for (const [, weekEl] of this.visibleWeeks) {
const overlay = weekEl._overlay || weekEl.querySelector('.week-overlay')
if (overlay) {
while (overlay.firstChild) overlay.removeChild(overlay.firstChild)
}
this.addEventsToWeek(weekEl)
}
}
addEventsToWeek(weekEl) {
const daysGrid = weekEl._daysGrid || weekEl.querySelector('.days-grid')
const overlay = weekEl._overlay || weekEl.querySelector('.week-overlay')
if (!daysGrid || !overlay) return
const cells = Array.from(daysGrid.querySelectorAll('.cell[data-date]'))
while (overlay.firstChild) overlay.removeChild(overlay.firstChild)
const weekEvents = new Map()
for (const cell of cells) {
const dateStr = cell.dataset.date
const events = this.eventManager.events.get(dateStr) || []
for (const ev of events) {
if (!weekEvents.has(ev.id)) {
weekEvents.set(ev.id, {
...ev,
startDateInWeek: dateStr,
endDateInWeek: dateStr,
startIdx: cells.indexOf(cell),
endIdx: cells.indexOf(cell)
})
} else {
const w = weekEvents.get(ev.id)
w.endDateInWeek = dateStr
w.endIdx = cells.indexOf(cell)
}
}
}
// If dragging, hide the original of the dragged event and inject preview if it intersects this week
if (this.eventManager.dragPreview && this.eventManager.dragPreview.id != null) {
const pv = this.eventManager.dragPreview
// Remove original entries of the dragged event for this week to prevent ghosts
if (weekEvents.has(pv.id)) weekEvents.delete(pv.id)
// Determine week range
const weekStart = cells[0]?.dataset?.date
const weekEnd = cells[cells.length - 1]?.dataset?.date
if (weekStart && weekEnd) {
const s = pv.startDate
const e = pv.endDate
// Intersect preview with this week
const startInWeek = s <= weekEnd && e >= weekStart ? (s < weekStart ? weekStart : s) : null
const endInWeek = s <= weekEnd && e >= weekStart ? (e > weekEnd ? weekEnd : e) : null
if (startInWeek && endInWeek) {
// Compute indices
let sIdx = cells.findIndex(c => c.dataset.date === startInWeek)
if (sIdx === -1) sIdx = cells.findIndex(c => c.dataset.date > startInWeek)
if (sIdx === -1) sIdx = 0
let eIdx = -1
for (let i = 0; i < cells.length; i++) {
if (cells[i].dataset.date <= endInWeek) eIdx = i
}
if (eIdx === -1) eIdx = cells.length - 1
// Build/override entry
const baseEv = this.eventManager.getEventById(pv.id)
if (baseEv) {
const entry = {
...baseEv,
startDateInWeek: startInWeek,
endDateInWeek: endInWeek,
startIdx: sIdx,
endIdx: eIdx
}
weekEvents.set(pv.id, entry)
}
}
}
}
const timeToMin = t => {
if (typeof t !== 'string') return 1e9
const m = t.match(/^(\d{2}):(\d{2})/)
if (!m) return 1e9
return Number(m[1]) * 60 + Number(m[2])
}
const spans = Array.from(weekEvents.values()).sort((a, b) => {
if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx
// Prefer longer spans to be placed first for packing
const aLen = a.endIdx - a.startIdx
const bLen = b.endIdx - b.startIdx
if (aLen !== bLen) return bLen - aLen
// Within the same day and same span length, order by start time
const at = timeToMin(a.startTime)
const bt = timeToMin(b.startTime)
if (at !== bt) return at - bt
// Stable fallback by id
return (a.id || 0) - (b.id || 0)
})
const rowsLastEnd = []
for (const w of spans) {
let placedRow = 0
while (placedRow < rowsLastEnd.length && !(w.startIdx > rowsLastEnd[placedRow])) placedRow++
if (placedRow === rowsLastEnd.length) rowsLastEnd.push(-1)
rowsLastEnd[placedRow] = w.endIdx
w._row = placedRow + 1
}
const numRows = Math.max(1, rowsLastEnd.length)
// Decide between "comfortable" layout (with gaps, not stretched)
// and "compressed" layout (fractional rows, no gaps) based on fit.
const cs = getComputedStyle(overlay)
const overlayHeight = overlay.getBoundingClientRect().height
const marginTopPx = parseFloat(cs.marginTop) || 0
const available = Math.max(0, overlayHeight - marginTopPx)
const baseEm = parseFloat(cs.fontSize) || 16
const rowPx = 1.2 * baseEm // preferred row height ~ 1.2em
const gapPx = 0.2 * baseEm // preferred gap ~ .2em
const needed = numRows * rowPx + (numRows - 1) * gapPx
if (needed <= available) {
// Comfortable: keep gaps and do not stretch rows to fill
overlay.style.gridTemplateRows = `repeat(${numRows}, ${rowPx}px)`
overlay.style.rowGap = `${gapPx}px`
} else {
// Compressed: use fractional rows so everything fits; remove gaps
overlay.style.gridTemplateRows = `repeat(${numRows}, 1fr)`
overlay.style.rowGap = '0'
}
// Create the spans
for (const w of spans) this.createOverlaySpan(overlay, w, weekEl)
}
createOverlaySpan(overlay, w, weekEl) {
const span = document.createElement('div')
span.className = `event-span event-color-${w.colorId}`
span.style.gridColumn = `${w.startIdx + 1} / ${w.endIdx + 2}`
span.style.gridRow = `${w._row}`
span.textContent = w.title
span.title = `${w.title} (${w.startDate === w.endDate ? w.startDate : w.startDate + ' - ' + w.endDate})`
span.dataset.eventId = String(w.id)
if (this.eventManager.dragEventState && this.eventManager.dragEventState.id === w.id) span.classList.add('dragging')
// Click opens edit if not dragging
span.addEventListener('click', e => {
e.stopPropagation()
if (this.eventManager.dragEventState || this.eventManager.justDragged) return
this.eventManager.showEventDialog('edit', { id: w.id })
})
// Add resize handles
const left = document.createElement('div')
left.className = 'resize-handle left'
const right = document.createElement('div')
right.className = 'resize-handle right'
span.appendChild(left)
span.appendChild(right)
// Pointer down handlers
const onPointerDown = (mode, ev) => {
ev.preventDefault()
ev.stopPropagation()
const point = ev.touches ? ev.touches[0] : ev
const hitAtStart = this.getDateUnderPointer(point.clientX, point.clientY)
this.eventManager.dragEventState = {
mode,
id: w.id,
originWeek: weekEl,
originStartIdx: w.startIdx,
originEndIdx: w.endIdx,
pointerStartX: point.clientX,
pointerStartY: point.clientY,
startDate: w.startDate,
endDate: w.endDate,
usingPointer: ev.type && ev.type.startsWith('pointer')
}
// compute anchor offset within the event based on where the pointer is
const spanDays = daysInclusive(w.startDate, w.endDate)
let anchorOffset = 0
if (hitAtStart && hitAtStart.date) {
const anchorDate = hitAtStart.date
// clamp anchorDate to within event span
if (anchorDate < w.startDate) anchorOffset = 0
else if (anchorDate > w.endDate) anchorOffset = spanDays - 1
else anchorOffset = daysInclusive(w.startDate, anchorDate) - 1
}
this.eventManager.dragEventState.anchorOffset = anchorOffset
this.eventManager.dragEventState.originSpanDays = spanDays
this.eventManager.dragEventState.originalStartDate = w.startDate
this.eventManager.dragEventState.originalEndDate = w.endDate
// capture pointer to ensure we receive the up even if cursor leaves element
if (this.eventManager.dragEventState.usingPointer && span.setPointerCapture && ev.pointerId != null) {
try { span.setPointerCapture(ev.pointerId) } catch {}
}
this.eventManager.dragEventState.element = span
this.eventManager.dragEventState.currentOverlay = overlay
this.eventManager._eventDragMoved = false
span.classList.add('dragging')
this.eventManager.installGlobalEventDragHandlers()
}
// Mouse
left.addEventListener('mousedown', e => onPointerDown('resize-left', e))
right.addEventListener('mousedown', e => onPointerDown('resize-right', e))
span.addEventListener('mousedown', e => {
if ((e.target).classList && (e.target).classList.contains('resize-handle')) return
onPointerDown('move', e)
})
// Pointer (preferred)
left.addEventListener('pointerdown', e => onPointerDown('resize-left', e))
right.addEventListener('pointerdown', e => onPointerDown('resize-right', e))
span.addEventListener('pointerdown', e => {
if ((e.target).classList && (e.target).classList.contains('resize-handle')) return
onPointerDown('move', e)
})
// Touch support
left.addEventListener('touchstart', e => onPointerDown('resize-left', e), { passive: false })
right.addEventListener('touchstart', e => onPointerDown('resize-right', e), { passive: false })
span.addEventListener('touchstart', e => {
if ((e.target).classList && (e.target).classList.contains('resize-handle')) return
onPointerDown('move', e)
}, { passive: false })
overlay.appendChild(span)
}
getDateUnderPointer(clientX, clientY) {
const el = document.elementFromPoint(clientX, clientY)
if (!el) return null
// Fast path: directly find the cell under the pointer
const directCell = el.closest && el.closest('.cell[data-date]')
if (directCell) {
const weekEl = directCell.closest('.week-row')
return weekEl ? { weekEl, overlay: (weekEl._overlay || weekEl.querySelector('.week-overlay')), col: -1, date: directCell.dataset.date } : null
}
const weekEl = el.closest && el.closest('.week-row')
if (!weekEl) return null
const overlay = weekEl._overlay || weekEl.querySelector('.week-overlay')
const daysGrid = weekEl._daysGrid || weekEl.querySelector('.days-grid')
if (!overlay || !daysGrid) return null
const rect = overlay.getBoundingClientRect()
if (rect.width <= 0) return null
const colWidth = rect.width / 7
let col = Math.floor((clientX - rect.left) / colWidth)
if (clientX < rect.left) col = 0
if (clientX > rect.right) col = 6
col = Math.max(0, Math.min(6, col))
const cells = Array.from(daysGrid.querySelectorAll('.cell[data-date]'))
const cell = cells[col]
return cell ? { weekEl, overlay, col, date: cell.dataset.date } : null
}
}
document.addEventListener('DOMContentLoaded', () => {
new InfiniteCalendar({
select_days: 1000
})
})