diff --git a/calendar.css b/calendar.css index 6124e78..1c42b59 100644 --- a/calendar.css +++ b/calendar.css @@ -1,87 +1,82 @@ -/* =============================== - LIGHT MODE (default) — colors only - =============================== */ +/* Color tokens */ :root { --bg: #f6f7fb; - --panel: #ffffff; + --panel: #fff; + --today: #f83; --ink: #111; --ink-rgb: 17, 17, 17; --muted: #888; --weekend: #888; --firstday: #000; + --select: #aaf; --label-bg: #fafbfe; --label-bg-rgb: 250, 251, 254; } -/* WINTER — cool gray-blue hue, light-dark-light */ -.cell.dec { background: hsl(220 20% 95%) } /* light */ -.cell.jan { background: hsl(220 20% 88%) } /* dark */ -.cell.feb { background: hsl(220 20% 95%) } /* light */ +/* 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; +} -/* SPRING — fresh green hue, dark-light-dark */ -.cell.mar { background: hsl(125 60% 88%) } /* dark */ -.cell.apr { background: hsl(125 60% 95%) } /* light */ -.cell.may { background: hsl(125 60% 88%) } /* dark */ +/* Month tints (light) */ +.cell.dec { background: hsl(220 20% 95%) } +.cell.jan { background: hsl(220 20% 88%) } +.cell.feb { background: hsl(220 20% 95%) } +.cell.mar { background: hsl(125 60% 88%) } +.cell.apr { background: hsl(125 60% 95%) } +.cell.may { background: hsl(125 60% 88%) } +.cell.jun { background: hsl(45 85% 95%) } +.cell.jul { background: hsl(45 85% 88%) } +.cell.aug { background: hsl(45 85% 95%) } +.cell.sep { background: hsl(18 78% 88%) } +.cell.oct { background: hsl(18 78% 95%) } +.cell.nov { background: hsl(18 78% 88%) } -/* SUMMER — golden yellow-brown hue, light-dark-light */ -.cell.jun { background: hsl(45 85% 95%) } /* light */ -.cell.jul { background: hsl(45 85% 88%) } /* dark */ -.cell.aug { background: hsl(45 85% 95%) } /* light */ - -/* AUTUMN — red-orange hue, dark-light-dark */ -.cell.sep { background: hsl(18 78% 88%) } /* dark */ -.cell.oct { background: hsl(18 78% 95%) } /* light */ -.cell.nov { background: hsl(18 78% 88%) } /* dark */ - -/* =============================== - DARK MODE — colors only - =============================== */ +/* Color tokens (dark) */ @media (prefers-color-scheme: dark) { :root { --bg: radial-gradient(1200px 800px at 20% -10%, #1c2130 0%, #0c0f16 35%, #0a0b11 100%); --panel: #111318; + --today: #f83; --ink: #ddd; --ink-rgb: 221, 221, 221; --muted: #888; --weekend: #999; --firstday: #fff; + --select: #44f; --label-bg: #1a1d25; --label-bg-rgb: 26, 29, 37; } - /* WINTER — cool gray-blue hue, light-dark-li666ght */ - .cell.dec { background: hsl(220 20% 22%) } /* light */ - .cell.jan { background: hsl(220 20% 16%) } /* dark */ - .cell.feb { background: hsl(220 20% 22%) } /* light */ - - /* SPRING — fresh green hue, dark-light-dark */ - .cell.mar { background: hsl(125 40% 18%) } /* dark */ - .cell.apr { background: hsl(125 40% 26%) } /* light */ - .cell.may { background: hsl(125 40% 18%) } /* dark */ - - /* SUMMER — golden yellow-brown hue, light-dark-light */ - .cell.jun { background: hsl(45 70% 24%) } /* light */ - .cell.jul { background: hsl(45 70% 18%) } /* dark */ - .cell.aug { background: hsl(45 70% 24%) } /* light */ - - /* AUTUMN — red-orange hue, dark-light-dark */ - .cell.sep { background: hsl(18 70% 18%) } /* dark */ - .cell.oct { background: hsl(18 70% 26%) } /* light */ - .cell.nov { background: hsl(18 70% 18%) } /* dark */ + /* Month tints (dark) */ + .cell.dec { background: hsl(220 20% 22%) } + .cell.jan { background: hsl(220 20% 16%) } + .cell.feb { background: hsl(220 20% 22%) } + .cell.mar { background: hsl(125 40% 18%) } + .cell.apr { background: hsl(125 40% 26%) } + .cell.may { background: hsl(125 40% 18%) } + .cell.jun { background: hsl(45 70% 24%) } + .cell.jul { background: hsl(45 70% 18%) } + .cell.aug { background: hsl(45 70% 24%) } + .cell.sep { background: hsl(18 70% 18%) } + .cell.oct { background: hsl(18 70% 26%) } + .cell.nov { background: hsl(18 70% 18%) } } - - -/* =============================== - Base layout & typography (mode-agnostic) - =============================== */ +/* Layout & typography */ :root { - --gap: 0; --row-h: 2.2em; - --w-label: 3em; - --w-cell: 3em; - --overlay-w: 3rem; /* extra right-side strip for month labels */ - --week-row-h: 4em; + --label-w: 4em; + --cell-w: 6em; + --cell-h: 6em; + --overlay-w: 3rem; } * { box-sizing: border-box } @@ -95,13 +90,13 @@ body { .wrap { width: fit-content; - max-width: none; margin: 2rem auto; background: var(--panel); height: calc(100vh - 4rem); display: flex; flex-direction: column; - min-width: calc(var(--w-label) + 7 * var(--w-cell) + 2.4rem); /* Account for padding */ + min-width: calc(var(--label-w) + 7 * var(--cell-w) + 2.4rem); + white-space: pre-wrap; } header { @@ -115,59 +110,22 @@ header { .header-controls { display: flex; align-items: center; - gap: 0.75rem; + gap: .75rem; } -.date-input { - background: var(--panel); - color: var(--ink); - border: 1px solid var(--muted); - padding: 0.5em 0.75em; - border-radius: 0.25rem; - font-weight: 500; - font-size: 0.9rem; - min-width: 120px; - font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, monospace; -} - -.date-input.clean-input { - background: transparent; - border: none; - padding: 0.5em 0; - color: var(--ink); -} - -.date-input:focus { - outline: none; - border-color: var(--ink); -} - -.date-input.clean-input:focus { - outline: none; - border: none; -} - -.today-button { - background: var(--ink); - color: var(--panel); - border: none; - padding: 0.5em 1em; - border-radius: 0.25rem; - font-weight: 600; - font-size: 0.9rem; +.today-date { cursor: pointer; - transition: opacity 0.2s ease; } - -.today-button:hover { - opacity: 0.8; +.today-date::first-line { + color: var(--today); } +.today-button:hover { opacity: .8 } .calendar-header { display: grid; - grid-template-columns: var(--w-label) repeat(7, var(--w-cell)) var(--overlay-w); - gap: 0; + grid-template-columns: var(--label-w) repeat(7, var(--cell-w)) var(--overlay-w); border-bottom: .1em solid var(--muted); + align-items: last baseline; flex-shrink: 0; } @@ -184,143 +142,116 @@ header { overflow-y: auto; overflow-x: hidden; flex: 0 0 auto; - width: calc(var(--w-label) + 7 * var(--w-cell) + var(--overlay-w)); - /* Hide scrollbar */ - scrollbar-width: none; /* Firefox */ - -ms-overflow-style: none; /* IE/Edge */ -} - -.calendar-viewport::-webkit-scrollbar { - display: none; /* Chrome/Safari/Webkit */ + width: calc(var(--label-w) + 7 * var(--cell-w) + var(--overlay-w)); + scrollbar-width: none; } +.calendar-viewport::-webkit-scrollbar { display: none } .jogwheel-viewport { position: absolute; - top: 0; - right: 0; - bottom: 0; + inset: 0 0 0 auto; width: var(--overlay-w); - height: 100%; overflow-y: auto; overflow-x: hidden; - /* Hide scrollbar */ - scrollbar-width: none; /* Firefox */ - -ms-overflow-style: none; /* IE/Edge */ - z-index: 20; /* Above calendar content and labels */ + scrollbar-width: none; + z-index: 20; + cursor: ns-resize; } +.jogwheel-viewport::-webkit-scrollbar { display: none } -.jogwheel-viewport::-webkit-scrollbar { - display: none; /* Chrome/Safari/Webkit */ -} +.jogwheel-content { position: relative; width: 100% } -.jogwheel-content { - position: relative; - width: 100%; - background: transparent; -} - -.calendar-content { - position: relative; -} +.calendar-content { position: relative } .week-row { display: grid; - grid-template-columns: var(--w-label) repeat(7, var(--w-cell)) var(--overlay-w); - gap: 0; + grid-template-columns: var(--label-w) repeat(7, var(--cell-w)) var(--overlay-w); position: relative; overflow: visible; - height: var(--week-row-h); + height: var(--cell-h); + scroll-snap-align: start; } -/* Ensure children match the fixed week height */ +/* Fixed heights for cells and labels */ .week-row .cell, -.week-row .week-label { height: var(--week-row-h); } +.week-row .week-label { height: var(--cell-h) } -header h1 { - margin: 0; - font-size: 1rem; -} +header h1 { margin: 0; font-size: 1rem } .dow-label, .week-label { display: grid; place-items: center; - width: var(--w-label); + width: var(--label-w); height: var(--row-h); color: var(--muted); + cursor: ns-resize; + font-size: 1.2em; } -.dow { - text-transform: uppercase; -} + +.dow { text-transform: uppercase; } .cell { display: grid; place-items: center; - width: var(--w-cell); + width: var(--cell-w); height: var(--row-h); font-weight: 700; - margin: 0; - padding: 0; - border: 1px transparent; cursor: pointer; - transition: background-color 0.15s ease; + transition: background-color .15s ease; } -.cell:hover { - background: rgba(var(--ink-rgb, 17, 17, 17), 0.1); -} .weekend { color: var(--weekend) } -.firstday { color: var(--firstday); text-shadow: 0 0 .1em var(--ink) } -.today h1 { - background: var(--ink) !important; - color: var(--panel) !important; - border-radius: 0.25rem; - font-weight: 700; - cursor: pointer; -} -.selected, -.range-start, -.range-end, -.range-middle, -.range-single { background: var(--weekend) !important; } +.firstday { color: var(--firstday); text-shadow: 0 0 .1em rgba(var(--ink-rgb), .5) } -.selected h1 { +.today h1 { + border-radius: 2em; + border: .2em dotted var(--today); + margin: -.2em; +} + +/* Selection */ +input { + background: transparent; + border: none; + color: var(--ink); + width: 11em; +} +label:has(input[value]) { + display: block; +} +.selected { + background: var(--select) !important; +} +.selected h1, +:is(.range-start,.range-end,.range-middle,.range-single) h1 { background: transparent !important; color: var(--panel) !important; font-weight: 700; - cursor: pointer; } -.range-start h1, -.range-end h1, -.range-middle h1, -.range-single h1 { - background: transparent !important; - color: var(--panel) !important; - font-weight: 700; - cursor: pointer; + +.cell { + position: relative; } + .cell h1 { - cursor: inherit !important; /* Inherit cursor from parent cell */ - padding: 0.25em; + position: absolute; + top: 0; + left: 0; + padding: .25em; margin: 0; - border-radius: 0.25rem; - transition: background-color 0.15s ease; + transition: background-color .15s ease; font-size: 1em; } -.cell h1:hover { - background: var(--muted); - color: var(--panel); -} +.cell:hover h1 { text-shadow: 0 0 .2em; } -/* =============================== - JOGWHEEL MONTH LABELS - =============================== */ +/* Month labels in jogwheel column */ .month-name-label { - grid-column: -2 / -1; /* last overlay column */ + grid-column: -2 / -1; font-size: 2em; font-weight: 700; color: var(--muted); display: flex; - align-items: center; /* center in container */ + align-items: center; justify-content: center; pointer-events: none; z-index: 15; diff --git a/calendar.js b/calendar.js index ed58146..efd05c1 100644 --- a/calendar.js +++ b/calendar.js @@ -1,803 +1,599 @@ -// calendar.js - Infinite scrolling week-by-week implementation +// calendar.js — Infinite scrolling week-by-week const monthAbbr = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'] +const DAY_MS = 86400000 +const WEEK_MS = 7 * DAY_MS -const isoWeekNumber = 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) - const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)) - const diffDays = Math.floor((d - yearStart) / 86400000) + 1 - return Math.ceil(diffDays / 7) + const year = d.getUTCFullYear() + const yearStart = new Date(Date.UTC(year, 0, 1)) + const diffDays = Math.floor((d - yearStart) / DAY_MS) + 1 + return { week: Math.ceil(diffDays / 7), year } } -const isoWeekYear = date => { - const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())) - const day = d.getUTCDay() || 7 - d.setUTCDate(d.getUTCDate() + 4 - day) - return d.getUTCFullYear() +function toLocalString(date = new Date()) { + const pad = n => String(Math.floor(Math.abs(n))).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +} + +function fromLocalString(dateString) { + const [year, month, day] = dateString.split('-').map(Number) + return new Date(year, month - 1, day) } const mondayIndex = d => (d.getDay() + 6) % 7 - const pad = n => String(n).padStart(2, '0') class InfiniteCalendar { constructor(config = {}) { - // Configuration options this.config = { - select_days: 0, // 0 = no selection, 1 = single selection, >1 = range selection up to N days - min_year: 2010, // Minimum year that can be viewed - max_year: 2030, // Maximum year that can be viewed + select_days: 0, + min_year: 1900, + max_year: 2100, ...config } - - // Configure which days are weekends (0=Sunday, 1=Monday, ..., 6=Saturday) - // Array of 7 booleans, indexed by day of week (0-6) - this.weekend = [true, false, false, false, false, false, true] // Sunday and Saturday - + + this.weekend = [true, false, false, false, false, false, true] + this.viewport = document.getElementById('calendar-viewport') this.content = document.getElementById('calendar-content') this.header = document.getElementById('calendar-header') this.jogwheelViewport = document.getElementById('jogwheel-viewport') this.jogwheelContent = document.getElementById('jogwheel-content') - this.todayButton = document.getElementById('go-to-today') this.selectedDateInput = document.getElementById('selected-date') - // Keep JS in sync with CSS variable --week-row-h (4em). We'll compute in pixels at runtime. - this.rowHeight = this.computeRowHeight() - this.visibleWeeks = new Map() // Start from a base date (many years ago) and calculate current week - this.baseDate = new Date(1970, 0, 5) // First Monday of 1970 - this.currentDate = new Date() - this.currentWeekIndex = this.getWeekIndex(this.currentDate) - - // Today's date for highlighting - this.today = new Date() - this.today.setHours(0, 0, 0, 0) // Normalize to start of day - - // Selected date tracking (only if selection is enabled) - if (this.config.select_days > 0) { - this.selectedDate = null - this.selectedCell = null - - // Date range selection tracking (only if range selection is enabled) - if (this.config.select_days > 1) { - this.isDragging = false - this.dragStartDate = null - this.dragStartCell = null - this.dragEndDate = null - this.dragEndCell = null - this.selectedRangeCells = new Set() - this.isRangeInvalid = false - this.constrainedStartDate = null - this.constrainedEndDate = null - } - } - - this.init() + + this.rowHeight = this.computeRowHeight() + this.visibleWeeks = new Map() + this.baseDate = new Date(2024, 0, 1) // 2024 begins with Monday + + // unified selection state (single or range) + this.selStart = null // 'YYYY-MM-DD' + this.selEnd = null // 'YYYY-MM-DD' + this.isDragging = false + this.dragAnchor = null // 'YYYY-MM-DD' + + this.init() } init() { this.createHeader() - this.setupInitialView() this.setupScrollListener() this.setupJogwheel() - this.setupTodayButton() + this.setupYearScroll() this.setupSelectionInput() - - // Only setup selection functionality if enabled - if (this.config.select_days > 1) { - this.setupGlobalDragHandlers() + this.setupCurrentDate() + this.setupInitialView() + } + + setupInitialView() { + const minYearDate = new Date(this.config.min_year, 0, 1) + const maxYearLastDay = new Date(this.config.max_year, 11, 31) + const lastWeekMonday = new Date(maxYearLastDay) + lastWeekMonday.setDate(maxYearLastDay.getDate() - mondayIndex(maxYearLastDay)) + + this.minVirtualWeek = this.getWeekIndex(minYearDate) + this.maxVirtualWeek = this.getWeekIndex(lastWeekMonday) + this.totalVirtualWeeks = this.maxVirtualWeek - this.minVirtualWeek + 1 + + this.content.style.height = `${this.totalVirtualWeeks * this.rowHeight}px` + this.jogwheelContent.style.height = `${(this.totalVirtualWeeks * this.rowHeight) / 10}px` + const initial = this.config.initial || this.today + this.navigateTo(fromLocalString(initial)) + } + + setupCurrentDate() { + const updateDate = () => { + this.now = new Date() + const today = toLocalString(this.now) + if (this.today === today) return + this.today = today + for (const cell of this.content.querySelectorAll('.cell.today')) cell.classList.remove('today') + const cell = this.content.querySelector(`.cell[data-date="${this.today}"]`) + if (cell) cell.classList.add('today') + const todayDateElement = document.getElementById('today-date') + const t = this.now.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric'}).replace(/,? /, "\n") + todayDateElement.textContent = t.charAt(0).toUpperCase() + t.slice(1) } + + const todayDateElement = document.getElementById('today-date') + todayDateElement.addEventListener('click', () => this.goToToday()) + if (this.config.select_days > 1) this.setupGlobalDragHandlers() + updateDate() + setInterval(updateDate, 1000) + } + + setupYearScroll() { + let throttled = false + const handleWheel = e => { + e.preventDefault() + e.stopPropagation() + const currentYear = parseInt(this.weekLabel.textContent) + const topDisplayIndex = Math.floor(this.viewport.scrollTop / this.rowHeight) + const currentWeekIndex = topDisplayIndex + this.minVirtualWeek + const sensitivity = 1/3 + const delta = Math.round(e.deltaY * sensitivity) + if (!delta) return + const newYear = Math.max(this.config.min_year, Math.min(this.config.max_year, currentYear + delta)) + if (newYear === currentYear || throttled) return + throttled = true + this.navigateToYear(newYear, currentWeekIndex) + setTimeout(() => throttled = false, 100) + } + this.weekLabel.addEventListener('wheel', handleWheel, { passive: false }) + } + + navigateTo(date) { + const targetWeekIndex = this.getWeekIndex(date) - 3 + return this.scrollToTarget(targetWeekIndex) + } + + navigateToYear(targetYear, weekIndex) { + const monday = this.getMondayForVirtualWeek(weekIndex) + const { week } = isoWeekInfo(monday) + const jan4 = new Date(targetYear, 0, 4) + const jan4Monday = new Date(jan4) + jan4Monday.setDate(jan4.getDate() - mondayIndex(jan4)) + const targetMonday = new Date(jan4Monday) + targetMonday.setDate(jan4Monday.getDate() + (week - 1) * 7) + this.scrollToTarget(targetMonday) + } + + scrollToTarget(target, options = {}) { + const { smooth = false, forceUpdate = true, allowFloating = false } = options + let targetWeekIndex + if (target instanceof Date) { + if (!this.isDateInAllowedRange(target)) return false + targetWeekIndex = this.getWeekIndex(target) + } else if (typeof target === 'number') { + targetWeekIndex = target + } else { + return false + } + + if (allowFloating) { + if (targetWeekIndex < this.minVirtualWeek - 0.5 || targetWeekIndex > this.maxVirtualWeek + 0.5) return false + } else { + if (targetWeekIndex < this.minVirtualWeek || targetWeekIndex > this.maxVirtualWeek) return false + } + + const targetScrollTop = (targetWeekIndex - this.minVirtualWeek) * this.rowHeight + + if (smooth) this.viewport.scrollTo({ top: targetScrollTop, behavior: 'smooth' }) + else this.viewport.scrollTop = targetScrollTop + + const mainScrollable = Math.max(0, this.content.scrollHeight - this.viewport.clientHeight) + const jogScrollable = Math.max(0, this.jogwheelContent.scrollHeight - this.jogwheelViewport.clientHeight) + const jogwheelTarget = mainScrollable > 0 ? (targetScrollTop / mainScrollable) * jogScrollable : 0 + + if (smooth) this.jogwheelViewport.scrollTo({ top: jogwheelTarget, behavior: 'smooth' }) + else this.jogwheelViewport.scrollTop = jogwheelTarget + + if (forceUpdate) this.updateVisibleWeeks() + return true } computeRowHeight() { - // Read computed value of CSS var --week-row-h. Fallback to 4em if unavailable. - const temp = document.createElement('div') - temp.style.position = 'absolute' - temp.style.visibility = 'hidden' - temp.style.height = 'var(--week-row-h, 4em)' - document.body.appendChild(temp) - const h = temp.getBoundingClientRect().height || 64 // approx 4em - temp.remove() + const el = document.createElement('div') + el.style.position = 'absolute' + el.style.visibility = 'hidden' + el.style.height = 'var(--cell-h)' + document.body.appendChild(el) + const h = el.getBoundingClientRect().height || 64 + el.remove() return Math.round(h) } getLocalizedWeekdayNames() { - // Generate localized weekday abbreviations starting from Monday - const weekdays = [] - // Create a date that starts on a Monday (January 6, 2025 is a Monday) - const baseDate = new Date(2025, 0, 6) // Monday - + const res = [] + const base = new Date(2025, 0, 6) for (let i = 0; i < 7; i++) { - const date = new Date(baseDate) - date.setDate(baseDate.getDate() + i) - // Get short weekday name (3 letters) in user's locale - const dayName = date.toLocaleDateString(undefined, { weekday: 'short' }) - weekdays.push(dayName) + const d = new Date(base) + d.setDate(base.getDate() + i) + res.push(d.toLocaleDateString(undefined, { weekday: 'short' })) } - - return weekdays + return res } - getLocalizedMonthName(monthIndex, short = false) { - // Generate localized month name for given month index (0-11) - const date = new Date(2025, monthIndex, 1) - return date.toLocaleDateString(undefined, { - month: short ? 'short' : 'long' - }) + getLocalizedMonthName(idx, short = false) { + const d = new Date(2025, idx, 1) + return d.toLocaleDateString(undefined, { month: short ? 'short' : 'long' }) } createHeader() { - // Create the fixed header with weekday names this.weekLabel = document.createElement('div') this.weekLabel.className = 'dow-label' - this.weekLabel.textContent = isoWeekYear(new Date()) // Initialize with current ISO week year + this.weekLabel.textContent = isoWeekInfo(new Date()).year this.header.appendChild(this.weekLabel) - - // Get localized weekday names starting from Monday - const localizedWeekdays = this.getLocalizedWeekdayNames() - - localizedWeekdays.forEach((d, i) => { + + const names = this.getLocalizedWeekdayNames() + names.forEach((name, i) => { const c = document.createElement('div') c.className = 'cell dow' - - // Apply weekend styling based on configuration - // Monday is index 1 in JavaScript's getDay(), so we need to map correctly - const dayOfWeekIndex = (i + 1) % 7 // Mon=1, Tue=2, ..., Sun=0 - if (this.weekend[dayOfWeekIndex]) { - c.classList.add('weekend') - } - - c.textContent = d + const dayIdx = (i + 1) % 7 + if (this.weekend[dayIdx]) c.classList.add('weekend') + c.textContent = name this.header.appendChild(c) }) - // Add an empty header cell for the overlay column to align grids - const overlayHeaderSpacer = document.createElement('div') - overlayHeaderSpacer.className = 'overlay-header-spacer' - this.header.appendChild(overlayHeaderSpacer) + const spacer = document.createElement('div') + spacer.className = 'overlay-header-spacer' + this.header.appendChild(spacer) } getWeekIndex(date) { - // Calculate week index from base date - const timeDiff = date.getTime() - this.baseDate.getTime() - return Math.floor(timeDiff / (7 * 24 * 60 * 60 * 1000)) - } - - getDateFromWeekIndex(weekIndex) { - // Get the Monday of the week at given index - const time = this.baseDate.getTime() + (weekIndex * 7 * 24 * 60 * 60 * 1000) - return new Date(time) + 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)) + monday.setDate(monday.getDate() + virtualWeek * 7) return monday } isDateInAllowedRange(date) { - // Check if a date falls within the configured min/max year range - const year = date.getFullYear() - return year >= this.config.min_year && year <= this.config.max_year + const y = date.getFullYear() + return y >= this.config.min_year && y <= this.config.max_year } - setupInitialView() { - // Calculate virtual week limits based on configured min/max years - const minYearDate = new Date(this.config.min_year, 0, 1) // January 1st of min year - // End limit: Monday of the last week that contains a day in max_year (week containing Dec 31) - 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) - - // Calculate total range for DOM height (inclusive of last week) - this.totalVirtualWeeks = (this.maxVirtualWeek - this.minVirtualWeek) + 1 - - // Set content height for absolute positioning - this.content.style.height = `${this.totalVirtualWeeks * this.rowHeight}px` - - // Setup jogwheel content height (10x smaller for 10x faster scrolling) - this.jogwheelContent.style.height = `${this.totalVirtualWeeks * this.rowHeight / 10}px` - - // Anchor date: Monday of the current week - this.mondayCurrent = new Date(this.currentDate) - this.mondayCurrent.setDate(this.mondayCurrent.getDate() - mondayIndex(this.mondayCurrent)) - - // Scroll to approximately current time - // Current week index relative to min virtual week - const currentWeekOffset = this.currentWeekIndex - this.minVirtualWeek - const initialScrollPos = currentWeekOffset * this.rowHeight - this.viewport.scrollTop = initialScrollPos - // Normalize initial jogwheel position to match main scroll progress - { - const mainScrollable = Math.max(0, this.content.scrollHeight - this.viewport.clientHeight) - const jogScrollable = Math.max(0, this.jogwheelContent.scrollHeight - this.jogwheelViewport.clientHeight) - this.jogwheelViewport.scrollTop = mainScrollable > 0 ? (initialScrollPos / mainScrollable) * jogScrollable : 0 - } - - this.updateVisibleWeeks() - } setupScrollListener() { - let scrollTimeout + setupScrollListener() { + let t this.viewport.addEventListener('scroll', () => { - // Immediate update for fast scrolling this.updateVisibleWeeks() - - // Also do a delayed update to catch any edge cases - clearTimeout(scrollTimeout) - scrollTimeout = setTimeout(() => { - this.updateVisibleWeeks() - }, 8) // Faster refresh rate + clearTimeout(t) + t = setTimeout(() => this.updateVisibleWeeks(), 8) }) } updateVisibleWeeks() { const scrollTop = this.viewport.scrollTop - const viewportHeight = this.viewport.clientHeight - - // Update year label based on top visible week + const viewportH = this.viewport.clientHeight + this.updateYearLabel(scrollTop) - - // Calculate which virtual weeks should be visible with generous buffer - const bufferWeeks = 10 // Increased buffer for fast scrolling - const startDisplayIndex = Math.floor((scrollTop - bufferWeeks * this.rowHeight) / this.rowHeight) - const endDisplayIndex = Math.ceil((scrollTop + viewportHeight + bufferWeeks * this.rowHeight) / this.rowHeight) - - // Convert display indices to actual virtual week indices - const startVirtualWeek = Math.max(this.minVirtualWeek, startDisplayIndex + this.minVirtualWeek) - const endVirtualWeek = Math.min(this.maxVirtualWeek, endDisplayIndex + this.minVirtualWeek) - - // Remove weeks that are no longer visible - for (const [virtualWeek, element] of this.visibleWeeks) { - if (virtualWeek < startVirtualWeek || virtualWeek > endVirtualWeek) { - element.remove() - this.visibleWeeks.delete(virtualWeek) - } - } - - // Add new visible weeks with precise positioning - for (let virtualWeek = startVirtualWeek; virtualWeek <= endVirtualWeek; virtualWeek++) { - if (!this.visibleWeeks.has(virtualWeek)) { - const weekElement = this.createWeekElement(virtualWeek) - weekElement.style.position = 'absolute' - weekElement.style.left = '0' - // Position based on offset from minVirtualWeek - const displayIndex = virtualWeek - this.minVirtualWeek - weekElement.style.top = `${displayIndex * this.rowHeight}px` - weekElement.style.width = '100%' - weekElement.style.height = `${this.rowHeight}px` - this.content.appendChild(weekElement) - this.visibleWeeks.set(virtualWeek, weekElement) + + 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) } } - // Month labels rendered per first-week only + 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.applySelectionToVisible() } updateYearLabel(scrollTop) { - // Calculate which virtual week is at the top of the viewport const topDisplayIndex = Math.floor(scrollTop / this.rowHeight) - const topVirtualWeek = topDisplayIndex + this.minVirtualWeek - - // Calculate actual date for this virtual week (direct offset from base date) - const topWeekMonday = this.getMondayForVirtualWeek(topVirtualWeek) - - // Get the ISO week year of the top visible week (not calendar year) - const topWeekYear = isoWeekYear(topWeekMonday) - - // Update the year label if it has changed - if (this.weekLabel.textContent !== String(topWeekYear)) { - this.weekLabel.textContent = topWeekYear - } + const topVW = topDisplayIndex + this.minVirtualWeek + const monday = this.getMondayForVirtualWeek(topVW) + const { year } = isoWeekInfo(monday) + if (this.weekLabel.textContent !== String(year)) this.weekLabel.textContent = year } createWeekElement(virtualWeek) { const weekDiv = document.createElement('div') weekDiv.className = 'week-row' - // Calculate actual date for this virtual week (direct offset from base date) - const monday = this.getMondayForVirtualWeek(virtualWeek) - - // Week label + const monday = this.getMondayForVirtualWeek(virtualWeek) + const wkLabel = document.createElement('div') wkLabel.className = 'week-label' - const wk = isoWeekNumber(monday) - wkLabel.textContent = `W${pad(wk)}` + wkLabel.textContent = `W${pad(isoWeekInfo(monday).week)}` weekDiv.appendChild(wkLabel) - - // Create 7 day cells + const cur = new Date(monday) - let hasFirstOfMonth = false - let monthToLabel = null - let labelYear = null - + 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() - // Use English month abbreviations for CSS classes to maintain styling compatibility cell.classList.add(monthAbbr[m]) - - const isFirstOfMonth = cur.getDate() === 1 - if (isFirstOfMonth) { - hasFirstOfMonth = true + + const isFirst = cur.getDate() === 1 + if (isFirst) { + hasFirst = true monthToLabel = m labelYear = cur.getFullYear() } - + const day = document.createElement('h1') day.textContent = String(cur.getDate()) - - // Store the date for this cell - const cellDate = new Date(cur) - cellDate.setHours(0, 0, 0, 0) - cell.dataset.date = cellDate.toISOString().split('T')[0] // Store ISO date - - // Check if this date is today - if (cellDate.getTime() === this.today.getTime()) { - cell.classList.add('today') - } - - // Check if this date is selected (single or range) - only if selection enabled - if (this.config.select_days > 0 && this.selectedDate && cellDate.getTime() === this.selectedDate.getTime()) { - cell.classList.add('selected') - this.selectedCell = cell - } - - // Check if this date is part of a range selection - only if range selection enabled - if (this.config.select_days > 1) { - this.updateCellRangeStatus(cell, cellDate) - } - - // Add selection event handlers only if selection is enabled + + const date = toLocalString(cur) + console.log(cur, date) + cell.dataset.date = date + if (this.today && date === this.today) cell.classList.add('today') + if (this.config.select_days > 0) { - // Add drag and click handlers to the entire cell for range selection - if (this.config.select_days > 1) { - cell.addEventListener('mousedown', (e) => { - e.preventDefault() - e.stopPropagation() - this.startDrag(cellDate, cell, e) - }) - - cell.addEventListener('mouseenter', (e) => { - if (this.isDragging) { - this.updateDragSelection(cellDate, cell) - } - }) - - cell.addEventListener('mouseup', (e) => { - e.stopPropagation() - if (this.isDragging) { - this.endDrag(cellDate, cell) - } - }) - } - - // Add click handler for single selection (works for both single-only and range modes) - day.addEventListener('click', (e) => { + // Allow selection start from anywhere in the cell + cell.addEventListener('mousedown', e => { + e.preventDefault() e.stopPropagation() - // Only handle click if we're not in the middle of a drag operation (or drag is disabled) - if (this.config.select_days === 1 || !this.isDragging) { - this.selectDate(cellDate, cell) + this.startDrag(dateStr) + }) + + // Touch events for mobile support + cell.addEventListener('touchstart', e => { + e.preventDefault() + e.stopPropagation() + this.startDrag(dateStr) + }) + + // Keep cell listeners for drag continuation + cell.addEventListener('mouseenter', () => { + if (this.isDragging) this.updateDrag(dateStr) + }) + cell.addEventListener('mouseup', e => { + e.stopPropagation() + if (this.isDragging) this.endDrag(dateStr) + }) + + // Touch drag continuation + cell.addEventListener('touchmove', e => { + if (this.isDragging) { + e.preventDefault() + // Get touch position and find the element underneath + const touch = e.touches[0] + const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY) + if (elementBelow && elementBelow.closest('.cell[data-date]')) { + const cellBelow = elementBelow.closest('.cell[data-date]') + const touchDateStr = cellBelow.dataset.date + if (touchDateStr) this.updateDrag(touchDateStr) + } } }) + + cell.addEventListener('touchend', e => { + e.stopPropagation() + if (this.isDragging) this.endDrag(dateStr) + }) } - - if (isFirstOfMonth) { - cell.classList.add("firstday") - // Use English month abbreviation for display, but show year for January + + if (isFirst) { + cell.classList.add('firstday') day.textContent = cur.getMonth() ? monthAbbr[m].slice(0,3).toUpperCase() : cur.getFullYear() } - + cell.appendChild(day) weekDiv.appendChild(cell) cur.setDate(cur.getDate() + 1) } - // Add overlay label only on the first week of the month, spanning all month weeks - if (hasFirstOfMonth && monthToLabel !== null) { - // If the month to label starts beyond the configured max year, skip the overlay + + if (hasFirst && monthToLabel !== null) { if (labelYear && labelYear > this.config.max_year) return weekDiv const overlayCell = document.createElement('div') overlayCell.className = 'month-name-label' - // Compute exact number of weeks this month spans - const d = new Date(cur) - d.setDate(cur.getDate() - 1) // Sunday of this week + 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++ } - // Clip overlay height so it doesn't extend past the max boundary + const remainingWeeks = Math.max(1, this.maxVirtualWeek - virtualWeek + 1) weeksSpan = Math.max(1, Math.min(weeksSpan, remainingWeeks)) - // Positional styles are in CSS + 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' - // Content for bottom-up readable text + const label = document.createElement('span') - const year = (labelYear ?? monday.getFullYear()).toString().substring(2) // Last two digits of year + const year = String((labelYear ?? monday.getFullYear())).slice(-2) label.textContent = `${this.getLocalizedMonthName(monthToLabel)} '${year}` overlayCell.appendChild(label) weekDiv.appendChild(overlayCell) - // Ensure this row (and its overlay) paint above subsequent rows weekDiv.style.zIndex = '18' } + return weekDiv } - - setupJogwheel() { - let isJogwheelScrolling = false - let isMainScrolling = false - - // Jogwheel scroll affects main view (map by scroll progress ratio) - this.jogwheelViewport.addEventListener('scroll', () => { - if (isMainScrolling) return - - isJogwheelScrolling = true - const jogScrollable = Math.max(0, this.jogwheelContent.scrollHeight - this.jogwheelViewport.clientHeight) - const mainScrollable = Math.max(0, this.content.scrollHeight - this.viewport.clientHeight) - const ratio = jogScrollable > 0 ? (this.jogwheelViewport.scrollTop / jogScrollable) : 0 - this.viewport.scrollTop = ratio * mainScrollable - - // Reset flag after a short delay - setTimeout(() => { - isJogwheelScrolling = false - }, 50) - }) - - // Main view scroll updates jogwheel position (map by scroll progress ratio) - this.viewport.addEventListener('scroll', () => { - if (isJogwheelScrolling) return - - isMainScrolling = true - const jogScrollable = Math.max(0, this.jogwheelContent.scrollHeight - this.jogwheelViewport.clientHeight) - const mainScrollable = Math.max(0, this.content.scrollHeight - this.viewport.clientHeight) - const ratio = mainScrollable > 0 ? (this.viewport.scrollTop / mainScrollable) : 0 - this.jogwheelViewport.scrollTop = ratio * jogScrollable - - // Reset flag after a short delay - setTimeout(() => { - isMainScrolling = false - }, 50) - }) - } + let lock = null + const sync = (fromEl, toEl, fromContent, toContent) => { + if (lock === toEl) return + lock = fromEl + const fromScrollable = Math.max(0, fromContent.scrollHeight - fromEl.clientHeight) + const toScrollable = Math.max(0, toContent.scrollHeight - toEl.clientHeight) + const ratio = fromScrollable > 0 ? fromEl.scrollTop / fromScrollable : 0 + toEl.scrollTop = ratio * toScrollable + setTimeout(() => { if (lock === fromEl) lock = null }, 50) + } - setupTodayButton() { - this.todayButton.addEventListener('click', () => { - this.goToToday() - }) + this.jogwheelViewport.addEventListener('scroll', () => + sync(this.jogwheelViewport, this.viewport, this.jogwheelContent, this.content) + ) + + this.viewport.addEventListener('scroll', () => + sync(this.viewport, this.jogwheelViewport, this.content, this.jogwheelContent) + ) } setupSelectionInput() { if (this.config.select_days === 0) { - // Hide the input when selection is disabled this.selectedDateInput.style.display = 'none' } else { - // Show and style the input when selection is enabled this.selectedDateInput.style.display = 'block' this.selectedDateInput.classList.add('clean-input') } } goToToday() { - // Check if today's date is within the allowed year range - if (!this.isDateInAllowedRange(this.today)) { - console.warn(`Today's date (${this.today.getFullYear()}) is outside the configured year range (${this.config.min_year}-${this.config.max_year})`) - return - } - - // Calculate the week index for today - const todayWeekIndex = this.getWeekIndex(this.today) - - // Calculate scroll position for today's week - const todayWeekOffset = todayWeekIndex - this.minVirtualWeek - const targetScrollPos = todayWeekOffset * this.rowHeight - - // Smooth scroll to today's week - this.viewport.scrollTo({ - top: targetScrollPos, - behavior: 'smooth' - }) - - // Update jogwheel position - this.jogwheelViewport.scrollTo({ - top: targetScrollPos / 10, - behavior: 'smooth' - }) + const top = new Date(this.now) + top.setDate(top.getDate() - 21) + this.scrollToTarget(top, { smooth: true }) } - selectDate(date, cell) { - // Clear any existing range selection - this.clearRangeSelection() - - // Remove previous single selection - if (this.selectedCell) { - this.selectedCell.classList.remove('selected') - } - - // Set new selection - this.selectedDate = new Date(date) - this.selectedCell = cell - cell.classList.add('selected') - - // Update input field with ISO format - this.selectedDateInput.value = date.toISOString().split('T')[0] + // -------- Selection -------- + + daysInclusive(aStr, bStr) { + const a = fromLocalString(aStr) + const b = fromLocalString(bStr) + const A = new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime() + const B = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime() + return Math.floor(Math.abs(B - A) / DAY_MS) + 1 } - setupGlobalDragHandlers() { - // Handle mouse up anywhere to end drag - document.addEventListener('mouseup', () => { - if (this.isDragging) { - this.isDragging = false - // Reset cursor when drag ends - document.body.style.cursor = 'default' - // Don't clear selection here, let the specific mouseup handler manage it - } - }) - - // Prevent text selection during drag - document.addEventListener('selectstart', (e) => { - if (this.isDragging) { - e.preventDefault() - } - }) + addDaysStr(str, n) { + const d = fromLocalString(str) + d.setDate(d.getDate() + n) + return toLocalString(d) } - startDrag(date, cell, event) { - this.isDragging = true - this.dragStartDate = new Date(date) - this.dragStartCell = cell - this.dragEndDate = new Date(date) - this.dragEndCell = cell - - // Clear any existing selections - this.clearRangeSelection() - if (this.selectedCell) { - this.selectedCell.classList.remove('selected') - this.selectedCell = null - this.selectedDate = null + clampRange(anchorStr, otherStr) { + if (this.config.select_days <= 1) return [otherStr, otherStr] + const limit = this.config.select_days + const forward = fromLocalString(otherStr) >= fromLocalString(anchorStr) + const span = this.daysInclusive(anchorStr, otherStr) + if (span <= limit) { + const a = [anchorStr, otherStr].sort() + return [a[0], a[1]] } - - // Start with single selection - this.updateRangeDisplay() + if (forward) return [anchorStr, this.addDaysStr(anchorStr, limit - 1)] + return [this.addDaysStr(anchorStr, -(limit - 1)), anchorStr] } - updateDragSelection(date, cell) { - if (!this.isDragging) return - - this.dragEndDate = new Date(date) - this.dragEndCell = cell - - // Calculate the potential range - const originalStartDate = new Date(Math.min(this.dragStartDate.getTime(), this.dragEndDate.getTime())) - const originalEndDate = new Date(Math.max(this.dragStartDate.getTime(), this.dragEndDate.getTime())) - const originalDaysDiff = Math.ceil((originalEndDate - originalStartDate) / (24 * 60 * 60 * 1000)) + 1 - - // Check if dragging backward from start point - const isDraggingBackward = this.dragEndDate.getTime() < this.dragStartDate.getTime() - - // Always constrain the visual selection to allowed days - let startDate, endDate - if (isDraggingBackward) { - // Dragging backward: keep drag start, limit how far back we can go - const limitedStartDate = new Date(this.dragStartDate) - limitedStartDate.setDate(this.dragStartDate.getDate() - this.config.select_days + 1) - startDate = new Date(Math.max(limitedStartDate.getTime(), this.dragEndDate.getTime())) - endDate = new Date(this.dragStartDate) - } else { - // Dragging forward: keep drag start, limit how far forward we can go - startDate = new Date(this.dragStartDate) - const limitedEndDate = new Date(this.dragStartDate) - limitedEndDate.setDate(this.dragStartDate.getDate() + this.config.select_days - 1) - endDate = new Date(Math.min(limitedEndDate.getTime(), this.dragEndDate.getTime())) - } - - // Show not-allowed cursor only when trying to exceed the limit - if (originalDaysDiff > this.config.select_days) { - document.body.style.cursor = 'not-allowed' - cell.style.cursor = 'not-allowed' - this.isRangeInvalid = true - } else { - document.body.style.cursor = 'default' - cell.style.cursor = 'pointer' - this.isRangeInvalid = false - } - - // Store the constrained range for display - this.constrainedStartDate = startDate - this.constrainedEndDate = endDate - - this.updateRangeDisplay() + setSelection(aStr, bStr) { + const [start, end] = this.clampRange(aStr, bStr) + this.selStart = start + this.selEnd = end + this.applySelectionToVisible() + this.selectedDateInput.value = this.formatDateRange(fromLocalString(start), fromLocalString(end)) } - endDrag(date, cell) { - if (!this.isDragging) return - - this.isDragging = false - this.dragEndDate = new Date(date) - this.dragEndCell = cell - - // Reset cursor - document.body.style.cursor = 'default' - cell.style.cursor = 'pointer' - - // Clear constrained range - this.constrainedStartDate = null - this.constrainedEndDate = null - - // Determine if this is a single click or a range - if (this.dragStartDate.getTime() === this.dragEndDate.getTime()) { - // Single date selection - this.selectDate(this.dragStartDate, this.dragStartCell) - } else { - // Check if range length is within limits - const startDate = new Date(Math.min(this.dragStartDate.getTime(), this.dragEndDate.getTime())) - const endDate = new Date(Math.max(this.dragStartDate.getTime(), this.dragEndDate.getTime())) - const daysDiff = Math.ceil((endDate - startDate) / (24 * 60 * 60 * 1000)) + 1 - - if (daysDiff <= this.config.select_days) { - // Range selection within limits - finalize the range - this.finalizeRangeSelection() - } else { - // Range too long - clear all selection - this.clearRangeSelection() - this.clearSingleSelection() - this.selectedDateInput.value = '' + clearSelection() { + this.selStart = null + this.selEnd = null + for (const [, weekEl] of this.visibleWeeks) { + weekEl.querySelectorAll('.cell.selected').forEach(c => c.classList.remove('selected')) + } + this.selectedDateInput.value = '' + } + + applySelectionToVisible() { + for (const [, weekEl] of this.visibleWeeks) { + const cells = weekEl.querySelectorAll('.cell[data-date]') + for (const cell of cells) { + if (!this.selStart || !this.selEnd) { + cell.classList.remove('selected') + continue + } + const ds = cell.dataset.date + const inRange = ds >= this.selStart && ds <= this.selEnd + cell.classList.toggle('selected', inRange) } } } - clearRangeSelection() { - // Remove range classes from all previously selected cells - for (const cell of this.selectedRangeCells) { - cell.classList.remove('range-start', 'range-end', 'range-middle', 'range-single') - } - this.selectedRangeCells.clear() - } - - clearSingleSelection() { - // Clear single date selection - if (this.selectedCell) { - this.selectedCell.classList.remove('selected') - this.selectedCell = null - } - this.selectedDate = null - } - formatDateRange(startDate, endDate) { - if (startDate.getTime() === endDate.getTime()) { - // Single date - return startDate.toISOString().split('T')[0] - } - - const startISO = startDate.toISOString().split('T')[0] // YYYY-MM-DD - const endISO = endDate.toISOString().split('T')[0] // YYYY-MM-DD - - const [startYear, startMonth, startDay] = startISO.split('-') - const [endYear, endMonth, endDay] = endISO.split('-') - - // Same year and month - show only day - if (startYear === endYear && startMonth === endMonth) { - return `${startISO}/${endDay}` - } - - // Same year, different month - show month and day - if (startYear === endYear) { - return `${startISO}/${endMonth}-${endDay}` - } - - // Different year - show full date + if (toLocalString(startDate) === toLocalString(endDate)) return toLocalString(startDate) + const startISO = toLocalString(startDate) + const endISO = toLocalString(endDate) + const [sy, sm] = startISO.split('-') + const [ey, em, ed] = endISO.split('-') + if (sy === ey && sm === em) return `${startISO}/${ed}` + if (sy === ey) return `${startISO}/${em}-${ed}` return `${startISO}/${endISO}` } - updateRangeDisplay() { - // Clear previous range styling - this.clearRangeSelection() + setupGlobalDragHandlers() { + // Mouse drag handlers + document.addEventListener('mouseup', () => { + if (!this.isDragging) return + this.isDragging = false + document.body.style.cursor = 'default' + }) - if (!this.dragStartDate || !this.dragEndDate) return + // Touch drag handlers + document.addEventListener('touchend', () => { + if (!this.isDragging) return + this.isDragging = false + document.body.style.cursor = 'default' + }) - // Use constrained range if available (during drag), otherwise use actual range - let startDate, endDate - if (this.constrainedStartDate && this.constrainedEndDate) { - startDate = new Date(this.constrainedStartDate) - endDate = new Date(this.constrainedEndDate) - } else { - startDate = new Date(Math.min(this.dragStartDate.getTime(), this.dragEndDate.getTime())) - endDate = new Date(Math.max(this.dragStartDate.getTime(), this.dragEndDate.getTime())) - - // Legacy fallback: check if range length is within limits - const daysDiff = Math.ceil((endDate - startDate) / (24 * 60 * 60 * 1000)) + 1 - if (daysDiff > this.config.select_days) { - // Range too long - only show up to the limit - const limitedEndDate = new Date(startDate) - limitedEndDate.setDate(startDate.getDate() + this.config.select_days - 1) - endDate.setTime(limitedEndDate.getTime()) + // Global touch move handler for smooth drag across elements + document.addEventListener('touchmove', e => { + if (!this.isDragging) return + e.preventDefault() + const touch = e.touches[0] + const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY) + if (elementBelow && elementBelow.closest('.cell[data-date]')) { + const cellBelow = elementBelow.closest('.cell[data-date]') + const touchDateStr = cellBelow.dataset.date + if (touchDateStr) this.updateDrag(touchDateStr) } - } + }, { passive: false }) - // Find all cells in the visible range that fall within our selection - for (const [virtualWeek, weekElement] of this.visibleWeeks) { - const cells = weekElement.querySelectorAll('.cell[data-date]') - for (const cell of cells) { - const cellDateStr = cell.dataset.date - if (cellDateStr) { - const cellDate = new Date(cellDateStr + 'T00:00:00') - - if (cellDate >= startDate && cellDate <= endDate) { - this.selectedRangeCells.add(cell) - - if (startDate.getTime() === endDate.getTime()) { - // Single date - cell.classList.add('range-single') - } else if (cellDate.getTime() === startDate.getTime()) { - // Start of range - cell.classList.add('range-start') - } else if (cellDate.getTime() === endDate.getTime()) { - // End of range - cell.classList.add('range-end') - } else { - // Middle of range - cell.classList.add('range-middle') - } - } - } - } - } + // Prevent text selection during drag + document.addEventListener('selectstart', e => { + if (this.isDragging) e.preventDefault() + }) - // Update input field - this.selectedDateInput.value = this.formatDateRange(startDate, endDate) + // Prevent context menu on long touch during drag + document.addEventListener('contextmenu', e => { + if (this.isDragging) e.preventDefault() + }) } - finalizeRangeSelection() { - // Range is already displayed, just ensure input is updated - const startDate = new Date(Math.min(this.dragStartDate.getTime(), this.dragEndDate.getTime())) - const endDate = new Date(Math.max(this.dragStartDate.getTime(), this.dragEndDate.getTime())) - - this.selectedDateInput.value = this.formatDateRange(startDate, endDate) - - // Clear single selection properties since we now have a range - this.selectedDate = null - this.selectedCell = null + startDrag(dateStr) { + if (this.config.select_days === 0) return + this.isDragging = true + this.dragAnchor = dateStr + this.setSelection(dateStr, dateStr) } - updateCellRangeStatus(cell, cellDate) { - // Check if this cell should be part of the current range selection - if (this.selectedRangeCells.size > 0) { - // We have an active range - check if this cell is in it - const cellDateStr = cellDate.toISOString().split('T')[0] - const existingCell = Array.from(this.selectedRangeCells).find(c => c.dataset.date === cellDateStr) - - if (existingCell) { - // This cell should be part of the range - copy the classes - this.selectedRangeCells.add(cell) - if (existingCell.classList.contains('range-start')) { - cell.classList.add('range-start') - } else if (existingCell.classList.contains('range-end')) { - cell.classList.add('range-end') - } else if (existingCell.classList.contains('range-middle')) { - cell.classList.add('range-middle') - } else if (existingCell.classList.contains('range-single')) { - cell.classList.add('range-single') - } - } - } + 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' } } -// Initialize the infinite calendar document.addEventListener('DOMContentLoaded', () => { - // Default configuration - you can modify this or pass custom config new InfiniteCalendar({ - select_days: 14 // Enable range selection up to 7 days (change to 0 to disable, 1 for single selection only) + select_days: 14 }) }) + diff --git a/index.html b/index.html index feb420c..36f5533 100644 --- a/index.html +++ b/index.html @@ -9,10 +9,10 @@
-

Infinite Calendar

+

Calendar

- - + +