// 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' 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 // DnD state for events this.dragEventState = null // { mode: 'move'|'resize-left'|'resize-right', id, originWeek, originStartIdx, originEndIdx, pointerStartX, pointerStartY, startDate, endDate } 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) 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 }) } // -------- 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 = ` ` 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) { const current = this.getEventById(eventId) if (!current) return const newStart = data.startDate || current.startDate const newEnd = data.endDate || current.endDate const datesChanged = (newStart !== current.startDate) || (newEnd !== current.endDate) if (datesChanged) { const multi = daysInclusive(newStart, newEnd) > 1 const payload = { ...current, title: data.title.trim(), colorId: data.colorId, startDate: newStart, endDate: newEnd, startTime: multi ? null : (data.startTime ?? current.startTime), durationMinutes: multi ? null : (data.duration ?? current.durationMinutes) } this.updateEventDatesAndReindex(eventId, payload) this.refreshEvents() return } // No date change: update in place across instances 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) } } 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.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.dragPreview && this.dragPreview.id != null) { const pv = this.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.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.dragEventState && this.dragEventState.id === w.id) span.classList.add('dragging') // Click opens edit if not dragging span.addEventListener('click', e => { e.stopPropagation() if (this.dragEventState || this.justDragged) return this.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.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.dragEventState.anchorOffset = anchorOffset this.dragEventState.originSpanDays = spanDays this.dragEventState.originalStartDate = w.startDate this.dragEventState.originalEndDate = w.endDate // capture pointer to ensure we receive the up even if cursor leaves element if (this.dragEventState.usingPointer && span.setPointerCapture && ev.pointerId != null) { try { span.setPointerCapture(ev.pointerId) } catch {} } this.dragEventState.element = span this.dragEventState.currentOverlay = overlay this._eventDragMoved = false span.classList.add('dragging') this.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) } installGlobalEventDragHandlers() { if (this._installedEventDrag) return this._installedEventDrag = true this._onMouseMoveEventDrag = e => this.onEventDragMove(e) this._onMouseUpEventDrag = e => this.onEventDragEnd(e) document.addEventListener('mousemove', this._onMouseMoveEventDrag) document.addEventListener('mouseup', this._onMouseUpEventDrag) // touch this._onTouchMoveEventDrag = e => this.onEventDragMove(e) this._onTouchEndEventDrag = e => this.onEventDragEnd(e) document.addEventListener('touchmove', this._onTouchMoveEventDrag, { passive: false }) document.addEventListener('touchend', this._onTouchEndEventDrag) // window-level safety to prevent stuck drags this._onWindowMouseUpEventDrag = e => this.onEventDragEnd(e) this._onWindowBlurEventDrag = () => this.onEventDragEnd() window.addEventListener('mouseup', this._onWindowMouseUpEventDrag) window.addEventListener('blur', this._onWindowBlurEventDrag) // pointer events this._onPointerMoveEventDrag = e => this.onEventDragMove(e) this._onPointerUpEventDrag = e => this.onEventDragEnd(e) this._onPointerCancelEventDrag = e => this.onEventDragEnd(e) window.addEventListener('pointermove', this._onPointerMoveEventDrag) window.addEventListener('pointerup', this._onPointerUpEventDrag) window.addEventListener('pointercancel', this._onPointerCancelEventDrag) } removeGlobalEventDragHandlers() { if (!this._installedEventDrag) return document.removeEventListener('mousemove', this._onMouseMoveEventDrag) document.removeEventListener('mouseup', this._onMouseUpEventDrag) document.removeEventListener('touchmove', this._onTouchMoveEventDrag) document.removeEventListener('touchend', this._onTouchEndEventDrag) window.removeEventListener('mouseup', this._onWindowMouseUpEventDrag) window.removeEventListener('blur', this._onWindowBlurEventDrag) window.removeEventListener('pointermove', this._onPointerMoveEventDrag) window.removeEventListener('pointerup', this._onPointerUpEventDrag) window.removeEventListener('pointercancel', this._onPointerCancelEventDrag) this._installedEventDrag = false } onEventDragMove(e) { if (!this.dragEventState) return if (this.dragEventState.usingPointer && !(e && e.type && e.type.startsWith('pointer'))) return const { pointerStartX } = this.dragEventState if (e && e.cancelable) e.preventDefault() const pt = e.touches ? e.touches[0] : e const hit = pt ? this.getDateUnderPointer(pt.clientX, pt.clientY) : null if (hit && hit.date) { const [s, en] = this.computeTentativeRangeFromPointer(hit.date) this.dragPreview = { id: this.dragEventState.id, startDate: s, endDate: en } } else { this.dragPreview = null } this._eventDragMoved = true this.forceUpdateVisibleWeeks() } onEventDragEnd(e) { if (!this.dragEventState) return if (this.dragEventState.usingPointer && e && !(e.type && e.type.startsWith('pointer'))) { // Ignore mouse/touch ups while using pointer stream return } const st = this.dragEventState const weekEl = st.originWeek const getPoint = ev => (ev && ev.changedTouches && ev.changedTouches[0]) ? ev.changedTouches[0] : (ev && ev.touches ? ev.touches[0] : ev) const pt = getPoint(e) let startDateStr = this.dragPreview?.startDate let endDateStr = this.dragPreview?.endDate // If no preview strings were set, derive from pointer now if (!startDateStr || !endDateStr) { const drop = pt ? this.getDateUnderPointer(pt.clientX, pt.clientY) : null if (drop && drop.date) { const pair = this.computeTentativeRangeFromPointer(drop.date) startDateStr = pair[0] endDateStr = pair[1] } else { // Fallback: keep original startDateStr = st.startDate endDateStr = st.endDate } } // Apply transformation: move or resize. const ev = this.getEventById(st.id) if (ev) { const updated = { ...ev } if (st.mode === 'move') { const spanDays = daysInclusive(ev.startDate, ev.endDate) updated.startDate = startDateStr updated.endDate = addDaysStr(startDateStr, spanDays - 1) } else { // Resize left/right updates start or end date if (startDateStr <= endDateStr) { updated.startDate = startDateStr updated.endDate = endDateStr } } // If now spans more than 1 day, force full-day semantics let [ns, ne] = this.normalizeDateOrder(updated.startDate, updated.endDate) updated.startDate = ns updated.endDate = ne const multi = daysInclusive(updated.startDate, updated.endDate) > 1 if (multi) { updated.startTime = null updated.durationMinutes = null } else { // Single-day: ensure we have a time window if (!updated.startTime) updated.startTime = '09:00' if (!updated.durationMinutes) updated.durationMinutes = 60 } this.updateEventDatesAndReindex(ev.id, updated) } // Cleanup // No need to directly manipulate DOM; we re-render // release pointer capture if any try { if (st.usingPointer && st.element && st.element.releasePointerCapture && e && e.pointerId != null) { st.element.releasePointerCapture(e.pointerId) } } catch {} this.dragEventState = null this.justDragged = !!this._eventDragMoved this._eventDragMoved = false this.removeGlobalEventDragHandlers() this.forceUpdateVisibleWeeks() // Clear justDragged after microtask so subsequent click isn't fired as edit setTimeout(() => { this.justDragged = false }, 0) this.dragPreview = null } 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 } computeTentativeRangeFromPointer(dropDateStr) { const st = this.dragEventState if (!st) return [null, null] const anchorOffset = st.anchorOffset || 0 const spanDays = st.originSpanDays || daysInclusive(st.startDate, st.endDate) let startStr = st.startDate let endStr = st.endDate if (st.mode === 'move') { startStr = addDaysStr(dropDateStr, -anchorOffset) endStr = addDaysStr(startStr, spanDays - 1) } else if (st.mode === 'resize-left') { startStr = dropDateStr endStr = st.originalEndDate || st.endDate } else if (st.mode === 'resize-right') { startStr = st.originalStartDate || st.startDate endStr = dropDateStr } const [ns, ne] = this.normalizeDateOrder(startStr, endStr) return [ns, ne] } computeSpanIndicesForWeek(cells, startStr, endStr) { if (!cells || cells.length !== 7) return null if (!startStr || !endStr) return null const sIdx = cells.findIndex(c => c.dataset.date >= startStr) const eIdx = (() => { let idx = -1 for (let i = 0; i < cells.length; i++) { if (cells[i].dataset.date <= endStr) idx = i } return idx })() if (sIdx === -1 || eIdx === -1 || sIdx > 6 || eIdx < 0) return null const start = Math.max(0, sIdx) const end = Math.min(6, eIdx) if (start > end) return null return [start, end] } // (preview functions removed; moving actual element during drag) normalizeDateOrder(aStr, bStr) { if (!aStr) return [bStr, bStr] if (!bStr) return [aStr, aStr] return aStr <= bStr ? [aStr, bStr] : [bStr, aStr] } updateEventDatesAndReindex(eventId, updated) { // Remove old instances for (const [date, list] of this.events) { const idx = list.findIndex(e => e.id === eventId) if (idx !== -1) list.splice(idx, 1) if (list.length === 0) this.events.delete(date) } // Re-add across new range const start = new Date(fromLocalString(updated.startDate)) const end = new Date(fromLocalString(updated.endDate)) const base = { id: updated.id, title: updated.title, colorId: updated.colorId, startDate: updated.startDate, endDate: updated.endDate, startTime: updated.startTime, durationMinutes: updated.durationMinutes } for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { const ds = toLocalString(d) if (!this.events.has(ds)) this.events.set(ds, []) this.events.get(ds).push({ ...base, isSpanning: start < end }) } } } document.addEventListener('DOMContentLoaded', () => { new InfiniteCalendar({ select_days: 1000 }) })