diff --git a/calendar.css b/calendar.css deleted file mode 100644 index 9597771..0000000 --- a/calendar.css +++ /dev/null @@ -1,6 +0,0 @@ -/* Calendar CSS - Main file with imports */ -@import url('colors.css'); -@import url('layout.css'); -@import url('cells.css'); -@import url('events.css'); -@import url('utilities.css'); diff --git a/calendar.js b/calendar.js deleted file mode 100644 index 8b736df..0000000 --- a/calendar.js +++ /dev/null @@ -1,484 +0,0 @@ -// calendar.js — Infinite scrolling week-by-week with overlay event rendering -import { - monthAbbr, - DAY_MS, - WEEK_MS, - isoWeekInfo, - toLocalString, - fromLocalString, - mondayIndex, - pad, - addDaysStr, - getLocalizedWeekdayNames, - getLocalizedMonthName, - formatDateRange, - lunarPhaseSymbol -} from './date-utils.js' -import { EventManager } from './event-manager.js' -import { JogwheelManager } from './jogwheel.js' - -class InfiniteCalendar { - constructor(config = {}) { - this.config = { - select_days: 0, - min_year: 1900, - max_year: 2100, - ...config - } - - this.weekend = [true, false, false, false, false, false, true] - - // Initialize event manager - this.eventManager = new EventManager(this) - - this.viewport = document.getElementById('calendar-viewport') - this.content = document.getElementById('calendar-content') - this.header = document.getElementById('calendar-header') - this.selectedDateInput = document.getElementById('selected-date') - - // Initialize jogwheel manager after DOM elements are available - this.jogwheelManager = new JogwheelManager(this) - - this.rowHeight = this.computeRowHeight() - this.visibleWeeks = new Map() - this.baseDate = new Date(2024, 0, 1) // 2024 begins with Monday - - this.init() - } init() { - this.createHeader() - this.setupScrollListener() - this.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.jogwheelManager.updateHeight(this.totalVirtualWeeks, this.rowHeight) - const initial = this.config.initial || this.today - this.navigateTo(fromLocalString(initial)) - } - - setupCurrentDate() { - const updateDate = () => { - this.now = new Date() - const today = toLocalString(this.now) - if (this.today === today) return - this.today = today - for (const cell of this.content.querySelectorAll('.cell.today')) cell.classList.remove('today') - const cell = this.content.querySelector(`.cell[data-date="${this.today}"]`) - if (cell) cell.classList.add('today') - const todayDateElement = document.getElementById('today-date') - const t = this.now.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric'}).replace(/,? /, "\n") - todayDateElement.textContent = t.charAt(0).toUpperCase() + t.slice(1) - } - - const todayDateElement = document.getElementById('today-date') - todayDateElement.addEventListener('click', () => this.goToToday()) - // Day selection drag functionality is handled through cell event handlers in EventManager - updateDate() - setInterval(updateDate, 1000) - } - - setupYearScroll() { - let throttled = false - const handleWheel = e => { - e.preventDefault() - e.stopPropagation() - const currentYear = parseInt(this.yearLabel.textContent) - const topDisplayIndex = Math.floor(this.viewport.scrollTop / this.rowHeight) - const currentWeekIndex = topDisplayIndex + this.minVirtualWeek - const sensitivity = 1/3 - const delta = Math.round(e.deltaY * sensitivity) - if (!delta) return - const newYear = Math.max(this.config.min_year, Math.min(this.config.max_year, currentYear + delta)) - if (newYear === currentYear || throttled) return - throttled = true - this.navigateToYear(newYear, currentWeekIndex) - setTimeout(() => throttled = false, 100) - } - this.yearLabel.addEventListener('wheel', handleWheel, { passive: false }) - } - - navigateTo(date) { - const targetWeekIndex = this.getWeekIndex(date) - 3 - return this.scrollToTarget(targetWeekIndex) - } - - navigateToYear(targetYear, weekIndex) { - const monday = this.getMondayForVirtualWeek(weekIndex) - const { week } = isoWeekInfo(monday) - const jan4 = new Date(targetYear, 0, 4) - const jan4Monday = new Date(jan4) - jan4Monday.setDate(jan4.getDate() - mondayIndex(jan4)) - const targetMonday = new Date(jan4Monday) - targetMonday.setDate(jan4Monday.getDate() + (week - 1) * 7) - this.scrollToTarget(targetMonday) - } - - scrollToTarget(target, options = {}) { - const { smooth = false, forceUpdate = true, allowFloating = false } = options - let targetWeekIndex - if (target instanceof Date) { - if (!this.isDateInAllowedRange(target)) return false - targetWeekIndex = this.getWeekIndex(target) - } else if (typeof target === 'number') { - targetWeekIndex = target - } else { - return false - } - - if (allowFloating) { - if (targetWeekIndex < this.minVirtualWeek - 0.5 || targetWeekIndex > this.maxVirtualWeek + 0.5) return false - } else { - if (targetWeekIndex < this.minVirtualWeek || targetWeekIndex > this.maxVirtualWeek) return false - } - - const targetScrollTop = (targetWeekIndex - this.minVirtualWeek) * this.rowHeight - - if (smooth) this.viewport.scrollTo({ top: targetScrollTop, behavior: 'smooth' }) - else this.viewport.scrollTop = targetScrollTop - - const mainScrollable = Math.max(0, this.content.scrollHeight - this.viewport.clientHeight) - this.jogwheelManager.scrollTo(targetScrollTop, mainScrollable, smooth) - - if (forceUpdate) this.updateVisibleWeeks() - return true - } - - computeRowHeight() { - const el = document.createElement('div') - el.style.position = 'absolute' - el.style.visibility = 'hidden' - el.style.height = 'var(--cell-h)' - document.body.appendChild(el) - const h = el.getBoundingClientRect().height || 64 - el.remove() - return Math.round(h) - } - - createHeader() { - this.yearLabel = document.createElement('div') - this.yearLabel.className = 'year-label' - this.yearLabel.textContent = isoWeekInfo(new Date()).year - this.header.appendChild(this.yearLabel) - - const names = getLocalizedWeekdayNames() - names.forEach((name, i) => { - const c = document.createElement('div') - c.classList.add('dow') - const dayIdx = (i + 1) % 7 - if (this.weekend[dayIdx]) c.classList.add('weekend') - c.textContent = name - this.header.appendChild(c) - }) - - const spacer = document.createElement('div') - spacer.className = 'overlay-header-spacer' - this.header.appendChild(spacer) - } - - getWeekIndex(date) { - const monday = new Date(date) - monday.setDate(date.getDate() - mondayIndex(date)) - return Math.floor((monday - this.baseDate) / WEEK_MS) - } - - getMondayForVirtualWeek(virtualWeek) { - const monday = new Date(this.baseDate) - monday.setDate(monday.getDate() + virtualWeek * 7) - return monday - } - - isDateInAllowedRange(date) { - const y = date.getFullYear() - return y >= this.config.min_year && y <= this.config.max_year - } - - setupScrollListener() { - let t - this.viewport.addEventListener('scroll', () => { - this.updateVisibleWeeks() - clearTimeout(t) - t = setTimeout(() => this.updateVisibleWeeks(), 8) - }) - } - - updateVisibleWeeks() { - const scrollTop = this.viewport.scrollTop - const viewportH = this.viewport.clientHeight - - this.updateYearLabel(scrollTop) - - const buffer = 10 - const startIdx = Math.floor((scrollTop - buffer * this.rowHeight) / this.rowHeight) - const endIdx = Math.ceil((scrollTop + viewportH + buffer * this.rowHeight) / this.rowHeight) - - const startVW = Math.max(this.minVirtualWeek, startIdx + this.minVirtualWeek) - const endVW = Math.min(this.maxVirtualWeek, endIdx + this.minVirtualWeek) - - for (const [vw, el] of this.visibleWeeks) { - if (vw < startVW || vw > endVW) { - el.remove() - this.visibleWeeks.delete(vw) - } - } - - for (let vw = startVW; vw <= endVW; vw++) { - if (this.visibleWeeks.has(vw)) continue - const weekEl = this.createWeekElement(vw) - weekEl.style.position = 'absolute' - weekEl.style.left = '0' - const displayIndex = vw - this.minVirtualWeek - weekEl.style.top = `${displayIndex * this.rowHeight}px` - weekEl.style.width = '100%' - weekEl.style.height = `${this.rowHeight}px` - this.content.appendChild(weekEl) - this.visibleWeeks.set(vw, weekEl) - this.addEventsToWeek(weekEl, vw) - } - - this.eventManager.applySelectionToVisible() - } - - updateYearLabel(scrollTop) { - const topDisplayIndex = Math.floor(scrollTop / this.rowHeight) - const topVW = topDisplayIndex + this.minVirtualWeek - const monday = this.getMondayForVirtualWeek(topVW) - const { year } = isoWeekInfo(monday) - if (this.yearLabel.textContent !== String(year)) this.yearLabel.textContent = year - } - - createWeekElement(virtualWeek) { - const weekDiv = document.createElement('div') - weekDiv.className = 'week-row' - const monday = this.getMondayForVirtualWeek(virtualWeek) - - const wkLabel = document.createElement('div') - wkLabel.className = 'week-label' - wkLabel.textContent = `W${pad(isoWeekInfo(monday).week)}` - weekDiv.appendChild(wkLabel) - - // days grid container to host cells and overlay - const daysGrid = document.createElement('div') - daysGrid.className = 'days-grid' - daysGrid.style.position = 'relative' - daysGrid.style.display = 'grid' - daysGrid.style.gridTemplateColumns = 'repeat(7, 1fr)' - daysGrid.style.gridAutoRows = '1fr' - daysGrid.style.height = '100%' - daysGrid.style.width = '100%' - weekDiv.appendChild(daysGrid) - - // overlay positioned above cells, same 7-col grid - const overlay = document.createElement('div') - overlay.className = 'week-overlay' - overlay.style.position = 'absolute' - overlay.style.inset = '0' - overlay.style.pointerEvents = 'none' - overlay.style.display = 'grid' - overlay.style.gridTemplateColumns = 'repeat(7, 1fr)' - overlay.style.gridAutoRows = '1fr' - overlay.style.zIndex = '15' - daysGrid.appendChild(overlay) - weekDiv._overlay = overlay - weekDiv._daysGrid = daysGrid - - const cur = new Date(monday) - let hasFirst = false - let monthToLabel = null - let labelYear = null - - for (let i = 0; i < 7; i++) { - const cell = document.createElement('div') - cell.className = 'cell' - const dateStr = toLocalString(cur) - cell.setAttribute('data-date', dateStr) - - const dow = cur.getDay() - if (this.weekend[dow]) cell.classList.add('weekend') - - const m = cur.getMonth() - cell.classList.add(monthAbbr[m]) - - const isFirst = cur.getDate() === 1 - if (isFirst) { - hasFirst = true - monthToLabel = m - labelYear = cur.getFullYear() - } - - const day = document.createElement('h1') - day.textContent = String(cur.getDate()) - - const date = toLocalString(cur) - cell.dataset.date = date - if (this.today && date === this.today) cell.classList.add('today') - - if (this.config.select_days > 0) { - cell.addEventListener('mousedown', e => { - e.preventDefault() - e.stopPropagation() - this.eventManager.startDrag(dateStr) - }) - cell.addEventListener('touchstart', e => { - e.preventDefault() - e.stopPropagation() - this.eventManager.startDrag(dateStr) - }) - cell.addEventListener('mouseenter', () => { - if (this.eventManager.isDragging) this.eventManager.updateDrag(dateStr) - }) - cell.addEventListener('mouseup', e => { - e.stopPropagation() - if (this.eventManager.isDragging) this.eventManager.endDrag(dateStr) - }) - cell.addEventListener('touchmove', e => { - if (this.eventManager.isDragging) { - e.preventDefault() - const touch = e.touches[0] - const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY) - if (elementBelow && elementBelow.closest('.cell[data-date]')) { - const cellBelow = elementBelow.closest('.cell[data-date]') - const touchDateStr = cellBelow.dataset.date - if (touchDateStr) this.eventManager.updateDrag(touchDateStr) - } - } - }) - cell.addEventListener('touchend', e => { - e.stopPropagation() - if (this.eventManager.isDragging) this.eventManager.endDrag(dateStr) - }) - } - - if (isFirst) { - cell.classList.add('firstday') - day.textContent = cur.getMonth() ? monthAbbr[m].slice(0,3).toUpperCase() : cur.getFullYear() - } - - cell.appendChild(day) - const luna = lunarPhaseSymbol(cur) - if (luna) { - const moon = document.createElement('span') - moon.className = 'lunar-phase' - moon.textContent = luna - cell.appendChild(moon) - } - daysGrid.appendChild(cell) - cur.setDate(cur.getDate() + 1) - } - - if (hasFirst && monthToLabel !== null) { - if (labelYear && labelYear > this.config.max_year) return weekDiv - const overlayCell = document.createElement('div') - overlayCell.className = 'month-name-label' - - let weeksSpan = 0 - const d = new Date(cur) - d.setDate(cur.getDate() - 1) - for (let i = 0; i < 6; i++) { - d.setDate(cur.getDate() - 1 + i * 7) - if (d.getMonth() === monthToLabel) weeksSpan++ - } - - const remainingWeeks = Math.max(1, this.maxVirtualWeek - virtualWeek + 1) - weeksSpan = Math.max(1, Math.min(weeksSpan, remainingWeeks)) - - overlayCell.style.height = `${weeksSpan * this.rowHeight}px` - overlayCell.style.display = 'flex' - overlayCell.style.alignItems = 'center' - overlayCell.style.justifyContent = 'center' - overlayCell.style.zIndex = '15' - overlayCell.style.pointerEvents = 'none' - - const label = document.createElement('span') - const year = String((labelYear ?? monday.getFullYear())).slice(-2) - label.textContent = `${getLocalizedMonthName(monthToLabel)} '${year}` - overlayCell.appendChild(label) - weekDiv.appendChild(overlayCell) - weekDiv.style.zIndex = '18' - } - - return weekDiv - } - - setupSelectionInput() { - if (this.config.select_days === 0) { - this.selectedDateInput.style.display = 'none' - } else { - this.selectedDateInput.style.display = 'block' - this.selectedDateInput.classList.add('clean-input') - } - } - - goToToday() { - const top = new Date(this.now) - top.setDate(top.getDate() - 21) - this.scrollToTarget(top, { smooth: true }) - } - - // -------- Event Rendering (overlay-based) -------- - - refreshEvents() { - for (const [, weekEl] of this.visibleWeeks) { - this.addEventsToWeek(weekEl) - } - } - - forceUpdateVisibleWeeks() { - // Force complete re-render of all visible weeks by clearing overlays first - for (const [, weekEl] of this.visibleWeeks) { - const overlay = weekEl._overlay || weekEl.querySelector('.week-overlay') - if (overlay) { - while (overlay.firstChild) overlay.removeChild(overlay.firstChild) - } - this.addEventsToWeek(weekEl) - } - } - - addEventsToWeek(weekEl) { - this.eventManager.addEventsToWeek(weekEl) - } - - getDateUnderPointer(clientX, clientY) { - const el = document.elementFromPoint(clientX, clientY) - if (!el) return null - // Fast path: directly find the cell under the pointer - const directCell = el.closest && el.closest('.cell[data-date]') - if (directCell) { - const weekEl = directCell.closest('.week-row') - return weekEl ? { weekEl, overlay: (weekEl._overlay || weekEl.querySelector('.week-overlay')), col: -1, date: directCell.dataset.date } : null - } - const weekEl = el.closest && el.closest('.week-row') - if (!weekEl) return null - const overlay = weekEl._overlay || weekEl.querySelector('.week-overlay') - const daysGrid = weekEl._daysGrid || weekEl.querySelector('.days-grid') - if (!overlay || !daysGrid) return null - const rect = overlay.getBoundingClientRect() - if (rect.width <= 0) return null - const colWidth = rect.width / 7 - let col = Math.floor((clientX - rect.left) / colWidth) - if (clientX < rect.left) col = 0 - if (clientX > rect.right) col = 6 - col = Math.max(0, Math.min(6, col)) - const cells = Array.from(daysGrid.querySelectorAll('.cell[data-date]')) - const cell = cells[col] - return cell ? { weekEl, overlay, col, date: cell.dataset.date } : null - } -} - -document.addEventListener('DOMContentLoaded', () => { - new InfiniteCalendar({ - select_days: 1000 - }) -}) diff --git a/cells.css b/cells.css deleted file mode 100644 index 64ceaea..0000000 --- a/cells.css +++ /dev/null @@ -1,38 +0,0 @@ -/* Day cells */ -.dow { text-transform: uppercase } -.cell { - position: relative; - display: flex; - flex-direction: row; - align-items: flex-start; - justify-content: flex-start; - padding: .25em; - overflow: hidden; - width: 100%; - height: var(--cell-h); - font-weight: 700; - cursor: pointer; - transition: background-color .15s ease; -} -.cell h1 { - top: .25em; - right: .25em; - padding: 0; - margin: 0; - min-width: 1.5em; - transition: background-color .15s ease; - font-size: 1em; -} -.cell.today h1 { - border-radius: 2em; - background: var(--today); - border: .2em solid var(--today); - margin: -.2em; -} -.cell:hover h1 { text-shadow: 0 0 .2em var(--shadow); } - -/* Fixed heights for cells and labels */ -.week-row .cell, .week-row .week-label { height: var(--cell-h) } - -.weekend { color: var(--weekend) } -.firstday { color: var(--firstday); text-shadow: 0 0 .1em var(--strong); } diff --git a/event-manager.js b/event-manager.js deleted file mode 100644 index 085a8d0..0000000 --- a/event-manager.js +++ /dev/null @@ -1,1030 +0,0 @@ -// event-manager.js — Event creation, editing, drag/drop, and selection logic -import { - toLocalString, - fromLocalString, - daysInclusive, - addDaysStr, - formatDateRange -} from './date-utils.js' - -export class EventManager { - constructor(calendar) { - this.calendar = calendar - this.events = new Map() // Map of date strings to arrays of events - - // Selection state - this.selStart = null - this.selEnd = null - this.isDragging = false - this.dragAnchor = null - - // Event drag state - this.dragEventState = null - this.justDragged = false - this._eventDragMoved = false - this._installedEventDrag = false - - this.setupEventDialog() - } - - // -------- Selection Logic -------- - - clampRange(anchorStr, otherStr) { - if (this.calendar.config.select_days <= 1) return [otherStr, otherStr] - const limit = this.calendar.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.calendar.selectedDateInput.value = formatDateRange(fromLocalString(start), fromLocalString(end)) - } - - clearSelection() { - this.selStart = null - this.selEnd = null - for (const [, weekEl] of this.calendar.visibleWeeks) { - weekEl.querySelectorAll('.cell.selected').forEach(c => c.classList.remove('selected')) - } - this.calendar.selectedDateInput.value = '' - } - - applySelectionToVisible() { - for (const [, weekEl] of this.calendar.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) - } - } - } - - startDrag(dateStr) { - if (this.calendar.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 -------- - - generateId() { - try { - if (window.crypto && typeof window.crypto.randomUUID === 'function') { - return window.crypto.randomUUID() - } - } catch {} - return 'e-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36) - } - - createEvent(eventData) { - const singleDay = eventData.startDate === eventData.endDate - const event = { - id: this.generateId(), - 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, - // Repeat metadata - repeat: eventData.repeat || 'none', - repeatCount: eventData.repeatCount || 'unlimited', - isRepeating: (eventData.repeat && eventData.repeat !== 'none') - } - - 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.calendar.forceUpdateVisibleWeeks() - return event.id - } - - createEventWithRepeat(eventData) { - // Just create a single event with repeat metadata - return this.createEvent(eventData) - } - - terminateRepeatSeriesAtIndex(baseEventId, terminateAtIndex) { - // Find the base event and modify its repeat count to stop before the termination index - for (const [, eventList] of this.events) { - const baseEvent = eventList.find(e => e.id === baseEventId) - if (baseEvent && baseEvent.isRepeating) { - // Set the repeat count to stop just before the termination index - baseEvent.repeatCount = terminateAtIndex.toString() - break - } - } - } - - moveRepeatSeries(baseEventId, newStartDateStr, newEndDateStr, mode) { - // Find the base event and update its dates, which will shift the entire series - for (const [, eventList] of this.events) { - const baseEvent = eventList.find(e => e.id === baseEventId) - if (baseEvent && baseEvent.isRepeating) { - const oldStartDate = baseEvent.startDate - const oldEndDate = baseEvent.endDate - - let updatedStartDate, updatedEndDate - if (mode === 'move') { - const spanDays = daysInclusive(oldStartDate, oldEndDate) - updatedStartDate = newStartDateStr - updatedEndDate = addDaysStr(newStartDateStr, spanDays - 1) - } else { - updatedStartDate = newStartDateStr - updatedEndDate = newEndDateStr - } - - // Update the base event with the new dates - const updated = { - ...baseEvent, - startDate: updatedStartDate, - endDate: updatedEndDate - } - - this.updateEventDatesAndReindex(baseEventId, updated) - break - } - } - } - - generateRepeatOccurrences(baseEvent, targetDateStr) { - if (!baseEvent.isRepeating || baseEvent.repeat === 'none') { - return [] - } - - const targetDate = new Date(fromLocalString(targetDateStr)) - const baseStartDate = new Date(fromLocalString(baseEvent.startDate)) - const baseEndDate = new Date(fromLocalString(baseEvent.endDate)) - const spanDays = Math.floor((baseEndDate - baseStartDate) / (24 * 60 * 60 * 1000)) - - const occurrences = [] - - // Calculate how many intervals have passed since the base event - let intervalsPassed = 0 - const timeDiff = targetDate - baseStartDate - - switch (baseEvent.repeat) { - case 'daily': - intervalsPassed = Math.floor(timeDiff / (24 * 60 * 60 * 1000)) - break - case 'weekly': - intervalsPassed = Math.floor(timeDiff / (7 * 24 * 60 * 60 * 1000)) - break - case 'biweekly': - intervalsPassed = Math.floor(timeDiff / (14 * 24 * 60 * 60 * 1000)) - break - case 'monthly': - intervalsPassed = Math.floor((targetDate.getFullYear() - baseStartDate.getFullYear()) * 12 + - (targetDate.getMonth() - baseStartDate.getMonth())) - break - case 'yearly': - intervalsPassed = targetDate.getFullYear() - baseStartDate.getFullYear() - break - } - - // Check a few occurrences around the target date - for (let i = Math.max(0, intervalsPassed - 2); i <= intervalsPassed + 2; i++) { - const maxOccurrences = baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10) - if (i >= maxOccurrences) break - - const currentStart = new Date(baseStartDate) - - switch (baseEvent.repeat) { - case 'daily': - currentStart.setDate(baseStartDate.getDate() + i) - break - case 'weekly': - currentStart.setDate(baseStartDate.getDate() + i * 7) - break - case 'biweekly': - currentStart.setDate(baseStartDate.getDate() + i * 14) - break - case 'monthly': - currentStart.setMonth(baseStartDate.getMonth() + i) - break - case 'yearly': - currentStart.setFullYear(baseStartDate.getFullYear() + i) - break - } - - const currentEnd = new Date(currentStart) - currentEnd.setDate(currentStart.getDate() + spanDays) - - // Check if this occurrence intersects with the target date - const currentStartStr = toLocalString(currentStart) - const currentEndStr = toLocalString(currentEnd) - - if (currentStartStr <= targetDateStr && targetDateStr <= currentEndStr) { - occurrences.push({ - ...baseEvent, - id: `${baseEvent.id}_repeat_${i}`, - startDate: currentStartStr, - endDate: currentEndStr, - isRepeatOccurrence: true, - repeatIndex: i - }) - } - } - - return occurrences - } - - getEventById(id) { - // Check for base events first - for (const [, list] of this.events) { - const found = list.find(e => e.id === id) - if (found) return found - } - - // Check if it's a repeat occurrence ID (format: baseId_repeat_index) - if (typeof id === 'string' && id.includes('_repeat_')) { - const parts = id.split('_repeat_') - const baseId = parts[0] // baseId is a string (UUID or similar) - const repeatIndex = parseInt(parts[1], 10) - - if (isNaN(repeatIndex)) return null - - const baseEvent = this.getEventById(baseId) - if (baseEvent && baseEvent.isRepeating) { - // Generate the specific occurrence - const baseStartDate = new Date(fromLocalString(baseEvent.startDate)) - const baseEndDate = new Date(fromLocalString(baseEvent.endDate)) - const spanDays = Math.floor((baseEndDate - baseStartDate) / (24 * 60 * 60 * 1000)) - - const currentStart = new Date(baseStartDate) - switch (baseEvent.repeat) { - case 'daily': - currentStart.setDate(baseStartDate.getDate() + repeatIndex) - break - case 'weekly': - currentStart.setDate(baseStartDate.getDate() + repeatIndex * 7) - break - case 'biweekly': - currentStart.setDate(baseStartDate.getDate() + repeatIndex * 14) - break - case 'monthly': - currentStart.setMonth(baseStartDate.getMonth() + repeatIndex) - break - case 'yearly': - currentStart.setFullYear(baseStartDate.getFullYear() + repeatIndex) - break - } - - const currentEnd = new Date(currentStart) - currentEnd.setDate(currentStart.getDate() + spanDays) - - return { - ...baseEvent, - id: id, - startDate: toLocalString(currentStart), - endDate: toLocalString(currentEnd), - isRepeatOccurrence: true, - repeatIndex: repeatIndex, - baseEventId: baseId - } - } - } - - return null - } - - selectEventColorId(startDateStr, endDateStr) { - 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]++ - } - } - } - - 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 - } - - 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.calendar.forceUpdateVisibleWeeks() - 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, - // Update repeat metadata - repeat: data.repeat || list[i].repeat || 'none', - repeatCount: data.repeatCount || list[i].repeatCount || 'unlimited', - isRepeating: (data.repeat && data.repeat !== 'none') || (list[i].repeat && list[i].repeat !== 'none') - } - } - } - } - this.calendar.forceUpdateVisibleWeeks() - } - - 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, - // Preserve repeat metadata - repeat: updated.repeat || 'none', - repeatCount: updated.repeatCount || 'unlimited', - isRepeating: updated.isRepeating || false - } - 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 }) - } - } - - // -------- Event Dialog -------- - - setupEventDialog() { - const tpl = document.createElement('template') - tpl.innerHTML = ` -