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 = ` - ` - - 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.eventRepeatInput = this.eventForm.elements['repeat'] - this.eventColorInputs = Array.from(this.eventForm.querySelectorAll('input[name="colorId"]')) - - // 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') { - this.createEventWithRepeat({ - title: data.title.trim(), - startDate: this.selStart, - endDate: this.selEnd, - colorId: data.colorId, - repeat: data.repeat, - repeatCount: data.repeatCount - }) - this.clearSelection() - } else if (this._dialogMode === 'edit' && this._editingEventId != null) { - const editingEvent = this.getEventById(this._editingEventId) - - if (editingEvent && editingEvent.isRepeatOccurrence && editingEvent.repeatIndex > 0) { - // Editing a repeat occurrence that's not the first one - // Terminate the original series and create a new event - this.terminateRepeatSeriesAtIndex(editingEvent.baseEventId, editingEvent.repeatIndex) - this.createEventWithRepeat({ - title: data.title.trim(), - startDate: editingEvent.startDate, - endDate: editingEvent.endDate, - colorId: data.colorId, - repeat: data.repeat, - repeatCount: data.repeatCount - }) - } else { - // Normal event edit - this.applyEventEdit(this._editingEventId, { - title: data.title.trim(), - colorId: data.colorId, - repeat: data.repeat, - repeatCount: data.repeatCount - }) - } - } - this.hideEventDialog() - }) - - this.eventForm.querySelector('[data-action="delete"]').addEventListener('click', () => { - if (this._dialogMode === 'edit' && this._editingEventId) { - const editingEvent = this.getEventById(this._editingEventId) - - if (editingEvent && editingEvent.isRepeatOccurrence && editingEvent.repeatIndex > 0) { - // Deleting a repeat occurrence that's not the first one - // Terminate the original series at this point - this.terminateRepeatSeriesAtIndex(editingEvent.baseEventId, editingEvent.repeatIndex) - this.calendar.forceUpdateVisibleWeeks() - } else { - // Normal event deletion - remove from ALL dates it spans across - const datesToCleanup = [] - for (const [dateStr, eventList] of this.events) { - const eventIndex = eventList.findIndex(event => event.id === this._editingEventId) - if (eventIndex !== -1) { - eventList.splice(eventIndex, 1) - // Mark date for cleanup if empty - if (eventList.length === 0) { - datesToCleanup.push(dateStr) - } - } - } - // Clean up empty date entries - datesToCleanup.forEach(dateStr => this.events.delete(dateStr)) - this.calendar.forceUpdateVisibleWeeks() - } - } - 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') { - this.eventTitleInput.value = '' - this.eventRepeatInput.value = 'none' - const suggested = this.selectEventColorId(this.selStart, this.selEnd) - this.eventColorInputs.forEach(r => r.checked = Number(r.value) === suggested) - } else if (mode === 'edit') { - const ev = this.getEventById(opts.id) - if (!ev) return - this._editingEventId = ev.id - - // For repeat occurrences, get the base event's repeat settings - let displayEvent = ev - if (ev.isRepeatOccurrence && ev.baseEventId) { - const baseEvent = this.getEventById(ev.baseEventId) - if (baseEvent) { - displayEvent = { ...ev, repeat: baseEvent.repeat, repeatCount: baseEvent.repeatCount } - } - } - - this.eventTitleInput.value = displayEvent.title || '' - this.eventRepeatInput.value = displayEvent.repeat || 'none' - this.eventColorInputs.forEach(r => r.checked = Number(r.value) === (displayEvent.colorId ?? 0)) - } - this.eventModal.hidden = false - setTimeout(() => this.eventTitleInput.focus(), 0) - } - - hideEventDialog() { - this.eventModal.hidden = true - } - - readEventForm() { - const colorId = Number(this.eventForm.querySelector('input[name="colorId"]:checked')?.value ?? 0) - return { - title: this.eventTitleInput.value, - repeat: this.eventRepeatInput.value, - repeatCount: 'unlimited', // Always unlimited - colorId - } - } - - // -------- Event Drag & Drop -------- - - installGlobalEventDragHandlers() { - if (this._installedEventDrag) return - this._installedEventDrag = true - 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) - this._onWindowBlurEventDrag = () => this.onEventDragEnd() - window.addEventListener('blur', this._onWindowBlurEventDrag) - } - - removeGlobalEventDragHandlers() { - if (!this._installedEventDrag) return - window.removeEventListener('pointermove', this._onPointerMoveEventDrag) - window.removeEventListener('pointerup', this._onPointerUpEventDrag) - window.removeEventListener('pointercancel', this._onPointerCancelEventDrag) - window.removeEventListener('blur', this._onWindowBlurEventDrag) - this._installedEventDrag = false - } - - onEventDragMove(e) { - if (!this.dragEventState) return - if (this.dragEventState.usingPointer && !(e && e.type && e.type.startsWith('pointer'))) return - const pt = e - - // Check if we've moved far enough to consider this a real drag - if (!this._eventDragMoved) { - const dx = pt.clientX - this.dragEventState.pointerStartX - const dy = pt.clientY - this.dragEventState.pointerStartY - const distance = Math.sqrt(dx * dx + dy * dy) - const minDragDistance = 5 // pixels - - if (distance < minDragDistance) return - // Only prevent default when we actually start dragging - if (e && e.cancelable) e.preventDefault() - this._eventDragMoved = true - } else { - // Already dragging, continue to prevent default - if (e && e.cancelable) e.preventDefault() - } - - const hit = pt ? this.calendar.getDateUnderPointer(pt.clientX, pt.clientY) : null - if (!hit || !hit.date) return - const [s, en] = this.computeTentativeRangeFromPointer(hit.date) - - const ev = this.getEventById(this.dragEventState.id) - if (!ev) { - // If we already split and created a new base series, keep moving that - if (this.dragEventState.splitNewBaseId) { - this.moveRepeatSeries(this.dragEventState.splitNewBaseId, s, en, this.dragEventState.mode) - this.calendar.forceUpdateVisibleWeeks() - } - return - } - - // Snapshot origin once - if (!this.dragEventState.originSnapshot) { - this.dragEventState.originSnapshot = { - baseId: ev.isRepeatOccurrence ? ev.baseEventId : ev.id, - isRepeat: !!(ev.isRepeatOccurrence || ev.isRepeating), - repeatIndex: ev.isRepeatOccurrence ? ev.repeatIndex : 0, - startDate: ev.startDate, - endDate: ev.endDate - } - } - - if (ev.isRepeatOccurrence) { - // Live-move: if first occurrence, shift entire series; else split once then move the new future series - if (ev.repeatIndex === 0) { - this.moveRepeatSeries(ev.baseEventId, s, en, this.dragEventState.mode) - } else { - // Split only once - if (!this.dragEventState.splitNewBaseId) { - this.terminateRepeatSeriesAtIndex(ev.baseEventId, ev.repeatIndex) - const base = this.getEventById(ev.baseEventId) - if (base) { - const newId = this.createEventWithRepeat({ - title: base.title, - startDate: s, - endDate: en, - colorId: base.colorId, - repeat: base.repeat, - repeatCount: base.repeatCount - }) - this.dragEventState.splitNewBaseId = newId - } - } else { - this.moveRepeatSeries(this.dragEventState.splitNewBaseId, s, en, this.dragEventState.mode) - } - } - } else { - // Non-repeating: mutate directly and repaint - const updated = { ...ev } - if (this.dragEventState.mode === 'move') { - const spanDays = daysInclusive(ev.startDate, ev.endDate) - updated.startDate = s - updated.endDate = addDaysStr(s, spanDays - 1) - } else { - if (s <= en) { - updated.startDate = s - updated.endDate = en - } - } - - 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 { - if (!updated.startTime) updated.startTime = '09:00' - if (!updated.durationMinutes) updated.durationMinutes = 60 - } - this.updateEventDatesAndReindex(ev.id, updated) - } - - this.calendar.forceUpdateVisibleWeeks() - } - - onEventDragEnd(e) { - if (!this.dragEventState) return - if (this.dragEventState.usingPointer && e && !(e.type && e.type.startsWith('pointer'))) { - return - } - - const st = this.dragEventState - - // If no actual drag movement occurred, do nothing (treat as click) - if (!this._eventDragMoved) { - // clean up only - try { - if (st.usingPointer && st.element && st.element.releasePointerCapture && e && e.pointerId != null) { - st.element.releasePointerCapture(e.pointerId) - } - } catch {} - this.dragEventState = null - this.justDragged = false - this._eventDragMoved = false - this.removeGlobalEventDragHandlers() - return - } - - try { - if (st.usingPointer && st.element && st.element.releasePointerCapture && e && e.pointerId != null) { - st.element.releasePointerCapture(e.pointerId) - } - } catch {} - - this.dragEventState = null - - // Only set justDragged if we actually moved and dragged - this.justDragged = !!this._eventDragMoved - - this._eventDragMoved = false - this.removeGlobalEventDragHandlers() - - // We already applied live updates during drag; ensure final repaint - if (this.justDragged) this.calendar.forceUpdateVisibleWeeks() - - // Clear justDragged flag after a short delay to allow click events to process - if (this.justDragged) { - setTimeout(() => { - this.justDragged = false - }, 100) - } - // no preview state to clear - } - - 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] - } - - normalizeDateOrder(aStr, bStr) { - if (!aStr) return [bStr, bStr] - if (!bStr) return [aStr, aStr] - return aStr <= bStr ? [aStr, bStr] : [bStr, aStr] - } - - 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() - - // Collect all repeating events from the entire events map - const allRepeatingEvents = [] - for (const [, eventList] of this.events) { - for (const event of eventList) { - if (event.isRepeating && !allRepeatingEvents.some(e => e.id === event.id)) { - allRepeatingEvents.push(event) - } - } - } - - for (const cell of cells) { - const dateStr = cell.dataset.date - const events = this.events.get(dateStr) || [] - - // Add regular events - 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) - } - } - - // Generate repeat occurrences for this date - for (const baseEvent of allRepeatingEvents) { - const repeatOccurrences = this.generateRepeatOccurrences(baseEvent, dateStr) - for (const repeatEvent of repeatOccurrences) { - // Skip if this is the original occurrence (already added above) - if (repeatEvent.startDate === baseEvent.startDate) continue - - if (!weekEvents.has(repeatEvent.id)) { - weekEvents.set(repeatEvent.id, { - ...repeatEvent, - startDateInWeek: dateStr, - endDateInWeek: dateStr, - startIdx: cells.indexOf(cell), - endIdx: cells.indexOf(cell) - }) - } else { - const w = weekEvents.get(repeatEvent.id) - w.endDateInWeek = dateStr - w.endIdx = cells.indexOf(cell) - } - } - } - } - - // No special preview overlay logic: we mutate events live during drag - - 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 String(a.id).localeCompare(String(b.id)) - }) - - 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() - - // Only block if we actually dragged (moved the mouse) - if (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) => { - // Prevent duplicate handling if we already have a drag state - if (this.dragEventState) return - - // Don't prevent default immediately - let click events through - ev.stopPropagation() - const point = ev - const hitAtStart = this.calendar.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() - } - - // Use pointer events (supported by all modern browsers) - 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) - }) - - // Pointer events cover mouse and touch - overlay.appendChild(span) - } -} diff --git a/events.css b/events.css deleted file mode 100644 index cb168a2..0000000 --- a/events.css +++ /dev/null @@ -1,69 +0,0 @@ -.event-span { - font-size: clamp(.45em, 1.8vh, .75em); - padding: 0 .5em; - border-radius: .4em; - color: var(--strong); - font-weight: 600; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: 1; /* let the track height define the visual height */ - height: 100%; /* fill the grid row height */ - align-self: stretch; /* fill row vertically */ - justify-self: stretch; /* stretch across chosen grid columns */ - display: flex; - align-items: center; - justify-content: center; - pointer-events: auto; /* clickable despite overlay having none */ - z-index: 1; - position: relative; - cursor: grab; -} - -/* Selection styles */ -.cell.selected { - background: var(--select); - box-shadow: 0 0 .1em var(--muted) inset; -} -.cell.selected .event { opacity: .7 } - -/* Dragging state */ -.event-span.dragging { - opacity: .9; - cursor: grabbing; - z-index: 4; -} - -/* Resize handles */ -.event-span .resize-handle { - position: absolute; - top: 0; - bottom: 0; - width: 6px; - background: transparent; - z-index: 2; -} -.event-span .resize-handle.left { - left: 0; - cursor: ew-resize; -} -.event-span .resize-handle.right { - right: 0; - cursor: ew-resize; -} - -/* Live preview ghost while dragging */ -.event-preview { - pointer-events: none; - opacity: .6; - outline: 2px dashed currentColor; - outline-offset: -2px; - border-radius: .4em; - display: flex; - align-items: center; - justify-content: center; - font-size: clamp(.45em, 1.8vh, .75em); - line-height: 1; - height: 100%; - z-index: 3; -} diff --git a/index.html b/index.html index 435ebcc..1506caa 100644 --- a/index.html +++ b/index.html @@ -4,26 +4,9 @@ Calendar - -
-
-

