diff --git a/src/main/resources/static/css/date-picker.css b/src/main/resources/static/css/date-picker.css index 93d73514..ddf71278 100644 --- a/src/main/resources/static/css/date-picker.css +++ b/src/main/resources/static/css/date-picker.css @@ -1,492 +1,274 @@ -.horizontal-date-picker { - position: fixed; - bottom: 0; - left: 0; +.date-picker { width: 100%; - background-color: rgba(59, 59, 59, 0.9); - backdrop-filter: blur(10px); - padding: 10px 0; - z-index: 50; - box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.3); - display: flex; - flex-direction: column; -} - -body.date-picker-hidden .horizontal-date-picker{ - height: 0; + height: 80px; overflow: hidden; - transition: all 0.3s ease; - opacity: 0; - pointer-events: none; + position: relative; + background: #fff; + font-family: var(--serif-font); + } .date-picker-container { display: flex; + height: 100%; overflow-x: auto; + overflow-y: hidden; scroll-behavior: smooth; - -webkit-overflow-scrolling: touch; - padding: 0 10px; - scrollbar-width: none; /* Firefox */ - -ms-overflow-style: none; /* IE and Edge */ - scroll-snap-type: x mandatory; + scrollbar-width: none; + transition: opacity 0.4s ease-out, transform 0.4s ease-out, filter 0.4s ease-out; + /* Prevent scroll chaining to the page and allow horizontal panning only */ + overscroll-behavior: contain; + touch-action: pan-x; +} + +/* Perspective Zoom Transition */ +.date-picker-container { + transition: opacity 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94), + transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94); + transform-style: preserve-3d; + perspective: 1000px; +} + +.date-picker-container.transitioning.forward { + opacity: 0.2; + transform: scale(1.3) translateZ(-100px); +} + +.date-picker-container.transitioning.backward { + opacity: 0.2; + transform: scale(0.7) translateZ(100px); } .date-picker-container::-webkit-scrollbar { - display: none; /* Chrome, Safari, Opera */ + display: none; } -.date-item { - flex: 0 0 auto; - padding: 8px 12px; - margin: 0 4px; - text-align: center; - border-radius: 20px; - cursor: pointer; - transition: all 0.3s ease; - color: #f8f8f8; +.timeband-item { min-width: 80px; - user-select: none; - scroll-snap-align: center; - position: relative; -} - - -.date-item.selected { - background-color: #fddca1; - color: white; - transform: scale(1.05); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); -} - -.date-item.selected::before { - content: "\eb47"; - font-family: 'Lineicons'; - font-weight: 900; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - font-size: 2rem; - color: rgba(255, 255, 255, 0.9); - opacity: 0; - transition: opacity 0.3s ease; - pointer-events: none; - z-index: 1; -} - -.date-item.selected:hover::before { - opacity: 1; -} - -.date-item.selected:hover .day-name, -.date-item.selected:hover .day-number, -.date-item.selected:hover .month-year-name { - opacity: 0.2; -} - -.date-item.unavailable { - opacity: 0.4; - cursor: not-allowed; -} - -.date-item.unavailable:hover { - background-color: transparent; - transform: none; -} - -/* Range mode styles */ -.date-item.range-start, -.date-item.range-end { - background-color: rgba(117, 117, 117, 0.67); - color: white; - transform: scale(1.05); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); - position: relative; -} - -.date-item.range-start::before, -.date-item.range-end::before { - content: "\ec2a"; - font-family: 'Lineicons'; - font-weight: 900; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - font-size: 2rem; - color: rgba(255, 255, 255, 0.9); - opacity: 0; - transition: opacity 0.3s ease; - pointer-events: none; - z-index: 1; -} - -.date-item.range-start:hover::before, -.date-item.range-end:hover::before { - opacity: 1; -} - -.date-item.range-start:hover .day-name, -.date-item.range-start:hover .day-number, -.date-item.range-end:hover .day-name, -.date-item.range-end:hover .day-number { - opacity: 0.2; -} - -.date-item.range-start::after { - content: 'Start'; - position: absolute; - bottom: 3px; - left: 50%; - transform: translateX(-50%); - font-size: 0.8rem; - color: wheat; - font-weight: bold; - white-space: nowrap; - transition: opacity 0.3s ease; -} - -.date-item.range-start:hover::after { - opacity: 0.2; -} - -.date-item.range-end::after { - content: 'End'; - position: absolute; - bottom: 3px; - left: 50%; - transform: translateX(-50%); - font-size: 0.8rem; - color: #fddca1; - font-weight: bold; - white-space: nowrap; - transition: opacity 0.3s ease; -} - -.date-item.range-end:hover::after { - opacity: 0.2; -} - -.date-item.range-preview, -.date-item.in-range { - background-color: rgba(117, 82, 0, 0.49); - color: white; -} - -.date-item.in-range:hover { - background-color: rgba(253, 220, 161, 0.75); -} - -/* Clear range button */ -.clear-range-button { - position: absolute; - top: 10px; - right: 10px; - background-color: rgba(220, 74, 74, 0.8); - color: white; - border: none; - border-radius: 20px; - padding: 6px 12px; - font-size: 0.85rem; - cursor: pointer; - z-index: 52; - display: flex; - align-items: center; - gap: 5px; - transition: all 0.3s ease; -} - -.clear-range-button:hover { - background-color: rgba(220, 74, 74, 1); - transform: scale(1.05); -} - -.date-item .day-name { - font-size: 0.8rem; - opacity: 0.8; - display: block; - transition: opacity 0.3s ease; - position: relative; - z-index: 0; -} - -.date-item .day-number { - font-size: 1.2rem; - font-weight: bold; - display: block; - transition: opacity 0.3s ease; - position: relative; - z-index: 0; -} - -.date-item .month-name { - font-size: 0.7rem; - display: block; -} - -.date-item .month-year-name { - font-size: 0.7rem; - display: block; - font-weight: bold; - margin-top: 2px; - transition: opacity 0.3s ease; - position: relative; - z-index: 0; -} - -.date-nav-button { - position: absolute; - top: 50%; - transform: translateY(-50%); - background-color: rgba(74, 137, 220, 0.8); - color: white; - border: none; - border-radius: 50%; - width: 30px; - height: 30px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - z-index: 51; -} - -.date-nav-button:hover { - background-color: rgba(74, 137, 220, 1); -} - -.date-nav-prev { - left: 10px; -} - -.date-nav-next { - right: 10px; -} - -/* Month row styles */ -.month-row-container { + height: 100%; display: flex; flex-direction: column; - overflow-x: auto; - scroll-behavior: smooth; - -webkit-overflow-scrolling: touch; - padding: 0 10px; - margin-bottom: 8px; - scrollbar-width: none; /* Firefox */ - -ms-overflow-style: none; /* IE and Edge */ -} - -.month-row-container::-webkit-scrollbar { - display: none; /* Chrome, Safari, Opera */ -} - -.year-row { - display: flex; + align-items: center; justify-content: center; - margin-bottom: 8px; + cursor: pointer; + user-select: none; + transition: background-color 0.25s ease-out, transform 0.25s ease-out, opacity 0.25s ease-out; position: relative; + opacity: 1; } -.today-button { - position: absolute; - left: 10px; - padding: 4px 12px; +/* Timeband-specific min-widths */ +.timeband-month .timeband-item { + min-width: 100px; +} + +.timeband-year .timeband-item { + min-width: 120px; +} + +.timeband-item:hover { + background-color: #f5f5f5; + transform: translateY(-1px); +} + +.timeband-item.selected { + background-color: #007bff; color: white; - border-radius: 16px; +} + +.timeband-item.in-range { + background-color: #e3f2fd; + color: #1976d2; +} + +.timeband-item.range-start { + background-color: #007bff; + color: white; +} + +.timeband-item.range-end { + background-color: #007bff; + color: white; +} + +.timeband-item.today .day-number { + text-decoration: underline; +} + +.timeband-item.locked.today { + border-color: #ffc107; +} + +.primary-text { + font-size: 18px; + font-weight: 500; + transition: font-size 0.25s ease-out; +} + +.secondary-text { + font-size: 12px; + opacity: 0.7; + margin-top: 2px; + transition: font-size 0.25s ease-out, opacity 0.25s ease-out; +} + +.tertiary-text { + font-size: 10px; + opacity: 0.6; + margin-top: 1px; + transition: font-size 0.25s ease-out, opacity 0.25s ease-out; +} + +/* Legacy classes for backward compatibility */ +.day-number { + font-size: 18px; + font-weight: 500; +} + +.day-name { + font-size: 12px; + opacity: 0.7; + margin-top: 2px; +} + +.month-year { + font-size: 10px; + opacity: 0.6; + margin-top: 1px; +} + +/* Timeband-specific styles */ +.timeband-day .primary-text { + font-size: 18px; +} + +.timeband-day .secondary-text { + font-size: 12px; +} + +.timeband-day .tertiary-text { + font-size: 10px; +} + +.timeband-month .primary-text { + font-size: 16px; +} + +.timeband-month .secondary-text { + font-size: 14px; +} + +.timeband-month .tertiary-text { + font-size: 11px; +} + +.timeband-year .primary-text { + font-size: 20px; +} + +.timeband-year .secondary-text { + font-size: 14px; +} + +.timeband-year .tertiary-text { + font-size: 12px; +} + +/* Demo styles */ +.demo-container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +.controls { + margin-top: 20px; + display: flex; + gap: 10px; + align-items: center; +} + +.controls button { + padding: 8px 16px; + border: 1px solid #ddd; + border-radius: 4px; + background: white; cursor: pointer; - font-size: 0.9rem; - transition: all 0.3s ease; + transition: background-color 0.2s ease; +} + +.controls button:hover { + background-color: #f5f5f5; +} + +.selected-range { + margin-left: 20px; + padding: 8px 12px; + background: #f8f9fa; + border-radius: 4px; + font-size: 14px; +} + +/* Range indicators */ +.date-picker-range-indicator { + position: absolute; + top: 10px; + transform: translateY(-50%); + width: 10px; display: flex; align-items: center; - gap: 5px; - z-index: 10; -} - -.today-button:hover { - background-color: rgba(255, 255, 255, 0.2); -} - -.year-item { - padding: 4px 16px; - margin: 0 8px; - text-align: center; - border-radius: 16px; - cursor: pointer; - transition: all 0.3s ease; - color: #f8f8f8; - font-size: 1rem; + justify-content: center; + color: white; + font-size: 12px; font-weight: bold; + opacity: 0; + transition: opacity 0.3s ease; + z-index: 5; + pointer-events: none; } -.year-item:hover { - background-color: rgba(255, 255, 255, 0.2); -} - -.year-item.selected { - background-color: #4a89dc; - color: white; -} - -.year-item.unavailable { - opacity: 0.4; - color: #999; - cursor: not-allowed; -} - -.year-item.unavailable:hover { - background-color: transparent; -} - -.month-row { - display: flex; - overflow-x: auto; - scroll-behavior: smooth; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; /* Firefox */ - -ms-overflow-style: none; /* IE and Edge */ -} - -.month-row::-webkit-scrollbar { - display: none; /* Chrome, Safari, Opera */ -} - -.month-item { - flex: 0 0 auto; - padding: 4px 12px; - margin: 0 4px; - text-align: center; - border-radius: 16px; - cursor: pointer; - transition: all 0.3s ease; - color: #f8f8f8; - min-width: 60px; - font-size: 0.8rem; - position: relative; -} - -.month-item:hover { - background-color: rgba(255, 255, 255, 0.2); -} - -.month-item.selected { - background-color: #4a89dc; - color: white; -} - -.month-item.unavailable { - opacity: 0.4; - color: #999; - cursor: not-allowed; -} - -.month-item.unavailable:hover { - background-color: transparent; -} - -.month-item .year-label { - position: absolute; - top: -12px; - left: 50%; - transform: translateX(-50%); - font-size: 0.7rem; - background-color: rgba(74, 137, 220, 0.8); - padding: 2px 6px; - border-radius: 10px; -} - -.date-item.selected { - background-color: unset; - color: var(--color-highlight); - transform: scale(1.05); - box-shadow: unset; -} - -.month-item.selected { - background-color: var(--color-highlight); - color: var(--color-highlight); - font-weight: bolder; - background: unset; -} - -.year-item.selected { - background-color: var(--color-highlight); - color: var(--color-highlight); - font-weight: bolder; - background: unset; -} - -.today-button { - background-color: unset; -} - -.date-item { - padding: unset; - margin: unset; - border-radius: 0; -} - -.date-item.selected:hover { - border-bottom: 0; -} - -.date-item .day-name, -.date-item .day-number { - font-size: 2rem; - font-weight: lighter; -} - -.horizontal-date-picker { - position: fixed; - font-family: var(--serif-font); - bottom: 0; +.date-picker-range-indicator.left { left: 0; - width: 100%; - background-color: rgba(59, 59, 59, 0.68); - backdrop-filter: blur(10px); - padding: 10px 0; - z-index: 50; - box-shadow: unset; + border-radius: 0 10px 10px 0; } -.date-item:hover { - background-color: rgba(255, 255, 255, 0.2); +.date-picker-range-indicator.right { + right: 0; + border-radius: 10px 0 0 10px; } -@media (max-width: 768px) { - .date-item { - min-width: 60px; - padding: 6px 8px; - } - - .date-item .day-number { - font-size: 1rem; - } - - .month-item { - min-width: 50px; - padding: 4px 8px; - font-size: 0.7rem; - } - - .today-button { - justify-content: center; - position: initial; - } - - .clear-range-button { - top: 5px; - right: 5px; - padding: 4px 8px; - font-size: 0.75rem; - } - - .date-item.selected::before { - font-size: 1.5rem; - } - - .date-item.range-start::before, - .date-item.range-end::before { - font-size: 1.5rem; - } - +/* Hover info */ +.date-picker-hover-info { + position: fixed; + background: rgba(255, 255, 255, 0.95); + border: 1px solid rgba(0, 0, 0, 0.1); + padding: 8px 12px; + font-size: 12px; + text-align: center; + min-height: 20px; + opacity: 0; + transition: opacity 0.2s ease; + border-radius: 4px; + z-index: 1000; + pointer-events: none; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + white-space: nowrap; +} + +.date-picker-hover-overlay { + position: fixed; + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 6px 10px; + font-size: 11px; + text-align: center; + opacity: 0; + transition: opacity 0.2s ease; + border-radius: 4px; + z-index: 1001; + pointer-events: none; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); + white-space: nowrap; + display: none; } diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css index 49d1de8b..bdc5b825 100644 --- a/src/main/resources/static/css/main.css +++ b/src/main/resources/static/css/main.css @@ -833,7 +833,7 @@ body.timeline-hidden.datepicker-hidden .timeline-toggle-btn { .timeline-toggle-btn, .datepicker-toggle-btn { - bottom: 188px; + bottom: 114px; } @@ -847,7 +847,7 @@ body.timeline-hidden.datepicker-hidden .timeline-toggle-btn { font-size: 1.2rem; } -body.datepicker-hidden #horizontal-date-picker-container { +body.datepicker-hidden #date-picker-container { transform: translateY(100%); transition: all 0.3s ease-in-out; } @@ -1848,40 +1848,6 @@ button:disabled { pointer-events: none; } -/* Create Memory FAB */ -.create-memory-fab { - position: fixed; - bottom: 320px; - right: 2rem; - z-index: 999; - animation: slideInUp 0.3s ease-out; -} - -.fab-button { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 1rem 1.5rem; - background-color: #ff6c00; - color: white; - border-radius: 50px; - text-decoration: none; - box-shadow: 0 4px 12px rgba(255, 108, 0, 0.4); - transition: all 0.3s ease; - font-weight: 600; - font-size: 1rem; -} - -.fab-button:hover { - background-color: #e66100; - box-shadow: 0 6px 16px rgba(255, 108, 0, 0.5); - transform: translateY(-2px); -} - -.fab-button i { - font-size: 1.2rem; -} - @keyframes slideInUp { from { opacity: 0; @@ -2416,3 +2382,82 @@ button:disabled { } } + +.date-picker { + display: flex; + flex-direction: column; + position: fixed; + bottom: 0; + left: 0; + width: 100%; + background-color: rgba(59, 59, 59, 0.68); + backdrop-filter: blur(10px); + z-index: 50; + box-shadow: unset; + color: white; + height: 110px; +} + +.timeband-item:hover { + background-color: rgba(255, 255, 255, 0.2); +} + +.timeband-item .primary-text { + font-size: 2rem; + font-weight: lighter; +} + +.timeband-item.selected { + background-color: unset; + color: var(--color-highlight); + transform: scale(1.05); + box-shadow: unset; +} + +.timeband-item.in-range { + background-color: rgba(117, 82, 0, 0.49); + color: white; +} + +.timeband-item.range-start, .timeband-item.range-end { + transform: scale(1.05); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + position: relative; + background-color: rgba(117, 82, 0, 0.49); + color: white; +} + +.timeband-item.range-start:hover::before, .timeband-item.range-end:hover::before { + opacity: 1; +} + +.timeband-item.selected::before { + content: "\eb47"; + font-family: 'Lineicons'; + font-weight: 900; + position: absolute; + top: 4px; + left: unset; + transform: unset; + font-size: 1.2rem; + color: rgba(255, 255, 255, 0.9); + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; + z-index: 1; + right: 4px; +} + +.timeband-item.locked::before, +.timeband-item.selected.range-start::before, +.timeband-item.selected.range-end::before{ + content: "\ec2a"; +} +.timeband-item.selected:hover::before { + opacity: 1; +} + +.timeband-item.locked { + +} + diff --git a/src/main/resources/static/js/date-picker-combined.js b/src/main/resources/static/js/date-picker-combined.js new file mode 100644 index 00000000..856f0604 --- /dev/null +++ b/src/main/resources/static/js/date-picker-combined.js @@ -0,0 +1,2087 @@ +/** Timeband constants and helpers **/ + +const TIMEBANDS = { + DAY: 'day', + MONTH: 'month', + YEAR: 'year' +}; + +const TIMEBAND_ORDER = { + [TIMEBANDS.YEAR]: 0, + [TIMEBANDS.MONTH]: 1, + [TIMEBANDS.DAY]: 2 +}; + +const DEFAULT_ITEMS_TO_ADD = { + [TIMEBANDS.DAY]: 25, + [TIMEBANDS.MONTH]: 6, + [TIMEBANDS.YEAR]: 5 +}; + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +function clampToStartOfDay(date) { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d; +} + +function areSameDay(a, b) { + return !!a && !!b && + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate(); +} + +function areSameMonth(a, b) { + return !!a && !!b && + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth(); +} + +function areSameYear(a, b) { + return !!a && !!b && a.getFullYear() === b.getFullYear(); +} + +function getTimebandStart(date, timeband) { + const d = new Date(date); + switch (timeband) { + case TIMEBANDS.DAY: + return clampToStartOfDay(d); + case TIMEBANDS.MONTH: + return new Date(d.getFullYear(), d.getMonth(), 1); + case TIMEBANDS.YEAR: + return new Date(d.getFullYear(), 0, 1); + default: + return clampToStartOfDay(d); + } +} + +function getTimebandEnd(date, timeband) { + const d = new Date(date); + switch (timeband) { + case TIMEBANDS.DAY: + return clampToStartOfDay(d); + case TIMEBANDS.MONTH: + // last day of current month + return new Date(d.getFullYear(), d.getMonth() + 1, 0); + case TIMEBANDS.YEAR: + return new Date(d.getFullYear(), 11, 31); + default: + return clampToStartOfDay(d); + } +} + +function addToTimeband(date, timeband, offset) { + const d = new Date(date); + switch (timeband) { + case TIMEBANDS.DAY: + return new Date(d.getTime() + offset * ONE_DAY_MS); + case TIMEBANDS.MONTH: + return new Date(d.getFullYear(), d.getMonth() + offset, 1); + case TIMEBANDS.YEAR: + return new Date(d.getFullYear() + offset, 0, 1); + default: + return new Date(d); + } +} + +function isSameByTimeband(a, b, timeband) { + switch (timeband) { + case TIMEBANDS.YEAR: + return areSameYear(a, b); + case TIMEBANDS.MONTH: + return areSameMonth(a, b); + case TIMEBANDS.DAY: + default: + return areSameDay(a, b); + } +} + +/** Timeband utilities **/ + +class TimebandUtils { + /** + * Dynamically add items to the left or right for a given timeband. + * Keeps scroll position stable when adding to the left. + */ + static addItemsToDirection(datePicker, direction, timeband) { + const isLeft = direction === 'left'; + const { items, scrollContainer, timebandConfigs } = datePicker; + + if (!items.length) return; + + const config = timebandConfigs[timeband]; + if (!config) return; + + const referenceDate = isLeft + ? items[0].date + : items[items.length - 1].date; + + const previousScrollLeft = scrollContainer.scrollLeft; + const previousBehavior = scrollContainer.style.scrollBehavior; + scrollContainer.style.scrollBehavior = 'auto'; + + const estimatedItemWidth = (config.itemWidth || 0) + 1; + const count = this.getItemsToAddCount(timeband); + const estimatedAddedWidth = estimatedItemWidth * count; + + const newItems = this.generateNewItems( + datePicker, + referenceDate, + timeband, + count, + isLeft + ); + + // Update in-memory items + if (isLeft) { + datePicker.items = newItems.concat(datePicker.items); + } else { + datePicker.items = datePicker.items.concat(newItems); + } + + // Render using fragment for minimal reflow + const fragment = document.createDocumentFragment(); + newItems.forEach((itemData) => { + const el = config.createItemElement(itemData, 0); // index will be updated later + itemData.element = el; + fragment.appendChild(el); + }); + + requestAnimationFrame(() => { + if (isLeft) { + scrollContainer.insertBefore(fragment, scrollContainer.firstChild); + scrollContainer.scrollLeft = previousScrollLeft + estimatedAddedWidth; + } else { + scrollContainer.appendChild(fragment); + } + + // Restore smooth scrolling after a short delay + setTimeout(() => { + scrollContainer.style.scrollBehavior = previousBehavior || 'smooth'; + }, 50); + + // Update dataset.idx for all elements to maintain correct indices + this.updateElementIndices(datePicker); + }); + } + + static updateElementIndices(datePicker) { + const { scrollContainer } = datePicker; + const elements = scrollContainer.querySelectorAll('.timeband-item'); + elements.forEach((el, index) => { + el.dataset.idx = String(index); + }); + } + + static getItemsToAddCount(timeband) { + return DEFAULT_ITEMS_TO_ADD[timeband] || DEFAULT_ITEMS_TO_ADD[TIMEBANDS.DAY]; + } + + static generateNewItems(datePicker, referenceDate, timeband, count, isLeft) { + const { timebandConfigs } = datePicker; + const cfg = timebandConfigs[timeband]; + if (!cfg || typeof cfg.getItemData !== 'function') return []; + + const items = []; + if (isLeft) { + // Insert dates before reference (negative offsets) + for (let i = count; i > 0; i--) { + const date = addToTimeband(referenceDate, timeband, -i); + items.push(cfg.getItemData(date)); + } + } else { + // Insert dates after reference (positive offsets) + for (let i = 1; i <= count; i++) { + const date = addToTimeband(referenceDate, timeband, i); + items.push(cfg.getItemData(date)); + } + } + return items; + } + + static getItemRangeStart(itemData, timeband) { + return getTimebandStart(itemData.date, timeband); + } + + static getItemRangeEnd(itemData, timeband) { + return getTimebandEnd(itemData.date, timeband); + } +} + +/** Selection manager **/ + +class SelectionManager { + /** + * Responsible solely for selection state + semantics. + * No DOM here; DatePicker asks it and updates UI. + */ + constructor(datePicker) { + this.datePicker = datePicker; + + this.selectedStartDate = null; + this.selectedEndDate = null; + + // true while user has picked start and is hovering/selecting end + this.isSelectingRange = false; + + // lock semantics for single-date mode + this.isDateLocked = false; + + // timeband where selection was started, used to validate completion + this.selectionTimeband = null; + } + + /* Basic API */ + + clearSelection() { + this.selectedStartDate = null; + this.selectedEndDate = null; + this.isSelectingRange = false; + this.isDateLocked = false; + this.selectionTimeband = null; + } + + setSelectedRange(startDate, endDate = null) { + this.selectedStartDate = startDate ? clampToStartOfDay(startDate) : null; + this.selectedEndDate = endDate ? clampToStartOfDay(endDate) : null; + this.isSelectingRange = false; + this.isDateLocked = false; + this.selectionTimeband = null; + } + + getSelectedRange() { + const { selectedStartDate, selectedEndDate, datePicker } = this; + return { + startDate: selectedStartDate + ? datePicker.formatDate(selectedStartDate) + : null, + endDate: selectedEndDate + ? datePicker.formatDate(selectedEndDate) + : null, + timeband: datePicker.currentTimeband + }; + } + + /* Day click handlers */ + + handleDayClick(itemData) { + const { options } = this.datePicker; + if (options.singleDateMode) { + this.#handleSingleDateModeDayClick(itemData); + } else if (!options.allowRangeSelection) { + this.#handleSingleSelectionClick(itemData); + } else { + this.#handleRangeSelectionClick(itemData); + } + } + + #handleSingleDateModeDayClick(itemData) { + const clicked = clampToStartOfDay(itemData.date); + + if (!this.selectedStartDate) { + return this.#selectSingleDate(clicked); + } + + const sameAsStart = this.isSameDate(clicked, this.selectedStartDate); + + if (sameAsStart && !this.selectedEndDate) { + return this.#toggleDateLock(); + } + + if (this.isDateLocked && !this.selectedEndDate) { + return this.#createRangeFromLockedDate(clicked); + } + + if (this.selectedStartDate && this.selectedEndDate) { + return this.#handleExistingRangeClick(clicked); + } + + return this.#selectSingleDate(clicked); + } + + #selectSingleDate(date) { + this.selectedStartDate = new Date(date); + this.selectedEndDate = null; + this.isSelectingRange = false; + this.isDateLocked = false; + this.selectionTimeband = this.datePicker.currentTimeband; + } + + #toggleDateLock() { + this.isDateLocked = !this.isDateLocked; + if (this.isDateLocked) { + this.selectionTimeband = this.datePicker.currentTimeband; + } + } + + #createRangeFromLockedDate(clicked) { + if (clicked < this.selectedStartDate) { + this.selectedEndDate = this.selectedStartDate; + this.selectedStartDate = clicked; + } else { + this.selectedEndDate = clicked; + } + this.isSelectingRange = false; + this.isDateLocked = false; + this.selectionTimeband = null; + } + + #handleExistingRangeClick(clicked) { + const start = new Date(this.selectedStartDate); + const end = new Date(this.selectedEndDate); + + if (this.isSameDate(clicked, start)) { + this.#handleSingleSelectionClick({date: clicked}); + } else if (this.isSameDate(clicked, end)) { + this.#handleSingleSelectionClick({date: clicked}); + } else if (clicked < start) { + this.selectedStartDate = clicked; + } else if (clicked > end) { + this.selectedEndDate = clicked; + } else { + // Click inside range adjusts the range start + this.selectedStartDate = clicked; + } + } + + #handleSingleSelectionClick(itemData) { + this.selectedStartDate = clampToStartOfDay(itemData.date); + this.selectedEndDate = null; + this.isSelectingRange = false; + this.isDateLocked = false; + this.selectionTimeband = null; + } + + #handleRangeSelectionClick(itemData) { + const clicked = clampToStartOfDay(itemData.date); + + if (!this.selectedStartDate || (this.selectedStartDate && this.selectedEndDate)) { + // Start new range + this.selectedStartDate = clicked; + this.selectedEndDate = null; + this.isSelectingRange = true; + this.selectionTimeband = this.datePicker.currentTimeband; + return; + } + + if (this.isSelectingRange) { + // Complete current range + if (clicked < this.selectedStartDate) { + this.selectedEndDate = this.selectedStartDate; + this.selectedStartDate = clicked; + } else { + this.selectedEndDate = clicked; + } + this.isSelectingRange = false; + this.selectionTimeband = null; + } + } + + /* Timeband-based interactions (month/year) */ + + handleTimebandRangeSelection(itemData, timeband) { + const clickedStart = getTimebandStart(itemData.date, timeband); + + if (this.datePicker.options.singleDateMode) { + this.#handleTimebandSingleDateMode(clickedStart, timeband, itemData); + } else { + this.#handleTimebandRangeMode(clickedStart, timeband, itemData); + } + } + + #handleTimebandSingleDateMode(clickedStart, timeband, itemData) { + if (!this.selectedStartDate) { + return this.#selectSingleTimebandDate(clickedStart, timeband); + } + + const same = this.isSameTimebandDate(clickedStart, this.selectedStartDate, timeband); + + if (same && !this.selectedEndDate) { + return this.#toggleDateLock(); + } + + if (this.isDateLocked && !this.selectedEndDate) { + return this.#createTimebandRangeFromLockedDate(clickedStart, timeband); + } + + if (this.selectedStartDate && this.selectedEndDate) { + return this.#handleExistingTimebandRangeClick(clickedStart, timeband); + } + + return this.#selectSingleTimebandDate(clickedStart, timeband); + } + + #selectSingleTimebandDate(date, timeband) { + this.selectedStartDate = new Date(date); + this.selectedEndDate = null; + this.isSelectingRange = false; + this.isDateLocked = false; + this.selectionTimeband = timeband; + } + + #createTimebandRangeFromLockedDate(clickedStart, timeband) { + const selectedStart = this.selectedStartDate; + const newEnd = getTimebandEnd(clickedStart, timeband); + + if (clickedStart < selectedStart) { + this.selectedEndDate = getTimebandEnd(selectedStart, timeband); + this.selectedStartDate = clickedStart; + } else { + this.selectedEndDate = newEnd; + } + + this.isSelectingRange = false; + this.isDateLocked = false; + this.selectionTimeband = null; + } + + #handleExistingTimebandRangeClick(clickedStart, timeband) { + const start = getTimebandStart(this.selectedStartDate, timeband); + const end = getTimebandStart(this.selectedEndDate, timeband); + + if (this.isSameTimebandDate(clickedStart, start, timeband) || + this.isSameTimebandDate(clickedStart, end, timeband)) { + this.clearSelection(); + return; + } + + if (clickedStart < start) { + this.selectedStartDate = clickedStart; + } else if (clickedStart > end) { + this.selectedEndDate = getTimebandEnd(clickedStart, timeband); + } else { + // Inside current range: treat as new start + this.selectedStartDate = clickedStart; + } + + this.isSelectingRange = false; + this.selectionTimeband = null; + } + + #handleTimebandRangeMode(clickedStart, timeband, itemData) { + if (!this.selectedStartDate || (this.selectedStartDate && this.selectedEndDate)) { + // Start new timeband range + this.startTimebandRangeSelection(clickedStart, timeband); + } else if (this.isSelectingRange) { + this.completeTimebandRangeSelection(clickedStart, timeband); + } else { + this.#handleExistingTimebandRangeClick(clickedStart, timeband); + } + } + + startTimebandRangeSelection(clickedStart, timeband) { + this.selectedStartDate = clickedStart; + this.selectedEndDate = null; + this.isSelectingRange = true; + this.selectionTimeband = timeband; + } + + completeTimebandRangeSelection(clickedStart, timeband) { + const normalizedStart = getTimebandStart(this.selectedStartDate, timeband); + + if (clickedStart < normalizedStart) { + this.selectedEndDate = getTimebandEnd(normalizedStart, timeband); + this.selectedStartDate = clickedStart; + } else { + this.selectedEndDate = getTimebandEnd(clickedStart, timeband); + } + + this.isSelectingRange = false; + this.selectionTimeband = null; + } + + selectFullTimeband(date, timeband) { + this.selectedStartDate = getTimebandStart(date, timeband); + this.selectedEndDate = getTimebandEnd(date, timeband); + this.isSelectingRange = false; + this.isDateLocked = false; + this.selectionTimeband = null; + } + + /* Cross-timeband range completion */ + + canCompleteRangeAtCurrentTimeband() { + if (!this.isSelectingRange || !this.selectedStartDate || !this.selectionTimeband) { + return false; + } + const current = this.datePicker.currentTimeband; + return TIMEBAND_ORDER[current] >= TIMEBAND_ORDER[this.selectionTimeband]; + } + + completeRangeAtTimeband(itemData) { + const tb = this.selectionTimeband; + if (!tb) return false; + + let end; + if (tb === TIMEBANDS.YEAR) { + end = this.#getYearEndDateForTimeband(itemData, this.datePicker.currentTimeband); + } else if (tb === TIMEBANDS.MONTH) { + end = this.#getMonthEndDateForTimeband(itemData, this.datePicker.currentTimeband); + } else if (tb === TIMEBANDS.DAY) { + end = this.#getDayEndDateForTimeband(itemData, this.datePicker.currentTimeband); + } + + if (!end) return false; + + if (end < this.selectedStartDate) { + this.selectedEndDate = this.selectedStartDate; + this.selectedStartDate = end; + } else { + this.selectedEndDate = end; + } + + this.isSelectingRange = false; + this.isDateLocked = false; + this.selectionTimeband = null; + return true; + } + + #getYearEndDateForTimeband(itemData, tb) { + const d = itemData.date; + if (tb === TIMEBANDS.YEAR) return new Date(d.getFullYear(), 11, 31); + if (tb === TIMEBANDS.MONTH) return new Date(d.getFullYear(), d.getMonth() + 1, 0); + if (tb === TIMEBANDS.DAY) return clampToStartOfDay(d); + return null; + } + + #getMonthEndDateForTimeband(itemData, tb) { + const d = itemData.date; + if (tb === TIMEBANDS.MONTH) return new Date(d.getFullYear(), d.getMonth() + 1, 0); + if (tb === TIMEBANDS.DAY) return clampToStartOfDay(d); + return null; + } + + #getDayEndDateForTimeband(itemData, tb) { + const d = itemData.date; + if (tb === TIMEBANDS.DAY) return clampToStartOfDay(d); + if (tb === TIMEBANDS.MONTH) return new Date(d.getFullYear(), d.getMonth() + 1, 0); + if (tb === TIMEBANDS.YEAR) return new Date(d.getFullYear(), 11, 31); + return null; + } + + /* Hover helpers */ + + getHoverOverlayText(itemData) { + if (!this.selectedStartDate) return null; + + const currentTimeband = this.datePicker.currentTimeband; + const clicked = new Date(itemData.date); + const selected = new Date(this.selectedStartDate); + + if (currentTimeband === TIMEBANDS.DAY) { + return this.#getDayHoverText(clicked, selected); + } + if (currentTimeband === TIMEBANDS.MONTH) { + return this.#getMonthHoverText(clicked, selected); + } + if (currentTimeband === TIMEBANDS.YEAR) { + return this.#getYearHoverText(clicked, selected); + } + return null; + } + + #getDayHoverText(clicked, selected) { + if (this.datePicker.isSameDay(clicked, selected)) { + if (!this.selectedEndDate) { + return this.isDateLocked ? 'Click to unlock date' : 'Click to lock date'; + } + return 'Click to clear selection'; + } + + if (this.isDateLocked && !this.selectedEndDate) { + return 'Click to create range'; + } + + if (this.selectedEndDate) { + return this.#getExistingRangeHoverText(clicked); + } + + return null; + } + + #getMonthHoverText(clicked, selected) { + if (this.datePicker.isSameMonth(clicked, selected)) { + if (!this.selectedEndDate) { + return this.isDateLocked ? 'Click to unlock month' : 'Click to lock month'; + } + return 'Click to clear selection'; + } + + if (this.isDateLocked && !this.selectedEndDate) { + return 'Click to create range'; + } + + if (this.selectedEndDate) { + return this.#getExistingRangeHoverText(clicked); + } + + return null; + } + + #getYearHoverText(clicked, selected) { + if (this.datePicker.isSameYear(clicked, selected)) { + if (!this.selectedEndDate) { + return this.isDateLocked ? 'Click to unlock year' : 'Click to lock year'; + } + return 'Click to clear selection'; + } + + if (this.isDateLocked && !this.selectedEndDate) { + return 'Click to create range'; + } + + if (this.selectedEndDate) { + return this.#getExistingRangeHoverText(clicked); + } + + return null; + } + + #getExistingRangeHoverText(clicked) { + const start = new Date(this.selectedStartDate); + const end = new Date(this.selectedEndDate); + const timeband = this.datePicker.currentTimeband; + + // Check if clicking on the end boundary based on current timeband + let isEndBoundary = false; + if (timeband === TIMEBANDS.DAY) { + isEndBoundary = this.datePicker.isSameDay(clicked, end); + } else if (timeband === TIMEBANDS.MONTH) { + isEndBoundary = this.datePicker.isSameMonth(clicked, end); + } else if (timeband === TIMEBANDS.YEAR) { + isEndBoundary = this.datePicker.isSameYear(clicked, end); + } + + if (isEndBoundary) { + return 'Click to clear selection'; + } + if (clicked < start) { + return 'Click to expand range backward'; + } + if (clicked > end) { + return 'Click to expand range forward'; + } + return 'Click to adjust range start'; + } + + getHoverTooltipText(itemData) { + if (!this.selectedStartDate || !this.isSelectingRange) return null; + + const timeband = this.datePicker.currentTimeband; + let start = this.selectedStartDate; + let end; + + if (timeband === TIMEBANDS.DAY) { + end = clampToStartOfDay(itemData.date); + } else if (timeband === TIMEBANDS.MONTH) { + end = this.#getMonthEndDateForTooltip(itemData); + } else if (timeband === TIMEBANDS.YEAR) { + end = this.#getYearEndDateForTooltip(itemData); + } + + if (!end) return null; + + if (end < start) { + [start, end] = [end, start]; + } + + if (this.datePicker.isSameDay(start, end)) { + return `Select: ${this.datePicker.formatDate(start)}`; + } + + return `Select: ${this.datePicker.formatDate(start)} to ${this.datePicker.formatDate(end)}`; + } + + #getMonthEndDateForTooltip(itemData) { + const d = itemData.date; + if (this.selectionTimeband === TIMEBANDS.DAY) { + // Align to month start if user started in day view + return new Date(d.getFullYear(), d.getMonth(), 1); + } + return new Date(d.getFullYear(), d.getMonth() + 1, 0); + } + + #getYearEndDateForTooltip(itemData) { + const d = itemData.date; + if (this.selectionTimeband === TIMEBANDS.DAY || + this.selectionTimeband === TIMEBANDS.MONTH) { + // Align to year start if started from a more granular band + return new Date(d.getFullYear(), 0, 1); + } + return new Date(d.getFullYear(), 11, 31); + } + + /* Comparators (delegate to DatePicker for consistency) */ + + isSameDate(a, b) { + return this.datePicker.isSameDay(a, b); + } + + isSameTimebandDate(a, b, timeband) { + if (timeband === TIMEBANDS.MONTH) return this.datePicker.isSameMonth(a, b); + if (timeband === TIMEBANDS.YEAR) return this.datePicker.isSameYear(a, b); + return this.datePicker.isSameDay(a, b); + } +} + +/** DatePicker core **/ + +class DatePicker { + constructor(containerId, options = {}) { + this.container = document.getElementById(containerId); + if (!this.container) { + throw new Error(`DatePicker: container with id "${containerId}" not found.`); + } + + const normalizedStart = clampToStartOfDay(options.startDate || new Date()); + + this.options = { + daysToShow: 14, + prefetchDays: 25, + allowRangeSelection: true, + allowMonthRangeSelection: true, + allowYearRangeSelection: true, + singleDateMode: false, + dateFormat: 'YYYY-MM-DD', + startDate: normalizedStart, + initialTimeband: TIMEBANDS.DAY, + transitionDuration: 400, + transitionEffect: 'perspective', + hoverInfoPosition: 'above', + renderDayItem: null, + renderMonthItem: null, + renderYearItem: null, + renderLeftIndicator: null, + renderRightIndicator: null, + renderHoverOverlay: null, + ...options, + }; + + this.items = []; + this.selectionManager = new SelectionManager(this); + this.eventListeners = {}; + this.currentTimeband = this.options.initialTimeband; + this.isTransitioning = false; + + this.hoverTooltip = null; + this.hoverOverlay = null; + + // For potential virtual scrolling; currently unused but kept for compatibility + this.elementPool = []; + this.visibleElements = new Map(); + this.lastVisibleRange = { start: -1, end: -1 }; + this.virtualWrapper = null; + + this.timebandConfigs = this.#createTimebandConfigs(); + + this.init(); + } + + #createTimebandConfigs() { + return { + [TIMEBANDS.DAY]: { + itemWidth: 80, + generateItems: () => this.#generateDays(), + createItemElement: (data, index) => this.createDayElement(data, index), + getItemData: (date) => this.createDayData(date), + addToLeft: () => TimebandUtils.addItemsToDirection(this, 'left', TIMEBANDS.DAY), + addToRight: () => TimebandUtils.addItemsToDirection(this, 'right', TIMEBANDS.DAY) + }, + [TIMEBANDS.MONTH]: { + itemWidth: 100, + generateItems: () => this.#generateMonths(), + createItemElement: (data, index) => this.createMonthElement(data, index), + getItemData: (date) => this.createMonthData(date), + addToLeft: () => TimebandUtils.addItemsToDirection(this, 'left', TIMEBANDS.MONTH), + addToRight: () => TimebandUtils.addItemsToDirection(this, 'right', TIMEBANDS.MONTH) + }, + [TIMEBANDS.YEAR]: { + itemWidth: 120, + generateItems: () => this.#generateYears(), + createItemElement: (data, index) => this.createYearElement(data, index), + getItemData: (date) => this.createYearData(date), + addToLeft: () => TimebandUtils.addItemsToDirection(this, 'left', TIMEBANDS.YEAR), + addToRight: () => TimebandUtils.addItemsToDirection(this, 'right', TIMEBANDS.YEAR) + } + }; + } + + /** Initialization **/ + + init() { + this.#createContainer(); + this.#generateInitialItems(); + this.render(); + this.#setupScrollListener(); + this.#setupDelegatedItemEvents(); + if (this.options.startDate) { + this.setSelectedRange(this.options.startDate); + } + } + + #createContainer() { + this.container.innerHTML = ''; + this.container.className = 'date-picker'; + + this.scrollContainer = document.createElement('div'); + this.scrollContainer.className = `date-picker-container timeband-${this.currentTimeband}`; + this.container.appendChild(this.scrollContainer); + + this.#createRangeIndicators(); + this.#createHoverInfo(); + this.#createHoverOverlay(); + } + + #generateInitialItems() { + const config = this.timebandConfigs[this.currentTimeband]; + if (config && typeof config.generateItems === 'function') { + config.generateItems(); + } + } + + #generateDays() { + const center = this.options.startDate || new Date(); + const total = this.options.daysToShow + (this.options.prefetchDays * 6); + const start = new Date(center); + start.setDate(center.getDate() - Math.floor(total / 2)); + + this.items = []; + for (let i = 0; i < total; i++) { + const d = new Date(start); + d.setDate(start.getDate() + i); + this.items.push(this.createDayData(d)); + } + } + + #generateMonths() { + const center = this.options.startDate || new Date(); + const total = 24; + const start = new Date(center.getFullYear(), center.getMonth() - Math.floor(total / 2), 1); + + this.items = []; + for (let i = 0; i < total; i++) { + const d = new Date(start.getFullYear(), start.getMonth() + i, 1); + this.items.push(this.createMonthData(d)); + } + } + + #generateYears() { + const center = this.options.startDate || new Date(); + const total = 20; + const startYear = center.getFullYear() - Math.floor(total / 2); + + this.items = []; + for (let i = 0; i < total; i++) { + const d = new Date(startYear + i, 0, 1); + this.items.push(this.createYearData(d)); + } + } + + /** Data factories **/ + + createDayData(date) { + const now = new Date(); + return { + date: clampToStartOfDay(date), + element: null, + isToday: areSameDay(date, now), + type: TIMEBANDS.DAY + }; + } + + createMonthData(date) { + const now = new Date(); + return { + date: new Date(date.getFullYear(), date.getMonth(), 1), + element: null, + isToday: areSameMonth(date, now), + type: TIMEBANDS.MONTH + }; + } + + createYearData(date) { + const now = new Date(); + return { + date: new Date(date.getFullYear(), 0, 1), + element: null, + isToday: areSameYear(date, now), + type: TIMEBANDS.YEAR + }; + } + + /** Rendering **/ + + render() { + const cfg = this.timebandConfigs[this.currentTimeband]; + if (!cfg) return; + + this.scrollContainer.innerHTML = ''; + this.scrollContainer.className = `date-picker-container timeband-${this.currentTimeband}`; + + this.elementPool = []; + this.visibleElements.clear(); + this.lastVisibleRange = { start: -1, end: -1 }; + this.itemByTime = new Map(); + + const fragment = document.createDocumentFragment(); + this.items.forEach((itemData, index) => { + const el = cfg.createItemElement(itemData, index); + // Ensure common metadata for event delegation + this.#prepareItemElement(el, itemData, index); + itemData.element = el; + fragment.appendChild(el); + }); + this.scrollContainer.appendChild(fragment); + + this.scrollToCenter(true); + this.requestSelectionUpdate(); + } + + /** Item elements **/ + + // Small helpers to reduce duplication in element creation + #maybeUseCustomRenderer(renderer, data, index) { + if (!renderer) return null; + const el = renderer(data, index, this); + if (el) this.#prepareItemElement(el, data, index); + return el || null; + } + + #buildItemElement({ baseClass = 'timeband-item', today = false, children = [] }, itemData, index) { + const el = document.createElement('div'); + el.className = baseClass; + if (today) el.classList.add('today'); + + for (const c of children) { + const child = document.createElement('div'); + if (c.className) child.className = c.className; + if (c.text != null) child.textContent = c.text; + el.appendChild(child); + } + + this.#prepareItemElement(el, itemData, index); + return el; + } + + createDayElement(dayData, index) { + const custom = this.#maybeUseCustomRenderer(this.options.renderDayItem, dayData, index); + if (custom) return custom; + + return this.#buildItemElement( + { + baseClass: 'date-day timeband-item', + today: dayData.isToday, + children: [ + { className: 'day-name secondary-text', text: this.getDayName(dayData.date) }, + { className: 'day-number primary-text', text: dayData.date.getDate() }, + { className: 'month-year tertiary-text', text: this.getMonthYear(dayData.date) } + ] + }, + dayData, + index + ); + } + + createMonthElement(monthData, index) { + const custom = this.#maybeUseCustomRenderer(this.options.renderMonthItem, monthData, index); + if (custom) return custom; + + return this.#buildItemElement( + { + today: monthData.isToday, + children: [ + { className: 'secondary-text', text: monthData.date.getFullYear() }, + { className: 'primary-text', text: this.getMonthName(monthData.date) } + ] + }, + monthData, + index + ); + } + + createYearElement(yearData, index) { + const custom = this.#maybeUseCustomRenderer(this.options.renderYearItem, yearData, index); + if (custom) return custom; + + return this.#buildItemElement( + { + today: yearData.isToday, + children: [ + { className: 'primary-text', text: yearData.date.getFullYear() } + ] + }, + yearData, + index + ); + } + + #attachItemEvents(element, itemData, index) { + // Backward compatibility for custom renderers that expect direct events. + this.#prepareItemElement(element, itemData, index); + } + + #prepareItemElement(el, itemData, index) { + if (!el) return; + if (!el.classList.contains('timeband-item')) { + el.classList.add('timeband-item'); + } + el.dataset.time = String(itemData.date.getTime()); + if (index != null) el.dataset.idx = String(index); + if (!this.itemByTime) this.itemByTime = new Map(); + this.itemByTime.set(itemData.date.getTime(), itemData); + } + + /** Click dispatch **/ + + #setupDelegatedItemEvents() { + // Store bound handlers for destroy() + this._onClick = (e) => { + const el = e.target && e.target.closest && e.target.closest('.timeband-item'); + if (!el || !this.scrollContainer.contains(el)) return; + const idx = el.dataset && el.dataset.idx ? Number(el.dataset.idx) : NaN; + let data = !Number.isNaN(idx) ? this.items[idx] : null; + if (!data && el.dataset && el.dataset.time && this.itemByTime) { + data = this.itemByTime.get(Number(el.dataset.time)) || null; + } + if (data) this.handleItemClick(data, Number.isNaN(idx) ? undefined : idx); + }; + + this._onMouseOver = (e) => { + const el = e.target && e.target.closest && e.target.closest('.timeband-item'); + if (!el || !this.scrollContainer.contains(el)) return; + const idx = el.dataset && el.dataset.idx ? Number(el.dataset.idx) : NaN; + let data = !Number.isNaN(idx) ? this.items[idx] : null; + if (!data && el.dataset && el.dataset.time && this.itemByTime) { + data = this.itemByTime.get(Number(el.dataset.time)) || null; + } + if (data) this.handleItemHover(data, e); + }; + + this._onMouseOut = (e) => { + const toEl = e.relatedTarget && (e.relatedTarget.closest ? e.relatedTarget.closest('.timeband-item') : null); + const fromEl = e.target && e.target.closest && e.target.closest('.timeband-item'); + if (fromEl && (!toEl || !this.scrollContainer.contains(toEl))) { + this.hideHoverInfo(); + this.hideHoverOverlay(); + } + }; + + this.scrollContainer.addEventListener('click', this._onClick, { passive: true }); + this.scrollContainer.addEventListener('mouseover', this._onMouseOver, { passive: true }); + this.scrollContainer.addEventListener('mouseout', this._onMouseOut, { passive: true }); + } + + handleItemClick(itemData, index) { + const sm = this.selectionManager; + + if (sm.isSelectingRange && + sm.selectedStartDate && + sm.canCompleteRangeAtCurrentTimeband()) { + this.completeRangeSelection(itemData); + return; + } + + if (sm.isSelectingRange && + sm.selectionTimeband && + sm.selectionTimeband !== this.currentTimeband) { + this.transitionToTimebandForRangeCompletion(itemData); + return; + } + + if (this.currentTimeband === TIMEBANDS.MONTH) { + this.handleMonthClick(itemData); + return; + } + + if (this.currentTimeband === TIMEBANDS.YEAR) { + this.handleYearClick(itemData); + return; + } + + this.handleDayClick(itemData); + } + + handleDayClick(itemData) { + this.selectionManager.handleDayClick(itemData); + this.emitSelectionChange(); + } + + handleMonthClick(itemData) { + if (this.options.allowMonthRangeSelection || this.options.singleDateMode) { + this.selectionManager.handleTimebandRangeSelection(itemData, TIMEBANDS.MONTH); + } else { + this.selectionManager.selectFullTimeband(itemData.date, TIMEBANDS.MONTH); + } + this.emitSelectionChange(); + } + + handleYearClick(itemData) { + if (this.options.allowYearRangeSelection || this.options.singleDateMode) { + this.selectionManager.handleTimebandRangeSelection(itemData, TIMEBANDS.YEAR); + } else { + this.selectionManager.selectFullTimeband(itemData.date, TIMEBANDS.YEAR); + } + this.emitSelectionChange(); + } + + completeRangeSelection(itemData) { + if (this.selectionManager.completeRangeAtTimeband(itemData)) { + this.requestSelectionUpdate(); + this.emitSelectionChange(); + } + } + + emitSelectionChange() { + this.requestSelectionUpdate(); + this.emit('selectionChange', this.selectionManager.getSelectedRange()); + } + + /** Wheel/timeband transitions **/ + + handleTimebandTransition(itemData) { + if (this.selectionManager.isSelectingRange && this.selectionManager.selectionTimeband) { + // Do not auto-drill while user is mid-selection + return; + } + + if (this.currentTimeband === TIMEBANDS.YEAR) { + const targetDate = new Date(itemData.date.getFullYear(), 0, 1); + this.transitionToTimeband(TIMEBANDS.MONTH, targetDate); + } else if (this.currentTimeband === TIMEBANDS.MONTH) { + const d = itemData.date; + const targetDate = new Date(d.getFullYear(), d.getMonth(), 1); + this.transitionToTimeband(TIMEBANDS.DAY, targetDate); + } + } + + handleWheelTransition(deltaY, event) { + if (this.isTransitioning) return; + + const mouseDate = this.getDateUnderMouse(event); + const mousePos = this.getMousePositionInContainer(event); + let targetDate; + + if (deltaY > 0) { + // Zoom out + if (this.currentTimeband === TIMEBANDS.DAY) { + this.transitionToTimeband(TIMEBANDS.MONTH, mouseDate, mousePos); + } else if (this.currentTimeband === TIMEBANDS.MONTH) { + this.transitionToTimeband(TIMEBANDS.YEAR, mouseDate, mousePos); + } + } else { + // Zoom in + if (this.currentTimeband === TIMEBANDS.YEAR) { + targetDate = new Date(mouseDate.getFullYear(), 0, 1); + this.transitionToTimeband(TIMEBANDS.MONTH, targetDate, mousePos); + } else if (this.currentTimeband === TIMEBANDS.MONTH) { + targetDate = new Date(mouseDate.getFullYear(), mouseDate.getMonth(), 1); + this.transitionToTimeband(TIMEBANDS.DAY, targetDate, mousePos); + } + } + } + + // Perform one wheel step based on direction and current timeband + #performWheelStep(dir, mouseDate, mousePos) { + if (this.isTransitioning) return; + const date = mouseDate || this.getCenterDate(); + const pos = (mousePos != null) ? mousePos : Math.floor(this.scrollContainer.clientWidth / 2); + + if (dir > 0) { // zoom out + if (this.currentTimeband === TIMEBANDS.DAY) { + this.transitionToTimeband(TIMEBANDS.MONTH, date, pos); + return; + } + if (this.currentTimeband === TIMEBANDS.MONTH) { + this.transitionToTimeband(TIMEBANDS.YEAR, date, pos); + return; + } + // Already at max zoom out (YEAR) + this._wheelChainCount = 0; + this._wheelChainDir = 0; + return; + } else if (dir < 0) { // zoom in + if (this.currentTimeband === TIMEBANDS.YEAR) { + const target = new Date(date.getFullYear(), 0, 1); + this.transitionToTimeband(TIMEBANDS.MONTH, target, pos); + return; + } + if (this.currentTimeband === TIMEBANDS.MONTH) { + const target = new Date(date.getFullYear(), date.getMonth(), 1); + this.transitionToTimeband(TIMEBANDS.DAY, target, pos); + return; + } + // Already at max zoom in (DAY) + this._wheelChainCount = 0; + this._wheelChainDir = 0; + return; + } + } + + /** Position helpers **/ + + getCenterDate() { + const { scrollLeft, clientWidth } = this.scrollContainer; + const cfg = this.timebandConfigs[this.currentTimeband]; + if (!cfg) return this.options.startDate || new Date(); + + const width = (cfg.itemWidth || 0) + 1; + const centerPos = scrollLeft + (clientWidth / 2); + const index = Math.floor(centerPos / width); + + if (index >= 0 && index < this.items.length) { + return this.items[index].date; + } + + return this.options.startDate || new Date(); + } + + getDateUnderMouse(event) { + const rect = this.scrollContainer.getBoundingClientRect(); + const mouseX = event.clientX - rect.left; + const { scrollLeft } = this.scrollContainer; + const cfg = this.timebandConfigs[this.currentTimeband]; + if (!cfg) return this.getCenterDate(); + + const width = (cfg.itemWidth || 0) + 1; + const position = scrollLeft + mouseX; + const index = Math.floor(position / width); + + if (index >= 0 && index < this.items.length) { + return this.items[index].date; + } + + return this.getCenterDate(); + } + + getMousePositionInContainer(event) { + const rect = this.scrollContainer.getBoundingClientRect(); + return event.clientX - rect.left; + } + + /** Selection UI **/ + + updateSelection() { + const tb = this.currentTimeband; + this.items.forEach(item => this.updateItemSelection(item, tb)); + this.updateRangeIndicators(); + } + + requestSelectionUpdate() { + if (this._pendingSelRaf) return; + this._pendingSelRaf = requestAnimationFrame(() => { + this._pendingSelRaf = 0; + this.updateSelection(); + }); + } + + updateItemSelection(itemData, timeband) { + const el = itemData.element; + if (!el) return; + + const sm = this.selectionManager; + + el.classList.remove( + 'selected', + 'in-range', + 'range-start', + 'range-end', + 'locked' + ); + + if (!sm.selectedStartDate) return; + + const itemStart = TimebandUtils.getItemRangeStart(itemData, timeband); + const itemEnd = TimebandUtils.getItemRangeEnd(itemData, timeband); + const selStart = sm.selectedStartDate; + const selEnd = sm.selectedEndDate || sm.selectedStartDate; + + const overlaps = + itemStart.getTime() <= selEnd.getTime() && + itemEnd.getTime() >= selStart.getTime(); + + if (!overlaps) return; + + const containsStart = + itemStart.getTime() <= selStart.getTime() && + itemEnd.getTime() >= selStart.getTime(); + const containsEnd = + itemStart.getTime() <= selEnd.getTime() && + itemEnd.getTime() >= selEnd.getTime(); + const fullyInside = + itemStart.getTime() >= selStart.getTime() && + itemEnd.getTime() <= selEnd.getTime(); + + if (containsStart && containsEnd) { + el.classList.add('selected', 'range-start', 'range-end'); + if (this.options.singleDateMode && sm.isDateLocked && !sm.selectedEndDate) { + el.classList.add('locked'); + } + } else if (containsStart) { + el.classList.add('selected', 'range-start'); + } else if (containsEnd) { + el.classList.add('selected', 'range-end'); + } else if (fullyInside) { + el.classList.add('in-range'); + } else { + el.classList.add('in-range'); + } + } + + /** Scroll / infinite items **/ + + #setupScrollListener() { + this._scrollTimeout = null; + this._wheelTimeout = null; + this._lastWheelTime = 0; + this._horizontalScrollActive = false; + this._horizontalTimeout = null; + this._touchStartX = 0; + this._touchStartY = 0; + this._touchLock = null; // 'x' | 'y' | null + // Chain state to allow multiple zoom steps without pauses + this._wheelChainDir = 0; // 1 = zoom out, -1 = zoom in + this._wheelChainCount = 0; // queued steps to continue after transition + this._wheelChainMouseDate = null; + this._wheelChainMousePos = null; + // Gesture aggregation to avoid skipping on a single mouse notch + this._wheelGestureDir = 0; + this._wheelGestureCount = 0; + this._wheelGestureWindowUntil = 0; + this._wheelGestureStart = 0; + this._wheelGestureDelta = 0; + + this._onScroll = () => { + this._horizontalScrollActive = true; + clearTimeout(this._horizontalTimeout); + this._horizontalTimeout = setTimeout(() => { + this._horizontalScrollActive = false; + }, 200); + + clearTimeout(this._scrollTimeout); + this._scrollTimeout = setTimeout(() => { + this.handleScroll(); + this.updateRangeIndicators(); + }, 50); + }; + + this._onWheel = (e) => { + const now = Date.now(); + const vertical = Math.abs(e.deltaY) > Math.abs(e.deltaX) && + Math.abs(e.deltaY) > 15 && + Math.abs(e.deltaX) < 5; + const horizontal = Math.abs(e.deltaX) > Math.abs(e.deltaY) && + Math.abs(e.deltaX) > 5; + + if (vertical) { + e.preventDefault(); + // Stop both propagation and immediate propagation to ensure + // no ancestor scroll handlers (including page) react. + if (e.stopImmediatePropagation) e.stopImmediatePropagation(); + e.stopPropagation(); + if (this._horizontalScrollActive) return; + + const dir = e.deltaY > 0 ? 1 : -1; // 1 = zoom out, -1 = zoom in + const mouseDate = this.getDateUnderMouse(e); + const mousePos = this.getMousePositionInContainer(e); + + if (this.isTransitioning) { + // Aggregate events within a short window before deciding to skip modes + const windowMs = 180; + if (this._wheelGestureDir !== dir || now > this._wheelGestureWindowUntil) { + this._wheelGestureDir = dir; + this._wheelGestureCount = 0; + this._wheelGestureStart = now; + this._wheelGestureDelta = 0; + } + this._wheelGestureWindowUntil = now + windowMs; + this._wheelGestureCount += 1; + this._wheelGestureDelta += Math.abs(e.deltaY); + + // Consider device type + const isDiscreteDevice = e.deltaMode === 1 || e.deltaMode === 2; // lines/pages + // Only consider extra steps if the gesture persists beyond a minimal duration + const gestureDuration = now - this._wheelGestureStart; + let desired = 0; + + if (isDiscreteDevice) { + // For mouse wheels with discrete notches: require multiple fast ticks to skip + if (gestureDuration >= 80) { + if (this._wheelGestureCount >= 4) desired = 2; + else if (this._wheelGestureCount >= 2) desired = 1; + } + } else { + // For trackpads/continuous devices: use higher thresholds to avoid accidental skips + if (gestureDuration >= 120) { + const byCount = (this._wheelGestureCount >= 12) ? 2 : (this._wheelGestureCount >= 6 ? 1 : 0); + const byDelta = (this._wheelGestureDelta >= 800) ? 2 : (this._wheelGestureDelta >= 400 ? 1 : 0); + desired = Math.max(byCount, byDelta); + } + } + + if (desired > 0) { + this._wheelChainDir = dir; + // Keep up to two queued steps total + this._wheelChainCount = Math.min(desired, 2); + this._wheelChainMouseDate = mouseDate; + this._wheelChainMousePos = mousePos; + } + return; + } + + // Start/refresh gesture window for the immediate step + this._wheelGestureDir = dir; + this._wheelGestureCount = 1; + this._wheelGestureStart = now; + this._wheelGestureDelta = Math.abs(e.deltaY); + this._wheelGestureWindowUntil = now + 180; + + // Perform one immediate step; any further wheel events during the + // transition will be queued and continued automatically + this.#performWheelStep(dir, mouseDate, mousePos); + } else if (horizontal) { + // native horizontal scroll + } else { + e.preventDefault(); + if (e.stopImmediatePropagation) e.stopImmediatePropagation(); + e.stopPropagation(); + } + }; + + // Touch gesture handling to block page scroll while interacting + this._onTouchStart = (e) => { + if (!e.touches || e.touches.length === 0) return; + const t = e.touches[0]; + this._touchStartX = t.clientX; + this._touchStartY = t.clientY; + this._touchLock = null; + }; + + this._onTouchMove = (e) => { + if (!e.touches || e.touches.length === 0) return; + const t = e.touches[0]; + const dx = Math.abs(t.clientX - this._touchStartX); + const dy = Math.abs(t.clientY - this._touchStartY); + if (!this._touchLock) { + // Determine intent after a small threshold + const threshold = 8; // px + if (dx < threshold && dy < threshold) return; + this._touchLock = dx > dy ? 'x' : 'y'; + } + // If vertical or ambiguous, prevent page scroll + if (this._touchLock === 'y') { + e.preventDefault(); + if (e.stopImmediatePropagation) e.stopImmediatePropagation(); + e.stopPropagation(); + } + // If horizontal, allow native horizontal scrolling of the container + }; + + this.scrollContainer.addEventListener('scroll', this._onScroll, { passive: true }); + // Use capture to intercept early and prevent page scroll leaks. + this.scrollContainer.addEventListener('wheel', this._onWheel, { passive: false, capture: true }); + this.scrollContainer.addEventListener('touchstart', this._onTouchStart, { passive: true }); + this.scrollContainer.addEventListener('touchmove', this._onTouchMove, { passive: false, capture: true }); + } + + handleScroll() { + const { scrollLeft, scrollWidth, clientWidth } = this.scrollContainer; + const cfg = this.timebandConfigs[this.currentTimeband]; + if (!cfg) return; + + const itemWidth = cfg.itemWidth || 0; + const threshold = itemWidth * 5 || 400; + + if (scrollLeft < threshold) { + cfg.addToLeft && cfg.addToLeft(); + } + + if (scrollLeft + clientWidth > scrollWidth - threshold) { + cfg.addToRight && cfg.addToRight(); + } + } + + scrollToCenter(instant = false) { + const centerDate = this.options.startDate || new Date(); + const cfg = this.timebandConfigs[this.currentTimeband]; + if (!cfg) return; + + const index = this.#findIndexForDate(centerDate, this.currentTimeband); + const itemWidth = (cfg.itemWidth || 0) + 1; + const containerWidth = this.scrollContainer.clientWidth; + + const target = + index >= 0 + ? (index * itemWidth) - (containerWidth / 2) + (itemWidth / 2) + : (this.scrollContainer.scrollWidth - containerWidth) / 2; + + this.#setScrollLeft(Math.max(0, target), instant); + } + + #findIndexForDate(targetDate, timeband) { + if (!targetDate) return -1; + + return this.items.findIndex(item => + isSameByTimeband(item.date, targetDate, timeband) + ); + } + + #setScrollLeft(value, instant) { + if (instant) { + const prev = this.scrollContainer.style.scrollBehavior; + this.scrollContainer.style.scrollBehavior = 'auto'; + this.scrollContainer.scrollLeft = value; + setTimeout(() => { + this.scrollContainer.style.scrollBehavior = prev || 'smooth'; + }, 50); + } else { + this.scrollContainer.scrollLeft = value; + } + } + + /** External selection API **/ + + setSelectedRange(startDate, endDate = null) { + this.selectionManager.setSelectedRange(startDate, endDate); + // If a start date is provided, transition to DAY timeband and scroll to it + if (startDate) { + const targetDate = clampToStartOfDay(startDate); + // If not already in DAY timeband, transition to it + if (this.currentTimeband !== TIMEBANDS.DAY) { + // Don't emit selection change yet - wait for transition to complete + this.pendingSelectionEmit = true; + this.transitionToTimeband(TIMEBANDS.DAY, targetDate); + } else { + // Already in DAY timeband, just scroll to the date + this.options.startDate = targetDate; + this.scrollToCenter(false); + this.requestSelectionUpdate(); + this.emit('selectionChange', this.selectionManager.getSelectedRange()); + } + } else { + this.requestSelectionUpdate(); + this.emit('selectionChange', this.selectionManager.getSelectedRange()); + } + } + + clearSelection() { + this.selectionManager.clearSelection(); + this.requestSelectionUpdate(); + this.emit('selectionChange', { startDate: null, endDate: null }); + } + + getSelectedRange() { + return this.selectionManager.getSelectedRange(); + } + + /** Events **/ + + on(event, callback) { + if (!this.eventListeners[event]) this.eventListeners[event] = []; + this.eventListeners[event].push(callback); + } + + off(event, callback) { + if (!this.eventListeners[event]) return; + this.eventListeners[event] = + this.eventListeners[event].filter(cb => cb !== callback); + } + + emit(event, data) { + const list = this.eventListeners[event]; + if (!list || !list.length) return; + list.forEach(cb => cb(data)); + } + + /** Lifecycle **/ + + destroy() { + // Remove scroll/wheel listeners + if (this.scrollContainer) { + if (this._onScroll) this.scrollContainer.removeEventListener('scroll', this._onScroll); + // Must match capture option used on addEventListener to successfully remove + if (this._onWheel) this.scrollContainer.removeEventListener('wheel', this._onWheel, { capture: true }); + if (this._onClick) this.scrollContainer.removeEventListener('click', this._onClick); + if (this._onMouseOver) this.scrollContainer.removeEventListener('mouseover', this._onMouseOver); + if (this._onMouseOut) this.scrollContainer.removeEventListener('mouseout', this._onMouseOut); + if (this._onTouchStart) this.scrollContainer.removeEventListener('touchstart', this._onTouchStart); + if (this._onTouchMove) this.scrollContainer.removeEventListener('touchmove', this._onTouchMove, { capture: true }); + } + + // Clear timers and RAFs + if (this._scrollTimeout) clearTimeout(this._scrollTimeout); + if (this._wheelTimeout) clearTimeout(this._wheelTimeout); + if (this._horizontalTimeout) clearTimeout(this._horizontalTimeout); + if (this._pendingSelRaf) cancelAnimationFrame(this._pendingSelRaf); + + // Remove indicators and overlays from DOM + if (this.leftIndicator && this.leftIndicator.parentNode) this.leftIndicator.parentNode.removeChild(this.leftIndicator); + if (this.rightIndicator && this.rightIndicator.parentNode) this.rightIndicator.parentNode.removeChild(this.rightIndicator); + if (this.hoverInfo && this.hoverInfo.parentNode) this.hoverInfo.parentNode.removeChild(this.hoverInfo); + if (this.hoverOverlay && this.hoverOverlay.parentNode) this.hoverOverlay.parentNode.removeChild(this.hoverOverlay); + + // Null references to help GC + this.items = []; + this.itemByTime && this.itemByTime.clear(); + this.itemByTime = null; + this.visibleElements && this.visibleElements.clear(); + this.visibleElements = null; + this.elementPool = null; + + this._onScroll = null; + this._onWheel = null; + this._onClick = null; + this._onMouseOver = null; + this._onMouseOut = null; + this._onTouchStart = null; + this._onTouchMove = null; + + // Reset wheel chain state + this._wheelChainDir = 0; + this._wheelChainCount = 0; + this._wheelChainMouseDate = null; + this._wheelChainMousePos = null; + this._wheelGestureDir = 0; + this._wheelGestureCount = 0; + this._wheelGestureWindowUntil = 0; + this._wheelGestureStart = 0; + this._wheelGestureDelta = 0; + } + + /** Compare + format helpers **/ + + isSameDay(a, b) { + return areSameDay(a, b); + } + + isSameMonth(a, b) { + return areSameMonth(a, b); + } + + isSameYear(a, b) { + return areSameYear(a, b); + } + + formatDate(date) { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + + switch (this.options.dateFormat) { + case 'MM/DD/YYYY': + return `${m}/${d}/${y}`; + case 'DD/MM/YYYY': + return `${d}/${m}/${y}`; + case 'YYYY-MM-DD': + default: + return `${y}-${m}-${d}`; + } + } + + // Cached Intl formatters for i18n-friendly names + #fmt = null; + #ensureFormatters() { + if (this.#fmt) return; + const locale = this.options && this.options.locale ? this.options.locale : undefined; + try { + this.#fmt = { + weekday: new Intl.DateTimeFormat(locale, { weekday: 'short' }), + month: new Intl.DateTimeFormat(locale, { month: 'short' }) + }; + } catch (e) { + // Fallback: leave #fmt null; we will use static arrays + this.#fmt = null; + } + } + + getDayName(date) { + this.#ensureFormatters(); + if (this.#fmt && this.#fmt.weekday) return this.#fmt.weekday.format(date); + return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][date.getDay()]; + } + + getMonthYear(date) { + const m = this.getMonthName(date); + return `${m} ${date.getFullYear()}`; + } + + getMonthName(date) { + this.#ensureFormatters(); + if (this.#fmt && this.#fmt.month) return this.#fmt.month.format(date); + return ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][date.getMonth()]; + } + + /** Timeband transitions **/ + + transitionToTimeband(timeband, centerDate = null, alignPosition = null) { + if (timeband === this.currentTimeband) { + // Already at target timeband, just update if needed + if (centerDate) { + this.options.startDate = new Date(centerDate); + this.scrollToCenter(false); + } + if (this.pendingSelectionEmit) { + this.pendingSelectionEmit = false; + this.requestSelectionUpdate(); + this.emit('selectionChange', this.selectionManager.getSelectedRange()); + } + return; + } + + if (this.isTransitioning) return; + + const cfg = this.timebandConfigs[timeband]; + if (!cfg) return; + + this.isTransitioning = true; + this.scrollContainer.classList.add('transitioning'); + + const from = TIMEBAND_ORDER[this.currentTimeband]; + const to = TIMEBAND_ORDER[timeband]; + + this.scrollContainer.classList.toggle('forward', to > from); + this.scrollContainer.classList.toggle('backward', to < from); + this.scrollContainer.classList.toggle('zoom-in', to > from); + this.scrollContainer.classList.toggle('zoom-out', to < from); + + if (centerDate) { + this.options.startDate = new Date(centerDate); + } + + this.pendingAlignPosition = alignPosition; + + const half = this.options.transitionDuration / 2; + + requestAnimationFrame(() => { + setTimeout(() => { + this.currentTimeband = timeband; + this.#generateInitialItems(); + this.render(); + + if (this.pendingAlignPosition != null) { + this.scrollToAlignPosition(this.options.startDate, this.pendingAlignPosition, true); + } else { + this.scrollToCenter(true); + } + + if (this.pendingRangeCompletion) { + this.completeRangeSelectionAfterTransition(); + } + + requestAnimationFrame(() => { + setTimeout(() => { + this.scrollContainer.classList.remove( + 'transitioning', + 'forward', + 'backward', + 'zoom-in', + 'zoom-out' + ); + this.isTransitioning = false; + this.pendingAlignPosition = null; + + this.emit('timebandChange', { + timeband: this.currentTimeband, + centerDate: this.options.startDate + }); + + this.requestSelectionUpdate(); + + // Emit pending selection change if setSelectedRange triggered this transition + if (this.pendingSelectionEmit) { + this.pendingSelectionEmit = false; + this.emit('selectionChange', this.selectionManager.getSelectedRange()); + } + + // If there are queued wheel steps, continue chaining without pause + this.#continueWheelChain(); + }, half); + }); + }, half); + }); + } + + // Continue queued wheel zoom steps after a transition completes + #continueWheelChain() { + if (!this._wheelChainCount || !this._wheelChainDir) return; + // Do not auto-drill while user is mid selection at a specific timeband + if (this.selectionManager && this.selectionManager.isSelectingRange && this.selectionManager.selectionTimeband) { + this._wheelChainCount = 0; + this._wheelChainDir = 0; + return; + } + + if (this.isTransitioning) return; + const date = this._wheelChainMouseDate || this.getCenterDate(); + const pos = (this._wheelChainMousePos != null) ? this._wheelChainMousePos : Math.floor(this.scrollContainer.clientWidth / 2); + + // Consume one step and perform. Remaining steps will be consumed on next end. + this._wheelChainCount = Math.max(0, this._wheelChainCount - 1); + this.#performWheelStep(this._wheelChainDir, date, pos); + // If performWheelStep hits a boundary and doesn't transition, clear state + if (!this.isTransitioning && this._wheelChainCount === 0) { + this._wheelChainDir = 0; + this._wheelChainMouseDate = null; + this._wheelChainMousePos = null; + } + } + + getCurrentTimeband() { + return this.currentTimeband; + } + + setTimeband(timeband, centerDate = null) { + this.transitionToTimeband(timeband, centerDate); + } + + scrollToAlignPosition(targetDate, alignPosition, instant = false) { + if (!targetDate && targetDate !== 0) return; + + const index = this.#findIndexForDate(targetDate, this.currentTimeband); + if (index < 0) return; + + const cfg = this.timebandConfigs[this.currentTimeband]; + if (!cfg) return; + + const itemWidth = (cfg.itemWidth || 0) + 1; + const center = (index * itemWidth) + (itemWidth / 2); + const scrollPos = center - alignPosition; + + this.#setScrollLeft(Math.max(0, scrollPos), instant); + } + + transitionToTimebandForRangeCompletion(itemData) { + const sm = this.selectionManager; + const startBand = sm.selectionTimeband; + if (!startBand) return; + + let targetBand = this.currentTimeband; + let targetDate = itemData.date; + + if (startBand === TIMEBANDS.DAY) { + // Need to zoom into day view from month/year to complete + if (this.currentTimeband === TIMEBANDS.MONTH) { + targetBand = TIMEBANDS.DAY; + targetDate = new Date(targetDate.getFullYear(), targetDate.getMonth(), 1); + } else if (this.currentTimeband === TIMEBANDS.YEAR) { + targetBand = TIMEBANDS.DAY; + targetDate = new Date(targetDate.getFullYear(), 0, 1); + } + } else if (startBand === TIMEBANDS.MONTH) { + if (this.currentTimeband === TIMEBANDS.YEAR) { + targetBand = TIMEBANDS.MONTH; + targetDate = new Date(targetDate.getFullYear(), 0, 1); + } + } + + this.pendingRangeCompletion = { itemData, originalTimeband: this.currentTimeband }; + this.transitionToTimeband(targetBand, targetDate); + } + + completeRangeSelectionAfterTransition() { + const sm = this.selectionManager; + const pending = this.pendingRangeCompletion; + if (!pending || !sm.selectionTimeband) { + this.pendingRangeCompletion = null; + return; + } + + const { itemData, originalTimeband } = pending; + let endDate = null; + + if (sm.selectionTimeband === TIMEBANDS.DAY) { + if (originalTimeband === TIMEBANDS.MONTH) { + endDate = new Date(itemData.date.getFullYear(), itemData.date.getMonth(), 1); + } else if (originalTimeband === TIMEBANDS.YEAR) { + endDate = new Date(itemData.date.getFullYear(), 0, 1); + } + } else if (sm.selectionTimeband === TIMEBANDS.MONTH) { + if (originalTimeband === TIMEBANDS.YEAR) { + endDate = new Date(itemData.date.getFullYear(), 0, 1); + } + } + + if (endDate) { + if (endDate < sm.selectedStartDate) { + sm.selectedEndDate = sm.selectedStartDate; + sm.selectedStartDate = endDate; + } else { + sm.selectedEndDate = endDate; + } + + sm.isSelectingRange = false; + sm.selectionTimeband = null; + this.requestSelectionUpdate(); + this.emit('selectionChange', sm.getSelectedRange()); + } + + this.pendingRangeCompletion = null; + } + + /** Indicators + hover **/ + + #createRangeIndicators() { + if (this.options.renderLeftIndicator) { + this.leftIndicator = this.options.renderLeftIndicator(this); + } else { + this.leftIndicator = document.createElement('div'); + this.leftIndicator.className = 'date-picker-range-indicator left'; + this.leftIndicator.innerHTML = 'â—€'; + } + this.container.appendChild(this.leftIndicator); + + if (this.options.renderRightIndicator) { + this.rightIndicator = this.options.renderRightIndicator(this); + } else { + this.rightIndicator = document.createElement('div'); + this.rightIndicator.className = 'date-picker-range-indicator right'; + this.rightIndicator.innerHTML = 'â–¶'; + } + this.container.appendChild(this.rightIndicator); + } + + #createHoverInfo() { + this.hoverInfo = document.createElement('div'); + this.hoverInfo.className = 'date-picker-hover-info'; + document.body.appendChild(this.hoverInfo); + } + + #createHoverOverlay() { + if (this.options.renderHoverOverlay) { + this.hoverOverlay = this.options.renderHoverOverlay(this); + } else { + this.hoverOverlay = document.createElement('div'); + this.hoverOverlay.className = 'date-picker-hover-overlay'; + } + document.body.appendChild(this.hoverOverlay); + } + + handleItemHover(itemData, event) { + // Single-date mode overlay text (lock/unlock/create range) + if (this.options.singleDateMode && this.selectionManager.selectedStartDate) { + const overlayText = this.selectionManager.getHoverOverlayText(itemData); + if (overlayText) { + this.showHoverOverlay(overlayText, event); + } else { + this.hideHoverOverlay(); + } + } + + // Range preview tooltip + if (!this.selectionManager.isSelectingRange || !this.selectionManager.selectedStartDate) { + this.hideHoverInfo(); + return; + } + + const tooltip = this.selectionManager.getHoverTooltipText(itemData); + if (tooltip) { + this.showHoverInfo(tooltip); + } else { + this.hideHoverInfo(); + } + } + + showHoverOverlay(text, event) { + if (!this.hoverOverlay) return; + + this.hoverOverlay.textContent = text; + this.hoverOverlay.style.display = 'block'; + this.hoverOverlay.style.opacity = '1'; + + this.hoverOverlay.style.left = `${event.clientX + 10}px`; + this.hoverOverlay.style.top = `${event.clientY - 30}px`; + } + + hideHoverOverlay() { + if (!this.hoverOverlay) return; + this.hoverOverlay.style.opacity = '0'; + setTimeout(() => { + if (this.hoverOverlay) this.hoverOverlay.style.display = 'none'; + }, 200); + } + + showHoverInfo(text) { + if (!this.hoverInfo) return; + const rect = this.container.getBoundingClientRect(); + const above = this.options.hoverInfoPosition === 'above'; + + this.hoverInfo.textContent = text; + this.hoverInfo.style.left = `${rect.left}px`; + this.hoverInfo.style.width = `${rect.width}px`; + this.hoverInfo.style.top = above + ? `${rect.top - 40}px` + : `${rect.bottom + 8}px`; + this.hoverInfo.style.opacity = '1'; + } + + hideHoverInfo() { + if (this.hoverInfo) { + this.hoverInfo.style.opacity = '0'; + } + } + + updateRangeIndicators() { + const sm = this.selectionManager; + if (!sm.selectedStartDate || + !this.leftIndicator || + !this.rightIndicator) { + if (this.leftIndicator) this.leftIndicator.style.opacity = '0'; + if (this.rightIndicator) this.rightIndicator.style.opacity = '0'; + return; + } + + const range = this.getVisibleDateRange(); + if (!range) { + this.leftIndicator.style.opacity = '0'; + this.rightIndicator.style.opacity = '0'; + return; + } + + const selectionStart = sm.selectedStartDate; + const selectionEnd = sm.selectedEndDate || sm.selectedStartDate; + + const startsBefore = selectionStart < range.start; + const endsAfter = selectionEnd > range.end; + + this.leftIndicator.style.opacity = startsBefore ? '1' : '0'; + this.rightIndicator.style.opacity = endsAfter ? '1' : '0'; + } + + getVisibleDateRange() { + if (!this.items.length) return null; + + const { scrollLeft, clientWidth } = this.scrollContainer; + const cfg = this.timebandConfigs[this.currentTimeband]; + if (!cfg) return null; + + const width = (cfg.itemWidth || 0) + 1; + const firstIndex = Math.floor(scrollLeft / width); + const lastIndex = Math.min( + this.items.length - 1, + Math.ceil((scrollLeft + clientWidth) / width) + ); + + if (firstIndex >= this.items.length || lastIndex < 0) return null; + + const firstItem = this.items[Math.max(0, firstIndex)]; + const lastItem = this.items[Math.max(0, lastIndex)]; + + return { + start: TimebandUtils.getItemRangeStart(firstItem, this.currentTimeband), + end: TimebandUtils.getItemRangeEnd(lastItem, this.currentTimeband) + }; + } + +} + +/** UMD-style export **/ + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { DatePicker, SelectionManager, TimebandUtils }; +} else if (typeof window !== 'undefined') { + window.DatePicker = DatePicker; + window.SelectionManager = SelectionManager; + window.TimebandUtils = TimebandUtils; +} diff --git a/src/main/resources/static/js/horizontal-date-picker.js b/src/main/resources/static/js/horizontal-date-picker.js deleted file mode 100644 index 8be84ce3..00000000 --- a/src/main/resources/static/js/horizontal-date-picker.js +++ /dev/null @@ -1,1643 +0,0 @@ -/** - * Horizontal Date Picker - * A responsive horizontal date picker that allows users to select dates by scrolling horizontally - */ -class HorizontalDatePicker { - constructor(options = {}) { - this.options = { - container: document.body, - daysToShow: 15, - daysBeforeToday: 7, - onDateSelect: null, - onDateRangeSelect: null, - selectedDate: new Date(), - showNavButtons: true, // Option to show/hide navigation buttons - minDate: null, // Minimum selectable date - maxDate: null, // Maximum selectable date - showMonthRow: false, // Option to show month selection row - showYearRow: true, // Option to show year selection row - yearsToShow: 3, // Number of years to show in the year row - allowFutureDates: true, // Option to allow selection of future dates - showTodayButton: false, // Option to show a "Today" button - ...options - }; - - // Track the last valid date for reverting invalid selections - this.lastValidDate = new Date(this.options.selectedDate); - - // Range mode properties - this.rangeMode = false; - this.rangeStartDate = null; - this.rangeEndDate = null; - - // Track original month/year for hover restoration - this.originalSelectedDate = null; - - this.init(); - } - - init() { - this._isManualSelection = false; - - this.createElements(); - this.populateDates(); - this.attachEventListeners(); - this.attachTouchEventListeners(); - - // Scroll to selected date after a brief delay to ensure DOM is ready - setTimeout(() => { - this.scrollToSelectedDate(false); - }, 0); - - // Highlight the current month in the month row - if (this.options.showMonthRow) { - this.highlightSelectedMonth(); - } - } - - createElements() { - // Create main container - this.element = document.createElement('div'); - this.element.className = 'horizontal-date-picker'; - - // Create month row if enabled - if (this.options.showMonthRow) { - this.monthRowContainer = document.createElement('div'); - this.monthRowContainer.className = 'month-row-container'; - this.element.appendChild(this.monthRowContainer); - - // Populate months - this.populateMonthRow(); - } - - // Create date container - this.dateContainer = document.createElement('div'); - this.dateContainer.className = 'date-picker-container'; - - // Create navigation buttons if enabled - if (this.options.showNavButtons) { - // Create navigation buttons - this.prevButton = document.createElement('button'); - this.prevButton.className = 'date-nav-button date-nav-prev'; - this.prevButton.innerHTML = ''; - - this.nextButton = document.createElement('button'); - this.nextButton.className = 'date-nav-button date-nav-next'; - this.nextButton.innerHTML = ''; - - // Append navigation buttons - this.element.appendChild(this.prevButton); - this.element.appendChild(this.nextButton); - } - - // Create clear range button (initially hidden) - this.clearRangeButton = document.createElement('button'); - this.clearRangeButton.className = 'clear-range-button'; - this.clearRangeButton.innerHTML = ' Clear Range'; - this.clearRangeButton.style.display = 'none'; - this.element.appendChild(this.clearRangeButton); - - // Append date container - this.element.appendChild(this.dateContainer); - - // Append to container - this.options.container.appendChild(this.element); - } - - populateDates() { - this.dateContainer.innerHTML = ''; - - // Use the selected date as the center point instead of today - const centerDate = this.options.selectedDate; - const startDate = new Date(centerDate); - startDate.setDate(centerDate.getDate() - this.options.daysBeforeToday); - - // Create more dates at the beginning for initial load (2 weeks before) - const extendedStartDate = new Date(startDate); - extendedStartDate.setDate(startDate.getDate() - 14); - - // Calculate total days to show (original + 2 weeks before + 2 weeks after) - const totalDaysToShow = this.options.daysToShow + 28; - - // Generate all dates including the extended range - for (let i = 0; i < totalDaysToShow; i++) { - const date = new Date(extendedStartDate); - date.setDate(extendedStartDate.getDate() + i); - - // Skip dates outside of min/max range if specified - if ((this.options.minDate && date < new Date(this.options.minDate)) || - (this.options.maxDate && date > new Date(this.options.maxDate))) { - continue; - } - - const dateItem = this.createDateElement(date); - this.dateContainer.appendChild(dateItem); - } - } - - attachEventListeners() { - // Date selection - this.dateContainer.addEventListener('click', (e) => { - const dateItem = e.target.closest('.date-item'); - if (dateItem) { - // Check if this date is already selected - if (dateItem.classList.contains('selected') && !this.rangeMode) { - // Clicking on selected date enters range mode - this.enterRangeMode(dateItem); - return; - } - - if (this.rangeMode) { - // Check if clicking on range start or end date to exit range mode - const clickedDate = this.parseDate(dateItem.dataset.date); - if ((this.rangeStartDate && this.isSameDay(clickedDate, this.rangeStartDate)) || - (this.rangeEndDate && this.isSameDay(clickedDate, this.rangeEndDate))) { - this.exitRangeMode(); - return; - } - - // In range mode, select the end date - this.selectRangeEnd(dateItem); - return; - } - - // Prevent auto-selection from interfering with manual clicks - this._isManualSelection = true; - - // Force selection of the clicked date - this.selectDateItem(dateItem, true); - - // Reset the manual selection flag after a delay - setTimeout(() => { - this._isManualSelection = false; - }, 500); - } - }); - - // Add hover listener for range preview - this.dateContainer.addEventListener('mouseover', (e) => { - const dateItem = e.target.closest('.date-item'); - if (dateItem) { - if (this.rangeMode && this.rangeStartDate) { - this.showRangePreview(dateItem); - } - - // Update month row for hovered date - this.updateMonthRowForHoveredDate(dateItem); - } - }); - - this.dateContainer.addEventListener('mouseout', (e) => { - const dateItem = e.target.closest('.date-item'); - if (dateItem && this.rangeMode && this.rangeStartDate) { - this.clearRangePreview(); - } - }); - - // Restore original month row when mouse leaves the date container - this.dateContainer.addEventListener('mouseleave', () => { - this.restoreOriginalMonthRow(); - }); - - // Clear range button - this.clearRangeButton.addEventListener('click', () => { - this.exitRangeMode(); - }); - - // Navigation buttons (if enabled) - if (this.options.showNavButtons) { - this.prevButton.addEventListener('click', () => { - this.navigateDates(-7); - }); - - this.nextButton.addEventListener('click', () => { - this.navigateDates(7); - }); - } - - // Scroll event handling - let scrollTimeout; - let isScrolling = false; - let lastScrollTime = 0; - - // Initialize properties for tracking - this._isAddingDates = false; - this._isManualSelection = false; - - this.dateContainer.addEventListener('scroll', () => { - // Throttle scroll events for better performance - const now = Date.now(); - if (now - lastScrollTime < 16) { // ~60fps - return; - } - lastScrollTime = now; - - // Clear the previous timeout - clearTimeout(scrollTimeout); - - // Check if we need to add more dates - this.checkScrollPosition(); - }, { passive: true }); // Add passive flag for better performance - } - - attachTouchEventListeners() { - let touchStartX = 0; - let touchStartY = 0; - let touchStartTime = 0; - let isTouchScrolling = false; - let touchScrollTimeout; - - // Touch start - this.dateContainer.addEventListener('touchstart', (e) => { - touchStartX = e.touches[0].clientX; - touchStartY = e.touches[0].clientY; - touchStartTime = Date.now(); - isTouchScrolling = false; - - // Clear any existing timeout - clearTimeout(touchScrollTimeout); - - }, { passive: true }); - - // Touch move - this.dateContainer.addEventListener('touchmove', (e) => { - if (!isTouchScrolling) { - isTouchScrolling = true; - } - - // Check if we need to add more dates - this.checkScrollPosition(); - }, { passive: true }); - - // Touch end - this.dateContainer.addEventListener('touchend', (e) => { - const touchEndX = e.changedTouches[0].clientX; - const touchEndY = e.changedTouches[0].clientY; - const touchEndTime = Date.now(); - - const deltaX = touchEndX - touchStartX; - const deltaY = touchEndY - touchStartY; - const deltaTime = touchEndTime - touchStartTime; - - // Check if this was a tap (short duration, small movement) - const isTap = deltaTime < 300 && Math.abs(deltaX) < 10 && Math.abs(deltaY) < 10; - - if (isTap) { - // Handle tap - find the date item that was tapped - const target = document.elementFromPoint(touchStartX, touchStartY); - const dateItem = target ? target.closest('.date-item') : null; - - if (dateItem) { - // Check if this date is already selected - if (dateItem.classList.contains('selected') && !this.rangeMode) { - // Clicking on selected date enters range mode - this.enterRangeMode(dateItem); - return; - } - - if (this.rangeMode) { - // Check if tapping on range start or end date to exit range mode - const tappedDate = this.parseDate(dateItem.dataset.date); - if ((this.rangeStartDate && this.isSameDay(tappedDate, this.rangeStartDate)) || - (this.rangeEndDate && this.isSameDay(tappedDate, this.rangeEndDate))) { - this.exitRangeMode(); - return; - } - - // In range mode, select the end date - this.selectRangeEnd(dateItem); - return; - } - - // Prevent auto-selection from interfering with manual taps - this._isManualSelection = true; - - // Force selection of the tapped date - this.selectDateItem(dateItem, true); - - // Reset the manual selection flag after a delay - setTimeout(() => { - this._isManualSelection = false; - }, 500); - } - } else if (isTouchScrolling) { - // Handle scroll end for touch - touchScrollTimeout = setTimeout(() => { - isTouchScrolling = false; - - // Only handle scroll end if not in manual selection mode - if (!this._isManualSelection) { - this.handleScrollEnd(); - } - }, 150); - } - }, { passive: true }); - - // Handle touch cancel - this.dateContainer.addEventListener('touchcancel', () => { - isTouchScrolling = false; - clearTimeout(touchScrollTimeout); - }, { passive: true }); - } - - // Check scroll position and add more dates if needed - checkScrollPosition() { - const container = this.dateContainer; - const scrollLeft = container.scrollLeft; - const scrollWidth = container.scrollWidth; - const clientWidth = container.clientWidth; - - // Use a debounce mechanism to prevent multiple rapid additions - if (this._isAddingDates) return; - - // If we're near the start, add more dates at the beginning - if (scrollLeft < clientWidth * 0.2) { - this._isAddingDates = true; - this.addMoreDatesAtStart(); - setTimeout(() => { - this._isAddingDates = false; - }, 200); - } - - // If we're near the end, add more dates at the end - if (scrollLeft + clientWidth > scrollWidth - clientWidth * 0.2) { - this._isAddingDates = true; - this.addMoreDatesAtEnd(); - setTimeout(() => { - this._isAddingDates = false; - }, 200); - } - } - - // Add more dates at the beginning of the container - addMoreDatesAtStart() { - // Get the first date currently displayed - const firstDateElement = this.dateContainer.firstElementChild; - if (!firstDateElement) return; - - const firstDate = this.parseDate(firstDateElement.dataset.date); - const currentScrollPosition = this.dateContainer.scrollLeft; - - // Temporarily disable smooth scrolling to prevent jank - this.dateContainer.style.scrollBehavior = 'auto'; - - // Add 7 more days before the current first date - const fragment = document.createDocumentFragment(); - - // Pre-calculate the width of new items (using a fixed width for consistency) - const estimatedItemWidth = 88; // 80px width + 8px margin - const itemsToAdd = 7; - const estimatedAddedWidth = estimatedItemWidth * itemsToAdd; - - for (let i = 7; i > 0; i--) { - const date = new Date(firstDate); - date.setDate(date.getDate() - i); - - // Skip dates outside of min/max range if specified - if ((this.options.minDate && date < new Date(this.options.minDate))) { - continue; - } - - const dateItem = this.createDateElement(date); - fragment.appendChild(dateItem); - } - - // Insert at the beginning - if (fragment.childNodes && fragment.childNodes.length > 0) { - // Batch DOM operations - requestAnimationFrame(() => { - this.dateContainer.insertBefore(fragment, this.dateContainer.firstChild); - - // Adjust scroll position to keep the same dates visible - this.dateContainer.scrollLeft = currentScrollPosition + estimatedAddedWidth; - - // Re-enable smooth scrolling after a short delay - setTimeout(() => { - this.dateContainer.style.scrollBehavior = 'smooth'; - }, 50); - }); - } - } - - // Add more dates at the end of the container - addMoreDatesAtEnd() { - // Get the last date currently displayed - const lastDateElement = this.dateContainer.lastElementChild; - if (!lastDateElement) return; - - const lastDate = this.parseDate(lastDateElement.dataset.date); - - // Temporarily disable smooth scrolling to prevent jank - this.dateContainer.style.scrollBehavior = 'auto'; - - // Add 7 more days after the current last date - const fragment = document.createDocumentFragment(); - for (let i = 1; i <= 7; i++) { - const date = new Date(lastDate); - date.setDate(date.getDate() + i); - - // Skip dates outside of min/max range if specified - if ((this.options.maxDate && date > new Date(this.options.maxDate))) { - continue; - } - - const dateItem = this.createDateElement(date); - fragment.appendChild(dateItem); - } - - // Append at the end - use requestAnimationFrame for smoother rendering - requestAnimationFrame(() => { - this.dateContainer.appendChild(fragment); - - // Re-enable smooth scrolling after a short delay - setTimeout(() => { - this.dateContainer.style.scrollBehavior = 'smooth'; - }, 50); - }); - } - - // Create a date element - createDateElement(date) { - const dateItem = document.createElement('div'); - dateItem.className = 'date-item'; - dateItem.dataset.date = this.formatDate(date); - - // Check if this date is unavailable - const isUnavailable = this.isDateUnavailable(date); - if (isUnavailable) { - dateItem.classList.add('unavailable'); - } - - // Check if this date is in a range - if (this.rangeMode && this.rangeStartDate && this.rangeEndDate) { - if (this.isDateInRange(date, this.rangeStartDate, this.rangeEndDate)) { - dateItem.classList.add('in-range'); - } - if (this.isSameDay(date, this.rangeStartDate)) { - dateItem.classList.add('range-start'); - } - if (this.isSameDay(date, this.rangeEndDate)) { - dateItem.classList.add('range-end'); - } - } else if (this.rangeMode && this.rangeStartDate && !this.rangeEndDate) { - // Only start date is selected - if (this.isSameDay(date, this.rangeStartDate)) { - dateItem.classList.add('range-start'); - } - } - - // Check if this date is selected (for non-range mode) - if (!this.rangeMode && this.isSameDay(date, this.options.selectedDate)) { - dateItem.classList.add('selected'); - this.selectedElement = dateItem; - } - - // Add day name (Mon, Tue, etc) - const dayName = document.createElement('span'); - dayName.className = 'day-name'; - dayName.textContent = this.getDayName(date); - dateItem.appendChild(dayName); - - // Add day number - const dayNumber = document.createElement('span'); - dayNumber.className = 'day-number'; - dayNumber.textContent = date.getDate(); - dateItem.appendChild(dayNumber); - - // Add month name for first day of month, but not if it's the selected date - // to avoid duplication with month-year-name - if (date.getDate() === 1 && !this.isSameDay(date, this.options.selectedDate)) { - const monthName = document.createElement('span'); - monthName.className = 'month-name'; - monthName.textContent = this.getMonthName(date); - dateItem.appendChild(monthName); - } - - // Add month and year for selected date (only in non-range mode) - if (!this.rangeMode && this.isSameDay(date, this.options.selectedDate)) { - const monthYearName = document.createElement('span'); - monthYearName.className = 'month-year-name'; - monthYearName.textContent = `${this.getMonthName(date)} ${date.getFullYear()}`; - dateItem.appendChild(monthYearName); - } - - return dateItem; - } - - selectDateItem(dateItem, isManualSelection = false) { - // Check if date is within min/max range, but only if they are set - const dateToSelect = this.parseDate(dateItem.dataset.date); - - if ((this.options.minDate && dateToSelect < new Date(this.options.minDate)) || - (this.options.maxDate && dateToSelect > new Date(this.options.maxDate))) { - if (isManualSelection) { - this.flashInvalidSelection(dateItem); - } - return; // Don't select dates outside the allowed range - } - - // Check if future dates are allowed - if (!this.options.allowFutureDates) { - const today = new Date(); - today.setHours(23, 59, 59, 59); - if (dateToSelect > today) { - if (isManualSelection) { - this.flashInvalidSelection(dateItem); - } - return; // Don't select future dates if not allowed - } - } - - // If we're already selecting this date and it's not a manual selection, skip - if (this.selectedElement === dateItem && !isManualSelection) { - return; - } - - // Clear any existing selection - if (this.selectedElement) { - this.selectedElement.classList.remove('selected'); - // Remove month-year-name from previously selected item - const monthYearEl = this.selectedElement.querySelector('.month-year-name'); - if (monthYearEl) { - this.selectedElement.removeChild(monthYearEl); - } - - // Restore month-name for first day of month on previously selected item - const prevDate = this.parseDate(this.selectedElement.dataset.date); - if (prevDate.getDate() === 1 && !this.selectedElement.querySelector('.month-name')) { - const monthName = document.createElement('span'); - monthName.className = 'month-name'; - monthName.textContent = this.getMonthName(prevDate); - this.selectedElement.appendChild(monthName); - } - } - - // Mark the new date as selected - dateItem.classList.add('selected'); - this.selectedElement = dateItem; - - // Remove month-name if it exists to avoid duplication - const monthNameEl = dateItem.querySelector('.month-name'); - if (monthNameEl) { - dateItem.removeChild(monthNameEl); - } - - // Add month and year to the selected item - if (!dateItem.querySelector('.month-year-name')) { - const monthYearName = document.createElement('span'); - monthYearName.className = 'month-year-name'; - monthYearName.textContent = `${this.getMonthName(dateToSelect)} ${dateToSelect.getFullYear()}`; - dateItem.appendChild(monthYearName); - } - - this.options.selectedDate = dateToSelect; - - // Update last valid date - this.lastValidDate = new Date(dateToSelect); - - // Update the month row to highlight the correct month - if (this.options.showMonthRow) { - this.highlightSelectedMonth(); - } - - // First center the selected date to ensure it's visible - this.scrollToSelectedDate(true); - - // For manual selections, call the callback immediately - if (isManualSelection) { - if (typeof this.options.onDateSelect === 'function') { - this.options.onDateSelect(dateToSelect, dateItem.dataset.date, true); - } - - // Dispatch custom event - const event = new CustomEvent('dateSelected', { - detail: { - date: dateToSelect, - formattedDate: dateItem.dataset.date, - isRange: false, - rangeStart: null, - rangeEnd: null - } - }); - this.element.dispatchEvent(event); - } - } - - // Enter range mode - enterRangeMode(dateItem) { - this.rangeMode = true; - this.rangeStartDate = this.parseDate(dateItem.dataset.date); - this.rangeEndDate = this.parseDate(dateItem.dataset.date); - - // Show clear range button - this.clearRangeButton.style.display = 'flex'; - - // Update all date items to show range mode - this.updateDateItemsForRange(); - - // Add visual feedback - dateItem.classList.add('range-start'); - dateItem.classList.remove('selected'); - - // Remove month-year-name from the start date - const monthYearEl = dateItem.querySelector('.month-year-name'); - if (monthYearEl) { - dateItem.removeChild(monthYearEl); - } - - console.log('Entered range mode, start date:', this.rangeStartDate); - } - - // Select range end date - selectRangeEnd(dateItem) { - const clickedDate = this.parseDate(dateItem.dataset.date); - - // Check if date is within min/max range - if ((this.options.minDate && clickedDate < new Date(this.options.minDate)) || - (this.options.maxDate && clickedDate > new Date(this.options.maxDate))) { - this.flashInvalidSelection(dateItem); - return; - } - - // Check if future dates are allowed - if (!this.options.allowFutureDates) { - const today = new Date(); - today.setHours(23, 59, 59, 59); - if (clickedDate > today) { - this.flashInvalidSelection(dateItem); - return; - } - } - - // Determine behavior based on where the clicked date is relative to the current range - if (clickedDate < this.rangeStartDate) { - // Clicking before the start: move the start date - this.rangeStartDate = clickedDate; - // Keep the end date as is (if it exists) - } else if (this.rangeEndDate && this.isDateInRange(clickedDate, this.rangeStartDate, this.rangeEndDate)) { - // Clicking inside the range: move the end date - this.rangeEndDate = clickedDate; - } else if (this.isSameDay(clickedDate, this.rangeStartDate)) { - // Clicking on the start date: exit range mode - this.exitRangeMode(); - return; - } else { - // Clicking after the start (or after the current end): move the end date - this.rangeEndDate = clickedDate; - } - - // Clear any preview - this.clearRangePreview(); - - // Update all date items to show the complete range - this.updateDateItemsForRange(); - - // Call the onDateRangeSelect callback with range information only if both dates are set - if (this.rangeStartDate && this.rangeEndDate) { - if (typeof this.options.onDateRangeSelect === 'function') { - this.options.onDateRangeSelect( - this.rangeStartDate, - this.rangeEndDate, - this.formatDate(this.rangeStartDate), - this.formatDate(this.rangeEndDate) - ); - } - } - - console.log('Range selected:', this.rangeStartDate, 'to', this.rangeEndDate); - } - - // Exit range mode - exitRangeMode() { - this.rangeMode = false; - const lastStartDate = this.rangeStartDate; - this.rangeStartDate = null; - this.rangeEndDate = null; - - // Hide clear range button - this.clearRangeButton.style.display = 'none'; - - // Clear any preview - this.clearRangePreview(); - - // Update all date items to remove range styling - this.updateDateItemsForRange(); - - // Restore the original selected date - if (lastStartDate) { - this.options.selectedDate = lastStartDate; - const dateItems = this.dateContainer.querySelectorAll('.date-item'); - const formattedDate = this.formatDate(lastStartDate); - - for (const item of dateItems) { - if (item.dataset.date === formattedDate) { - this.selectDateItem(item, true); - break; - } - } - } - - console.log('Exited range mode'); - } - - // Show range preview on hover - showRangePreview(dateItem) { - const hoveredDate = this.parseDate(dateItem.dataset.date); - - // Don't show preview if hovering over the start date or end date - if (this.isSameDay(hoveredDate, this.rangeStartDate) || - (this.rangeEndDate && this.isSameDay(hoveredDate, this.rangeEndDate))) { - return; - } - - // Clear any existing preview - this.clearRangePreview(); - - // Determine the preview range based on where the hovered date is - let previewStart, previewEnd; - - if (hoveredDate < this.rangeStartDate) { - // Hovering before start: preview shows new start to current end (or current start if no end) - previewStart = hoveredDate; - previewEnd = this.rangeEndDate || this.rangeStartDate; - } else if (this.rangeEndDate && hoveredDate <= this.rangeEndDate) { - // Hovering inside the range: preview shows start to hovered date - previewStart = this.rangeStartDate; - previewEnd = hoveredDate; - } else { - // Hovering after start (or after end): preview shows start to hovered date - previewStart = this.rangeStartDate; - previewEnd = hoveredDate; - } - - // Apply preview styling to dates in the range - const dateItems = this.dateContainer.querySelectorAll('.date-item'); - dateItems.forEach(item => { - const date = this.parseDate(item.dataset.date); - - // Don't apply preview to start/end dates - if (this.isDateInRange(date, previewStart, previewEnd) && - !this.isSameDay(date, this.rangeStartDate) && - !(this.rangeEndDate && this.isSameDay(date, this.rangeEndDate))) { - item.classList.add('range-preview'); - } - }); - } - - // Clear range preview - clearRangePreview() { - const dateItems = this.dateContainer.querySelectorAll('.date-item'); - dateItems.forEach(item => { - item.classList.remove('range-preview'); - }); - } - - // Update all date items to reflect range mode - updateDateItemsForRange() { - const dateItems = this.dateContainer.querySelectorAll('.date-item'); - - dateItems.forEach(item => { - const date = this.parseDate(item.dataset.date); - - // Remove all range-related classes - item.classList.remove('in-range', 'range-start', 'range-end', 'selected', 'range-preview'); - - // Remove month-year-name if it exists - const monthYearEl = item.querySelector('.month-year-name'); - if (monthYearEl) { - item.removeChild(monthYearEl); - } - - // Restore month-name for first day of month - if (date.getDate() === 1 && !item.querySelector('.month-name')) { - const monthName = document.createElement('span'); - monthName.className = 'month-name'; - monthName.textContent = this.getMonthName(date); - item.appendChild(monthName); - } - - if (this.rangeMode) { - if (this.rangeStartDate && this.isSameDay(date, this.rangeStartDate)) { - item.classList.add('range-start'); - } - - if (this.rangeEndDate) { - if (this.isSameDay(date, this.rangeEndDate)) { - item.classList.add('range-end'); - } - - if (this.isDateInRange(date, this.rangeStartDate, this.rangeEndDate)) { - item.classList.add('in-range'); - } - } - } else { - // Restore selected state for non-range mode - if (this.isSameDay(date, this.options.selectedDate)) { - item.classList.add('selected'); - this.selectedElement = item; - - // Remove month-name to avoid duplication - const monthNameEl = item.querySelector('.month-name'); - if (monthNameEl) { - item.removeChild(monthNameEl); - } - - // Add month-year-name - if (!item.querySelector('.month-year-name')) { - const monthYearName = document.createElement('span'); - monthYearName.className = 'month-year-name'; - monthYearName.textContent = `${this.getMonthName(date)} ${date.getFullYear()}`; - item.appendChild(monthYearName); - } - } - } - }); - } - - // Update month row for hovered date - updateMonthRowForHoveredDate(dateItem) { - if (!this.options.showMonthRow) return; - - const hoveredDate = this.parseDate(dateItem.dataset.date); - const hoveredYear = hoveredDate.getFullYear(); - const hoveredMonth = hoveredDate.getMonth(); - - // Store the original selected date if not already stored - if (!this.originalSelectedDate) { - this.originalSelectedDate = new Date(this.options.selectedDate); - } - - // Check if the hovered date is in a different month or year - const selectedYear = this.options.selectedDate.getFullYear(); - const selectedMonth = this.options.selectedDate.getMonth(); - - // Update year items - const yearItems = this.monthRowContainer.querySelectorAll('.year-item'); - yearItems.forEach(item => { - const itemYear = parseInt(item.dataset.year); - item.classList.remove('selected'); - - if (itemYear === hoveredYear) { - item.classList.add('selected'); - } - }); - - // Update month items - const monthItems = this.monthRowContainer.querySelectorAll('.month-item'); - monthItems.forEach(item => { - const itemYear = parseInt(item.dataset.year); - const itemMonth = parseInt(item.dataset.month); - item.classList.remove('selected'); - - if (itemYear === hoveredYear && itemMonth === hoveredMonth) { - item.classList.add('selected'); - - // Scroll to the hovered month - item.scrollIntoView({behavior: 'smooth', block: 'nearest', inline: 'center'}); - } - }); - } - - // Restore original month row - restoreOriginalMonthRow() { - if (!this.options.showMonthRow || !this.originalSelectedDate) return; - - const originalYear = this.originalSelectedDate.getFullYear(); - const originalMonth = this.originalSelectedDate.getMonth(); - - // Restore year items - const yearItems = this.monthRowContainer.querySelectorAll('.year-item'); - yearItems.forEach(item => { - const itemYear = parseInt(item.dataset.year); - item.classList.remove('selected'); - - if (itemYear === originalYear) { - item.classList.add('selected'); - } - }); - - // Restore month items - const monthItems = this.monthRowContainer.querySelectorAll('.month-item'); - monthItems.forEach(item => { - const itemYear = parseInt(item.dataset.year); - const itemMonth = parseInt(item.dataset.month); - item.classList.remove('selected'); - - if (itemYear === originalYear && itemMonth === originalMonth) { - item.classList.add('selected'); - - // Scroll back to the original month - item.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); - } - }); - - // Clear the stored original date - this.originalSelectedDate = null; - } - - // Check if a date is in a range - isDateInRange(date, startDate, endDate) { - if (!startDate || !endDate) return false; - - const dateTime = date.getTime(); - const startTime = startDate.getTime(); - const endTime = endDate.getTime(); - - return dateTime >= startTime && dateTime <= endTime; - } - - navigateDates(offset) { - const firstDateElement = this.dateContainer.firstElementChild; - const firstDate = this.parseDate(firstDateElement.dataset.date); - - // Create a new start date by adding the offset to the first date - const newStartDate = new Date(firstDate); - newStartDate.setDate(newStartDate.getDate() + offset); - - // Check if navigation would go beyond min/max dates, but only if they are set - if (this.options.minDate) { - const minDate = new Date(this.options.minDate); - if (newStartDate < minDate) { - newStartDate.setTime(minDate.getTime()); - } - } - - if (this.options.maxDate) { - const maxDate = new Date(this.options.maxDate); - const lastVisibleDate = new Date(newStartDate); - lastVisibleDate.setDate(lastVisibleDate.getDate() + this.options.daysToShow - 1); - - if (lastVisibleDate > maxDate) { - // Adjust start date so that max date is the last visible date - newStartDate.setTime(maxDate.getTime()); - newStartDate.setDate(newStartDate.getDate() - this.options.daysToShow + 1); - } - } - - // Clear the container - this.dateContainer.innerHTML = ''; - - // Generate new dates starting from the new start date - for (let i = 0; i < this.options.daysToShow; i++) { - const date = new Date(newStartDate); - date.setDate(newStartDate.getDate() + i); - - // Skip dates outside of min/max range if specified - if ((this.options.minDate && date < new Date(this.options.minDate)) || - (this.options.maxDate && date > new Date(this.options.maxDate))) { - continue; - } - - const dateItem = this.createDateElement(date); - this.dateContainer.appendChild(dateItem); - } - - // Add extra dates at both ends for continuous scrolling - this.addMoreDatesAtStart(); - this.addMoreDatesAtEnd(); - - // Scroll to the selected date if it's visible, otherwise to the middle - if (this.selectedElement) { - this.scrollToSelectedDate(true); - } else { - // Scroll to the middle of the container - this.dateContainer.scrollTo({ - left: this.dateContainer.scrollWidth / 2 - this.dateContainer.clientWidth / 2, - behavior: 'smooth' - }); - } - } - - scrollToSelectedDate(smooth = true) { - if (this.selectedElement) { - const containerWidth = this.dateContainer.offsetWidth; - const itemLeft = this.selectedElement.offsetLeft; - const itemWidth = this.selectedElement.offsetWidth; - - const scrollPosition = itemLeft - (containerWidth / 2) + (itemWidth / 2); - - this.dateContainer.scrollTo({ - left: scrollPosition, - behavior: smooth ? 'smooth' : 'auto' - }); - - // Add animation effect to the selected element - if (smooth) { - this.selectedElement.style.transition = 'transform 0.3s ease, background-color 0.3s ease, box-shadow 0.3s ease'; - this.selectedElement.style.transform = 'scale(1.05)'; - setTimeout(() => { - this.selectedElement.style.transform = ''; - }, 300); - } - } - } - - // Helper methods - formatDate(date) { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; - } - - parseDate(dateString) { - const parts = dateString.split('-'); - const year = parseInt(parts[0], 10); - const month = parseInt(parts[1], 10) - 1; // Month is 0-indexed - const day = parseInt(parts[2], 10); - return new Date(year, month, day); - } - - getDayName(date) { - return window.locale?.days ? window.locale.days[date.getDay()] : - ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][date.getDay()]; - } - - getMonthName(date) { - return window.locale?.months ? window.locale.months[date.getMonth()] : - ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][date.getMonth()]; - } - - isSameDay(date1, date2) { - return date1.getDate() === date2.getDate() && - date1.getMonth() === date2.getMonth() && - date1.getFullYear() === date2.getFullYear(); - } - - // Check if a date is unavailable - isDateUnavailable(date) { - // Check if date is outside min/max range - if (this.options.minDate && date < new Date(this.options.minDate)) { - return true; - } - - if (this.options.maxDate && date > new Date(this.options.maxDate)) { - return true; - } - - // Check if future dates are not allowed - if (!this.options.allowFutureDates) { - const today = new Date(); - today.setHours(23, 59, 59, 59); - - if (date > today) { - return true; - } - } - - return false; - } - - // Check if a year is unavailable - isYearUnavailable(year) { - // Check if the entire year is outside min/max range - const yearStart = new Date(year, 0, 1); - const yearEnd = new Date(year, 11, 31); - - if (this.options.minDate && yearEnd < new Date(this.options.minDate)) { - return true; - } - - if (this.options.maxDate && yearStart > new Date(this.options.maxDate)) { - return true; - } - - // Check if future years are not allowed - if (!this.options.allowFutureDates) { - const today = new Date(); - const currentYear = today.getFullYear(); - - if (year > currentYear) { - return true; - } - } - - return false; - } - - // Check if a month is unavailable - isMonthUnavailable(year, month) { - // Check if the entire month is outside min/max range - const monthStart = new Date(year, month, 1); - const monthEnd = new Date(year, month + 1, 0); - - if (this.options.minDate && monthEnd < new Date(this.options.minDate)) { - return true; - } - - if (this.options.maxDate && monthStart > new Date(this.options.maxDate)) { - return true; - } - - // Check if future months are not allowed - if (!this.options.allowFutureDates) { - const today = new Date(); - const currentYear = today.getFullYear(); - const currentMonth = today.getMonth(); - - if (year > currentYear || (year === currentYear && month > currentMonth)) { - return true; - } - } - - return false; - } - - // Flash invalid selection and select today's date - flashInvalidSelection(element) { - if (!element) return; - - // Store original background color - const originalBackground = element.style.backgroundColor || ''; - - // Set up transition for smooth flashing - element.style.transition = 'background-color 0.15s ease'; - - let flashCount = 0; - const maxFlashes = 6; // 3 complete flash cycles (red -> original -> red -> original -> red -> original) - - const flash = () => { - if (flashCount < maxFlashes) { - // Alternate between red and original color - element.style.backgroundColor = flashCount % 2 === 0 ? '#ff4444' : originalBackground; - flashCount++; - - // Continue flashing - setTimeout(flash, 150); - } else { - // Restore original background and go to today - element.style.backgroundColor = originalBackground; - - // Go to today's date after flashing is complete - setTimeout(() => { - this.goToToday(); - }, 100); - } - }; - - // Start the flashing animation - flash(); - } - - // Populate the month row - populateMonthRow() { - this.monthRowContainer.innerHTML = ''; - - const selectedYear = this.options.selectedDate.getFullYear(); - - - // Add Today button if enabled - if (this.options.showTodayButton) { - const todayButton = document.createElement('div'); - todayButton.className = 'today-button'; - todayButton.innerHTML = ` ${window.locale?.today || 'Today'}`; - todayButton.addEventListener('click', () => { - this.goToToday(); - }); - this.monthRowContainer.appendChild(todayButton); - } - // Create year row if enabled - if (this.options.showYearRow) { - const yearRow = document.createElement('div'); - yearRow.className = 'year-row'; - - - // Calculate how many years to show before and after the selected year - const yearsToShow = this.options.yearsToShow; - const halfYears = Math.floor(yearsToShow / 2); - const startYear = selectedYear - halfYears; - - // Add years to the row - for (let i = 0; i < yearsToShow; i++) { - const year = startYear + i; - const yearItem = document.createElement('div'); - yearItem.className = 'year-item'; - yearItem.textContent = year; - yearItem.dataset.year = year; - - // Check if this year is unavailable - const isUnavailable = this.isYearUnavailable(year); - if (isUnavailable) { - yearItem.classList.add('unavailable'); - } - - // Mark selected year - if (year === selectedYear) { - yearItem.classList.add('selected'); - } - - yearItem.addEventListener('click', () => { - if (!isUnavailable) { - this.selectYear(year); - } - }); - yearRow.appendChild(yearItem); - } - - this.monthRowContainer.appendChild(yearRow); - } - - // Create month row - const monthRow = document.createElement('div'); - monthRow.className = 'month-row'; - - // Show all 12 months of the selected year - for (let month = 0; month < 12; month++) { - let year = selectedYear; - - const monthDate = new Date(year, month, 1); - - // Skip months outside min/max range if specified - if ((this.options.minDate && monthDate < new Date(this.options.minDate)) || - (this.options.maxDate && monthDate > new Date(this.options.maxDate))) { - continue; - } - - const monthItem = document.createElement('div'); - monthItem.className = 'month-item'; - monthItem.dataset.year = year; - monthItem.dataset.month = month; - - // Check if this month is unavailable - const isUnavailable = this.isMonthUnavailable(year, month); - if (isUnavailable) { - monthItem.classList.add('unavailable'); - } - - // Check if this is the selected month - if (year === this.options.selectedDate.getFullYear() && - month === this.options.selectedDate.getMonth()) { - monthItem.classList.add('selected'); - this.selectedMonthElement = monthItem; - } - - // Add month name - monthItem.textContent = this.getMonthName(monthDate); - - // Add click event - monthItem.addEventListener('click', () => { - // Check if this month is already selected - if (year === this.options.selectedDate.getFullYear() && - month === this.options.selectedDate.getMonth()) { - return; // Do nothing if clicking on already selected month - } - if (!isUnavailable) { - this.selectMonth(year, month); - } - }); - - monthRow.appendChild(monthItem); - } - - this.monthRowContainer.appendChild(monthRow); - } - - // Select a month - selectMonth(year, month) { - // Check if this month is unavailable - if (this.isMonthUnavailable(year, month)) { - // Find the month element and flash it - const monthItems = this.monthRowContainer.querySelectorAll('.month-item'); - for (const item of monthItems) { - if (parseInt(item.dataset.year) === year && parseInt(item.dataset.month) === month) { - this.flashInvalidSelection(item); - break; - } - } - return; - } - - // Get the current day from the selected date - const currentDay = this.options.selectedDate.getDate(); - - // Create a new date with the selected month and current year - const newDate = new Date(this.options.selectedDate); - newDate.setMonth(month); - - // Check if future dates are allowed - if (!this.options.allowFutureDates) { - const today = new Date(); - today.setHours(23, 59, 59, 59); - - if (newDate > today) { - // Find the month element and flash it - const monthItems = this.monthRowContainer.querySelectorAll('.month-item'); - for (const item of monthItems) { - if (parseInt(item.dataset.year) === year && parseInt(item.dataset.month) === month) { - this.flashInvalidSelection(item); - break; - } - } - return; // Don't select future months if not allowed - } - } - - // Get the last day of the selected month - const lastDayOfMonth = new Date(newDate.getFullYear(), month + 1, 0).getDate(); - - // Set the day to either the current day or the last day of the month if the current day exceeds it - newDate.setDate(Math.min(currentDay, lastDayOfMonth)); - - // Store the exact date we want to select - const exactSelectedDate = new Date(newDate); - - // Update last valid date - this.lastValidDate = new Date(exactSelectedDate); - - // Completely recreate the date picker with the new date as the center - this.options.selectedDate = exactSelectedDate; - this.options.daysBeforeToday = Math.floor(this.options.daysToShow / 2); - this.populateDates(); - - // Find and force select the exact date we want - setTimeout(() => { - const dateItems = this.dateContainer.querySelectorAll('.date-item'); - const formattedExactDate = this.formatDate(exactSelectedDate); - - for (const item of dateItems) { - if (item.dataset.date === formattedExactDate) { - this.selectDateItem(item, true); - break; - } - } - - // Highlight the selected month - this.highlightSelectedMonth(); - }, 0); - - // Call onDateSelect callback if provided - const formattedDate = this.formatDate(exactSelectedDate); - if (typeof this.options.onDateSelect === 'function') { - this.options.onDateSelect(exactSelectedDate, formattedDate, false); - } - - // Dispatch custom event - const event = new CustomEvent('dateSelected', { - detail: { - date: exactSelectedDate, - formattedDate: formattedDate, - isRange: false, - rangeStart: null, - rangeEnd: null - } - }); - this.element.dispatchEvent(event); - } - - // Select a year - selectYear(year) { - // Check if this year is unavailable - if (this.isYearUnavailable(year)) { - // Find the year element and flash it - const yearItems = this.monthRowContainer.querySelectorAll('.year-item'); - for (const item of yearItems) { - if (parseInt(item.dataset.year) === year) { - this.flashInvalidSelection(item); - break; - } - } - return; - } - - // Get the current month and day from the selected date - const currentDate = new Date(this.options.selectedDate); - - // Create a new date with the selected year but keep month and day - const newDate = new Date(currentDate); - newDate.setFullYear(year); - - // Check if future dates are allowed - if (!this.options.allowFutureDates) { - const today = new Date(); - today.setHours(23, 59, 59, 59); - - if (newDate > today) { - // Find the year element and flash it - const yearItems = this.monthRowContainer.querySelectorAll('.year-item'); - for (const item of yearItems) { - if (parseInt(item.dataset.year) === year) { - this.flashInvalidSelection(item); - break; - } - } - return; // Don't select future years if not allowed - } - } - - // Get the last day of the selected month in the new year - const month = newDate.getMonth(); - const lastDayOfMonth = new Date(year, month + 1, 0).getDate(); - - // Set the day to either the current day or the last day of the month if the current day exceeds it - if (currentDate.getDate() > lastDayOfMonth) { - newDate.setDate(lastDayOfMonth); - } - - // Store the exact date we want to select - const exactSelectedDate = new Date(newDate); - - // Update last valid date - this.lastValidDate = new Date(exactSelectedDate); - - // Completely recreate the date picker with the new date as the center - this.options.selectedDate = exactSelectedDate; - this.options.daysBeforeToday = Math.floor(this.options.daysToShow / 2); - this.populateDates(); - - // Find and force select the exact date we want - setTimeout(() => { - const dateItems = this.dateContainer.querySelectorAll('.date-item'); - const formattedExactDate = this.formatDate(exactSelectedDate); - - for (const item of dateItems) { - if (item.dataset.date === formattedExactDate) { - this.selectDateItem(item, true); - break; - } - } - - // Repopulate the month row to show the new year - this.populateMonthRow(); - }, 0); - - // Call onDateSelect callback if provided - const formattedDate = this.formatDate(exactSelectedDate); - if (typeof this.options.onDateSelect === 'function') { - this.options.onDateSelect(exactSelectedDate, formattedDate, false); - } - - // Dispatch custom event - const event = new CustomEvent('dateSelected', { - detail: { - date: exactSelectedDate, - formattedDate: formattedDate, - isRange: false, - rangeStart: null, - rangeEnd: null - } - }); - this.element.dispatchEvent(event); - } - - // Highlight the selected month in the month row - highlightSelectedMonth() { - if (!this.options.showMonthRow) return; - - // Check if we need to repopulate the month row to keep the selected month visible - const selectedYear = this.options.selectedDate.getFullYear(); - const selectedMonth = this.options.selectedDate.getMonth(); - - // Update year selection - const yearItems = this.monthRowContainer.querySelectorAll('.year-item'); - let yearVisible = false; - - yearItems.forEach(item => { - const itemYear = parseInt(item.dataset.year); - item.classList.remove('selected'); - - if (itemYear === selectedYear) { - item.classList.add('selected'); - yearVisible = true; - } - }); - - // If selected year is not visible, repopulate the month row - if (!yearVisible) { - this.populateMonthRow(); - return; - } - - // Update month selection - let selectedMonthVisible = false; - const monthItems = this.monthRowContainer.querySelectorAll('.month-item'); - - // Remove selected class from all month items - monthItems.forEach(item => { - item.classList.remove('selected'); - - const itemYear = parseInt(item.dataset.year); - const itemMonth = parseInt(item.dataset.month); - - if (itemYear === selectedYear && itemMonth === selectedMonth) { - item.classList.add('selected'); - this.selectedMonthElement = item; - selectedMonthVisible = true; - - // Scroll to the selected month - item.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); - } - }); - - // If selected month is not visible, repopulate the month row - if (!selectedMonthVisible) { - this.populateMonthRow(); - } - } - - // Public methods - setDate(date) { - const newDate = new Date(date); - // Check if date is within min/max range, but only if they are set - if ((this.options.minDate && newDate < new Date(this.options.minDate)) || - (this.options.maxDate && newDate > new Date(this.options.maxDate))) { - console.warn('Date is outside of allowed min/max range'); - return; - } - - this.options.selectedDate = newDate; - - // Update last valid date - this.lastValidDate = new Date(newDate); - - // Adjust daysBeforeToday to center the selected date - const today = new Date(); - if (newDate < today || newDate > today) { - // Calculate days difference between selected date and today - const diffTime = Math.abs(newDate - today); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - - // Center the selected date in the view - if (newDate < today) { - this.options.daysBeforeToday = diffDays + Math.floor(this.options.daysToShow / 2); - } else { - this.options.daysBeforeToday = Math.floor(this.options.daysToShow / 2) - diffDays; - } - } - - this.populateDates(); - - // Update the month row if enabled - if (this.options.showMonthRow) { - this.highlightSelectedMonth(); - } - - - // Find and mark the selected date element - setTimeout(() => { - const dateItems = this.dateContainer.querySelectorAll('.date-item'); - const formattedDate = this.formatDate(newDate); - - for (const item of dateItems) { - if (item.dataset.date === formattedDate) { - if (this.selectedElement) { - this.selectedElement.classList.remove('selected'); - } - item.classList.add('selected'); - this.selectedElement = item; - break; - } - } - - if (typeof this.options.onDateSelect === 'function') { - this.options.onDateSelect(today, formattedDate, false); - } - // Center the selected date - this.scrollToSelectedDate(false); - const event = new CustomEvent('dateSelected', { - detail: { - date: newDate, - formattedDate: formattedDate, - isRange: false, - rangeStart: null, - rangeEnd: null - } - }); - this.element.dispatchEvent(event); - - }, 0); - } - - // Go to today's date - goToToday() { - const today = new Date(); - - // Check if future dates are allowed - if (!this.options.allowFutureDates) { - today.setHours(23, 59, 59, 59); - } - - // Reset daysBeforeToday to default - this.options.daysBeforeToday = Math.floor(this.options.daysToShow / 2); - - // Set date to today - this.setDate(today); - - // Call onDateSelect callback - const formattedDate = this.formatDate(today); - if (typeof this.options.onDateSelect === 'function') { - this.options.onDateSelect(today, formattedDate, true); - } - - // Dispatch custom event - const event = new CustomEvent('dateSelected', { - detail: { - date: today, - formattedDate: formattedDate, - isRange: false, - rangeStart: null, - rangeEnd: null - } - }); - this.element.dispatchEvent(event); - } -} diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index 0f81d579..a47038c4 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -16,7 +16,7 @@ - + @@ -61,7 +61,7 @@ -
+
@@ -582,41 +582,66 @@ dateToUse = new Date(year, month - 1, day); } - // Initialize horizontal date picker - window.horizontalDatePicker = new HorizontalDatePicker({ - container: document.getElementById('horizontal-date-picker-container'), - selectedDate: dateToUse, - showNavButtons: false, - daysToShow: 21, - showMonthRow: true, - showYearRow: true, - yearsToShow: 5, - allowFutureDates: false, - showTodayButton: true, - onDateSelect: (date, formattedDate) => { - // Update URL - updateUrlWithDate(formattedDate); - // Clear any existing date range - currentDateRange = null; - // Trigger HTMX reload of timeline - document.body.dispatchEvent(new CustomEvent('dateChanged')); - }, - onDateRangeSelect: (startDate, endDate, formattedStartDate, formattedEndDate) => { - // Store the date range - currentDateRange = { - startDate: formattedStartDate, - endDate: formattedEndDate - }; - // Update URL to show range - const url = new URL(window.location); - url.searchParams.set('startDate', formattedStartDate); - url.searchParams.set('endDate', formattedEndDate); - url.searchParams.delete('date'); - window.history.pushState({}, '', url); - // Trigger HTMX reload of timeline - document.body.dispatchEvent(new CustomEvent('dateChanged')); - } + window.horizontalDatePicker = new DatePicker('date-picker-container', { + daysToShow: 14, + prefetchDays: 25, + allowRangeSelection: true, + singleDateMode: true, // Enable single date mode with locking + dateFormat: 'YYYY-MM-DD', + startDate: dateToUse, + }); + + window.horizontalDatePicker.on('selectionChange', function (data) { + console.log(`Selection changed`, data); + updateUrlWithDate(data.startDate); + + const startDate = new Date(data.startDate); + let endDate; + if (data.endDate) { + endDate = new Date(data.endDate); + } else { + switch (data.timeband) { + case 'day': endDate = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate() + 1); break; + case 'month': endDate = new Date(startDate.getFullYear(), startDate.getMonth() + 1, 1); break; + case 'year': endDate = new Date(startDate.getFullYear() + 1, 0, 1); break + } + } + currentDateRange = { + startDate: window.horizontalDatePicker.formatDate(startDate), + endDate: window.horizontalDatePicker.formatDate(endDate) + }; + const url = new URL(window.location); + url.searchParams.set('startDate', currentDateRange.startDate); + url.searchParams.set('endDate', currentDateRange.endDate); + url.searchParams.delete('date'); + window.history.pushState({}, '', url); + // Trigger HTMX reload of timeline + document.body.dispatchEvent(new CustomEvent('dateChanged')); + }); + // onDateSelect: (date, formattedDate) => { + // // Update URL + // updateUrlWithDate(formattedDate); + // // Clear any existing date range + // currentDateRange = null; + // // Trigger HTMX reload of timeline + // document.body.dispatchEvent(new CustomEvent('dateChanged')); + // }, + // onDateRangeSelect: (startDate, endDate, formattedStartDate, formattedEndDate) => { + // // Store the date range + // currentDateRange = { + // startDate: formattedStartDate, + // endDate: formattedEndDate + // }; + // // Update URL to show range + // const url = new URL(window.location); + // url.searchParams.set('startDate', formattedStartDate); + // url.searchParams.set('endDate', formattedEndDate); + // url.searchParams.delete('date'); + // window.history.pushState({}, '', url); + // // Trigger HTMX reload of timeline + // document.body.dispatchEvent(new CustomEvent('dateChanged')); + // } }); @@ -646,7 +671,7 @@ // Update the date picker to today if (window.horizontalDatePicker) { - window.horizontalDatePicker.setDate(new Date()); + window.horizontalDatePicker.setSelectedRange(new Date(), null); } // Start the timer to check for date changes every 30 seconds @@ -716,8 +741,7 @@ icon.className = 'lni lni-play'; btn.title = 'Auto Update'; if (window.horizontalDatePicker) { - window.horizontalDatePicker.exitRangeMode(); - window.horizontalDatePicker.setDate(getSelectedDate()); + window.horizontalDatePicker.setSelectedRange(getSelectedDate()); } document.body.classList.remove('auto-update-mode'); @@ -735,7 +759,7 @@ if (!isSelectedDateToday()) { console.log('Auto-update: Switching to today\'s date'); if (window.horizontalDatePicker) { - window.horizontalDatePicker.setDate(new Date()); + window.horizontalDatePicker.setSelectedRange(new Date()); } } }, 30000); // 30 seconds