From 272a3bd61e98a7625dd48044ff3010d422e0821b Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Tue, 19 Aug 2025 20:20:40 -0600 Subject: [PATCH] Initial commit --- calendar.css | 341 ++++++++++++++++++++++ calendar.js | 803 +++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 29 ++ 3 files changed, 1173 insertions(+) create mode 100644 calendar.css create mode 100644 calendar.js create mode 100644 index.html diff --git a/calendar.css b/calendar.css new file mode 100644 index 0000000..6124e78 --- /dev/null +++ b/calendar.css @@ -0,0 +1,341 @@ +/* =============================== + LIGHT MODE (default) — colors only + =============================== */ +:root { + --bg: #f6f7fb; + --panel: #ffffff; + --ink: #111; + --ink-rgb: 17, 17, 17; + --muted: #888; + --weekend: #888; + --firstday: #000; + --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 */ + +/* 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 */ + +/* 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 + =============================== */ +@media (prefers-color-scheme: dark) { + :root { + --bg: radial-gradient(1200px 800px at 20% -10%, #1c2130 0%, #0c0f16 35%, #0a0b11 100%); + --panel: #111318; + --ink: #ddd; + --ink-rgb: 221, 221, 221; + --muted: #888; + --weekend: #999; + --firstday: #fff; + --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 */ +} + + + +/* =============================== + Base layout & typography (mode-agnostic) + =============================== */ +: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; +} + +* { box-sizing: border-box } + +body { + margin: 0; + font: 500 14px/1.2 ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Inter, Arial; + background: var(--bg); + color: var(--ink); +} + +.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 */ +} + +header { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: .75rem; + flex-shrink: 0; +} + +.header-controls { + display: flex; + align-items: center; + gap: 0.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; + cursor: pointer; + transition: opacity 0.2s ease; +} + +.today-button:hover { + opacity: 0.8; +} + +.calendar-header { + display: grid; + grid-template-columns: var(--w-label) repeat(7, var(--w-cell)) var(--overlay-w); + gap: 0; + border-bottom: .1em solid var(--muted); + flex-shrink: 0; +} + +.calendar-container { + flex: 1; + overflow: hidden; + position: relative; + width: 100%; + display: flex; +} + +.calendar-viewport { + height: 100%; + 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 */ +} + +.jogwheel-viewport { + position: absolute; + top: 0; + right: 0; + bottom: 0; + 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 */ +} + +.jogwheel-viewport::-webkit-scrollbar { + display: none; /* Chrome/Safari/Webkit */ +} + +.jogwheel-content { + position: relative; + width: 100%; + background: transparent; +} + +.calendar-content { + position: relative; +} + +.week-row { + display: grid; + grid-template-columns: var(--w-label) repeat(7, var(--w-cell)) var(--overlay-w); + gap: 0; + position: relative; + overflow: visible; + height: var(--week-row-h); +} + +/* Ensure children match the fixed week height */ +.week-row .cell, +.week-row .week-label { height: var(--week-row-h); } + +header h1 { + margin: 0; + font-size: 1rem; +} + +.dow-label, +.week-label { + display: grid; + place-items: center; + width: var(--w-label); + height: var(--row-h); + color: var(--muted); +} +.dow { + text-transform: uppercase; +} +.cell { + display: grid; + place-items: center; + width: var(--w-cell); + height: var(--row-h); + font-weight: 700; + margin: 0; + padding: 0; + border: 1px transparent; + cursor: pointer; + transition: background-color 0.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; } + +.selected 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 h1 { + cursor: inherit !important; /* Inherit cursor from parent cell */ + padding: 0.25em; + margin: 0; + border-radius: 0.25rem; + transition: background-color 0.15s ease; + font-size: 1em; +} +.cell h1:hover { + background: var(--muted); + color: var(--panel); +} + +/* =============================== + JOGWHEEL MONTH LABELS + =============================== */ +.month-name-label { + grid-column: -2 / -1; /* last overlay column */ + font-size: 2em; + font-weight: 700; + color: var(--muted); + display: flex; + align-items: center; /* center in container */ + justify-content: center; + pointer-events: none; + z-index: 15; + overflow: visible; + position: absolute; + top: 0; + right: 0; + width: var(--overlay-w); +} + +.month-name-label > span { + display: inline-block; + white-space: nowrap; + writing-mode: vertical-rl; + text-orientation: mixed; + transform: rotate(180deg); + transform-origin: center; +} diff --git a/calendar.js b/calendar.js new file mode 100644 index 0000000..ed58146 --- /dev/null +++ b/calendar.js @@ -0,0 +1,803 @@ +// calendar.js - Infinite scrolling week-by-week implementation +const monthAbbr = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'] + +const isoWeekNumber = 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 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() +} + +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 + ...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.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() + } + + init() { + this.createHeader() + this.setupInitialView() + this.setupScrollListener() + this.setupJogwheel() + this.setupTodayButton() + this.setupSelectionInput() + + // Only setup selection functionality if enabled + if (this.config.select_days > 1) { + this.setupGlobalDragHandlers() + } + } + + 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() + 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 + + 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) + } + + return weekdays + } + + 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' + }) + } + + 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.header.appendChild(this.weekLabel) + + // Get localized weekday names starting from Monday + const localizedWeekdays = this.getLocalizedWeekdayNames() + + localizedWeekdays.forEach((d, 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 + 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) + } + + 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) + } + + getMondayForVirtualWeek(virtualWeek) { + const monday = new Date(this.baseDate) + 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 + } + + 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 + 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 + }) + } + + updateVisibleWeeks() { + const scrollTop = this.viewport.scrollTop + const viewportHeight = this.viewport.clientHeight + + // Update year label based on top visible week + 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) + } + } + + // Month labels rendered per first-week only + } + + 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 + } + } + + 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 wkLabel = document.createElement('div') + wkLabel.className = 'week-label' + const wk = isoWeekNumber(monday) + wkLabel.textContent = `W${pad(wk)}` + weekDiv.appendChild(wkLabel) + + // Create 7 day cells + const cur = new Date(monday) + let hasFirstOfMonth = false + let monthToLabel = null + let labelYear = null + + for (let i = 0; i < 7; i++) { + const cell = document.createElement('div') + cell.className = 'cell' + + 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 + 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 + 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) => { + 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) + } + }) + } + + if (isFirstOfMonth) { + cell.classList.add("firstday") + // Use English month abbreviation for display, but show year for January + 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 (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 + 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 + 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) + }) + } + + setupTodayButton() { + this.todayButton.addEventListener('click', () => { + this.goToToday() + }) + } + + 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' + }) + } + + 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] + } + + 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() + } + }) + } + + 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 + } + + // Start with single selection + this.updateRangeDisplay() + } + + 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() + } + + 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 = '' + } + } + } + + 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 + return `${startISO}/${endISO}` + } + + updateRangeDisplay() { + // Clear previous range styling + this.clearRangeSelection() + + if (!this.dragStartDate || !this.dragEndDate) return + + // 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()) + } + } + + // 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') + } + } + } + } + } + + // Update input field + this.selectedDateInput.value = this.formatDateRange(startDate, endDate) + } + + 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 + } + + 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') + } + } + } + } +} + +// 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) + }) +}) diff --git a/index.html b/index.html new file mode 100644 index 0000000..feb420c --- /dev/null +++ b/index.html @@ -0,0 +1,29 @@ + + + + +Calendar + + + + +
+
+

Infinite Calendar

+
+ + +
+
+
+
+
+
+
+
+
+
+
+ +
+ \ No newline at end of file