Calendar

-
- -
-
-
-
-
-
-
-
-
-
-
-
- -
- \ No newline at end of file +
+ + + diff --git a/jogwheel.js b/jogwheel.js deleted file mode 100644 index 1c70050..0000000 --- a/jogwheel.js +++ /dev/null @@ -1,79 +0,0 @@ -// jogwheel.js — Jogwheel synchronization for calendar scrolling - -export class JogwheelManager { - constructor(calendar) { - this.calendar = calendar - this.viewport = null - this.content = null - this.syncLock = null - this.init() - } - - init() { - this.viewport = document.getElementById('jogwheel-viewport') - this.content = document.getElementById('jogwheel-content') - - if (!this.viewport || !this.content) { - console.warn('Jogwheel elements not found - jogwheel functionality disabled') - return - } - - this.setupScrollSync() - } - - setupScrollSync() { - // Check if calendar viewport is available - if (!this.calendar.viewport || !this.calendar.content) { - console.warn('Calendar viewport not available - jogwheel sync disabled') - return - } - - // Bind sync function to maintain proper context - const sync = this.sync.bind(this) - - this.viewport.addEventListener('scroll', () => - sync(this.viewport, this.calendar.viewport, this.content, this.calendar.content) - ) - - this.calendar.viewport.addEventListener('scroll', () => - sync(this.calendar.viewport, this.viewport, this.calendar.content, this.content) - ) - } - - sync(fromEl, toEl, fromContent, toContent) { - if (this.syncLock === toEl) return - this.syncLock = 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 (this.syncLock === fromEl) this.syncLock = null - }, 50) - } - - updateHeight(totalVirtualWeeks, rowHeight) { - if (this.content) { - this.content.style.height = `${(totalVirtualWeeks * rowHeight) / 10}px` - } - } - - scrollTo(targetScrollTop, mainScrollable, smooth = false) { - if (!this.viewport || !this.content) return - - const jogScrollable = Math.max(0, this.content.scrollHeight - this.viewport.clientHeight) - const jogwheelTarget = mainScrollable > 0 ? (targetScrollTop / mainScrollable) * jogScrollable : 0 - - if (smooth) { - this.viewport.scrollTo({ top: jogwheelTarget, behavior: 'smooth' }) - } else { - this.viewport.scrollTop = jogwheelTarget - } - } - - isAvailable() { - return !!(this.viewport && this.content) - } -} diff --git a/src/App.vue b/src/App.vue index 6ec9f60..1de60f0 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,11 +1,9 @@ - + diff --git a/src/assets/calendar.css b/src/assets/calendar.css new file mode 100644 index 0000000..e1c5ee5 --- /dev/null +++ b/src/assets/calendar.css @@ -0,0 +1,3 @@ +/* Calendar CSS - Main file with imports */ +@import url('./colors.css'); +@import url('./layout.css'); diff --git a/colors.css b/src/assets/colors.css similarity index 92% rename from colors.css rename to src/assets/colors.css index b3fe54c..4eb3e60 100644 --- a/colors.css +++ b/src/assets/colors.css @@ -11,6 +11,10 @@ --shadow: #fff; --label-bg: #fafbfe; --label-bg-rgb: 250, 251, 254; + + /* Vue component color mappings */ + --bg: var(--panel); + --border-color: #ddd; } /* Month tints (light) */ @@ -40,7 +44,7 @@ /* Color tokens (dark) */ @media (prefers-color-scheme: dark) { :root { - --panel: #111318; + --panel: #000; --today: #f83; --ink: #ddd; --strong: #fff; @@ -51,6 +55,10 @@ --shadow: #888; --label-bg: #1a1d25; --label-bg-rgb: 26, 29, 37; + + /* Vue component color mappings (dark) */ + --bg: var(--panel); + --border-color: #333; } .dec { background: hsl(220 50% 8%) } @@ -74,4 +82,4 @@ .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/layout.css b/src/assets/layout.css similarity index 85% rename from layout.css rename to src/assets/layout.css index 5a291e9..1d5c9c3 100644 --- a/layout.css +++ b/src/assets/layout.css @@ -18,17 +18,6 @@ body { color: var(--ink); } -.wrap { - width: 100%; - margin: 0; - background: var(--panel); - height: 100vh; - display: flex; - flex-direction: column; - padding: 1rem; - white-space: pre-wrap; -} - header { display: flex; align-items: baseline; @@ -129,19 +118,6 @@ header { width: 100%; } -/* Overlay sitting above the day cells, same 7-col grid */ -.week-row > .days-grid > .week-overlay { - margin-top: 1.5em; - position: absolute; - inset: 0; - pointer-events: none; - display: grid; - grid-template-columns: repeat(7, 1fr); - grid-auto-rows: 1fr; - row-gap: 0; /* eliminate gaps so space is fully usable */ - z-index: 15; -} - .month-name-label { grid-column: -2 / -1; font-size: 2em; diff --git a/src/components/Calendar.vue b/src/components/Calendar.vue new file mode 100644 index 0000000..010074a --- /dev/null +++ b/src/components/Calendar.vue @@ -0,0 +1,35 @@ + + + diff --git a/src/components/CalendarDay.vue b/src/components/CalendarDay.vue new file mode 100644 index 0000000..465a506 --- /dev/null +++ b/src/components/CalendarDay.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/src/components/CalendarGrid.vue b/src/components/CalendarGrid.vue new file mode 100644 index 0000000..77a57af --- /dev/null +++ b/src/components/CalendarGrid.vue @@ -0,0 +1,169 @@ + + + diff --git a/src/components/CalendarHeader.vue b/src/components/CalendarHeader.vue new file mode 100644 index 0000000..ce49a42 --- /dev/null +++ b/src/components/CalendarHeader.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/src/components/CalendarView.vue b/src/components/CalendarView.vue new file mode 100644 index 0000000..9f8d963 --- /dev/null +++ b/src/components/CalendarView.vue @@ -0,0 +1,465 @@ + + + + + diff --git a/src/components/CalendarWeek.vue b/src/components/CalendarWeek.vue new file mode 100644 index 0000000..ded3d9c --- /dev/null +++ b/src/components/CalendarWeek.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/src/components/DayCell.vue b/src/components/DayCell.vue new file mode 100644 index 0000000..2dd49df --- /dev/null +++ b/src/components/DayCell.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/components/EventDialog.vue b/src/components/EventDialog.vue new file mode 100644 index 0000000..caecb77 --- /dev/null +++ b/src/components/EventDialog.vue @@ -0,0 +1,342 @@ + + + + + diff --git a/src/components/EventOverlay.vue b/src/components/EventOverlay.vue new file mode 100644 index 0000000..b5c7add --- /dev/null +++ b/src/components/EventOverlay.vue @@ -0,0 +1,561 @@ + + + + + diff --git a/src/components/Jogwheel.vue b/src/components/Jogwheel.vue new file mode 100644 index 0000000..b46acce --- /dev/null +++ b/src/components/Jogwheel.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/src/components/WeekRow.vue b/src/components/WeekRow.vue new file mode 100644 index 0000000..65a49ad --- /dev/null +++ b/src/components/WeekRow.vue @@ -0,0 +1,66 @@ + + + diff --git a/src/main.js b/src/main.js index 5f77a89..963ce07 100644 --- a/src/main.js +++ b/src/main.js @@ -1,5 +1,8 @@ +import './assets/calendar.css' + import { createApp } from 'vue' import { createPinia } from 'pinia' + import App from './App.vue' const app = createApp(App) @@ -7,3 +10,4 @@ const app = createApp(App) app.use(createPinia()) app.mount('#app') + diff --git a/src/stores/CalendarStore.js b/src/stores/CalendarStore.js new file mode 100644 index 0000000..94456dd --- /dev/null +++ b/src/stores/CalendarStore.js @@ -0,0 +1,325 @@ +import { defineStore } from 'pinia' +import { toLocalString, fromLocalString } from '@/utils/date' + +const MIN_YEAR = 1900 +const MAX_YEAR = 2100 + +export const useCalendarStore = defineStore('calendar', { + state: () => ({ + today: toLocalString(new Date()), + now: new Date(), + events: new Map(), // Map of date strings to arrays of events + weekend: [true, false, false, false, false, false, true], // Sunday to Saturday + config: { + select_days: 1000, + min_year: MIN_YEAR, + max_year: MAX_YEAR + } + }), + + getters: { + // Basic configuration getters + minYear: () => MIN_YEAR, + maxYear: () => MAX_YEAR + }, + + actions: { + updateCurrentDate() { + this.now = new Date() + const today = toLocalString(this.now) + if (this.today !== today) { + this.today = today + } + }, + + // 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: 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 }) + } + return event.id + }, + + 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 + }, + + deleteEvent(eventId) { + const datesToCleanup = [] + for (const [dateStr, eventList] of this.events) { + const eventIndex = eventList.findIndex(event => event.id === eventId) + if (eventIndex !== -1) { + eventList.splice(eventIndex, 1) + if (eventList.length === 0) { + datesToCleanup.push(dateStr) + } + } + } + datesToCleanup.forEach(dateStr => this.events.delete(dateStr)) + }, + + updateEvent(eventId, updates) { + // Remove event from current dates + for (const [dateStr, eventList] of this.events) { + const index = eventList.findIndex(e => e.id === eventId) + if (index !== -1) { + const event = eventList[index] + eventList.splice(index, 1) + if (eventList.length === 0) { + this.events.delete(dateStr) + } + + // Create updated event and add to new date range + const updatedEvent = { ...event, ...updates } + this._addEventToDateRange(updatedEvent) + return + } + } + }, + + // Minimal public API for component-driven drag + setEventRange(eventId, startDate, endDate) { + const snapshot = this._snapshotBaseEvent(eventId) + if (!snapshot) return + this._removeEventFromAllDatesById(eventId) + this._addEventToDateRangeWithId(eventId, snapshot, startDate, endDate) + }, + + splitRepeatSeries(baseId, index, startDate, endDate) { + const base = this.getEventById(baseId) + if (!base) return null + + const originalRepeatCount = base.repeatCount + + this._terminateRepeatSeriesAtIndex(baseId, index) + + let newRepeatCount = 'unlimited' + if (originalRepeatCount !== 'unlimited') { + const originalCount = parseInt(originalRepeatCount, 10) + const remaining = originalCount - index + newRepeatCount = remaining > 0 ? String(remaining) : '0' + } + + const newId = this.createEvent({ + title: base.title, + startDate, + endDate, + colorId: base.colorId, + repeat: base.repeat, + repeatCount: newRepeatCount + }) + return newId + }, + + + _snapshotBaseEvent(eventId) { + // Return a shallow snapshot of any instance for metadata + for (const [, eventList] of this.events) { + const e = eventList.find(x => x.id === eventId) + if (e) return { ...e } + } + return null + }, + + _removeEventFromAllDatesById(eventId) { + for (const [dateStr, list] of this.events) { + for (let i = list.length - 1; i >= 0; i--) { + if (list[i].id === eventId) { + list.splice(i, 1) + } + } + if (list.length === 0) this.events.delete(dateStr) + } + }, + + _addEventToDateRangeWithId(eventId, baseData, startDate, endDate) { + const s = fromLocalString(startDate) + const e = fromLocalString(endDate) + const multi = startDate < endDate + const payload = { + ...baseData, + id: eventId, + startDate, + endDate, + isSpanning: multi + } + // Normalize single-day time fields + if (!multi) { + if (!payload.startTime) payload.startTime = '09:00' + if (!payload.durationMinutes) payload.durationMinutes = 60 + } else { + payload.startTime = null + payload.durationMinutes = null + } + const cur = new Date(s) + while (cur <= e) { + const dateStr = toLocalString(cur) + if (!this.events.has(dateStr)) this.events.set(dateStr, []) + this.events.get(dateStr).push({ ...payload }) + cur.setDate(cur.getDate() + 1) + } + }, + + _reindexBaseEvent(eventId, snapshot, startDate, endDate) { + if (!snapshot) return + this._removeEventFromAllDatesById(eventId) + this._addEventToDateRangeWithId(eventId, snapshot, startDate, endDate) + }, + + _terminateRepeatSeriesAtIndex(baseId, index) { + // Reduce repeatCount of base series to the given index + for (const [, list] of this.events) { + for (const ev of list) { + if (ev.id === baseId && ev.isRepeating) { + const rc = ev.repeatCount === 'unlimited' ? Infinity : parseInt(ev.repeatCount, 10) + const newCount = Math.min(isFinite(rc) ? rc : index, index) + ev.repeatCount = String(newCount) + } + } + } + }, + + _findEventInAnyList(eventId) { + for (const [, eventList] of this.events) { + const found = eventList.find(e => e.id === eventId) + if (found) return found + } + return null + }, + + _addEventToDateRange(event) { + const startDate = fromLocalString(event.startDate) + const endDate = fromLocalString(event.endDate) + const cur = new Date(startDate) + + while (cur <= endDate) { + const dateStr = toLocalString(cur) + if (!this.events.has(dateStr)) { + this.events.set(dateStr, []) + } + this.events.get(dateStr).push({ ...event, isSpanning: event.startDate < event.endDate }) + cur.setDate(cur.getDate() + 1) + } + }, + + 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 + if (typeof id === 'string' && id.includes('_repeat_')) { + const parts = id.split('_repeat_') + const baseId = parts[0] + 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 + } + } + } + + return null + } + } +}) diff --git a/src/stores/counter.js b/src/stores/counter.js deleted file mode 100644 index b6757ba..0000000 --- a/src/stores/counter.js +++ /dev/null @@ -1,12 +0,0 @@ -import { ref, computed } from 'vue' -import { defineStore } from 'pinia' - -export const useCounterStore = defineStore('counter', () => { - const count = ref(0) - const doubleCount = computed(() => count.value * 2) - function increment() { - count.value++ - } - - return { count, doubleCount, increment } -}) diff --git a/date-utils.js b/src/utils/date.js similarity index 99% rename from date-utils.js rename to src/utils/date.js index 446a045..594d33c 100644 --- a/date-utils.js +++ b/src/utils/date.js @@ -164,6 +164,6 @@ export { addDaysStr, getLocalizedWeekdayNames, getLocalizedMonthName, - formatDateRange - ,lunarPhaseSymbol + formatDateRange, + lunarPhaseSymbol } diff --git a/utilities.css b/utilities.css deleted file mode 100644 index 88cfcba..0000000 --- a/utilities.css +++ /dev/null @@ -1,59 +0,0 @@ -/* Prevent text selection in calendar */ -#calendar-viewport, #calendar-content, .week-row, .cell, -.calendar-header, .week-label, .month-name-label, -.calendar-container, .jogwheel-viewport, .jogwheel-content { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - -webkit-touch-callout: none; - -webkit-tap-highlight-color: transparent; -} - -input { - background: transparent; - border: none; - color: var(--ink); - width: 11em; -} -label:has(input[value]) { display: block } - -/* Modal dialog */ -.ec-modal-backdrop[hidden] { display: none } -.ec-modal-backdrop { - position: fixed; - inset: 0; - background: color-mix(in srgb, var(--strong) 30%, transparent); - display: grid; - place-items: center; - z-index: 1000; -} -.ec-modal { - background: var(--panel); - color: var(--ink); - border-radius: .6rem; - min-width: 320px; - max-width: min(520px, 90vw); - box-shadow: 0 10px 30px rgba(0,0,0,.35); -} -.ec-form { padding: 1rem; display: grid; gap: .75rem } -.ec-header h2 { margin: 0; font-size: 1.1rem } -.ec-body { display: grid; gap: .75rem } -.ec-row { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem } -.ec-field { display: grid; gap: .25rem } -.ec-field > span { font-size: .85em; color: var(--muted) } -.ec-field input[type="text"], -.ec-field input[type="time"], -.ec-field input[type="number"] { - border: 1px solid var(--muted); - border-radius: .4rem; - padding: .5rem .6rem; - width: 100%; -} -.ec-color-swatches { display: grid; grid-template-columns: repeat(4, 1fr); gap: .3rem } -.ec-color-swatches .swatch { display: grid; place-items: center; border-radius: .4rem; padding: .25rem; outline: 2px solid transparent; outline-offset: 2px; cursor: pointer } -.ec-color-swatches .swatch { appearance: none; width: 3em; height: 1em; } -.ec-color-swatches .swatch:checked { outline-color: var(--ink) } -.ec-footer { display: flex; justify-content: end; gap: .5rem } -.ec-btn { border: 1px solid var(--muted); background: transparent; color: var(--ink); padding: .5rem .8rem; border-radius: .4rem; cursor: pointer } -.ec-btn.primary { background: var(--today); color: #000; border-color: transparent }