963 lines
33 KiB
JavaScript
963 lines
33 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
|
|
} from './date-utils.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]
|
|
|
|
// Event storage
|
|
this.events = new Map() // Map of date strings to arrays of events
|
|
this.eventIdCounter = 1
|
|
|
|
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
|
|
|
|
// unified selection state (single or range)
|
|
this.selStart = null
|
|
this.selEnd = null
|
|
this.isDragging = false
|
|
this.dragAnchor = null
|
|
|
|
this.init()
|
|
}
|
|
|
|
init() {
|
|
this.createHeader()
|
|
this.setupScrollListener()
|
|
this.setupJogwheel()
|
|
this.setupYearScroll()
|
|
this.setupSelectionInput()
|
|
this.setupCurrentDate()
|
|
this.setupEventDialog()
|
|
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())
|
|
if (this.config.select_days > 1) this.setupGlobalDragHandlers()
|
|
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.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.startDrag(dateStr)
|
|
})
|
|
cell.addEventListener('touchstart', e => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
this.startDrag(dateStr)
|
|
})
|
|
cell.addEventListener('mouseenter', () => {
|
|
if (this.isDragging) this.updateDrag(dateStr)
|
|
})
|
|
cell.addEventListener('mouseup', e => {
|
|
e.stopPropagation()
|
|
if (this.isDragging) this.endDrag(dateStr)
|
|
})
|
|
cell.addEventListener('touchmove', e => {
|
|
if (this.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.updateDrag(touchDateStr)
|
|
}
|
|
}
|
|
})
|
|
cell.addEventListener('touchend', e => {
|
|
e.stopPropagation()
|
|
if (this.isDragging) this.endDrag(dateStr)
|
|
})
|
|
}
|
|
|
|
if (isFirst) {
|
|
cell.classList.add('firstday')
|
|
day.textContent = cur.getMonth() ? monthAbbr[m].slice(0,3).toUpperCase() : cur.getFullYear()
|
|
}
|
|
|
|
cell.appendChild(day)
|
|
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 })
|
|
}
|
|
|
|
// -------- Selection --------
|
|
|
|
clampRange(anchorStr, otherStr) {
|
|
if (this.config.select_days <= 1) return [otherStr, otherStr]
|
|
const limit = this.config.select_days
|
|
const forward = fromLocalString(otherStr) >= fromLocalString(anchorStr)
|
|
const span = daysInclusive(anchorStr, otherStr)
|
|
if (span <= limit) {
|
|
const a = [anchorStr, otherStr].sort()
|
|
return [a[0], a[1]]
|
|
}
|
|
if (forward) return [anchorStr, addDaysStr(anchorStr, limit - 1)]
|
|
return [addDaysStr(anchorStr, -(limit - 1)), anchorStr]
|
|
}
|
|
|
|
setSelection(aStr, bStr) {
|
|
const [start, end] = this.clampRange(aStr, bStr)
|
|
this.selStart = start
|
|
this.selEnd = end
|
|
this.applySelectionToVisible()
|
|
this.selectedDateInput.value = formatDateRange(fromLocalString(start), fromLocalString(end))
|
|
}
|
|
|
|
clearSelection() {
|
|
this.selStart = null
|
|
this.selEnd = null
|
|
for (const [, weekEl] of this.visibleWeeks) {
|
|
weekEl.querySelectorAll('.cell.selected').forEach(c => c.classList.remove('selected'))
|
|
}
|
|
this.selectedDateInput.value = ''
|
|
}
|
|
|
|
applySelectionToVisible() {
|
|
for (const [, weekEl] of this.visibleWeeks) {
|
|
const cells = weekEl.querySelectorAll('.cell[data-date]')
|
|
for (const cell of cells) {
|
|
if (!this.selStart || !this.selEnd) {
|
|
cell.classList.remove('selected')
|
|
continue
|
|
}
|
|
const ds = cell.dataset.date
|
|
const inRange = ds >= this.selStart && ds <= this.selEnd
|
|
cell.classList.toggle('selected', inRange)
|
|
}
|
|
}
|
|
}
|
|
|
|
setupGlobalDragHandlers() {
|
|
document.addEventListener('mouseup', () => {
|
|
if (!this.isDragging) return
|
|
this.isDragging = false
|
|
document.body.style.cursor = 'default'
|
|
})
|
|
document.addEventListener('touchend', () => {
|
|
if (!this.isDragging) return
|
|
this.isDragging = false
|
|
document.body.style.cursor = 'default'
|
|
})
|
|
document.addEventListener('touchmove', e => {
|
|
if (!this.isDragging) return
|
|
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.updateDrag(touchDateStr)
|
|
}
|
|
}, { passive: false })
|
|
document.addEventListener('selectstart', e => {
|
|
if (this.isDragging) e.preventDefault()
|
|
})
|
|
document.addEventListener('contextmenu', e => {
|
|
if (this.isDragging) e.preventDefault()
|
|
})
|
|
}
|
|
|
|
startDrag(dateStr) {
|
|
if (this.config.select_days === 0) return
|
|
this.isDragging = true
|
|
this.dragAnchor = dateStr
|
|
this.setSelection(dateStr, dateStr)
|
|
}
|
|
|
|
updateDrag(dateStr) {
|
|
if (!this.isDragging) return
|
|
this.setSelection(this.dragAnchor, dateStr)
|
|
document.body.style.cursor = 'default'
|
|
}
|
|
|
|
endDrag(dateStr) {
|
|
if (!this.isDragging) return
|
|
this.isDragging = false
|
|
this.setSelection(this.dragAnchor, dateStr)
|
|
document.body.style.cursor = 'default'
|
|
if (this.selStart && this.selEnd) {
|
|
setTimeout(() => this.showEventDialog('create'), 50)
|
|
}
|
|
}
|
|
|
|
// -------- Event Management (overlay-based) --------
|
|
|
|
// Build dialog DOM once
|
|
setupEventDialog() {
|
|
const tpl = document.createElement('template')
|
|
tpl.innerHTML = `
|
|
<div class="ec-modal-backdrop" part="backdrop" hidden>
|
|
<div class="ec-modal" role="dialog" aria-modal="true" aria-labelledby="ec-modal-title">
|
|
<form class="ec-form" novalidate>
|
|
<header class="ec-header">
|
|
<h2 id="ec-modal-title">Event</h2>
|
|
</header>
|
|
<div class="ec-body">
|
|
<label class="ec-field">
|
|
<span>Title</span>
|
|
<input type="text" name="title" autocomplete="off" required />
|
|
</label>
|
|
<div class="ec-row">
|
|
<label class="ec-field">
|
|
<span>Start day</span>
|
|
<input type="date" name="startDate" />
|
|
</label>
|
|
<label class="ec-field">
|
|
<span>Duration</span>
|
|
<select name="duration">
|
|
<option value="15">15 minutes</option>
|
|
<option value="30">30 minutes</option>
|
|
<option value="45">45 minutes</option>
|
|
<option value="60" selected>1 hour</option>
|
|
<option value="90">1.5 hours</option>
|
|
<option value="120">2 hours</option>
|
|
<option value="180">3 hours</option>
|
|
<option value="240">4 hours</option>
|
|
<option value="480">8 hours</option>
|
|
<option value="720">12 hours</option>
|
|
<option value="1440">Full day</option>
|
|
<option value="2880">2 days</option>
|
|
<option value="4320">3 days</option>
|
|
<option value="10080">7 days</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div class="ec-row ec-time-row">
|
|
<label class="ec-field">
|
|
<span>Start time</span>
|
|
<input type="time" name="startTime" step="300" />
|
|
</label>
|
|
<div></div>
|
|
</div>
|
|
<div class="ec-color-swatches">
|
|
${Array.from({ length: 8 }, (_, i) => `
|
|
<input class="swatch event-color-${i}" type="radio" name="colorId" value="${i}">
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
<footer class="ec-footer">
|
|
<button type="button" class="ec-btn" data-action="cancel">Cancel</button>
|
|
<button type="submit" class="ec-btn primary">Save</button>
|
|
</footer>
|
|
</form>
|
|
</div>
|
|
</div>`
|
|
|
|
document.body.appendChild(tpl.content)
|
|
this.eventModal = document.querySelector('.ec-modal-backdrop')
|
|
this.eventForm = this.eventModal.querySelector('form.ec-form')
|
|
this.eventTitleInput = this.eventForm.elements['title']
|
|
this.eventStartDateInput = this.eventForm.elements['startDate']
|
|
this.eventStartTimeInput = this.eventForm.elements['startTime']
|
|
this.eventDurationInput = this.eventForm.elements['duration']
|
|
this.eventTimeRow = this.eventForm.querySelector('.ec-time-row')
|
|
this.eventColorInputs = Array.from(this.eventForm.querySelectorAll('input[name="colorId"]'))
|
|
// duration change toggles time visibility
|
|
this.eventDurationInput.addEventListener('change', () => this.updateTimeVisibilityByDuration())
|
|
// color selection visual state
|
|
this.eventColorInputs.forEach(radio => {
|
|
radio.addEventListener('change', () => {
|
|
const swatches = this.eventForm.querySelectorAll('.ec-color-swatches .swatch')
|
|
swatches.forEach(s => s.classList.toggle('selected', s.checked))
|
|
})
|
|
})
|
|
|
|
this.eventForm.addEventListener('submit', e => {
|
|
e.preventDefault()
|
|
const data = this.readEventForm()
|
|
if (!data.title.trim()) return
|
|
if (this._dialogMode === 'create') {
|
|
const computed = this.computeDatesFromForm(data)
|
|
this.createEvent({
|
|
title: data.title.trim(),
|
|
startDate: computed.startDate,
|
|
endDate: computed.endDate,
|
|
colorId: data.colorId,
|
|
startTime: data.startTime,
|
|
durationMinutes: data.duration
|
|
})
|
|
this.clearSelection()
|
|
} else if (this._dialogMode === 'edit' && this._editingEventId != null) {
|
|
const computed = this.computeDatesFromForm(data)
|
|
this.applyEventEdit(this._editingEventId, { ...data, ...computed })
|
|
}
|
|
this.hideEventDialog()
|
|
})
|
|
|
|
this.eventForm.querySelector('[data-action="cancel"]').addEventListener('click', () => {
|
|
this.hideEventDialog()
|
|
if (this._dialogMode === 'create') this.clearSelection()
|
|
})
|
|
|
|
this.eventModal.addEventListener('click', e => {
|
|
if (e.target === this.eventModal) this.hideEventDialog()
|
|
})
|
|
document.addEventListener('keydown', e => {
|
|
if (this.eventModal.hidden) return
|
|
if (e.key === 'Escape') {
|
|
this.hideEventDialog()
|
|
if (this._dialogMode === 'create') this.clearSelection()
|
|
}
|
|
})
|
|
}
|
|
|
|
showEventDialog(mode, opts = {}) {
|
|
this._dialogMode = mode
|
|
this._editingEventId = null
|
|
|
|
if (mode === 'create') {
|
|
// Defaults for new event
|
|
this.eventTitleInput.value = ''
|
|
this.eventStartTimeInput.value = '09:00'
|
|
// start date defaults
|
|
this.eventStartDateInput.value = this.selStart || toLocalString(new Date())
|
|
// duration defaults from selection (full days) or 60 min
|
|
if (this.selStart && this.selEnd) {
|
|
const days = daysInclusive(this.selStart, this.selEnd)
|
|
this.setDurationValue(days * 1440)
|
|
} else {
|
|
this.setDurationValue(60)
|
|
}
|
|
// suggest least-used color across range
|
|
const suggested = this.selectEventColorId(this.selStart, this.selEnd)
|
|
this.eventColorInputs.forEach(r => r.checked = Number(r.value) === suggested)
|
|
this.updateTimeVisibilityByDuration()
|
|
} else if (mode === 'edit') {
|
|
const ev = this.getEventById(opts.id)
|
|
if (!ev) return
|
|
this._editingEventId = ev.id
|
|
this.eventTitleInput.value = ev.title || ''
|
|
this.eventStartDateInput.value = ev.startDate
|
|
if (ev.startDate !== ev.endDate) {
|
|
const days = daysInclusive(ev.startDate, ev.endDate)
|
|
this.setDurationValue(days * 1440)
|
|
} else {
|
|
this.setDurationValue(ev.durationMinutes || 60)
|
|
}
|
|
this.eventStartTimeInput.value = ev.startTime || '09:00'
|
|
this.eventColorInputs.forEach(r => r.checked = Number(r.value) === (ev.colorId ?? 0))
|
|
this.updateTimeVisibilityByDuration()
|
|
}
|
|
this.eventModal.hidden = false
|
|
// simple focus
|
|
setTimeout(() => this.eventTitleInput.focus(), 0)
|
|
}
|
|
|
|
toggleTimeRow(show) {
|
|
if (!this.eventTimeRow) return
|
|
this.eventTimeRow.style.display = show ? '' : 'none'
|
|
}
|
|
|
|
updateTimeVisibilityByDuration() {
|
|
const minutes = Number(this.eventDurationInput.value || 0)
|
|
const isFullDayOrMore = minutes >= 1440
|
|
this.toggleTimeRow(!isFullDayOrMore)
|
|
}
|
|
|
|
hideEventDialog() {
|
|
this.eventModal.hidden = true
|
|
}
|
|
|
|
readEventForm() {
|
|
const colorId = Number(this.eventForm.querySelector('input[name="colorId"]:checked')?.value ?? 0)
|
|
const timeRowVisible = this.eventTimeRow && this.eventTimeRow.style.display !== 'none'
|
|
return {
|
|
title: this.eventTitleInput.value,
|
|
startDate: this.eventStartDateInput.value,
|
|
startTime: timeRowVisible ? (this.eventStartTimeInput.value || '09:00') : null,
|
|
duration: timeRowVisible ? Math.max(15, Number(this.eventDurationInput.value) || 60) : null,
|
|
colorId
|
|
}
|
|
}
|
|
|
|
setDurationValue(minutes) {
|
|
const v = String(minutes)
|
|
const exists = Array.from(this.eventDurationInput.options).some(o => o.value === v)
|
|
if (!exists) {
|
|
const opt = document.createElement('option')
|
|
opt.value = v
|
|
const days = Math.floor(minutes / 1440)
|
|
opt.textContent = days >= 1 ? `${days} day${days > 1 ? 's' : ''}` : `${minutes} minutes`
|
|
this.eventDurationInput.appendChild(opt)
|
|
}
|
|
this.eventDurationInput.value = v
|
|
}
|
|
|
|
computeDatesFromForm(data) {
|
|
const minutes = Number(this.eventDurationInput.value || 0)
|
|
if (minutes >= 1440) {
|
|
const days = Math.max(1, Math.floor(minutes / 1440))
|
|
return { startDate: data.startDate, endDate: addDaysStr(data.startDate, days - 1) }
|
|
}
|
|
return { startDate: data.startDate, endDate: data.startDate }
|
|
}
|
|
|
|
createEvent(eventData) {
|
|
const singleDay = eventData.startDate === eventData.endDate
|
|
const event = {
|
|
id: this.eventIdCounter++,
|
|
title: eventData.title,
|
|
startDate: eventData.startDate,
|
|
endDate: eventData.endDate,
|
|
colorId: eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate),
|
|
startTime: singleDay ? (eventData.startTime || '09:00') : null,
|
|
durationMinutes: singleDay ? (eventData.durationMinutes || 60) : null
|
|
}
|
|
|
|
const startDate = new Date(fromLocalString(event.startDate))
|
|
const endDate = new Date(fromLocalString(event.endDate))
|
|
|
|
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
|
const dateStr = toLocalString(d)
|
|
if (!this.events.has(dateStr)) this.events.set(dateStr, [])
|
|
this.events.get(dateStr).push({ ...event, isSpanning: startDate < endDate })
|
|
}
|
|
|
|
this.refreshEvents()
|
|
}
|
|
|
|
applyEventEdit(eventId, data) {
|
|
// Update all instances of this event across dates
|
|
for (const [, list] of this.events) {
|
|
for (let i = 0; i < list.length; i++) {
|
|
if (list[i].id === eventId) {
|
|
const isMulti = list[i].startDate !== list[i].endDate
|
|
list[i] = {
|
|
...list[i],
|
|
title: data.title.trim(),
|
|
colorId: data.colorId,
|
|
startTime: isMulti ? null : data.startTime,
|
|
durationMinutes: isMulti ? null : data.duration
|
|
}
|
|
}
|
|
}
|
|
}
|
|
this.refreshEvents()
|
|
}
|
|
|
|
getEventById(id) {
|
|
for (const [, list] of this.events) {
|
|
const found = list.find(e => e.id === id)
|
|
if (found) return found
|
|
}
|
|
return null
|
|
}
|
|
|
|
selectEventColorId(startDateStr, endDateStr) {
|
|
// Count frequency of each color used on the date range
|
|
const colorCounts = [0, 0, 0, 0, 0, 0, 0, 0]
|
|
const startDate = new Date(fromLocalString(startDateStr))
|
|
const endDate = new Date(fromLocalString(endDateStr))
|
|
|
|
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
|
const dateStr = toLocalString(d)
|
|
const dayEvents = this.events.get(dateStr) || []
|
|
for (const event of dayEvents) {
|
|
if (event.colorId >= 0 && event.colorId < 8) {
|
|
colorCounts[event.colorId]++
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find the color with the lowest count
|
|
// For equal counts, prefer the lowest color number
|
|
let minCount = colorCounts[0]
|
|
let selectedColor = 0
|
|
|
|
for (let colorId = 1; colorId < 8; colorId++) {
|
|
if (colorCounts[colorId] < minCount) {
|
|
minCount = colorCounts[colorId]
|
|
selectedColor = colorId
|
|
}
|
|
}
|
|
|
|
return selectedColor
|
|
}
|
|
|
|
refreshEvents() {
|
|
for (const [, weekEl] of this.visibleWeeks) {
|
|
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.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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
createOverlaySpan(overlay, w) {
|
|
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.addEventListener('click', e => {
|
|
e.stopPropagation()
|
|
this.showEventDialog('edit', { id: w.id })
|
|
})
|
|
overlay.appendChild(span)
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
new InfiniteCalendar({
|
|
select_days: 1000
|
|
})
|
|
})
|