From 018b9ecc55492fa8612e1af81a2f56c10c9e934b Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 22 Aug 2025 23:34:33 +0100 Subject: [PATCH] vue (#1) Port to Vue. Also implements plenty of new functionality. --- .gitignore | 30 + calendar.css | 6 - calendar.js | 484 --------------- cells.css | 38 -- colors.css | 77 --- eslint.config.js | 28 + event-manager.js | 851 ------------------------- events.css | 69 -- index.html | 25 +- jogwheel.js | 79 --- jsconfig.json | 8 + package.json | 37 ++ public/favicon.ico | Bin 0 -> 4286 bytes src/App.vue | 36 ++ src/assets/calendar.css | 3 + src/assets/colors.css | 116 ++++ layout.css => src/assets/layout.css | 24 - src/components/Calendar.vue | 35 ++ src/components/CalendarDay.vue | 110 ++++ src/components/CalendarGrid.vue | 184 ++++++ src/components/CalendarHeader.vue | 92 +++ src/components/CalendarView.vue | 494 +++++++++++++++ src/components/CalendarWeek.vue | 105 ++++ src/components/DayCell.vue | 27 + src/components/EventDialog.vue | 933 ++++++++++++++++++++++++++++ src/components/EventOverlay.vue | 620 ++++++++++++++++++ src/components/Jogwheel.vue | 112 ++++ src/components/Numeric.vue | 251 ++++++++ src/components/WeekRow.vue | 66 ++ src/components/WeekdaySelector.vue | 252 ++++++++ src/main.js | 12 + src/stores/CalendarStore.js | 503 +++++++++++++++ date-utils.js => src/utils/date.js | 70 ++- utilities.css | 59 -- vite.config.js | 18 + 35 files changed, 4137 insertions(+), 1717 deletions(-) create mode 100644 .gitignore delete mode 100644 calendar.css delete mode 100644 calendar.js delete mode 100644 cells.css delete mode 100644 colors.css create mode 100644 eslint.config.js delete mode 100644 event-manager.js delete mode 100644 events.css delete mode 100644 jogwheel.js create mode 100644 jsconfig.json create mode 100644 package.json create mode 100644 public/favicon.ico create mode 100644 src/App.vue create mode 100644 src/assets/calendar.css create mode 100644 src/assets/colors.css rename layout.css => src/assets/layout.css (85%) create mode 100644 src/components/Calendar.vue create mode 100644 src/components/CalendarDay.vue create mode 100644 src/components/CalendarGrid.vue create mode 100644 src/components/CalendarHeader.vue create mode 100644 src/components/CalendarView.vue create mode 100644 src/components/CalendarWeek.vue create mode 100644 src/components/DayCell.vue create mode 100644 src/components/EventDialog.vue create mode 100644 src/components/EventOverlay.vue create mode 100644 src/components/Jogwheel.vue create mode 100644 src/components/Numeric.vue create mode 100644 src/components/WeekRow.vue create mode 100644 src/components/WeekdaySelector.vue create mode 100644 src/main.js create mode 100644 src/stores/CalendarStore.js rename date-utils.js => src/utils/date.js (74%) delete mode 100644 utilities.css create mode 100644 vite.config.js 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 = ` - ` - - 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.eventRepeatCountInput = this.eventForm.elements['repeatCount'] - this.eventRepeatCountRow = this.eventForm.querySelector('.ec-repeat-count-row') - this.eventColorInputs = Array.from(this.eventForm.querySelectorAll('input[name="colorId"]')) - - // Repeat change toggles repeat count visibility - this.eventRepeatInput.addEventListener('change', () => { - const showRepeatCount = this.eventRepeatInput.value !== 'none' - this.eventRepeatCountRow.style.display = showRepeatCount ? 'block' : 'none' - }) - - // 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) { - this.applyEventEdit(this._editingEventId, { - title: data.title.trim(), - colorId: data.colorId, - repeat: data.repeat, - repeatCount: data.repeatCount - }) - } - this.hideEventDialog() - }) - - this.eventForm.querySelector('[data-action="cancel"]').addEventListener('click', () => { - this.hideEventDialog() - if (this._dialogMode === 'create') this.clearSelection() - }) - - this.eventModal.addEventListener('click', e => { - if (e.target === this.eventModal) this.hideEventDialog() - }) - - document.addEventListener('keydown', e => { - if (this.eventModal.hidden) return - if (e.key === 'Escape') { - this.hideEventDialog() - if (this._dialogMode === 'create') this.clearSelection() - } - }) - } - - showEventDialog(mode, opts = {}) { - this._dialogMode = mode - this._editingEventId = null - - if (mode === 'create') { - this.eventTitleInput.value = '' - this.eventRepeatInput.value = 'none' - this.eventRepeatCountInput.value = '5' - this.eventRepeatCountRow.style.display = '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 - this.eventTitleInput.value = ev.title || '' - this.eventRepeatInput.value = ev.repeat || 'none' - this.eventRepeatCountInput.value = ev.repeatCount || '5' - this.eventRepeatCountRow.style.display = (ev.repeat && ev.repeat !== 'none') ? 'block' : 'none' - this.eventColorInputs.forEach(r => r.checked = Number(r.value) === (ev.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: this.eventRepeatCountInput.value, - colorId - } - } - - // -------- Event Drag & Drop -------- - - installGlobalEventDragHandlers() { - if (this._installedEventDrag) return - this._installedEventDrag = true - this._onMouseMoveEventDrag = e => this.onEventDragMove(e) - this._onMouseUpEventDrag = e => this.onEventDragEnd(e) - document.addEventListener('mousemove', this._onMouseMoveEventDrag) - document.addEventListener('mouseup', this._onMouseUpEventDrag) - - this._onTouchMoveEventDrag = e => this.onEventDragMove(e) - this._onTouchEndEventDrag = e => this.onEventDragEnd(e) - document.addEventListener('touchmove', this._onTouchMoveEventDrag, { passive: false }) - document.addEventListener('touchend', this._onTouchEndEventDrag) - - 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._onWindowMouseUpEventDrag = e => this.onEventDragEnd(e) - this._onWindowBlurEventDrag = () => this.onEventDragEnd() - window.addEventListener('mouseup', this._onWindowMouseUpEventDrag) - window.addEventListener('blur', this._onWindowBlurEventDrag) - } - - removeGlobalEventDragHandlers() { - if (!this._installedEventDrag) return - document.removeEventListener('mousemove', this._onMouseMoveEventDrag) - document.removeEventListener('mouseup', this._onMouseUpEventDrag) - document.removeEventListener('touchmove', this._onTouchMoveEventDrag) - document.removeEventListener('touchend', this._onTouchEndEventDrag) - window.removeEventListener('pointermove', this._onPointerMoveEventDrag) - window.removeEventListener('pointerup', this._onPointerUpEventDrag) - window.removeEventListener('pointercancel', this._onPointerCancelEventDrag) - window.removeEventListener('mouseup', this._onWindowMouseUpEventDrag) - 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.touches ? e.touches[0] : 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 // Don't start dragging yet - } - // 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) { - const [s, en] = this.computeTentativeRangeFromPointer(hit.date) - this.dragPreview = { id: this.dragEventState.id, startDate: s, endDate: en } - } else { - this.dragPreview = null - } - 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 - - let startDateStr = this.dragPreview?.startDate - let endDateStr = this.dragPreview?.endDate - - if (!startDateStr || !endDateStr) { - const getPoint = ev => (ev && ev.changedTouches && ev.changedTouches[0]) ? ev.changedTouches[0] : (ev && ev.touches ? ev.touches[0] : ev) - const pt = getPoint(e) - const drop = pt ? this.calendar.getDateUnderPointer(pt.clientX, pt.clientY) : null - if (drop && drop.date) { - const pair = this.computeTentativeRangeFromPointer(drop.date) - startDateStr = pair[0] - endDateStr = pair[1] - } else { - startDateStr = st.startDate - endDateStr = st.endDate - } - } - - const ev = this.getEventById(st.id) - if (ev) { - const updated = { ...ev } - if (st.mode === 'move') { - const spanDays = daysInclusive(ev.startDate, ev.endDate) - updated.startDate = startDateStr - updated.endDate = addDaysStr(startDateStr, spanDays - 1) - } else { - if (startDateStr <= endDateStr) { - updated.startDate = startDateStr - updated.endDate = endDateStr - } - } - - 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) - } - - 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() - - // Only update visible weeks if we actually dragged - 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) - } - this.dragPreview = null - } - - computeTentativeRangeFromPointer(dropDateStr) { - const st = this.dragEventState - if (!st) return [null, null] - const anchorOffset = st.anchorOffset || 0 - const spanDays = st.originSpanDays || daysInclusive(st.startDate, st.endDate) - let startStr = st.startDate - let endStr = st.endDate - if (st.mode === 'move') { - startStr = addDaysStr(dropDateStr, -anchorOffset) - endStr = addDaysStr(startStr, spanDays - 1) - } else if (st.mode === 'resize-left') { - startStr = dropDateStr - endStr = st.originalEndDate || st.endDate - } else if (st.mode === 'resize-right') { - startStr = st.originalStartDate || st.startDate - endStr = dropDateStr - } - const [ns, ne] = this.normalizeDateOrder(startStr, endStr) - return [ns, ne] - } - - 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() - for (const cell of cells) { - const dateStr = cell.dataset.date - const events = this.events.get(dateStr) || [] - for (const ev of events) { - if (!weekEvents.has(ev.id)) { - weekEvents.set(ev.id, { - ...ev, - startDateInWeek: dateStr, - endDateInWeek: dateStr, - startIdx: cells.indexOf(cell), - endIdx: cells.indexOf(cell) - }) - } else { - const w = weekEvents.get(ev.id) - w.endDateInWeek = dateStr - w.endIdx = cells.indexOf(cell) - } - } - } - - // If dragging, hide the original of the dragged event and inject preview if it intersects this week - if (this.dragPreview && this.dragPreview.id != null) { - const pv = this.dragPreview - // Remove original entries of the dragged event for this week to prevent ghosts - if (weekEvents.has(pv.id)) weekEvents.delete(pv.id) - // Determine week range - const weekStart = cells[0]?.dataset?.date - const weekEnd = cells[cells.length - 1]?.dataset?.date - if (weekStart && weekEnd) { - const s = pv.startDate - const e = pv.endDate - // Intersect preview with this week - const startInWeek = s <= weekEnd && e >= weekStart ? (s < weekStart ? weekStart : s) : null - const endInWeek = s <= weekEnd && e >= weekStart ? (e > weekEnd ? weekEnd : e) : null - if (startInWeek && endInWeek) { - // Compute indices - let sIdx = cells.findIndex(c => c.dataset.date === startInWeek) - if (sIdx === -1) sIdx = cells.findIndex(c => c.dataset.date > startInWeek) - if (sIdx === -1) sIdx = 0 - let eIdx = -1 - for (let i = 0; i < cells.length; i++) { - if (cells[i].dataset.date <= endInWeek) eIdx = i - } - if (eIdx === -1) eIdx = cells.length - 1 - - // Build/override entry - const baseEv = this.getEventById(pv.id) - if (baseEv) { - const entry = { - ...baseEv, - startDateInWeek: startInWeek, - endDateInWeek: endInWeek, - startIdx: sIdx, - endIdx: eIdx - } - weekEvents.set(pv.id, entry) - } - } - } - } - - const timeToMin = t => { - if (typeof t !== 'string') return 1e9 - const m = t.match(/^(\d{2}):(\d{2})/) - if (!m) return 1e9 - return Number(m[1]) * 60 + Number(m[2]) - } - - const spans = Array.from(weekEvents.values()).sort((a, b) => { - if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx - // Prefer longer spans to be placed first for packing - const aLen = a.endIdx - a.startIdx - const bLen = b.endIdx - b.startIdx - if (aLen !== bLen) return bLen - aLen - // Within the same day and same span length, order by start time - const at = timeToMin(a.startTime) - const bt = timeToMin(b.startTime) - if (at !== bt) return at - bt - // Stable fallback by id - return (a.id || 0) - (b.id || 0) - }) - - const rowsLastEnd = [] - for (const w of spans) { - let placedRow = 0 - while (placedRow < rowsLastEnd.length && !(w.startIdx > rowsLastEnd[placedRow])) placedRow++ - if (placedRow === rowsLastEnd.length) rowsLastEnd.push(-1) - rowsLastEnd[placedRow] = w.endIdx - w._row = placedRow + 1 - } - - const numRows = Math.max(1, rowsLastEnd.length) - - // Decide between "comfortable" layout (with gaps, not stretched) - // and "compressed" layout (fractional rows, no gaps) based on fit. - const cs = getComputedStyle(overlay) - const overlayHeight = overlay.getBoundingClientRect().height - const marginTopPx = parseFloat(cs.marginTop) || 0 - const available = Math.max(0, overlayHeight - marginTopPx) - const baseEm = parseFloat(cs.fontSize) || 16 - const rowPx = 1.2 * baseEm // preferred row height ~ 1.2em - const gapPx = 0.2 * baseEm // preferred gap ~ .2em - const needed = numRows * rowPx + (numRows - 1) * gapPx - - if (needed <= available) { - // Comfortable: keep gaps and do not stretch rows to fill - overlay.style.gridTemplateRows = `repeat(${numRows}, ${rowPx}px)` - overlay.style.rowGap = `${gapPx}px` - } else { - // Compressed: use fractional rows so everything fits; remove gaps - overlay.style.gridTemplateRows = `repeat(${numRows}, 1fr)` - overlay.style.rowGap = '0' - } - - // Create the spans - for (const w of spans) this.createOverlaySpan(overlay, w, weekEl) - } - - createOverlaySpan(overlay, w, weekEl) { - const span = document.createElement('div') - span.className = `event-span event-color-${w.colorId}` - span.style.gridColumn = `${w.startIdx + 1} / ${w.endIdx + 2}` - span.style.gridRow = `${w._row}` - span.textContent = w.title - span.title = `${w.title} (${w.startDate === w.endDate ? w.startDate : w.startDate + ' - ' + w.endDate})` - span.dataset.eventId = String(w.id) - if (this.dragEventState && this.dragEventState.id === w.id) span.classList.add('dragging') - - // Click opens edit if not dragging - span.addEventListener('click', e => { - e.stopPropagation() - - // 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.touches ? ev.touches[0] : 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) - }) - - // Touch support (for compatibility with older mobile browsers) - left.addEventListener('touchstart', e => onPointerDown('resize-left', e), { passive: false }) - right.addEventListener('touchstart', e => onPointerDown('resize-right', e), { passive: false }) - span.addEventListener('touchstart', e => { - if ((e.target).classList && (e.target).classList.contains('resize-handle')) return - onPointerDown('move', e) - }, { passive: false }) - overlay.appendChild(span) - } -} 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/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..5a1f2d2 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "paths": { + "@/*": ["./src/*"] + } + }, + "exclude": ["node_modules", "dist"] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..497bcdf --- /dev/null +++ b/package.json @@ -0,0 +1,37 @@ +{ + "name": "calendar", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore", + "lint:eslint": "eslint . --fix", + "lint": "run-s lint:*", + "format": "prettier --write src/" + }, + "dependencies": { + "pinia": "^3.0.3", + "vue": "^3.5.18" + }, + "devDependencies": { + "@eslint/js": "^9.31.0", + "@prettier/plugin-oxc": "^0.0.4", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/eslint-config-prettier": "^10.2.0", + "eslint": "^9.31.0", + "eslint-plugin-oxlint": "~1.8.0", + "eslint-plugin-vue": "~10.3.0", + "globals": "^16.3.0", + "npm-run-all2": "^8.0.4", + "oxlint": "~1.8.0", + "prettier": "3.6.2", + "vite": "npm:rolldown-vite@latest", + "vite-plugin-vue-devtools": "^8.0.0" + } +} \ No newline at end of file diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..df36fcfb72584e00488330b560ebcf34a41c64c2 GIT binary patch literal 4286 zcmZQzU}RuqP*4ET3Jfa*7#PGD7#K7d7#I{77#JKFAmR)lAOIqU2X5Zs$bgLL=_@3A zjhlBkf-u-E^l}5#e(vTSjvJvE#HNe&P`g3?jcVTE_#KKtY>*hu-2k;;s(FXw$>tr7 z|DhPf28q$seyH6be^xc`aQp|g8{`HM8zcsjqlp`k?AB}E;dmd(Zjk*T3=#v$(Zmf< z`&pZJIL^XiH^_bv2FZccP&Evoc7y!o(Y(X)10MT9av(JzwN!Hh)P8~H9gaKj*bVYO z2!qss)KbNMsNEp{q&4qw{6&QQAT=PhAUzbj0cyWO^A5*L7iKh$o<<{gf0*zB%ZV*ek6akv4b2c(xQH$d$Mg`s)#4##Kc_BU;D{GXL$3C18c zx;#`5NH53?lHCBcpR;*~<1!4hcRKzrpKSX--p3S-L2Mjh0MZLGgCzT*c7xm<)V#y- z3yS?a9sf71bNHW@Xz@SJ!xW4`Y>*fhH-Pkl%mA51v>TxIi#G3YJcMF5D4p$e{9n{+ z^FPkh6a|CCu-Feuiy$*VW)WpS)NYV_3!8U1{z0*Sr{n+HW%mD*!_C3|hP%PT6f6dk z!{P>z86dMjW)gG*)P9ZT9geq9><0OLyW{`dGAmTOVd3Cm3YKf$4zCkIeurU@Ss*j< z+7Gpxxp{}*uwyWnns+ArO_!|@D?-JmqL!|{JXy){Z+gZlMPypL%f2*-Jv z{(*|Y)q(V2GYe`5$S$z`P`g3ysYPp3f$NrjCM-5(c2Q8ptk?oiME5yuZM-3=hU zAT!X-h1vzO6J$So^A5*3=xSPaIsUJhX8S+E&kP=>F!STRO_wBvm~$isnlXSdhz$~h z$-`)nUXU3ev(U|l+6l5d9HUJID&yBX{7+ATmhrH(1){x7pC(=HT=LB0y}A7)UP8)AS^=9*`Lzvp{CT%!kq-J3)5yHScf)ByT?WW^if zV!|8eX^Mgqe9c%vaSpN<8H2>Ya%k#7X5$(!Tt{e|LXt$|6>oq zKji=a2jLI=|NlQ=hu{Ou|Nnz<1LOby3=ClWkAa~cg#R!w*n{v71_t>L3=I4r{D6Uh a9fS`sFffB~17iat17ib}cK|F4vKj!X^7qaF literal 0 HcmV?d00001 diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..8d02976 --- /dev/null +++ b/src/App.vue @@ -0,0 +1,36 @@ + + + + + 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/src/assets/colors.css b/src/assets/colors.css new file mode 100644 index 0000000..3472b49 --- /dev/null +++ b/src/assets/colors.css @@ -0,0 +1,116 @@ +/* Color tokens */ +:root { + --panel: #ffffff; + --panel-alt: #f6f8fa; + --panel-accent: #eef4ff; + --today: #f83; + --ink: #222; + --strong: #000; + --muted: #6a6f76; + --muted-alt: #9aa2ad; + --accent: #2563eb; /* blue */ + --accent-soft: #dbeafe; + --accent-hover: #1d4ed8; + --danger: #dc2626; + --danger-hover: #b91c1c; + --weekend: #888; + --firstday: #000; + --select: #aaf; + --shadow: #fff; + --label-bg: #fafbfe; + --label-bg-rgb: 250, 251, 254; + + /* Input / recurrence tokens */ + --input-border: var(--muted-alt); + --input-focus: var(--accent); + --pill-bg: var(--panel-alt); + --pill-active-bg: var(--accent); + --pill-active-ink: #fff; + --pill-hover-bg: var(--accent-soft); + + /* Vue component color mappings */ + --bg: var(--panel); + --border-color: #ddd; +} + +/* 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, 70%, 70%) } /* red */ +.event-color-5 { background: hsl(90, 70%, 70%) } /* green */ +.event-color-6 { background: hsl(230, 70%, 70%) } /* blue */ +.event-color-7 { background: hsl(280, 70%, 70%) } /* purple */ + +/* Color tokens (dark) */ +@media (prefers-color-scheme: dark) { + :root { + --panel: #121417; + --panel-alt: #1d2228; + --panel-accent: #1a2634; + --today: #f83; + --ink: #e5e7eb; + --strong: #fff; + --muted: #7d8691; + --muted-alt: #5d646d; + --accent: #3b82f6; + --accent-soft: rgba(59,130,246,0.15); + --accent-hover: #2563eb; + --danger: #ef4444; + --danger-hover: #dc2626; + --workday: var(--ink); + --weekend: #999; + --firstday: #fff; + --select: #3355ff; + --shadow: #000; + --label-bg: #1a1d25; + --label-bg-rgb: 26, 29, 37; + --input-border: var(--muted-alt); + --input-focus: var(--accent); + --pill-bg: #222a32; + --pill-active-bg: var(--accent); + --pill-active-ink: #fff; + --pill-hover-bg: rgba(255,255,255,0.08); + + /* Vue component color mappings (dark) */ + --bg: var(--panel); + --border-color: #333; + } + + .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%, 50%) } /* lightest grey */ + .event-color-1 { background: hsl(0, 0%, 40%) } /* light grey */ + .event-color-2 { background: hsl(0, 0%, 30%) } /* medium grey */ + .event-color-3 { background: hsl(0, 0%, 20%) } /* dark grey */ + .event-color-4 { background: hsl(0, 70%, 40%) } /* red */ + .event-color-5 { background: hsl(90, 70%, 30%) } /* green - darker for perceptional purposes */ + .event-color-6 { background: hsl(230, 70%, 40%) } /* blue */ + .event-color-7 { background: hsl(280, 70%, 40%) } /* purple */ +} 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..b68648b --- /dev/null +++ b/src/components/CalendarDay.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/src/components/CalendarGrid.vue b/src/components/CalendarGrid.vue new file mode 100644 index 0000000..d9adbd8 --- /dev/null +++ b/src/components/CalendarGrid.vue @@ -0,0 +1,184 @@ + + + diff --git a/src/components/CalendarHeader.vue b/src/components/CalendarHeader.vue new file mode 100644 index 0000000..1e52287 --- /dev/null +++ b/src/components/CalendarHeader.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/src/components/CalendarView.vue b/src/components/CalendarView.vue new file mode 100644 index 0000000..ff470a1 --- /dev/null +++ b/src/components/CalendarView.vue @@ -0,0 +1,494 @@ + + + + + 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..21ca90a --- /dev/null +++ b/src/components/EventDialog.vue @@ -0,0 +1,933 @@ + + + + + diff --git a/src/components/EventOverlay.vue b/src/components/EventOverlay.vue new file mode 100644 index 0000000..d01f4d5 --- /dev/null +++ b/src/components/EventOverlay.vue @@ -0,0 +1,620 @@ + + + + + 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/Numeric.vue b/src/components/Numeric.vue new file mode 100644 index 0000000..1beb9bf --- /dev/null +++ b/src/components/Numeric.vue @@ -0,0 +1,251 @@ + + + + + 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/components/WeekdaySelector.vue b/src/components/WeekdaySelector.vue new file mode 100644 index 0000000..601e7ae --- /dev/null +++ b/src/components/WeekdaySelector.vue @@ -0,0 +1,252 @@ + + + + + diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..a7a997b --- /dev/null +++ b/src/main.js @@ -0,0 +1,12 @@ +import './assets/calendar.css' + +import { createApp } from 'vue' +import { createPinia } from 'pinia' + +import App from './App.vue' + +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..f9b91a2 --- /dev/null +++ b/src/stores/CalendarStore.js @@ -0,0 +1,503 @@ +import { defineStore } from 'pinia' +import { + toLocalString, + fromLocalString, + getLocaleFirstDay, + getLocaleWeekendDays, +} from '@/utils/date' + +/** + * Calendar configuration can be overridden via window.calendarConfig: + * + * window.calendarConfig = { + * firstDay: 0, // 0=Sunday, 1=Monday, etc. (default: 1) + * firstDay: 'auto', // Use locale detection + * weekendDays: [true, false, false, false, false, false, true], // Custom weekend + * weekendDays: 'auto' // Use locale detection (default) + * } + */ + +const MIN_YEAR = 1900 +const MAX_YEAR = 2100 + +// Helper function to determine first day with config override support +function getConfiguredFirstDay() { + // Check for environment variable or global config + const configOverride = window?.calendarConfig?.firstDay + if (configOverride !== undefined) { + return configOverride === 'auto' ? getLocaleFirstDay() : Number(configOverride) + } + // Default to Monday (1) instead of locale + return 1 +} + +// Helper function to determine weekend days with config override support +function getConfiguredWeekendDays() { + // Check for environment variable or global config + const configOverride = window?.calendarConfig?.weekendDays + if (configOverride !== undefined) { + return configOverride === 'auto' ? getLocaleWeekendDays() : configOverride + } + // Default to locale-based weekend days + return getLocaleWeekendDays() +} + +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: getConfiguredWeekendDays(), + config: { + select_days: 1000, + min_year: MIN_YEAR, + max_year: MAX_YEAR, + first_day: getConfiguredFirstDay(), + }, + }), + + 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 === 'weekly' + ? 'weeks' + : eventData.repeat === 'monthly' + ? 'months' + : eventData.repeat) || 'none', + repeatInterval: eventData.repeatInterval || 1, + repeatCount: eventData.repeatCount || 'unlimited', + repeatWeekdays: eventData.repeatWeekdays, + 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 }) + } + // No physical expansion; repeats are virtual + 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)) + }, + + deleteSingleOccurrence(ctx) { + const { baseId, occurrenceIndex } = ctx + const base = this.getEventById(baseId) + if (!base || base.repeat !== 'weekly') return + if (!base || base.repeat !== 'weeks') return + // Strategy: clone pattern into two: reduce base repeatCount to exclude this occurrence; create new series for remainder excluding this one + // Simpler: convert base to explicit exception by creating a one-off non-repeating event? For now: create exception by inserting a blocking dummy? Instead just split by shifting repeatCount logic: decrement repeatCount and create a new series starting after this single occurrence. + // Implementation (approx): terminate at occurrenceIndex; create a new series starting next occurrence with remaining occurrences. + const remaining = + base.repeatCount === 'unlimited' + ? 'unlimited' + : String(Math.max(0, parseInt(base.repeatCount, 10) - (occurrenceIndex + 1))) + this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex) + if (remaining === '0') return + // Find date of next occurrence + const startDate = new Date(base.startDate + 'T00:00:00') + let idx = 0 + let cur = new Date(startDate) + while (idx <= occurrenceIndex && idx < 10000) { + cur.setDate(cur.getDate() + 1) + if (base.repeatWeekdays[cur.getDay()]) idx++ + } + const nextStartStr = toLocalString(cur) + this.createEvent({ + title: base.title, + startDate: nextStartStr, + endDate: nextStartStr, + colorId: base.colorId, + repeat: 'weeks', + repeatCount: remaining, + repeatWeekdays: base.repeatWeekdays, + }) + }, + + deleteFromOccurrence(ctx) { + const { baseId, occurrenceIndex } = ctx + this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex) + }, + + deleteFirstOccurrence(baseId) { + const base = this.getEventById(baseId) + if (!base || !base.isRepeating) return + const oldStart = new Date(fromLocalString(base.startDate)) + const oldEnd = new Date(fromLocalString(base.endDate)) + const spanDays = Math.round((oldEnd - oldStart) / (24 * 60 * 60 * 1000)) + let newStart = null + + if (base.repeat === 'weeks' && base.repeatWeekdays) { + const probe = new Date(oldStart) + for (let i = 0; i < 14; i++) { + // search ahead up to 2 weeks + probe.setDate(probe.getDate() + 1) + if (base.repeatWeekdays[probe.getDay()]) { + newStart = new Date(probe) + break + } + } + } else if (base.repeat === 'months') { + newStart = new Date(oldStart) + newStart.setMonth(newStart.getMonth() + 1) + } else { + // Unknown pattern: delete entire series + this.deleteEvent(baseId) + return + } + + if (!newStart) { + // No subsequent occurrence -> delete entire series + this.deleteEvent(baseId) + return + } + + if (base.repeatCount !== 'unlimited') { + const rc = parseInt(base.repeatCount, 10) + if (!isNaN(rc)) { + const newRc = Math.max(0, rc - 1) + if (newRc === 0) { + this.deleteEvent(baseId) + return + } + base.repeatCount = String(newRc) + } + } + + const newEnd = new Date(newStart) + newEnd.setDate(newEnd.getDate() + spanDays) + base.startDate = toLocalString(newStart) + base.endDate = toLocalString(newEnd) + // old occurrence expansion removed (series handled differently now) + const originalRepeatCount = base.repeatCount + // Always cap original series at the split occurrence index (occurrences 0..index-1) + // Keep its weekday pattern unchanged. + this._terminateRepeatSeriesAtIndex(baseId, index) + + let newRepeatCount = 'unlimited' + if (originalRepeatCount !== 'unlimited') { + const originalCount = parseInt(originalRepeatCount, 10) + if (!isNaN(originalCount)) { + const remaining = originalCount - index + // remaining occurrences go to new series; ensure at least 1 (the dragged occurrence itself) + newRepeatCount = remaining > 0 ? String(remaining) : '1' + } + } else { + // Original was unlimited: original now capped, new stays unlimited + newRepeatCount = 'unlimited' + } + + // Handle weekdays for weekly repeats + let newRepeatWeekdays = base.repeatWeekdays + if (base.repeat === 'weeks' && base.repeatWeekdays) { + const newStartDate = new Date(fromLocalString(startDate)) + let dayShift = 0 + if (grabbedWeekday != null) { + // Rotate so that the grabbed weekday maps to the new start weekday + dayShift = newStartDate.getDay() - grabbedWeekday + } else { + // Fallback: rotate by difference between new and original start weekday + const originalStartDate = new Date(fromLocalString(base.startDate)) + dayShift = newStartDate.getDay() - originalStartDate.getDay() + } + if (dayShift !== 0) { + const rotatedWeekdays = [false, false, false, false, false, false, false] + for (let i = 0; i < 7; i++) { + if (base.repeatWeekdays[i]) { + let nd = (i + dayShift) % 7 + if (nd < 0) nd += 7 + rotatedWeekdays[nd] = true + } + } + newRepeatWeekdays = rotatedWeekdays + } + } + + const newId = this.createEvent({ + title: base.title, + startDate, + endDate, + colorId: base.colorId, + repeat: base.repeat, + repeatCount: newRepeatCount, + repeatWeekdays: newRepeatWeekdays, + }) + 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) + } + }, + + // expandRepeats removed: no physical occurrence expansion + + // Adjust start/end range of a base event (non-generated) and reindex occurrences + setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto' } = {}) { + const snapshot = this._findEventInAnyList(eventId) + if (!snapshot) return + // Calculate current duration in days (inclusive) + const prevStart = new Date(fromLocalString(snapshot.startDate)) + const prevEnd = new Date(fromLocalString(snapshot.endDate)) + const prevDurationDays = Math.max( + 0, + Math.round((prevEnd - prevStart) / (24 * 60 * 60 * 1000)), + ) + + const newStart = new Date(fromLocalString(newStartStr)) + const newEnd = new Date(fromLocalString(newEndStr)) + const proposedDurationDays = Math.max( + 0, + Math.round((newEnd - newStart) / (24 * 60 * 60 * 1000)), + ) + + let finalDurationDays = prevDurationDays + if (mode === 'resize-left' || mode === 'resize-right') { + finalDurationDays = proposedDurationDays + } + + snapshot.startDate = newStartStr + snapshot.endDate = toLocalString( + new Date( + new Date(fromLocalString(newStartStr)).setDate( + new Date(fromLocalString(newStartStr)).getDate() + finalDurationDays, + ), + ), + ) + // Rotate weekly recurrence pattern when moving (not resizing) so selected weekdays track shift + if ( + mode === 'move' && + snapshot.isRepeating && + snapshot.repeat === 'weeks' && + Array.isArray(snapshot.repeatWeekdays) + ) { + const oldDow = prevStart.getDay() + const newDow = newStart.getDay() + const shift = newDow - oldDow + if (shift !== 0) { + const rotated = [false, false, false, false, false, false, false] + for (let i = 0; i < 7; i++) { + if (snapshot.repeatWeekdays[i]) { + let ni = (i + shift) % 7 + if (ni < 0) ni += 7 + rotated[ni] = true + } + } + snapshot.repeatWeekdays = rotated + } + } + // Reindex + this._removeEventFromAllDatesById(eventId) + this._addEventToDateRangeWithId(eventId, snapshot, snapshot.startDate, snapshot.endDate) + // no expansion + }, + + // Split a repeating series at a given occurrence index; returns new series id + splitRepeatSeries(baseId, occurrenceIndex, newStartStr, newEndStr, _grabbedWeekday) { + const base = this._findEventInAnyList(baseId) + if (!base || !base.isRepeating) return null + // Capture original repeatCount BEFORE truncation + const originalCountRaw = base.repeatCount + // Truncate base to keep occurrences before split point (indices 0..occurrenceIndex-1) + this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex) + // Compute new series repeatCount (remaining occurrences starting at occurrenceIndex) + let newSeriesCount = 'unlimited' + if (originalCountRaw !== 'unlimited') { + const originalNum = parseInt(originalCountRaw, 10) + if (!isNaN(originalNum)) { + const remaining = originalNum - occurrenceIndex + newSeriesCount = String(Math.max(1, remaining)) + } + } + const newId = this.createEvent({ + title: base.title, + startDate: newStartStr, + endDate: newEndStr, + colorId: base.colorId, + repeat: base.repeat, + repeatInterval: base.repeatInterval, + repeatCount: newSeriesCount, + repeatWeekdays: base.repeatWeekdays, + }) + return newId + }, + + _reindexBaseEvent(eventId, snapshot, startDate, endDate) { + if (!snapshot) return + this._removeEventFromAllDatesById(eventId) + this._addEventToDateRangeWithId(eventId, snapshot, startDate, endDate) + }, + + _terminateRepeatSeriesAtIndex(baseId, index) { + // Cap repeatCount to 'index' total occurrences (0-based index means index occurrences before split) + for (const [, list] of this.events) { + for (const ev of list) { + if (ev.id === baseId && ev.isRepeating) { + if (ev.repeatCount === 'unlimited') { + ev.repeatCount = String(index) + } else { + const rc = parseInt(ev.repeatCount, 10) + if (!isNaN(rc)) ev.repeatCount = String(Math.min(rc, index)) + } + } + } + } + }, + + _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) + } + }, + + // NOTE: legacy dynamic getEventById for synthetic occurrences removed. + }, +}) diff --git a/date-utils.js b/src/utils/date.js similarity index 74% rename from date-utils.js rename to src/utils/date.js index 446a045..d386e4f 100644 --- a/date-utils.js +++ b/src/utils/date.js @@ -1,5 +1,18 @@ // date-utils.js — Date handling utilities for the calendar -const monthAbbr = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'] +const monthAbbr = [ + 'jan', + 'feb', + 'mar', + 'apr', + 'may', + 'jun', + 'jul', + 'aug', + 'sep', + 'oct', + 'nov', + 'dec', +] const DAY_MS = 86400000 const WEEK_MS = 7 * DAY_MS @@ -8,7 +21,7 @@ const WEEK_MS = 7 * DAY_MS * @param {Date} date - The date to get week info for * @returns {Object} Object containing week number and year */ -const isoWeekInfo = date => { +const isoWeekInfo = (date) => { const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())) const day = d.getUTCDay() || 7 d.setUTCDate(d.getUTCDate() + 4 - day) @@ -24,7 +37,7 @@ const isoWeekInfo = date => { * @returns {string} Date string in YYYY-MM-DD format */ function toLocalString(date = new Date()) { - const pad = n => String(Math.floor(Math.abs(n))).padStart(2, '0') + const pad = (n) => String(Math.floor(Math.abs(n))).padStart(2, '0') return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` } @@ -43,14 +56,14 @@ function fromLocalString(dateString) { * @param {Date} d - The date * @returns {number} Monday index (0-6) */ -const mondayIndex = d => (d.getDay() + 6) % 7 +const mondayIndex = (d) => (d.getDay() + 6) % 7 /** * Pad a number with leading zeros to make it 2 digits * @param {number} n - Number to pad * @returns {string} Padded string */ -const pad = n => String(n).padStart(2, '0') +const pad = (n) => String(n).padStart(2, '0') /** * Calculate number of days between two date strings (inclusive) @@ -93,6 +106,42 @@ function getLocalizedWeekdayNames() { return res } +/** + * Get the locale's first day of the week (0=Sunday, 1=Monday, etc.) + * @returns {number} First day of the week (0-6) + */ +function getLocaleFirstDay() { + try { + return new Intl.Locale(navigator.language).weekInfo.firstDay % 7 + } catch { + return 1 // Default to Monday if locale info not available + } +} + +/** + * Get the locale's weekend days as an array of booleans (Sunday=index 0) + * @returns {Array} Array where true indicates a weekend day + */ +function getLocaleWeekendDays() { + try { + const localeWeekend = new Intl.Locale(navigator.language).weekInfo.weekend + const dayidx = new Set(localeWeekend) + return Array.from({ length: 7 }, (_, i) => dayidx.has(i || 7)) + } catch { + return [true, false, false, false, false, false, true] // Default to Saturday/Sunday weekend + } +} + +/** + * Reorder a 7-element array based on the first day of the week + * @param {Array} days - Array of 7 elements (Sunday=index 0) + * @param {number} firstDay - First day of the week (0=Sunday, 1=Monday, etc.) + * @returns {Array} Reordered array + */ +function reorderByFirstDay(days, firstDay) { + return Array.from({ length: 7 }, (_, i) => days[(i + firstDay) % 7]) +} + /** * Get localized month name * @param {number} idx - Month index (0-11) @@ -133,12 +182,12 @@ function lunarPhaseSymbol(date) { // Use UTC noon of given date to reduce timezone edge effects const dUTC = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0) const daysSince = (dUTC - ref) / DAY_MS - const phase = ((daysSince / synodic) % 1 + 1) % 1 + const phase = (((daysSince / synodic) % 1) + 1) % 1 const phases = [ { t: 0.0, s: '🌑' }, // New Moon { t: 0.25, s: '🌓' }, // First Quarter { t: 0.5, s: '🌕' }, // Full Moon - { t: 0.75, s: '🌗' } // Last Quarter + { t: 0.75, s: '🌗' }, // Last Quarter ] // threshold in days from exact phase to still count for this date const thresholdDays = 0.5 // ±12 hours @@ -163,7 +212,10 @@ export { daysInclusive, addDaysStr, getLocalizedWeekdayNames, + getLocaleFirstDay, + getLocaleWeekendDays, + reorderByFirstDay, 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 } diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..4217010 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,18 @@ +import { fileURLToPath, URL } from 'node:url' + +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import vueDevTools from 'vite-plugin-vue-devtools' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + vue(), + vueDevTools(), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + }, + }, +})