diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..678b300 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +.* +!.gitignore +*.lock + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +coverage +*.local + +/cypress/videos/ +/cypress/screenshots/ + +# Editor directories and files +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +*.tsbuildinfo 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/colors.css b/colors.css deleted file mode 100644 index b3fe54c..0000000 --- a/colors.css +++ /dev/null @@ -1,77 +0,0 @@ -/* Color tokens */ -:root { - --panel: #fff; - --today: #f83; - --ink: #222; - --strong: #000; - --muted: #888; - --weekend: #888; - --firstday: #000; - --select: #aaf; - --shadow: #fff; - --label-bg: #fafbfe; - --label-bg-rgb: 250, 251, 254; -} - -/* Month tints (light) */ -.dec { background: hsl(220 50% 95%) } -.jan { background: hsl(220 50% 92%) } -.feb { background: hsl(220 50% 95%) } -.mar { background: hsl(125 60% 92%) } -.apr { background: hsl(125 60% 95%) } -.may { background: hsl(125 60% 92%) } -.jun { background: hsl(45 85% 95%) } -.jul { background: hsl(45 85% 92%) } -.aug { background: hsl(45 85% 95%) } -.sep { background: hsl(18 78% 92%) } -.oct { background: hsl(18 78% 95%) } -.nov { background: hsl(18 78% 92%) } - -/* Light mode — gray shades and colors */ -.event-color-0 { background: hsl(0, 0%, 85%) } /* lightest grey */ -.event-color-1 { background: hsl(0, 0%, 75%) } /* light grey */ -.event-color-2 { background: hsl(0, 0%, 65%) } /* medium grey */ -.event-color-3 { background: hsl(0, 0%, 55%) } /* dark grey */ -.event-color-4 { background: hsl(0, 80%, 70%) } /* red */ -.event-color-5 { background: hsl(40, 80%, 70%) } /* orange */ -.event-color-6 { background: hsl(200, 80%, 70%) } /* green */ -.event-color-7 { background: hsl(280, 80%, 70%) } /* purple */ - -/* Color tokens (dark) */ -@media (prefers-color-scheme: dark) { - :root { - --panel: #111318; - --today: #f83; - --ink: #ddd; - --strong: #fff; - --muted: #888; - --weekend: #999; - --firstday: #fff; - --select: #22a; - --shadow: #888; - --label-bg: #1a1d25; - --label-bg-rgb: 26, 29, 37; - } - - .dec { background: hsl(220 50% 8%) } - .jan { background: hsl(220 50% 6%) } - .feb { background: hsl(220 50% 8%) } - .mar { background: hsl(125 60% 6%) } - .apr { background: hsl(125 60% 8%) } - .may { background: hsl(125 60% 6%) } - .jun { background: hsl(45 85% 8%) } - .jul { background: hsl(45 85% 6%) } - .aug { background: hsl(45 85% 8%) } - .sep { background: hsl(18 78% 6%) } - .oct { background: hsl(18 78% 8%) } - .nov { background: hsl(18 78% 6%) } - - .event-color-0 { background: hsl(0, 0%, 20%) } /* lightest grey */ - .event-color-1 { background: hsl(0, 0%, 30%) } /* light grey */ - .event-color-2 { background: hsl(0, 0%, 40%) } /* medium grey */ - .event-color-3 { background: hsl(0, 0%, 50%) } /* dark grey */ - .event-color-4 { background: hsl(0, 70%, 50%) } /* red */ - .event-color-5 { background: hsl(40, 70%, 50%) } /* orange */ - .event-color-6 { background: hsl(200, 70%, 50%) } /* green */ - .event-color-7 { background: hsl(280, 70%, 50%) } /* purple */ -} \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..aaf1136 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,28 @@ +import { defineConfig, globalIgnores } from 'eslint/config' +import globals from 'globals' +import js from '@eslint/js' +import pluginVue from 'eslint-plugin-vue' +import pluginOxlint from 'eslint-plugin-oxlint' +import skipFormatting from '@vue/eslint-config-prettier/skip-formatting' + +export default defineConfig([ + { + name: 'app/files-to-lint', + files: ['**/*.{js,mjs,jsx,vue}'], + }, + + globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']), + + { + languageOptions: { + globals: { + ...globals.browser, + }, + }, + }, + + js.configs.recommended, + ...pluginVue.configs['flat/essential'], + ...pluginOxlint.configs['flat/recommended'], + skipFormatting, +]) diff --git a/event-manager.js b/event-manager.js deleted file mode 100644 index 6f5a2fe..0000000 --- a/event-manager.js +++ /dev/null @@ -1,851 +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 - this.eventIdCounter = 1 - - // Selection state - this.selStart = null - this.selEnd = null - this.isDragging = false - this.dragAnchor = null - - // Event drag state - this.dragEventState = null - this.dragPreview = 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 -------- - - 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.calendar.forceUpdateVisibleWeeks() - } - - createEventWithRepeat(eventData) { - const { repeat, repeatCount, ...baseEventData } = eventData - - if (repeat === 'none') { - // Single event - this.createEvent(baseEventData) - return - } - - // Calculate dates for repeating events - const startDate = new Date(fromLocalString(baseEventData.startDate)) - const endDate = new Date(fromLocalString(baseEventData.endDate)) - const spanDays = Math.floor((endDate - startDate) / (24 * 60 * 60 * 1000)) - - const maxOccurrences = repeatCount === 'unlimited' ? 104 : parseInt(repeatCount, 10) // Cap unlimited at ~2 years - const dates = [] - - for (let i = 0; i < maxOccurrences; i++) { - const currentStart = new Date(startDate) - - switch (repeat) { - case 'daily': - currentStart.setDate(startDate.getDate() + i) - break - case 'weekly': - currentStart.setDate(startDate.getDate() + i * 7) - break - case 'biweekly': - currentStart.setDate(startDate.getDate() + i * 14) - break - case 'monthly': - currentStart.setMonth(startDate.getMonth() + i) - break - case 'yearly': - currentStart.setFullYear(startDate.getFullYear() + i) - break - } - - const currentEnd = new Date(currentStart) - currentEnd.setDate(currentStart.getDate() + spanDays) - - dates.push({ - startDate: toLocalString(currentStart), - endDate: toLocalString(currentEnd) - }) - } - - // Create events for all dates - dates.forEach(({ startDate, endDate }) => { - this.createEvent({ - ...baseEventData, - startDate, - endDate - }) - }) - } - - 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) { - 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 - } - } - } - } - 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 - } - 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 = ` -