// calendar.js — Infinite scrolling week-by-week const monthAbbr = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'] const DAY_MS = 86400000 const WEEK_MS = 7 * DAY_MS const isoWeekInfo = date => { const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())) const day = d.getUTCDay() || 7 d.setUTCDate(d.getUTCDate() + 4 - day) const year = d.getUTCFullYear() const yearStart = new Date(Date.UTC(year, 0, 1)) const diffDays = Math.floor((d - yearStart) / DAY_MS) + 1 return { week: Math.ceil(diffDays / 7), year } } function toLocalString(date = new Date()) { const pad = n => String(Math.floor(Math.abs(n))).padStart(2, '0') return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` } function fromLocalString(dateString) { const [year, month, day] = dateString.split('-').map(Number) return new Date(year, month - 1, day) } const mondayIndex = d => (d.getDay() + 6) % 7 const pad = n => String(n).padStart(2, '0') 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 // 'YYYY-MM-DD' this.selEnd = null // 'YYYY-MM-DD' this.isDragging = false this.dragAnchor = null // 'YYYY-MM-DD' 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()) 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.weekLabel.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.weekLabel.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) } getLocalizedWeekdayNames() { const res = [] const base = new Date(2025, 0, 6) for (let i = 0; i < 7; i++) { const d = new Date(base) d.setDate(base.getDate() + i) res.push(d.toLocaleDateString(undefined, { weekday: 'short' })) } return res } getLocalizedMonthName(idx, short = false) { const d = new Date(2025, idx, 1) return d.toLocaleDateString(undefined, { month: short ? 'short' : 'long' }) } createHeader() { this.weekLabel = document.createElement('div') this.weekLabel.className = 'dow-label' this.weekLabel.textContent = isoWeekInfo(new Date()).year this.header.appendChild(this.weekLabel) const names = this.getLocalizedWeekdayNames() names.forEach((name, i) => { const c = document.createElement('div') c.className = 'cell 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) // Add events to the newly created week 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.weekLabel.textContent !== String(year)) this.weekLabel.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) 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) console.log(cur, date) cell.dataset.date = date if (this.today && date === this.today) cell.classList.add('today') if (this.config.select_days > 0) { // Allow selection start from anywhere in the cell cell.addEventListener('mousedown', e => { e.preventDefault() e.stopPropagation() this.startDrag(dateStr) }) // Touch events for mobile support cell.addEventListener('touchstart', e => { e.preventDefault() e.stopPropagation() this.startDrag(dateStr) }) // Keep cell listeners for drag continuation cell.addEventListener('mouseenter', () => { if (this.isDragging) this.updateDrag(dateStr) }) cell.addEventListener('mouseup', e => { e.stopPropagation() if (this.isDragging) this.endDrag(dateStr) }) // Touch drag continuation cell.addEventListener('touchmove', e => { if (this.isDragging) { e.preventDefault() // Get touch position and find the element underneath 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) weekDiv.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 = `${this.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 -------- daysInclusive(aStr, bStr) { const a = fromLocalString(aStr) const b = fromLocalString(bStr) const A = new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime() const B = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime() return Math.floor(Math.abs(B - A) / DAY_MS) + 1 } addDaysStr(str, n) { const d = fromLocalString(str) d.setDate(d.getDate() + n) return toLocalString(d) } 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 = this.daysInclusive(anchorStr, otherStr) if (span <= limit) { const a = [anchorStr, otherStr].sort() return [a[0], a[1]] } if (forward) return [anchorStr, this.addDaysStr(anchorStr, limit - 1)] return [this.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 = this.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) } } } formatDateRange(startDate, endDate) { if (toLocalString(startDate) === toLocalString(endDate)) return toLocalString(startDate) const startISO = toLocalString(startDate) const endISO = toLocalString(endDate) const [sy, sm] = startISO.split('-') const [ey, em, ed] = endISO.split('-') if (sy === ey && sm === em) return `${startISO}/${ed}` if (sy === ey) return `${startISO}/${em}-${ed}` return `${startISO}/${endISO}` } setupGlobalDragHandlers() { // Mouse drag handlers document.addEventListener('mouseup', () => { if (!this.isDragging) return this.isDragging = false document.body.style.cursor = 'default' }) // Touch drag handlers document.addEventListener('touchend', () => { if (!this.isDragging) return this.isDragging = false document.body.style.cursor = 'default' }) // Global touch move handler for smooth drag across elements 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 }) // Prevent text selection during drag document.addEventListener('selectstart', e => { if (this.isDragging) e.preventDefault() }) // Prevent context menu on long touch during drag 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' // Trigger event creation after selection with a small delay if (this.selStart && this.selEnd) { setTimeout(() => this.promptForEvent(), 100) } } // -------- Event Management -------- promptForEvent() { const title = prompt('Enter event title:') if (!title || title.trim() === '') { this.clearSelection() return } this.createEvent({ title: title.trim(), startDate: this.selStart, endDate: this.selEnd }) this.clearSelection() } createEvent(eventData) { const event = { id: this.eventIdCounter++, title: eventData.title, startDate: eventData.startDate, endDate: eventData.endDate, color: this.generateEventColor() } // Add event to all dates in the range 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}) } // Re-render visible weeks to show the new event this.refreshEvents() } generateEventColor() { const colors = [ '#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#feca57', '#ff9ff3', '#54a0ff', '#5f27cd', '#00d2d3', '#ff9f43' ] return colors[Math.floor(Math.random() * colors.length)] } refreshEvents() { // Re-render events for all visible weeks for (const [weekDateStr, weekEl] of this.visibleWeeks) { this.addEventsToWeek(weekEl, weekDateStr) } } addEventsToWeek(weekEl, weekDateStr) { const cells = weekEl.querySelectorAll('.cell[data-date]') // Remove existing event elements from both week and individual cells weekEl.querySelectorAll('.event-span').forEach(el => el.remove()) cells.forEach(cell => { cell.querySelectorAll('.event-span').forEach(el => el.remove()) }) // Group events by their date ranges within this week const weekEvents = new Map() // Map of event ID to event info for (const cell of cells) { const dateStr = cell.dataset.date const events = this.events.get(dateStr) || [] events.forEach(event => { if (!weekEvents.has(event.id)) { weekEvents.set(event.id, { ...event, startDateInWeek: dateStr, endDateInWeek: dateStr, startCell: cell, endCell: cell, daysInWeek: 1 }) } else { const weekEvent = weekEvents.get(event.id) weekEvent.endDateInWeek = dateStr weekEvent.endCell = cell weekEvent.daysInWeek++ } }) } // Create spanning elements for each event weekEvents.forEach((weekEvent, eventId) => { this.createSpanningEvent(weekEl, weekEvent) }) } createSpanningEvent(weekEl, weekEvent) { const spanEl = document.createElement('div') spanEl.className = 'event-span' spanEl.style.backgroundColor = weekEvent.color spanEl.textContent = weekEvent.title spanEl.title = `${weekEvent.title} (${weekEvent.startDate === weekEvent.endDate ? weekEvent.startDate : weekEvent.startDate + ' - ' + weekEvent.endDate})` // Get all cells in the week (excluding week label and overlay) const cells = Array.from(weekEl.querySelectorAll('.cell[data-date]')) const startCellIndex = cells.indexOf(weekEvent.startCell) const endCellIndex = cells.indexOf(weekEvent.endCell) const spanDays = endCellIndex - startCellIndex + 1 // Use CSS custom properties for positioning spanEl.style.setProperty('--start-day', startCellIndex) spanEl.style.setProperty('--span-days', spanDays) // Style the spanning event spanEl.style.position = 'absolute' spanEl.style.top = '0.25em' spanEl.style.height = '1.2em' spanEl.style.zIndex = '15' spanEl.style.fontSize = '0.75em' spanEl.style.padding = '0.1em 0.3em' spanEl.style.borderRadius = '0.2em' spanEl.style.color = 'white' spanEl.style.fontWeight = '500' spanEl.style.whiteSpace = 'nowrap' spanEl.style.overflow = 'hidden' spanEl.style.textOverflow = 'ellipsis' spanEl.style.cursor = 'pointer' spanEl.style.lineHeight = '1.2' spanEl.style.pointerEvents = 'auto' // Append to the week element (not individual cells) weekEl.appendChild(spanEl) } } document.addEventListener('DOMContentLoaded', () => { new InfiniteCalendar({ select_days: 14 }) })