From 9e3f7ddd5746597bd9ab4c75aad61a27dc0b75cd Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Tue, 26 Aug 2025 05:58:24 +0100 Subject: [PATCH] Major new version (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release Notes Architecture - Component refactor: removed monoliths (`Calendar.vue`, `CalendarGrid.vue`); expanded granular view + header/control components. - Dialog system introduced (`BaseDialog`, `SettingsDialog`). State & Data - Store redesigned: Map-based events + recurrence map; mutation counters. - Local persistence + undo/redo history (custom plugins). Date & Holidays - Migrated all date logic to `date-fns` (+ tz). - Added national holiday support (toggle + loading utilities). Recurrence & Events - Consolidated recurrence handling; fixes for monthly edge days (29–31), annual, multi‑day, and complex weekly repeats. - Reliable splitting/moving/resizing/deletion of repeating and multi‑day events. Interaction & UX - Double‑tap to create events; improved drag (multi‑day + position retention). - Scroll & inertial/momentum navigation; year change via numeric scroller. - Movable event dialog; live settings application. Performance - Progressive / virtual week rendering, reduced off‑screen buffer. - Targeted repaint strategy; minimized full re-renders. Plugins Added - History, undo normalization, persistence, scroll manager, virtual weeks. Styling & Layout - Responsive + compact layout refinements; header restructured. - Simplified visual elements (removed dots/overflow text); holiday styling adjustments. Reliability / Fixes - Numerous recurrence, deletion, orientation/rotation, and event indexing corrections. - Cross-browser fallback (Firefox week info). Dependencies Added - date-fns, date-fns-tz, date-holidays, pinia-plugin-persistedstate. Net Change - 28 files modified; ~4.4K insertions / ~2.2K deletions (major refactor + feature set). --- package.json | 6 +- src/App.vue | 42 +- src/assets/colors.css | 172 ++++-- src/assets/layout.css | 99 +-- src/components/BaseDialog.vue | 264 ++++++++ src/components/Calendar.vue | 35 -- src/components/CalendarDay.vue | 95 +-- src/components/CalendarGrid.vue | 184 ------ src/components/CalendarHeader.vue | 100 ++- src/components/CalendarView.vue | 704 ++++++++++++--------- src/components/CalendarWeek.vue | 69 +-- src/components/EventDialog.vue | 885 +++++++++++++++------------ src/components/EventOverlay.vue | 580 +++++++----------- src/components/HeaderControls.vue | 210 +++++++ src/components/Jogwheel.vue | 148 ++++- src/components/Numeric.vue | 249 ++++---- src/components/SettingsDialog.vue | 309 ++++++++++ src/components/WeekRow.vue | 41 +- src/components/WeekdaySelector.vue | 61 +- src/main.js | 8 +- src/plugins/calendarHistory.js | 200 ++++++ src/plugins/calendarUndoNormalize.js | 57 ++ src/plugins/persist.js | 24 + src/plugins/scrollManager.js | 331 ++++++++++ src/plugins/virtualWeeks.js | 400 ++++++++++++ src/stores/CalendarStore.js | 774 ++++++++++++----------- src/utils/date.js | 450 +++++++++----- src/utils/holidays.js | 179 ++++++ 28 files changed, 4467 insertions(+), 2209 deletions(-) create mode 100644 src/components/BaseDialog.vue delete mode 100644 src/components/Calendar.vue delete mode 100644 src/components/CalendarGrid.vue create mode 100644 src/components/HeaderControls.vue create mode 100644 src/components/SettingsDialog.vue create mode 100644 src/plugins/calendarHistory.js create mode 100644 src/plugins/calendarUndoNormalize.js create mode 100644 src/plugins/persist.js create mode 100644 src/plugins/scrollManager.js create mode 100644 src/plugins/virtualWeeks.js create mode 100644 src/utils/holidays.js diff --git a/package.json b/package.json index 497bcdf..cca40c0 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,11 @@ "format": "prettier --write src/" }, "dependencies": { + "date-fns": "^3.6.0", + "date-fns-tz": "^3.0.0", + "date-holidays": "^3.25.1", "pinia": "^3.0.3", + "pinia-plugin-persistedstate": "^4.5.0", "vue": "^3.5.18" }, "devDependencies": { @@ -34,4 +38,4 @@ "vite": "npm:rolldown-vite@latest", "vite-plugin-vue-devtools": "^8.0.0" } -} \ No newline at end of file +} diff --git a/src/App.vue b/src/App.vue index 8d02976..5836a2c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,9 +1,45 @@ + + + + diff --git a/src/components/Calendar.vue b/src/components/Calendar.vue deleted file mode 100644 index 010074a..0000000 --- a/src/components/Calendar.vue +++ /dev/null @@ -1,35 +0,0 @@ - - - diff --git a/src/components/CalendarDay.vue b/src/components/CalendarDay.vue index b68648b..fde7669 100644 --- a/src/components/CalendarDay.vue +++ b/src/components/CalendarDay.vue @@ -1,18 +1,14 @@ @@ -50,7 +38,6 @@ const handleEventClick = (eventId) => { border-right: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color); user-select: none; - touch-action: none; display: flex; flex-direction: row; align-items: flex-start; @@ -58,7 +45,7 @@ const handleEventClick = (eventId) => { padding: 0.25em; overflow: hidden; width: 100%; - height: var(--cell-h); + height: var(--row-h); font-weight: 700; transition: background-color 0.15s ease; } @@ -72,20 +59,6 @@ const handleEventClick = (eventId) => { color: var(--ink); transition: background-color 0.15s ease; } - -.cell.today h1 { - border-radius: 2em; - background: var(--today); - border: 0.2em solid var(--today); - margin: -0.2em; - color: white; - font-weight: bold; -} - -.cell:hover h1 { - text-shadow: 0 0 0.2em var(--shadow); -} - .cell.weekend h1 { color: var(--weekend); } @@ -93,18 +66,64 @@ const handleEventClick = (eventId) => { color: var(--firstday); text-shadow: 0 0 0.1em var(--strong); } +.cell.today h1 { + border-radius: 2em; + background: var(--today); + border: 0.2em solid var(--today); + margin: -0.2em; + color: var(--strong); + font-weight: bold; +} .cell.selected { filter: hue-rotate(180deg); } .cell.selected h1 { color: var(--strong); } - .lunar-phase { position: absolute; - top: 0.1em; - right: 0.1em; + top: 0.5em; + right: 0.2em; font-size: 0.8em; opacity: 0.7; } +.cell.holiday { + background-image: linear-gradient( + 135deg, + var(--holiday-grad-start, rgba(255, 255, 255, 0.5)) 0%, + var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70% + ); +} +@media (prefers-color-scheme: dark) { + .cell.holiday { + background-image: linear-gradient( + 135deg, + var(--holiday-grad-start, rgba(255, 255, 255, 0.1)) 0%, + var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70% + ); + } +} +.cell.holiday h1 { + /* Slight emphasis without forcing a specific hue */ + color: var(--holiday); + text-shadow: 0 0 0.3em rgba(255, 255, 255, 0.4); +} +.holiday-info { + position: absolute; + bottom: 0.1em; + left: 0.1em; + right: 0.1em; + line-height: 1; + overflow: hidden; + font-size: clamp(1.2vw, 0.6em, 1em); +} + +.holiday-name { + display: block; + color: var(--holiday-label); + padding: 0.15em 0.35em 0.15em 0.25em; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} diff --git a/src/components/CalendarGrid.vue b/src/components/CalendarGrid.vue deleted file mode 100644 index d9adbd8..0000000 --- a/src/components/CalendarGrid.vue +++ /dev/null @@ -1,184 +0,0 @@ - - - diff --git a/src/components/CalendarHeader.vue b/src/components/CalendarHeader.vue index 1e52287..48f36f0 100644 --- a/src/components/CalendarHeader.vue +++ b/src/components/CalendarHeader.vue @@ -1,7 +1,16 @@ diff --git a/src/components/CalendarWeek.vue b/src/components/CalendarWeek.vue index ded3d9c..b17f955 100644 --- a/src/components/CalendarWeek.vue +++ b/src/components/CalendarWeek.vue @@ -2,11 +2,15 @@ import CalendarDay from './CalendarDay.vue' import EventOverlay from './EventOverlay.vue' -const props = defineProps({ - week: Object -}) +const props = defineProps({ week: Object, dragging: { type: Boolean, default: false } }) -const emit = defineEmits(['day-mousedown', 'day-mouseenter', 'day-mouseup', 'day-touchstart', 'day-touchmove', 'day-touchend', 'event-click']) +const emit = defineEmits([ + 'day-mousedown', + 'day-mouseenter', + 'day-mouseup', + 'day-touchstart', + 'event-click', +]) const handleDayMouseDown = (dateStr) => { emit('day-mousedown', dateStr) @@ -24,42 +28,38 @@ const handleDayTouchStart = (dateStr) => { emit('day-touchstart', dateStr) } -const handleDayTouchMove = (dateStr) => { - emit('day-touchmove', dateStr) +// touchmove & touchend handled globally in CalendarView + +const handleEventClick = (payload) => { + emit('event-click', payload) } -const handleDayTouchEnd = (dateStr) => { - emit('day-touchend', dateStr) -} - -const handleEventClick = (eventId) => { - emit('event-click', eventId) +// Only apply upside-down rotation (bottomup) for Latin script month labels +function shouldRotateMonth(label) { + if (!label) return false + try { + return /\p{Script=Latin}/u.test(label) + } catch (e) { + return /[A-Za-z\u00C0-\u024F\u1E00-\u1EFF]/u.test(label) + } } @@ -67,9 +67,9 @@ const handleEventClick = (eventId) => { diff --git a/src/components/EventDialog.vue b/src/components/EventDialog.vue index 21ca90a..1f2e569 100644 --- a/src/components/EventDialog.vue +++ b/src/components/EventDialog.vue @@ -1,9 +1,18 @@ diff --git a/src/components/Jogwheel.vue b/src/components/Jogwheel.vue index b46acce..3ef0cc8 100644 --- a/src/components/Jogwheel.vue +++ b/src/components/Jogwheel.vue @@ -1,17 +1,21 @@ @@ -85,20 +183,12 @@ defineExpose({ top: 0; right: 0; bottom: 0; - width: 3rem; /* Use fixed width since minmax() doesn't work for absolute positioning */ + width: var(--month-w); overflow-y: auto; overflow-x: hidden; scrollbar-width: none; z-index: 20; cursor: ns-resize; - background: rgba(0, 0, 0, 0.05); /* Temporary background to see the area */ - /* Prevent text selection */ - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - -webkit-touch-callout: none; - -webkit-tap-highlight-color: transparent; } .jogwheel-viewport::-webkit-scrollbar { diff --git a/src/components/Numeric.vue b/src/components/Numeric.vue index 1beb9bf..c0af7bd 100644 --- a/src/components/Numeric.vue +++ b/src/components/Numeric.vue @@ -7,21 +7,20 @@ role="spinbutton" :aria-valuemin="minValue" :aria-valuemax="maxValue" - :aria-valuenow="isPrefix(current) ? undefined : current" + :aria-valuenow="isPrefix(model) ? undefined : model" :aria-valuetext="display" tabindex="0" @pointerdown="onPointerDown" @keydown="onKey" + @wheel.prevent="onWheel" > - {{ display }} + {{ display }} diff --git a/src/components/WeekRow.vue b/src/components/WeekRow.vue index 65a49ad..578bd74 100644 --- a/src/components/WeekRow.vue +++ b/src/components/WeekRow.vue @@ -3,11 +3,13 @@
W{{ weekNumber }}
-
- -
+
-
+
{{ monthLabel.name }} '{{ monthLabel.year }}
@@ -16,51 +18,56 @@ diff --git a/src/components/WeekdaySelector.vue b/src/components/WeekdaySelector.vue index 601e7ae..ef35aa4 100644 --- a/src/components/WeekdaySelector.vue +++ b/src/components/WeekdaySelector.vue @@ -33,7 +33,7 @@ diff --git a/src/main.js b/src/main.js index a7a997b..82ac165 100644 --- a/src/main.js +++ b/src/main.js @@ -2,11 +2,17 @@ import './assets/calendar.css' import { createApp } from 'vue' import { createPinia } from 'pinia' +import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' +import { calendarHistory } from '@/plugins/calendarHistory' import App from './App.vue' const app = createApp(App) -app.use(createPinia()) +const pinia = createPinia() +// Order: persistence first so snapshots recorded by undo reflect already-hydrated state +pinia.use(piniaPluginPersistedstate) +pinia.use(calendarHistory) +app.use(pinia) app.mount('#app') diff --git a/src/plugins/calendarHistory.js b/src/plugins/calendarHistory.js new file mode 100644 index 0000000..e74c544 --- /dev/null +++ b/src/plugins/calendarHistory.js @@ -0,0 +1,200 @@ +// Custom lightweight undo/redo specifically for calendar store with Map support +// Adds store.$history = { undo(), redo(), canUndo, canRedo, clear(), pushManual() } +// Wraps action calls to create history entries only for meaningful mutations. + +function deepCloneCalendarState(raw) { + // We only need to snapshot keys we care about; omit volatile fields + const { today, events, config, weekend } = raw + return { + today, + weekend: Array.isArray(weekend) ? [...weekend] : weekend, + config: JSON.parse(JSON.stringify(config)), + events: new Map([...events].map(([k, v]) => [k, { ...v }])), + } +} + +function restoreCalendarState(store, snap) { + store.today = snap.today + store.weekend = Array.isArray(snap.weekend) ? [...snap.weekend] : snap.weekend + store.config = JSON.parse(JSON.stringify(snap.config)) + store.events = new Map([...snap.events].map(([k, v]) => [k, { ...v }])) + store.eventsMutation = (store.eventsMutation + 1) % 1_000_000_000 +} + +export function calendarHistory({ store }) { + if (store.$id !== 'calendar') return + + const max = 100 // history depth limit + const history = [] // past states + let pointer = -1 // index of current state in history + let isRestoring = false + let lastSerialized = null + // Compound editing session (e.g. event dialog) flags + let compoundActive = false + let compoundBaseSig = null + let compoundChanged = false + + function serializeForComparison() { + const evCount = store.events instanceof Map ? store.events.size : 0 + const em = store.eventsMutation || 0 + return `${em}|${evCount}|${store.today}|${JSON.stringify(store.config)}` + } + + function pushSnapshot() { + if (isRestoring) return + const sig = serializeForComparison() + if (sig === lastSerialized) return + // Drop any redo branch + if (pointer < history.length - 1) history.splice(pointer + 1) + history.push(deepCloneCalendarState(store)) + if (history.length > max) history.shift() + pointer = history.length - 1 + lastSerialized = sig + bumpIndicators() + // console.log('[history] pushed', pointer, sig) + } + + function bumpIndicators() { + if (typeof store.historyTick === 'number') { + store.historyTick = (store.historyTick + 1) % 1_000_000_000 + } + if (typeof store.historyCanUndo === 'boolean') { + store.historyCanUndo = pointer > 0 + } + if (typeof store.historyCanRedo === 'boolean') { + store.historyCanRedo = pointer >= 0 && pointer < history.length - 1 + } + } + + function markPotentialChange() { + if (isRestoring) return + if (compoundActive) { + const sig = serializeForComparison() + if (sig !== compoundBaseSig) compoundChanged = true + return + } + pushSnapshot() + } + + function beginCompound() { + if (compoundActive) return + compoundActive = true + compoundBaseSig = serializeForComparison() + compoundChanged = false + } + function endCompound() { + if (!compoundActive) return + const finalSig = serializeForComparison() + const changed = compoundChanged || finalSig !== compoundBaseSig + compoundActive = false + compoundBaseSig = null + if (changed) pushSnapshot() + else bumpIndicators() // session ended without change – still refresh flags + } + + function undo() { + // Ensure any active compound changes are finalized before moving back + if (compoundActive) endCompound() + else { + // If current state differs from last snapshot, push it so redo can restore it + const curSig = serializeForComparison() + if (curSig !== lastSerialized) pushSnapshot() + } + if (pointer <= 0) return + pointer-- + isRestoring = true + try { + restoreCalendarState(store, history[pointer]) + lastSerialized = serializeForComparison() + } finally { + isRestoring = false + } + bumpIndicators() + } + + function redo() { + if (compoundActive) endCompound() + else { + const curSig = serializeForComparison() + if (curSig !== lastSerialized) pushSnapshot() + } + if (pointer >= history.length - 1) return + pointer++ + isRestoring = true + try { + restoreCalendarState(store, history[pointer]) + lastSerialized = serializeForComparison() + } finally { + isRestoring = false + } + bumpIndicators() + } + + function clear() { + history.length = 0 + pointer = -1 + lastSerialized = null + bumpIndicators() + } + + // Wrap selected mutating actions to push snapshot AFTER they run if state changed. + const actionNames = [ + 'createEvent', + 'deleteEvent', + 'deleteFirstOccurrence', + 'deleteSingleOccurrence', + 'deleteFromOccurrence', + 'setEventRange', + 'splitMoveVirtualOccurrence', + 'splitRepeatSeries', + '_terminateRepeatSeriesAtIndex', + 'toggleHolidays', + 'initializeHolidays', + ] + + for (const name of actionNames) { + if (typeof store[name] === 'function') { + const original = store[name].bind(store) + store[name] = (...args) => { + const beforeSig = serializeForComparison() + const result = original(...args) + const afterSig = serializeForComparison() + if (afterSig !== beforeSig) markPotentialChange() + return result + } + } + } + + // Capture direct property edits (e.g., deep field edits signaled via touchEvents()) + store.$subscribe((mutation, _state) => { + if (mutation.storeId !== 'calendar') return + markPotentialChange() + }) + + // Initial snapshot after hydration (next microtask to let persistence load) + Promise.resolve().then(() => pushSnapshot()) + + store.$history = { + undo, + redo, + clear, + pushManual: pushSnapshot, + beginCompound, + endCompound, + flush() { + pushSnapshot() + }, + get canUndo() { + return pointer > 0 + }, + get canRedo() { + return pointer >= 0 && pointer < history.length - 1 + }, + get compoundActive() { + return compoundActive + }, + _debug() { + return { pointer, length: history.length } + }, + } +} diff --git a/src/plugins/calendarUndoNormalize.js b/src/plugins/calendarUndoNormalize.js new file mode 100644 index 0000000..af72c75 --- /dev/null +++ b/src/plugins/calendarUndoNormalize.js @@ -0,0 +1,57 @@ +// Pinia plugin to ensure calendar store keeps Map for events after undo/redo snapshots +export function calendarUndoNormalize({ store }) { + if (store.$id !== 'calendar') return + + function fixEvents() { + const evs = store.events + if (evs instanceof Map) return + // If serialized form { __map: true, data: [...] } + if (evs && evs.__map && Array.isArray(evs.data)) { + store.events = new Map(evs.data) + return + } + // If an array of [k,v] + if (Array.isArray(evs) && evs.every((x) => Array.isArray(x) && x.length === 2)) { + store.events = new Map(evs) + return + } + // If plain object, convert own enumerable props + if (evs && typeof evs === 'object') { + store.events = new Map(Object.entries(evs)) + } + } + + // Patch undo/redo if present (after pinia-undo is installed) + const patchFns = ['undo', 'redo'] + for (const fn of patchFns) { + if (typeof store[fn] === 'function') { + const original = store[fn].bind(store) + store[fn] = (...args) => { + console.log(`[calendar history] ${fn} invoked`) + const beforeType = store.events && store.events.constructor && store.events.constructor.name + const out = original(...args) + const afterRawType = + store.events && store.events.constructor && store.events.constructor.name + fixEvents() + const finalType = store.events && store.events.constructor && store.events.constructor.name + let size = null + try { + if (store.events instanceof Map) size = store.events.size + else if (Array.isArray(store.events)) size = store.events.length + } catch {} + console.log( + `[calendar history] ${fn} types: before=${beforeType} afterRaw=${afterRawType} final=${finalType} size=${size}`, + ) + return out + } + } + } + + // Also watch all mutations (includes direct assigns and action commits) + store.$subscribe(() => { + fixEvents() + }) + + // Initial sanity + fixEvents() +} diff --git a/src/plugins/persist.js b/src/plugins/persist.js new file mode 100644 index 0000000..c41031d --- /dev/null +++ b/src/plugins/persist.js @@ -0,0 +1,24 @@ +// Simple Pinia persistence plugin supporting `persist: true` and Map serialization. +export function persistPlugin({ store }) { + if (!store.$options || !store.$options.persist) return + const key = `pinia-${store.$id}` + try { + const raw = localStorage.getItem(key) + if (raw) { + const state = JSON.parse(raw, (k, v) => { + if (v && v.__map === true && Array.isArray(v.data)) return new Map(v.data) + return v + }) + store.$patch(state) + } + } catch {} + store.$subscribe((_mutation, state) => { + try { + const json = JSON.stringify(state, (_k, v) => { + if (v instanceof Map) return { __map: true, data: Array.from(v.entries()) } + return v + }) + localStorage.setItem(key, json) + } catch {} + }) +} diff --git a/src/plugins/scrollManager.js b/src/plugins/scrollManager.js new file mode 100644 index 0000000..21fb1d9 --- /dev/null +++ b/src/plugins/scrollManager.js @@ -0,0 +1,331 @@ +import { ref } from 'vue' + +function createMomentumDrag({ + viewport, + viewportHeight, + contentHeight, + setScrollTop, + speed, + reasonDragPointer, + reasonDragTouch, + reasonMomentum, + allowTouch, + hitTest, +}) { + let dragging = false + let startY = 0 + let startScroll = 0 + let velocity = 0 + let samples = [] // { timestamp, position } + let momentumActive = false + let momentumFrame = null + let dragAccumY = 0 // used when pointer lock active + let usingPointerLock = false + const frictionPerMs = 0.0018 + const MIN_V = 0.03 + const VELOCITY_MS = 50 + + function cancelMomentum() { + if (!momentumActive) return + momentumActive = false + if (momentumFrame) cancelAnimationFrame(momentumFrame) + momentumFrame = null + } + function startMomentum() { + if (Math.abs(velocity) < MIN_V) return + cancelMomentum() + momentumActive = true + let lastTs = performance.now() + const step = () => { + if (!momentumActive) return + const now = performance.now() + const dt = now - lastTs + lastTs = now + if (dt <= 0) { + momentumFrame = requestAnimationFrame(step) + return + } + const decay = Math.exp(-frictionPerMs * dt) + velocity *= decay + const delta = velocity * dt + if (viewport.value) { + let cur = viewport.value.scrollTop + let target = cur + delta + const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value) + if (target < 0) { + target = 0 + velocity = 0 + } else if (target > maxScroll) { + target = maxScroll + velocity = 0 + } + setScrollTop(target, reasonMomentum) + } + if (Math.abs(velocity) < MIN_V * 0.6) { + momentumActive = false + return + } + momentumFrame = requestAnimationFrame(step) + } + momentumFrame = requestAnimationFrame(step) + } + function applyDragByDelta(deltaY, reason) { + const now = performance.now() + while (samples[0]?.timestamp < now - VELOCITY_MS) samples.shift() + samples.push({ timestamp: now, position: deltaY }) + const newScrollTop = startScroll - deltaY * speed + const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value) + const clamped = Math.max(0, Math.min(newScrollTop, maxScroll)) + setScrollTop(clamped, reason) + } + function applyDragPosition(clientY, reason) { + const deltaY = clientY - startY + applyDragByDelta(deltaY, reason) + } + function endDrag() { + if (!dragging) return + dragging = false + window.removeEventListener('pointermove', onPointerMove, true) + window.removeEventListener('pointerup', endDrag, true) + window.removeEventListener('pointercancel', endDrag, true) + if (allowTouch) { + window.removeEventListener('touchmove', onTouchMove) + window.removeEventListener('touchend', endDrag) + window.removeEventListener('touchcancel', endDrag) + } + document.removeEventListener('pointerlockchange', onPointerLockChange, true) + if (usingPointerLock && document.pointerLockElement === viewport.value) { + try { + document.exitPointerLock() + } catch {} + } + usingPointerLock = false + velocity = 0 + if (samples.length) { + const first = samples[0] + const now = performance.now() + const last = samples[samples.length - 1] + const dy = last.position - first.position + if (Math.abs(dy) > 5) velocity = (-dy * speed) / (now - first.timestamp) + } + samples = [] + startMomentum() + } + function onPointerMove(e) { + if (!dragging || document.pointerLockElement !== viewport.value) return + dragAccumY += e.movementY + applyDragByDelta(dragAccumY, reasonDragPointer) + e.preventDefault() + } + function onTouchMove(e) { + if (!dragging) return + if (e.touches.length !== 1) { + endDrag() + return + } + applyDragPosition(e.touches[0].clientY, reasonDragTouch) + e.preventDefault() + } + function handlePointerDown(e) { + if (e.button !== undefined && e.button !== 0) return + if (hitTest && !hitTest(e)) return + e.preventDefault() + cancelMomentum() + dragging = true + startY = e.clientY + startScroll = viewport.value?.scrollTop || 0 + velocity = 0 + dragAccumY = 0 + samples = [{ timestamp: performance.now(), position: e.clientY }] + window.addEventListener('pointermove', onPointerMove, true) + window.addEventListener('pointerup', endDrag, true) + window.addEventListener('pointercancel', endDrag, true) + document.addEventListener('pointerlockchange', onPointerLockChange, true) + viewport.value.requestPointerLock({ unadjustedMovement: true }) + } + function handleTouchStart(e) { + if (!allowTouch) return + if (e.touches.length !== 1) return + if (hitTest && !hitTest(e.touches[0])) return + cancelMomentum() + dragging = true + const t = e.touches[0] + startY = t.clientY + startScroll = viewport.value?.scrollTop || 0 + velocity = 0 + dragAccumY = 0 + samples = [{ timestamp: performance.now(), position: t.clientY }] + window.addEventListener('touchmove', onTouchMove, { passive: false }) + window.addEventListener('touchend', endDrag, { passive: false }) + window.addEventListener('touchcancel', endDrag, { passive: false }) + e.preventDefault() + } + function onPointerLockChange() { + const lockedEl = document.pointerLockElement + if (dragging && lockedEl === viewport.value) { + usingPointerLock = true + return + } + if (dragging && usingPointerLock && lockedEl !== viewport.value) endDrag() + if (!dragging) usingPointerLock = false + } + return { handlePointerDown, handleTouchStart, cancelMomentum } +} + +export function createScrollManager({ viewport, scheduleRebuild }) { + const scrollTop = ref(0) + let lastProgrammatic = null + let pendingTarget = null + let pendingAttempts = 0 + let pendingLoopActive = false + + function setScrollTop(val, reason = 'programmatic') { + let applied = val + if (viewport.value) { + const maxScroll = viewport.value.scrollHeight - viewport.value.clientHeight + if (applied > maxScroll) { + applied = maxScroll < 0 ? 0 : maxScroll + pendingTarget = val + pendingAttempts = 0 + startPendingLoop() + } + if (applied < 0) applied = 0 + viewport.value.scrollTop = applied + } + scrollTop.value = applied + lastProgrammatic = applied + scheduleRebuild(reason) + } + + function onScroll() { + if (!viewport.value) return + const cur = viewport.value.scrollTop + const maxScroll = viewport.value.scrollHeight - viewport.value.clientHeight + let effective = cur + if (cur < 0) effective = 0 + else if (cur > maxScroll) effective = maxScroll + scrollTop.value = effective + if (lastProgrammatic !== null && effective === lastProgrammatic) { + lastProgrammatic = null + return + } + if (pendingTarget !== null && Math.abs(effective - pendingTarget) > 4) { + pendingTarget = null + } + scheduleRebuild('scroll') + } + + function startPendingLoop() { + if (pendingLoopActive || !viewport.value) return + pendingLoopActive = true + const loop = () => { + if (pendingTarget == null || !viewport.value) { + pendingLoopActive = false + return + } + const maxScroll = viewport.value.scrollHeight - viewport.value.clientHeight + if (pendingTarget <= maxScroll) { + setScrollTop(pendingTarget, 'pending-fulfill') + pendingTarget = null + pendingLoopActive = false + return + } + pendingAttempts++ + if (pendingAttempts > 120) { + pendingTarget = null + pendingLoopActive = false + return + } + requestAnimationFrame(loop) + } + requestAnimationFrame(loop) + } + + return { scrollTop, setScrollTop, onScroll } +} + +export function createWeekColumnScrollManager({ + viewport, + viewportHeight, + contentHeight, + setScrollTop, +}) { + const isWeekColDragging = ref(false) + function getWeekLabelRect() { + const headerYear = document.querySelector('.calendar-header .year-label') + if (headerYear) return headerYear.getBoundingClientRect() + const weekLabel = viewport.value?.querySelector('.week-row .week-label') + return weekLabel ? weekLabel.getBoundingClientRect() : null + } + const drag = createMomentumDrag({ + viewport, + viewportHeight, + contentHeight, + setScrollTop, + speed: 1, + reasonDragPointer: 'week-col-drag', + reasonDragTouch: 'week-col-drag', + reasonMomentum: 'week-col-momentum', + allowTouch: false, + hitTest: (e) => { + const rect = getWeekLabelRect() + if (!rect) return false + const x = e.clientX ?? e.pageX + return x >= rect.left && x <= rect.right + }, + }) + function handleWeekColMouseDown(e) { + if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return + isWeekColDragging.value = true + drag.handlePointerDown(e) + const end = () => { + isWeekColDragging.value = false + window.removeEventListener('pointerup', end, true) + window.removeEventListener('pointercancel', end, true) + } + window.addEventListener('pointerup', end, true) + window.addEventListener('pointercancel', end, true) + } + function handlePointerLockChange() { + if (document.pointerLockElement !== viewport.value) { + isWeekColDragging.value = false + } + } + return { isWeekColDragging, handleWeekColMouseDown, handlePointerLockChange } +} + +export function createMonthScrollManager({ + viewport, + viewportHeight, + contentHeight, + setScrollTop, +}) { + const drag = createMomentumDrag({ + viewport, + viewportHeight, + contentHeight, + setScrollTop, + speed: 10, + reasonDragPointer: 'month-scroll-drag', + reasonDragTouch: 'month-scroll-touch', + reasonMomentum: 'month-scroll-momentum', + allowTouch: true, + hitTest: null, + }) + function handleMonthScrollPointerDown(e) { + drag.handlePointerDown(e) + } + function handleMonthScrollTouchStart(e) { + drag.handleTouchStart(e) + } + function handleMonthScrollWheel(e) { + drag.cancelMomentum() + const currentScroll = viewport.value?.scrollTop || 0 + const newScrollTop = currentScroll + e.deltaY * 10 + const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value) + const clamped = Math.max(0, Math.min(newScrollTop, maxScroll)) + setScrollTop(clamped, 'month-scroll-wheel') + e.preventDefault() + } + return { handleMonthScrollPointerDown, handleMonthScrollTouchStart, handleMonthScrollWheel } +} diff --git a/src/plugins/virtualWeeks.js b/src/plugins/virtualWeeks.js new file mode 100644 index 0000000..6d73466 --- /dev/null +++ b/src/plugins/virtualWeeks.js @@ -0,0 +1,400 @@ +import { ref } from 'vue' +import { addDays, differenceInWeeks } from 'date-fns' +import { + toLocalString, + fromLocalString, + DEFAULT_TZ, + getISOWeek, + addDaysStr, + pad, + getLocalizedMonthName, + monthAbbr, + lunarPhaseSymbol, + MAX_YEAR, + getOccurrenceIndex, + getVirtualOccurrenceEndDate, +} from '@/utils/date' +import { getHolidayForDate } from '@/utils/holidays' + +/** + * Factory handling virtual week window & incremental building. + * Exposes reactive visibleWeeks plus scheduling functions. + */ +export function createVirtualWeekManager({ + calendarStore, + viewport, + viewportHeight, + rowHeight, + selection, + baseDate, + minVirtualWeek, + maxVirtualWeek, + contentHeight, // not currently used inside manager but kept for future +}) { + const visibleWeeks = ref([]) + let lastScrollRange = { startVW: null, endVW: null } + let updating = false + // Scroll refs injected later to break cyclic dependency with scroll manager + let scrollTopRef = null + let setScrollTopFn = null + + function attachScroll(scrollTop, setScrollTop) { + scrollTopRef = scrollTop + setScrollTopFn = setScrollTop + } + + function getWeekIndex(date) { + const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7 + const firstDayOfWeek = addDays(date, -dayOffset) + return differenceInWeeks(firstDayOfWeek, baseDate.value) + } + function getFirstDayForVirtualWeek(virtualWeek) { + return addDays(baseDate.value, virtualWeek * 7) + } + + function createWeek(virtualWeek) { + const firstDay = getFirstDayForVirtualWeek(virtualWeek) + const isoAnchor = addDays(firstDay, (4 - firstDay.getDay() + 7) % 7) + const weekNumber = getISOWeek(isoAnchor) + const days = [] + let cur = new Date(firstDay) + let hasFirst = false + let monthToLabel = null + let labelYear = null + + const repeatingBases = [] + if (calendarStore.events) { + for (const ev of calendarStore.events.values()) { + if (ev.recur) repeatingBases.push(ev) + } + } + + const collectEventsForDate = (dateStr, curDateObj) => { + const storedEvents = [] + for (const ev of calendarStore.events.values()) { + if (!ev.recur) { + const evEnd = toLocalString( + addDays(fromLocalString(ev.startDate, DEFAULT_TZ), (ev.days || 1) - 1), + DEFAULT_TZ, + ) + if (dateStr >= ev.startDate && dateStr <= evEnd) { + storedEvents.push({ ...ev, endDate: evEnd }) + } + } + } + const dayEvents = [...storedEvents] + for (const base of repeatingBases) { + const baseEnd = toLocalString( + addDays(fromLocalString(base.startDate, DEFAULT_TZ), (base.days || 1) - 1), + DEFAULT_TZ, + ) + if (dateStr >= base.startDate && dateStr <= baseEnd) { + dayEvents.push({ ...base, endDate: baseEnd, _recurrenceIndex: 0, _baseId: base.id }) + continue + } + const spanDays = (base.days || 1) - 1 + const currentDate = curDateObj + let occurrenceFound = false + for (let offset = 0; offset <= spanDays && !occurrenceFound; offset++) { + const candidateStart = addDays(currentDate, -offset) + const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ) + const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr, DEFAULT_TZ) + if (occurrenceIndex !== null) { + const virtualEndDate = getVirtualOccurrenceEndDate(base, candidateStartStr, DEFAULT_TZ) + if (dateStr >= candidateStartStr && dateStr <= virtualEndDate) { + const virtualId = base.id + '_v_' + candidateStartStr + const alreadyExists = dayEvents.some((ev) => ev.id === virtualId) + if (!alreadyExists) { + dayEvents.push({ + ...base, + id: virtualId, + startDate: candidateStartStr, + endDate: virtualEndDate, + _recurrenceIndex: occurrenceIndex, + _baseId: base.id, + }) + } + occurrenceFound = true + } + } + } + } + return dayEvents + } + + for (let i = 0; i < 7; i++) { + const dateStr = toLocalString(cur, DEFAULT_TZ) + const dayEvents = collectEventsForDate(dateStr, fromLocalString(dateStr, DEFAULT_TZ)) + const dow = cur.getDay() + const isFirst = cur.getDate() === 1 + if (isFirst) { + hasFirst = true + monthToLabel = cur.getMonth() + labelYear = cur.getFullYear() + } + let displayText = String(cur.getDate()) + if (isFirst) { + if (cur.getMonth() === 0) displayText = cur.getFullYear() + else displayText = monthAbbr[cur.getMonth()].slice(0, 3).toUpperCase() + } + let holiday = null + if (calendarStore.config.holidays.enabled) { + calendarStore._ensureHolidaysInitialized?.() + holiday = getHolidayForDate(dateStr) + } + days.push({ + date: dateStr, + dayOfMonth: cur.getDate(), + displayText, + monthClass: monthAbbr[cur.getMonth()], + isToday: dateStr === calendarStore.today, + isWeekend: calendarStore.weekend[dow], + isFirstDay: isFirst, + lunarPhase: lunarPhaseSymbol(cur), + holiday, + isHoliday: holiday !== null, + isSelected: + selection.value.startDate && + selection.value.dayCount > 0 && + dateStr >= selection.value.startDate && + dateStr <= addDaysStr(selection.value.startDate, selection.value.dayCount - 1), + events: dayEvents, + }) + cur = addDays(cur, 1) + } + let monthLabel = null + if (hasFirst && monthToLabel !== null) { + if (labelYear && labelYear <= MAX_YEAR) { + let weeksSpan = 0 + const d = addDays(cur, -1) + for (let i = 0; i < 6; i++) { + const probe = addDays(cur, -1 + i * 7) + d.setTime(probe.getTime()) + if (d.getMonth() === monthToLabel) weeksSpan++ + } + const remainingWeeks = Math.max(1, maxVirtualWeek.value - virtualWeek + 1) + weeksSpan = Math.max(1, Math.min(weeksSpan, remainingWeeks)) + const year = String(labelYear).slice(-2) + monthLabel = { + text: `${getLocalizedMonthName(monthToLabel)} '${year}`, + month: monthToLabel, + weeksSpan, + monthClass: monthAbbr[monthToLabel], + } + } + } + return { + virtualWeek, + weekNumber: pad(weekNumber), + days, + monthLabel, + top: (virtualWeek - minVirtualWeek.value) * rowHeight.value, + } + } + + function internalWindowCalc() { + const buffer = 6 + const currentScrollTop = viewport.value?.scrollTop ?? scrollTopRef?.value ?? 0 + const startIdx = Math.floor((currentScrollTop - buffer * rowHeight.value) / rowHeight.value) + const endIdx = Math.ceil( + (currentScrollTop + viewportHeight.value + buffer * rowHeight.value) / rowHeight.value, + ) + const startVW = Math.max(minVirtualWeek.value, startIdx + minVirtualWeek.value) + const endVW = Math.min(maxVirtualWeek.value, endIdx + minVirtualWeek.value) + return { startVW, endVW } + } + + function updateVisibleWeeks(_reason) { + const { startVW, endVW } = internalWindowCalc() + // Prune outside + if (visibleWeeks.value.length) { + while (visibleWeeks.value.length && visibleWeeks.value[0].virtualWeek < startVW) { + visibleWeeks.value.shift() + } + while ( + visibleWeeks.value.length && + visibleWeeks.value[visibleWeeks.value.length - 1].virtualWeek > endVW + ) { + visibleWeeks.value.pop() + } + } + // Add at most one week (ensuring contiguity) + let added = false + if (!visibleWeeks.value.length) { + visibleWeeks.value.push(createWeek(startVW)) + added = true + } else { + visibleWeeks.value.sort((a, b) => a.virtualWeek - b.virtualWeek) + const firstVW = visibleWeeks.value[0].virtualWeek + const lastVW = visibleWeeks.value[visibleWeeks.value.length - 1].virtualWeek + if (firstVW > startVW) { + visibleWeeks.value.unshift(createWeek(firstVW - 1)) + added = true + } else { + let gapInserted = false + for (let i = 0; i < visibleWeeks.value.length - 1; i++) { + const curVW = visibleWeeks.value[i].virtualWeek + const nextVW = visibleWeeks.value[i + 1].virtualWeek + if (nextVW - curVW > 1 && curVW < endVW) { + visibleWeeks.value.splice(i + 1, 0, createWeek(curVW + 1)) + added = true + gapInserted = true + break + } + } + if (!gapInserted && lastVW < endVW) { + visibleWeeks.value.push(createWeek(lastVW + 1)) + added = true + } + } + } + // Coverage check + const firstAfter = visibleWeeks.value[0].virtualWeek + const lastAfter = visibleWeeks.value[visibleWeeks.value.length - 1].virtualWeek + let contiguous = true + for (let i = 0; i < visibleWeeks.value.length - 1; i++) { + if (visibleWeeks.value[i + 1].virtualWeek !== visibleWeeks.value[i].virtualWeek + 1) { + contiguous = false + break + } + } + const coverageComplete = + firstAfter <= startVW && + lastAfter >= endVW && + contiguous && + visibleWeeks.value.length === endVW - startVW + 1 + if (!coverageComplete) return false + if ( + lastScrollRange.startVW === startVW && + lastScrollRange.endVW === endVW && + !added && + visibleWeeks.value.length + ) { + return true + } + lastScrollRange = { startVW, endVW } + return true + } + + function scheduleWindowUpdate(reason) { + if (updating) return + updating = true + const run = () => { + updating = false + updateVisibleWeeks(reason) || scheduleWindowUpdate('incremental-build') + } + if ('requestIdleCallback' in window) requestIdleCallback(run, { timeout: 16 }) + else requestAnimationFrame(run) + } + function resetWeeks(reason = 'reset') { + visibleWeeks.value = [] + lastScrollRange = { startVW: null, endVW: null } + scheduleWindowUpdate(reason) + } + + // Reflective update of only events inside currently visible weeks (keeps week objects stable) + function refreshEvents(reason = 'events-refresh') { + if (!visibleWeeks.value.length) return + const repeatingBases = [] + if (calendarStore.events) { + for (const ev of calendarStore.events.values()) if (ev.recur) repeatingBases.push(ev) + } + const selStart = selection.value.startDate + const selCount = selection.value.dayCount + const selEnd = selStart && selCount > 0 ? addDaysStr(selStart, selCount - 1) : null + for (const week of visibleWeeks.value) { + for (const day of week.days) { + const dateStr = day.date + // Update selection flag + if (selStart && selEnd) day.isSelected = dateStr >= selStart && dateStr <= selEnd + else day.isSelected = false + // Rebuild events list for this day + const storedEvents = [] + for (const ev of calendarStore.events.values()) { + if (!ev.recur) { + const evEnd = toLocalString( + addDays(fromLocalString(ev.startDate, DEFAULT_TZ), (ev.days || 1) - 1), + DEFAULT_TZ, + ) + if (dateStr >= ev.startDate && dateStr <= evEnd) { + storedEvents.push({ ...ev, endDate: evEnd }) + } + } + } + const dayEvents = [...storedEvents] + for (const base of repeatingBases) { + const baseEndStr = toLocalString( + addDays(fromLocalString(base.startDate, DEFAULT_TZ), (base.days || 1) - 1), + DEFAULT_TZ, + ) + if (dateStr >= base.startDate && dateStr <= baseEndStr) { + dayEvents.push({ ...base, endDate: baseEndStr, _recurrenceIndex: 0, _baseId: base.id }) + continue + } + const spanDays = (base.days || 1) - 1 + const currentDate = fromLocalString(dateStr, DEFAULT_TZ) + let occurrenceFound = false + for (let offset = 0; offset <= spanDays && !occurrenceFound; offset++) { + const candidateStart = addDays(currentDate, -offset) + const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ) + const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr, DEFAULT_TZ) + if (occurrenceIndex !== null) { + const virtualEndDate = getVirtualOccurrenceEndDate( + base, + candidateStartStr, + DEFAULT_TZ, + ) + if (dateStr >= candidateStartStr && dateStr <= virtualEndDate) { + const virtualId = base.id + '_v_' + candidateStartStr + const alreadyExists = dayEvents.some((ev) => ev.id === virtualId) + if (!alreadyExists) { + dayEvents.push({ + ...base, + id: virtualId, + startDate: candidateStartStr, + endDate: virtualEndDate, + _recurrenceIndex: occurrenceIndex, + _baseId: base.id, + }) + } + occurrenceFound = true + } + } + } + } + day.events = dayEvents + } + } + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.debug('[VirtualWeeks] refreshEvents', reason, { weeks: visibleWeeks.value.length }) + } + } + + function goToToday() { + const top = addDays(new Date(calendarStore.now), -21) + const targetWeekIndex = getWeekIndex(top) + const newScrollTop = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value + if (setScrollTopFn) setScrollTopFn(newScrollTop, 'go-to-today') + } + + function handleHeaderYearChange({ scrollTop }) { + const maxScroll = contentHeight.value - viewportHeight.value + const clamped = Math.max(0, Math.min(scrollTop, isFinite(maxScroll) ? maxScroll : scrollTop)) + if (setScrollTopFn) setScrollTopFn(clamped, 'header-year-change') + resetWeeks('header-year-change') + } + + return { + visibleWeeks, + scheduleWindowUpdate, + resetWeeks, + updateVisibleWeeks, + refreshEvents, + getWeekIndex, + getFirstDayForVirtualWeek, + goToToday, + handleHeaderYearChange, + attachScroll, + } +} diff --git a/src/stores/CalendarStore.js b/src/stores/CalendarStore.js index f9b91a2..5b5adab 100644 --- a/src/stores/CalendarStore.js +++ b/src/stores/CalendarStore.js @@ -2,76 +2,113 @@ import { defineStore } from 'pinia' import { toLocalString, fromLocalString, - getLocaleFirstDay, getLocaleWeekendDays, + getMondayOfISOWeek, + getOccurrenceDate, + DEFAULT_TZ, } from '@/utils/date' - -/** - * Calendar configuration can be overridden via window.calendarConfig: - * - * window.calendarConfig = { - * firstDay: 0, // 0=Sunday, 1=Monday, etc. (default: 1) - * firstDay: 'auto', // Use locale detection - * weekendDays: [true, false, false, false, false, false, true], // Custom weekend - * weekendDays: 'auto' // Use locale detection (default) - * } - */ - -const MIN_YEAR = 1900 -const MAX_YEAR = 2100 - -// Helper function to determine first day with config override support -function getConfiguredFirstDay() { - // Check for environment variable or global config - const configOverride = window?.calendarConfig?.firstDay - if (configOverride !== undefined) { - return configOverride === 'auto' ? getLocaleFirstDay() : Number(configOverride) - } - // Default to Monday (1) instead of locale - return 1 -} - -// Helper function to determine weekend days with config override support -function getConfiguredWeekendDays() { - // Check for environment variable or global config - const configOverride = window?.calendarConfig?.weekendDays - if (configOverride !== undefined) { - return configOverride === 'auto' ? getLocaleWeekendDays() : configOverride - } - // Default to locale-based weekend days - return getLocaleWeekendDays() -} +import { differenceInCalendarDays, addDays } from 'date-fns' +import { initializeHolidays, getAvailableCountries, getAvailableStates } from '@/utils/holidays' export const useCalendarStore = defineStore('calendar', { state: () => ({ - today: toLocalString(new Date()), - now: new Date(), - events: new Map(), // Map of date strings to arrays of events - weekend: getConfiguredWeekendDays(), + today: toLocalString(new Date(), DEFAULT_TZ), + now: new Date().toISOString(), + events: new Map(), + // Lightweight mutation counter so views can rebuild in a throttled / idle way + // without tracking deep reactivity on every event object. + eventsMutation: 0, + // Incremented internally by history plugin to force reactive updates for canUndo/canRedo + historyTick: 0, + historyCanUndo: false, + historyCanRedo: false, + weekend: getLocaleWeekendDays(), + _holidayConfigSignature: null, + _holidaysInitialized: false, config: { - select_days: 1000, - min_year: MIN_YEAR, - max_year: MAX_YEAR, - first_day: getConfiguredFirstDay(), + select_days: 14, + first_day: 1, + holidays: { + enabled: true, + country: 'auto', + state: null, + region: null, + }, }, }), - - getters: { - // Basic configuration getters - minYear: () => MIN_YEAR, - maxYear: () => MAX_YEAR, - }, - actions: { - updateCurrentDate() { - this.now = new Date() - const today = toLocalString(this.now) - if (this.today !== today) { - this.today = today - } + _rotateWeekdayPattern(pattern, shift) { + const k = (7 - (shift % 7)) % 7 + return pattern.slice(k).concat(pattern.slice(0, k)) + }, + _resolveCountry(code) { + if (!code || code !== 'auto') return code + const locale = navigator.language || navigator.languages?.[0] + if (!locale) return null + const parts = locale.split('-') + if (parts.length < 2) return null + return parts[parts.length - 1].toUpperCase() + }, + + initializeHolidaysFromConfig() { + if (!this.config.holidays.enabled) return false + const country = this._resolveCountry(this.config.holidays.country) + if (country) { + return this.initializeHolidays( + country, + this.config.holidays.state, + this.config.holidays.region, + ) + } + return false + }, + + updateCurrentDate() { + const d = new Date() + this.now = d.toISOString() + const today = toLocalString(d, DEFAULT_TZ) + if (this.today !== today) this.today = today + }, + + initializeHolidays(country, state = null, region = null) { + const actualCountry = this._resolveCountry(country) + if (this.config.holidays.country !== 'auto') this.config.holidays.country = country + this.config.holidays.state = state + this.config.holidays.region = region + this._holidayConfigSignature = null + this._holidaysInitialized = false + return initializeHolidays(actualCountry, state, region) + }, + + _ensureHolidaysInitialized() { + if (!this.config.holidays.enabled) return false + const actualCountry = this._resolveCountry(this.config.holidays.country) + const sig = `${actualCountry}-${this.config.holidays.state}-${this.config.holidays.region}-${this.config.holidays.enabled}` + if (this._holidayConfigSignature !== sig || !this._holidaysInitialized) { + const ok = initializeHolidays( + actualCountry, + this.config.holidays.state, + this.config.holidays.region, + ) + if (ok) { + this._holidayConfigSignature = sig + this._holidaysInitialized = true + } + return ok + } + return this._holidaysInitialized + }, + + getAvailableCountries() { + return getAvailableCountries() || [] + }, + getAvailableStates(country) { + return getAvailableStates(country) || [] + }, + toggleHolidays() { + this.config.holidays.enabled = !this.config.holidays.enabled }, - // Event management generateId() { try { if (window.crypto && typeof window.crypto.randomUUID === 'function') { @@ -81,357 +118,308 @@ export const useCalendarStore = defineStore('calendar', { return 'e-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36) }, + notifyEventsChanged() { + // Bump simple counter (wrapping to avoid overflow in extreme long sessions) + this.eventsMutation = (this.eventsMutation + 1) % 1_000_000_000 + }, + touchEvents() { + this.notifyEventsChanged() + }, + createEvent(eventData) { - const singleDay = eventData.startDate === eventData.endDate + let days = 1 + if (typeof eventData.days === 'number') { + days = Math.max(1, Math.floor(eventData.days)) + } + const singleDay = days === 1 const event = { id: this.generateId(), title: eventData.title, startDate: eventData.startDate, - endDate: eventData.endDate, + days, colorId: - eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate), + eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.startDate), startTime: singleDay ? eventData.startTime || '09:00' : null, durationMinutes: singleDay ? eventData.durationMinutes || 60 : null, - repeat: - (eventData.repeat === 'weekly' - ? 'weeks' - : eventData.repeat === 'monthly' - ? 'months' - : eventData.repeat) || 'none', - repeatInterval: eventData.repeatInterval || 1, - repeatCount: eventData.repeatCount || 'unlimited', - repeatWeekdays: eventData.repeatWeekdays, - isRepeating: eventData.repeat && eventData.repeat !== 'none', + recur: + eventData.recur && ['weeks', 'months'].includes(eventData.recur.freq) + ? { + freq: eventData.recur.freq, + interval: eventData.recur.interval || 1, + count: eventData.recur.count ?? 'unlimited', + weekdays: Array.isArray(eventData.recur.weekdays) + ? [...eventData.recur.weekdays] + : null, + } + : null, } - - const startDate = new Date(fromLocalString(event.startDate)) - const endDate = new Date(fromLocalString(event.endDate)) - - for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { - const dateStr = toLocalString(d) - if (!this.events.has(dateStr)) { - this.events.set(dateStr, []) - } - this.events.get(dateStr).push({ ...event, isSpanning: startDate < endDate }) - } - // No physical expansion; repeats are virtual + this.events.set(event.id, { ...event, isSpanning: event.days > 1 }) + this.notifyEventsChanged() return event.id }, getEventById(id) { - for (const [, list] of this.events) { - const found = list.find((e) => e.id === id) - if (found) return found - } - return null + return this.events.get(id) || null }, selectEventColorId(startDateStr, endDateStr) { const colorCounts = [0, 0, 0, 0, 0, 0, 0, 0] - const startDate = new Date(fromLocalString(startDateStr)) - const endDate = new Date(fromLocalString(endDateStr)) - - for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { - const dateStr = toLocalString(d) - const dayEvents = this.events.get(dateStr) || [] - for (const event of dayEvents) { - if (event.colorId >= 0 && event.colorId < 8) { - colorCounts[event.colorId]++ - } - } + const startDate = fromLocalString(startDateStr, DEFAULT_TZ) + const endDate = fromLocalString(endDateStr, DEFAULT_TZ) + for (const ev of this.events.values()) { + const evStart = fromLocalString(ev.startDate) + const evEnd = addDays(evStart, (ev.days || 1) - 1) + if (evEnd < startDate || evStart > endDate) continue + if (ev.colorId >= 0 && ev.colorId < 8) colorCounts[ev.colorId]++ } - let minCount = colorCounts[0] let selectedColor = 0 - - for (let colorId = 1; colorId < 8; colorId++) { - if (colorCounts[colorId] < minCount) { - minCount = colorCounts[colorId] - selectedColor = colorId + for (let c = 1; c < 8; c++) { + if (colorCounts[c] < minCount) { + minCount = colorCounts[c] + selectedColor = c } } - return selectedColor }, deleteEvent(eventId) { - const datesToCleanup = [] - for (const [dateStr, eventList] of this.events) { - const eventIndex = eventList.findIndex((event) => event.id === eventId) - if (eventIndex !== -1) { - eventList.splice(eventIndex, 1) - if (eventList.length === 0) { - datesToCleanup.push(dateStr) - } - } - } - datesToCleanup.forEach((dateStr) => this.events.delete(dateStr)) - }, - - deleteSingleOccurrence(ctx) { - const { baseId, occurrenceIndex } = ctx - const base = this.getEventById(baseId) - if (!base || base.repeat !== 'weekly') return - if (!base || base.repeat !== 'weeks') return - // Strategy: clone pattern into two: reduce base repeatCount to exclude this occurrence; create new series for remainder excluding this one - // Simpler: convert base to explicit exception by creating a one-off non-repeating event? For now: create exception by inserting a blocking dummy? Instead just split by shifting repeatCount logic: decrement repeatCount and create a new series starting after this single occurrence. - // Implementation (approx): terminate at occurrenceIndex; create a new series starting next occurrence with remaining occurrences. - const remaining = - base.repeatCount === 'unlimited' - ? 'unlimited' - : String(Math.max(0, parseInt(base.repeatCount, 10) - (occurrenceIndex + 1))) - this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex) - if (remaining === '0') return - // Find date of next occurrence - const startDate = new Date(base.startDate + 'T00:00:00') - let idx = 0 - let cur = new Date(startDate) - while (idx <= occurrenceIndex && idx < 10000) { - cur.setDate(cur.getDate() + 1) - if (base.repeatWeekdays[cur.getDay()]) idx++ - } - const nextStartStr = toLocalString(cur) - this.createEvent({ - title: base.title, - startDate: nextStartStr, - endDate: nextStartStr, - colorId: base.colorId, - repeat: 'weeks', - repeatCount: remaining, - repeatWeekdays: base.repeatWeekdays, - }) - }, - - deleteFromOccurrence(ctx) { - const { baseId, occurrenceIndex } = ctx - this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex) + this.events.delete(eventId) + this.notifyEventsChanged() }, deleteFirstOccurrence(baseId) { const base = this.getEventById(baseId) - if (!base || !base.isRepeating) return - const oldStart = new Date(fromLocalString(base.startDate)) - const oldEnd = new Date(fromLocalString(base.endDate)) - const spanDays = Math.round((oldEnd - oldStart) / (24 * 60 * 60 * 1000)) - let newStart = null - - if (base.repeat === 'weeks' && base.repeatWeekdays) { - const probe = new Date(oldStart) - for (let i = 0; i < 14; i++) { - // search ahead up to 2 weeks - probe.setDate(probe.getDate() + 1) - if (base.repeatWeekdays[probe.getDay()]) { - newStart = new Date(probe) - break - } - } - } else if (base.repeat === 'months') { - newStart = new Date(oldStart) - newStart.setMonth(newStart.getMonth() + 1) - } else { - // Unknown pattern: delete entire series + if (!base) return + if (!base.recur) { this.deleteEvent(baseId) return } - - if (!newStart) { - // No subsequent occurrence -> delete entire series + const numericCount = + base.recur.count === 'unlimited' ? Infinity : parseInt(base.recur.count, 10) + if (numericCount <= 1) { this.deleteEvent(baseId) return } - - if (base.repeatCount !== 'unlimited') { - const rc = parseInt(base.repeatCount, 10) - if (!isNaN(rc)) { - const newRc = Math.max(0, rc - 1) - if (newRc === 0) { - this.deleteEvent(baseId) - return - } - base.repeatCount = String(newRc) - } + const nextStartStr = getOccurrenceDate(base, 1, DEFAULT_TZ) + if (!nextStartStr) { + this.deleteEvent(baseId) + return } + base.startDate = nextStartStr + // keep same days length + if (numericCount !== Infinity) base.recur.count = String(Math.max(1, numericCount - 1)) + this.events.set(baseId, { ...base, isSpanning: base.days > 1 }) + this.notifyEventsChanged() + }, - const newEnd = new Date(newStart) - newEnd.setDate(newEnd.getDate() + spanDays) - base.startDate = toLocalString(newStart) - base.endDate = toLocalString(newEnd) - // old occurrence expansion removed (series handled differently now) - const originalRepeatCount = base.repeatCount - // Always cap original series at the split occurrence index (occurrences 0..index-1) - // Keep its weekday pattern unchanged. - this._terminateRepeatSeriesAtIndex(baseId, index) - - let newRepeatCount = 'unlimited' - if (originalRepeatCount !== 'unlimited') { - const originalCount = parseInt(originalRepeatCount, 10) - if (!isNaN(originalCount)) { - const remaining = originalCount - index - // remaining occurrences go to new series; ensure at least 1 (the dragged occurrence itself) - newRepeatCount = remaining > 0 ? String(remaining) : '1' - } - } else { - // Original was unlimited: original now capped, new stays unlimited - newRepeatCount = 'unlimited' + deleteSingleOccurrence(ctx) { + const { baseId, occurrenceIndex } = ctx || {} + if (occurrenceIndex == null) return + const base = this.getEventById(baseId) + if (!base) return + if (!base.recur) { + if (occurrenceIndex === 0) this.deleteEvent(baseId) + return } - - // Handle weekdays for weekly repeats - let newRepeatWeekdays = base.repeatWeekdays - if (base.repeat === 'weeks' && base.repeatWeekdays) { - const newStartDate = new Date(fromLocalString(startDate)) - let dayShift = 0 - if (grabbedWeekday != null) { - // Rotate so that the grabbed weekday maps to the new start weekday - dayShift = newStartDate.getDay() - grabbedWeekday - } else { - // Fallback: rotate by difference between new and original start weekday - const originalStartDate = new Date(fromLocalString(base.startDate)) - dayShift = newStartDate.getDay() - originalStartDate.getDay() - } - if (dayShift !== 0) { - const rotatedWeekdays = [false, false, false, false, false, false, false] - for (let i = 0; i < 7; i++) { - if (base.repeatWeekdays[i]) { - let nd = (i + dayShift) % 7 - if (nd < 0) nd += 7 - rotatedWeekdays[nd] = true + if (occurrenceIndex === 0) { + this.deleteFirstOccurrence(baseId) + return + } + const snapshot = { ...base } + snapshot.recur = snapshot.recur ? { ...snapshot.recur } : null + base.recur.count = occurrenceIndex + const nextStartStr = getOccurrenceDate(snapshot, occurrenceIndex + 1, DEFAULT_TZ) + if (!nextStartStr) return + const originalNumeric = + snapshot.recur.count === 'unlimited' ? Infinity : parseInt(snapshot.recur.count, 10) + let remainingCount = 'unlimited' + if (originalNumeric !== Infinity) { + const rem = originalNumeric - (occurrenceIndex + 1) + if (rem <= 0) return + remainingCount = String(rem) + } + this.createEvent({ + title: snapshot.title, + startDate: nextStartStr, + days: snapshot.days, + colorId: snapshot.colorId, + recur: snapshot.recur + ? { + freq: snapshot.recur.freq, + interval: snapshot.recur.interval, + count: remainingCount, + weekdays: snapshot.recur.weekdays ? [...snapshot.recur.weekdays] : null, } - } - newRepeatWeekdays = rotatedWeekdays - } - } - - const newId = this.createEvent({ - title: base.title, - startDate, - endDate, - colorId: base.colorId, - repeat: base.repeat, - repeatCount: newRepeatCount, - repeatWeekdays: newRepeatWeekdays, + : null, }) - return newId + this.notifyEventsChanged() }, - _snapshotBaseEvent(eventId) { - // Return a shallow snapshot of any instance for metadata - for (const [, eventList] of this.events) { - const e = eventList.find((x) => x.id === eventId) - if (e) return { ...e } + deleteFromOccurrence(ctx) { + const { baseId, occurrenceIndex } = ctx + const base = this.getEventById(baseId) + if (!base || !base.recur) return + if (occurrenceIndex === 0) { + this.deleteEvent(baseId) + return } - return null + this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex) + this.notifyEventsChanged() }, - _removeEventFromAllDatesById(eventId) { - for (const [dateStr, list] of this.events) { - for (let i = list.length - 1; i >= 0; i--) { - if (list[i].id === eventId) { - list.splice(i, 1) - } - } - if (list.length === 0) this.events.delete(dateStr) - } - }, - - _addEventToDateRangeWithId(eventId, baseData, startDate, endDate) { - const s = fromLocalString(startDate) - const e = fromLocalString(endDate) - const multi = startDate < endDate - const payload = { - ...baseData, - id: eventId, - startDate, - endDate, - isSpanning: multi, - } - // Normalize single-day time fields - if (!multi) { - if (!payload.startTime) payload.startTime = '09:00' - if (!payload.durationMinutes) payload.durationMinutes = 60 - } else { - payload.startTime = null - payload.durationMinutes = null - } - const cur = new Date(s) - while (cur <= e) { - const dateStr = toLocalString(cur) - if (!this.events.has(dateStr)) this.events.set(dateStr, []) - this.events.get(dateStr).push({ ...payload }) - cur.setDate(cur.getDate() + 1) - } - }, - - // expandRepeats removed: no physical occurrence expansion - - // Adjust start/end range of a base event (non-generated) and reindex occurrences - setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto' } = {}) { - const snapshot = this._findEventInAnyList(eventId) + setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto', rotatePattern = true } = {}) { + const snapshot = this.events.get(eventId) if (!snapshot) return - // Calculate current duration in days (inclusive) - const prevStart = new Date(fromLocalString(snapshot.startDate)) - const prevEnd = new Date(fromLocalString(snapshot.endDate)) - const prevDurationDays = Math.max( - 0, - Math.round((prevEnd - prevStart) / (24 * 60 * 60 * 1000)), - ) - - const newStart = new Date(fromLocalString(newStartStr)) - const newEnd = new Date(fromLocalString(newEndStr)) - const proposedDurationDays = Math.max( - 0, - Math.round((newEnd - newStart) / (24 * 60 * 60 * 1000)), - ) - + const prevStart = fromLocalString(snapshot.startDate, DEFAULT_TZ) + const prevDurationDays = (snapshot.days || 1) - 1 + const newStart = fromLocalString(newStartStr, DEFAULT_TZ) + const newEnd = fromLocalString(newEndStr, DEFAULT_TZ) + const proposedDurationDays = Math.max(0, differenceInCalendarDays(newEnd, newStart)) let finalDurationDays = prevDurationDays - if (mode === 'resize-left' || mode === 'resize-right') { + if (mode === 'resize-left' || mode === 'resize-right') finalDurationDays = proposedDurationDays - } - snapshot.startDate = newStartStr - snapshot.endDate = toLocalString( - new Date( - new Date(fromLocalString(newStartStr)).setDate( - new Date(fromLocalString(newStartStr)).getDate() + finalDurationDays, - ), - ), - ) - // Rotate weekly recurrence pattern when moving (not resizing) so selected weekdays track shift + snapshot.days = finalDurationDays + 1 if ( - mode === 'move' && - snapshot.isRepeating && - snapshot.repeat === 'weeks' && - Array.isArray(snapshot.repeatWeekdays) + rotatePattern && + (mode === 'move' || mode === 'resize-left') && + snapshot.recur && + snapshot.recur.freq === 'weeks' && + Array.isArray(snapshot.recur.weekdays) ) { const oldDow = prevStart.getDay() const newDow = newStart.getDay() const shift = newDow - oldDow if (shift !== 0) { - const rotated = [false, false, false, false, false, false, false] - for (let i = 0; i < 7; i++) { - if (snapshot.repeatWeekdays[i]) { - let ni = (i + shift) % 7 - if (ni < 0) ni += 7 - rotated[ni] = true - } - } - snapshot.repeatWeekdays = rotated + snapshot.recur.weekdays = this._rotateWeekdayPattern(snapshot.recur.weekdays, shift) } } - // Reindex - this._removeEventFromAllDatesById(eventId) - this._addEventToDateRangeWithId(eventId, snapshot, snapshot.startDate, snapshot.endDate) - // no expansion + this.events.set(eventId, { ...snapshot, isSpanning: snapshot.days > 1 }) + this.notifyEventsChanged() }, - // Split a repeating series at a given occurrence index; returns new series id - splitRepeatSeries(baseId, occurrenceIndex, newStartStr, newEndStr, _grabbedWeekday) { - const base = this._findEventInAnyList(baseId) - if (!base || !base.isRepeating) return null - // Capture original repeatCount BEFORE truncation - const originalCountRaw = base.repeatCount - // Truncate base to keep occurrences before split point (indices 0..occurrenceIndex-1) + splitMoveVirtualOccurrence(baseId, occurrenceDateStr, newStartStr, newEndStr) { + const base = this.events.get(baseId) + if (!base || !base.recur) return + const originalCountRaw = base.recur.count + const occurrenceDate = fromLocalString(occurrenceDateStr, DEFAULT_TZ) + const baseStart = fromLocalString(base.startDate, DEFAULT_TZ) + // If series effectively has <=1 occurrence, treat as simple move (no split) and flatten + let totalOccurrences = Infinity + if (originalCountRaw !== 'unlimited') { + const parsed = parseInt(originalCountRaw, 10) + if (!isNaN(parsed)) totalOccurrences = parsed + } + if (totalOccurrences <= 1) { + // Flatten to non-repeating if not already + if (base.recur) { + base.recur = null + this.events.set(baseId, { ...base }) + } + this.setEventRange(baseId, newStartStr, newEndStr, { mode: 'move', rotatePattern: true }) + return baseId + } + if (occurrenceDate <= baseStart) { + this.setEventRange(baseId, newStartStr, newEndStr, { mode: 'move' }) + return baseId + } + let keptOccurrences = 0 + if (base.recur.freq === 'weeks') { + const interval = base.recur.interval || 1 + const pattern = base.recur.weekdays || [] + if (!pattern.some(Boolean)) return + const WEEK_MS = 7 * 86400000 + const blockStartBase = getMondayOfISOWeek(baseStart) + function isAligned(d) { + const blk = getMondayOfISOWeek(d) + const diff = Math.floor((blk - blockStartBase) / WEEK_MS) + return diff % interval === 0 + } + let cursor = new Date(baseStart) + while (cursor < occurrenceDate) { + if (pattern[cursor.getDay()] && isAligned(cursor)) keptOccurrences++ + cursor = addDays(cursor, 1) + } + } else if (base.recur.freq === 'months') { + const diffMonths = + (occurrenceDate.getFullYear() - baseStart.getFullYear()) * 12 + + (occurrenceDate.getMonth() - baseStart.getMonth()) + const interval = base.recur.interval || 1 + if (diffMonths <= 0 || diffMonths % interval !== 0) return + keptOccurrences = diffMonths + } else { + return + } + this._terminateRepeatSeriesAtIndex(baseId, keptOccurrences) + // After truncation compute base kept count + const truncated = this.events.get(baseId) + if ( + truncated && + truncated.recur && + truncated.recur.count && + truncated.recur.count !== 'unlimited' + ) { + // keptOccurrences already reflects number before split; adjust not needed further + } + let remainingCount = 'unlimited' + if (originalCountRaw !== 'unlimited') { + const total = parseInt(originalCountRaw, 10) + if (!isNaN(total)) { + const rem = total - keptOccurrences + if (rem <= 0) return + remainingCount = String(rem) + } + } + let weekdays = base.recur.weekdays + if (base.recur.freq === 'weeks' && Array.isArray(base.recur.weekdays)) { + const origWeekday = occurrenceDate.getDay() + const newWeekday = fromLocalString(newStartStr, DEFAULT_TZ).getDay() + const shift = newWeekday - origWeekday + if (shift !== 0) { + weekdays = this._rotateWeekdayPattern(base.recur.weekdays, shift) + } + } + const newId = this.createEvent({ + title: base.title, + startDate: newStartStr, + days: base.days, + colorId: base.colorId, + recur: { + freq: base.recur.freq, + interval: base.recur.interval, + count: remainingCount, + weekdays, + }, + }) + // Flatten base if single occurrence now + if (truncated && truncated.recur) { + const baseCountNum = + truncated.recur.count === 'unlimited' ? Infinity : parseInt(truncated.recur.count, 10) + if (baseCountNum <= 1) { + truncated.recur = null + this.events.set(baseId, { ...truncated }) + } + } + // Flatten new if single occurrence only + const newly = this.events.get(newId) + if (newly && newly.recur) { + const newCountNum = + newly.recur.count === 'unlimited' ? Infinity : parseInt(newly.recur.count, 10) + if (newCountNum <= 1) { + newly.recur = null + this.events.set(newId, { ...newly }) + } + } + this.notifyEventsChanged() + return newId + }, + + splitRepeatSeries(baseId, occurrenceIndex, newStartStr, _newEndStr) { + const base = this.events.get(baseId) + if (!base || !base.recur) return null + const originalCountRaw = base.recur.count this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex) - // Compute new series repeatCount (remaining occurrences starting at occurrenceIndex) let newSeriesCount = 'unlimited' if (originalCountRaw !== 'unlimited') { const originalNum = parseInt(originalCountRaw, 10) @@ -440,64 +428,54 @@ export const useCalendarStore = defineStore('calendar', { newSeriesCount = String(Math.max(1, remaining)) } } - const newId = this.createEvent({ + return this.createEvent({ title: base.title, startDate: newStartStr, - endDate: newEndStr, + days: base.days, colorId: base.colorId, - repeat: base.repeat, - repeatInterval: base.repeatInterval, - repeatCount: newSeriesCount, - repeatWeekdays: base.repeatWeekdays, + recur: base.recur + ? { + freq: base.recur.freq, + interval: base.recur.interval, + count: newSeriesCount, + weekdays: base.recur.weekdays ? [...base.recur.weekdays] : null, + } + : null, }) - return newId - }, - - _reindexBaseEvent(eventId, snapshot, startDate, endDate) { - if (!snapshot) return - this._removeEventFromAllDatesById(eventId) - this._addEventToDateRangeWithId(eventId, snapshot, startDate, endDate) }, _terminateRepeatSeriesAtIndex(baseId, index) { - // Cap repeatCount to 'index' total occurrences (0-based index means index occurrences before split) - for (const [, list] of this.events) { - for (const ev of list) { - if (ev.id === baseId && ev.isRepeating) { - if (ev.repeatCount === 'unlimited') { - ev.repeatCount = String(index) - } else { - const rc = parseInt(ev.repeatCount, 10) - if (!isNaN(rc)) ev.repeatCount = String(Math.min(rc, index)) - } - } - } + const ev = this.events.get(baseId) + if (!ev || !ev.recur) return + if (ev.recur.count === 'unlimited') { + ev.recur.count = String(index) + } else { + const rc = parseInt(ev.recur.count, 10) + if (!isNaN(rc)) ev.recur.count = String(Math.min(rc, index)) } + this.notifyEventsChanged() }, - - _findEventInAnyList(eventId) { - for (const [, eventList] of this.events) { - const found = eventList.find((e) => e.id === eventId) - if (found) return found - } - return null + }, + persist: { + key: 'calendar-store', + storage: localStorage, + paths: ['today', 'config', 'events'], + serializer: { + serialize(value) { + return JSON.stringify(value, (_k, v) => { + if (v instanceof Map) return { __map: true, data: [...v] } + if (v instanceof Set) return { __set: true, data: [...v] } + return v + }) + }, + deserialize(value) { + const revived = JSON.parse(value, (_k, v) => { + if (v && v.__map) return new Map(v.data) + if (v && v.__set) return new Set(v.data) + return v + }) + return revived + }, }, - - _addEventToDateRange(event) { - const startDate = fromLocalString(event.startDate) - const endDate = fromLocalString(event.endDate) - const cur = new Date(startDate) - - while (cur <= endDate) { - const dateStr = toLocalString(cur) - if (!this.events.has(dateStr)) { - this.events.set(dateStr, []) - } - this.events.get(dateStr).push({ ...event, isSpanning: event.startDate < event.endDate }) - cur.setDate(cur.getDate() + 1) - } - }, - - // NOTE: legacy dynamic getEventById for synthetic occurrences removed. }, }) diff --git a/src/utils/date.js b/src/utils/date.js index d386e4f..e8608d3 100644 --- a/src/utils/date.js +++ b/src/utils/date.js @@ -1,4 +1,14 @@ -// date-utils.js — Date handling utilities for the calendar +// date-utils.js — Restored & clean utilities (date-fns + timezone aware) +import * as dateFns from 'date-fns' +import { fromZonedTime, toZonedTime } from 'date-fns-tz' + +const DEFAULT_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' + +// Re-exported iso helpers (keep the same exported names used elsewhere) +const getISOWeek = dateFns.getISOWeek +const getISOWeekYear = dateFns.getISOWeekYear + +// Constants const monthAbbr = [ 'jan', 'feb', @@ -13,201 +23,342 @@ const monthAbbr = [ 'nov', 'dec', ] -const DAY_MS = 86400000 -const WEEK_MS = 7 * DAY_MS +const MIN_YEAR = 100 // less than 100 is interpreted as 19xx +const MAX_YEAR = 9999 +// Core helpers ------------------------------------------------------------ /** - * Get ISO week information for a given date - * @param {Date} date - The date to get week info for - * @returns {Object} Object containing week number and year + * Construct a date at local midnight in the specified IANA timezone. + * Returns a native Date whose wall-clock components in that zone are (Y, M, D 00:00:00). */ -const isoWeekInfo = (date) => { - const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())) - const day = d.getUTCDay() || 7 - d.setUTCDate(d.getUTCDate() + 4 - day) - const year = d.getUTCFullYear() - const yearStart = new Date(Date.UTC(year, 0, 1)) - const diffDays = Math.floor((d - yearStart) / DAY_MS) + 1 - return { week: Math.ceil(diffDays / 7), year } +function makeTZDate(year, monthIndex, day, timeZone = DEFAULT_TZ) { + const iso = `${String(year).padStart(4, '0')}-${String(monthIndex + 1).padStart(2, '0')}-${String( + day, + ).padStart(2, '0')}` + const utcDate = fromZonedTime(`${iso}T00:00:00`, timeZone) + return toZonedTime(utcDate, timeZone) } /** - * Convert a Date object to a local date string (YYYY-MM-DD format) - * @param {Date} date - The date to convert (defaults to new Date()) - * @returns {string} Date string in YYYY-MM-DD format + * Alias constructor for timezone-specific calendar date (semantic sugar over makeTZDate). */ -function toLocalString(date = new Date()) { - const pad = (n) => String(Math.floor(Math.abs(n))).padStart(2, '0') - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +const TZDate = (year, monthIndex, day, timeZone = DEFAULT_TZ) => + makeTZDate(year, monthIndex, day, timeZone) + +/** + * Construct a UTC-based date/time (wrapper for Date.UTC for consistency). + */ +const UTCDate = (year, monthIndex, day, hour = 0, minute = 0, second = 0, ms = 0) => + new Date(Date.UTC(year, monthIndex, day, hour, minute, second, ms)) + +function toLocalString(date = new Date(), timeZone = DEFAULT_TZ) { + return dateFns.format(toZonedTime(date, timeZone), 'yyyy-MM-dd') } -/** - * Convert a local date string (YYYY-MM-DD) to a Date object - * @param {string} dateString - Date string in YYYY-MM-DD format - * @returns {Date} Date object - */ -function fromLocalString(dateString) { - const [year, month, day] = dateString.split('-').map(Number) - return new Date(year, month - 1, day) +function fromLocalString(dateString, timeZone = DEFAULT_TZ) { + if (!dateString) return makeTZDate(1970, 0, 1, timeZone) + const parsed = dateFns.parseISO(dateString) + const utcDate = fromZonedTime(`${dateString}T00:00:00`, timeZone) + return toZonedTime(utcDate, timeZone) || parsed } -/** - * Get the index of Monday for a given date (0-6, where Monday = 0) - * @param {Date} d - The date - * @returns {number} Monday index (0-6) - */ -const mondayIndex = (d) => (d.getDay() + 6) % 7 +function getMondayOfISOWeek(date, timeZone = DEFAULT_TZ) { + const d = toZonedTime(date, timeZone) + const dow = (dateFns.getDay(d) + 6) % 7 // Monday=0 + return dateFns.addDays(dateFns.startOfDay(d), -dow) +} -/** - * Pad a number with leading zeros to make it 2 digits - * @param {number} n - Number to pad - * @returns {string} Padded string - */ +const mondayIndex = (d) => (dateFns.getDay(d) + 6) % 7 + +// Count how many days in [startDate..endDate] match the boolean `pattern` array +function countPatternDaysInInterval(startDate, endDate, patternArr) { + const days = dateFns.eachDayOfInterval({ + start: dateFns.startOfDay(startDate), + end: dateFns.startOfDay(endDate), + }) + return days.reduce((c, d) => c + (patternArr[dateFns.getDay(d)] ? 1 : 0), 0) +} + +// Recurrence: Weekly ------------------------------------------------------ +function _getRecur(event) { + return event?.recur ?? null +} + +function getWeeklyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) { + const recur = _getRecur(event) + if (!recur || recur.freq !== 'weeks') return null + const pattern = recur.weekdays || [] + if (!pattern.some(Boolean)) return null + + const target = fromLocalString(dateStr, timeZone) + const baseStart = fromLocalString(event.startDate, timeZone) + if (target < baseStart) return null + + const dow = dateFns.getDay(target) + if (!pattern[dow]) return null // target not active + + const interval = recur.interval || 1 + const baseBlockStart = getMondayOfISOWeek(baseStart, timeZone) + const currentBlockStart = getMondayOfISOWeek(target, timeZone) + // Number of weeks between block starts (each block start is a Monday) + const weekDiff = dateFns.differenceInCalendarWeeks(currentBlockStart, baseBlockStart) + if (weekDiff < 0 || weekDiff % interval !== 0) return null + + const baseDow = dateFns.getDay(baseStart) + const baseCountsAsPattern = !!pattern[baseDow] + + // Same ISO week as base: count pattern days from baseStart up to target (inclusive) + if (weekDiff === 0) { + let n = countPatternDaysInInterval(baseStart, target, pattern) - 1 + if (!baseCountsAsPattern) n += 1 + const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10) + return n < 0 || n >= maxCount ? null : n + } + + const baseWeekEnd = dateFns.addDays(baseBlockStart, 6) + // Count pattern days in the first (possibly partial) week from baseStart..baseWeekEnd + const firstWeekCount = countPatternDaysInInterval(baseStart, baseWeekEnd, pattern) + const alignedWeeksBetween = weekDiff / interval - 1 + const fullPatternWeekCount = pattern.filter(Boolean).length + const middleWeeksCount = alignedWeeksBetween > 0 ? alignedWeeksBetween * fullPatternWeekCount : 0 + // Count pattern days in the current (possibly partial) week from currentBlockStart..target + const currentWeekCount = countPatternDaysInInterval(currentBlockStart, target, pattern) + let n = firstWeekCount + middleWeeksCount + currentWeekCount - 1 + if (!baseCountsAsPattern) n += 1 + const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10) + return n >= maxCount ? null : n +} + +// Recurrence: Monthly ----------------------------------------------------- +function getMonthlyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) { + const recur = _getRecur(event) + if (!recur || recur.freq !== 'months') return null + const baseStart = fromLocalString(event.startDate, timeZone) + const d = fromLocalString(dateStr, timeZone) + const diffMonths = dateFns.differenceInCalendarMonths(d, baseStart) + if (diffMonths < 0) return null + const interval = recur.interval || 1 + if (diffMonths % interval !== 0) return null + const baseDay = dateFns.getDate(baseStart) + const effectiveDay = Math.min(baseDay, dateFns.getDaysInMonth(d)) + if (dateFns.getDate(d) !== effectiveDay) return null + const n = diffMonths / interval + const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10) + return n >= maxCount ? null : n +} + +function getOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) { + const recur = _getRecur(event) + if (!recur) return null + if (dateStr < event.startDate) return null + if (recur.freq === 'weeks') return getWeeklyOccurrenceIndex(event, dateStr, timeZone) + if (recur.freq === 'months') return getMonthlyOccurrenceIndex(event, dateStr, timeZone) + return null +} + +// Reverse lookup: given a recurrence index (0-based) return the occurrence start date string. +// Returns null if the index is out of range or the event is not repeating. +function getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) { + const recur = _getRecur(event) + if (!recur || recur.freq !== 'weeks') return null + if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null + const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10) + if (occurrenceIndex >= maxCount) return null + const pattern = recur.weekdays || [] + if (!pattern.some(Boolean)) return null + const interval = recur.interval || 1 + const baseStart = fromLocalString(event.startDate, timeZone) + if (occurrenceIndex === 0) return toLocalString(baseStart, timeZone) + const baseWeekMonday = getMondayOfISOWeek(baseStart, timeZone) + const baseDow = dateFns.getDay(baseStart) + const baseCountsAsPattern = !!pattern[baseDow] + // Adjust index if base weekday is not part of the pattern (pattern occurrences shift by +1) + let occ = occurrenceIndex + if (!baseCountsAsPattern) occ -= 1 + if (occ < 0) return null + // Sorted list of active weekday indices + const patternDays = [] + for (let d = 0; d < 7; d++) if (pattern[d]) patternDays.push(d) + // First (possibly partial) week: only pattern days >= baseDow and >= baseStart date + const firstWeekDates = [] + for (const d of patternDays) { + if (d < baseDow) continue + const date = dateFns.addDays(baseWeekMonday, d) + if (date < baseStart) continue + firstWeekDates.push(date) + } + const F = firstWeekDates.length + if (occ < F) { + return toLocalString(firstWeekDates[occ], timeZone) + } + const remaining = occ - F + const P = patternDays.length + if (P === 0) return null + // Determine aligned week group (k >= 1) in which the remaining-th occurrence lies + const k = Math.floor(remaining / P) + 1 // 1-based aligned week count after base week + const indexInWeek = remaining % P + const dow = patternDays[indexInWeek] + const occurrenceDate = dateFns.addDays(baseWeekMonday, k * interval * 7 + dow) + return toLocalString(occurrenceDate, timeZone) +} + +function getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) { + const recur = _getRecur(event) + if (!recur || recur.freq !== 'months') return null + if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null + const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10) + if (occurrenceIndex >= maxCount) return null + const interval = recur.interval || 1 + const baseStart = fromLocalString(event.startDate, timeZone) + const targetMonthOffset = occurrenceIndex * interval + const monthDate = dateFns.addMonths(baseStart, targetMonthOffset) + // Adjust day for shorter months (clamp like forward logic) + const baseDay = dateFns.getDate(baseStart) + const daysInTargetMonth = dateFns.getDaysInMonth(monthDate) + const day = Math.min(baseDay, daysInTargetMonth) + const actual = makeTZDate(dateFns.getYear(monthDate), dateFns.getMonth(monthDate), day, timeZone) + return toLocalString(actual, timeZone) +} + +function getOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) { + const recur = _getRecur(event) + if (!recur) return null + if (recur.freq === 'weeks') return getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone) + if (recur.freq === 'months') return getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone) + return null +} + +function getVirtualOccurrenceEndDate(event, occurrenceStartDate, timeZone = DEFAULT_TZ) { + const spanDays = Math.max(0, (event.days || 1) - 1) + const occurrenceStart = fromLocalString(occurrenceStartDate, timeZone) + return toLocalString(dateFns.addDays(occurrenceStart, spanDays), timeZone) +} + +// Utility formatting & localization --------------------------------------- const pad = (n) => String(n).padStart(2, '0') -/** - * Calculate number of days between two date strings (inclusive) - * @param {string} aStr - First date string (YYYY-MM-DD) - * @param {string} bStr - Second date string (YYYY-MM-DD) - * @returns {number} Number of days inclusive - */ -function daysInclusive(aStr, bStr) { - const a = fromLocalString(aStr) - const b = fromLocalString(bStr) - const A = new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime() - const B = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime() - return Math.floor(Math.abs(B - A) / DAY_MS) + 1 +function daysInclusive(aStr, bStr, timeZone = DEFAULT_TZ) { + const a = fromLocalString(aStr, timeZone) + const b = fromLocalString(bStr, timeZone) + return ( + Math.abs(dateFns.differenceInCalendarDays(dateFns.startOfDay(a), dateFns.startOfDay(b))) + 1 + ) } -/** - * Add days to a date string - * @param {string} str - Date string in YYYY-MM-DD format - * @param {number} n - Number of days to add (can be negative) - * @returns {string} New date string - */ -function addDaysStr(str, n) { - const d = fromLocalString(str) - d.setDate(d.getDate() + n) - return toLocalString(d) +function addDaysStr(str, n, timeZone = DEFAULT_TZ) { + return toLocalString(dateFns.addDays(fromLocalString(str, timeZone), n), timeZone) } -/** - * Get localized weekday names starting from Monday - * @returns {Array} Array of localized weekday names - */ -function getLocalizedWeekdayNames() { - const res = [] - const base = new Date(2025, 0, 6) // A Monday - for (let i = 0; i < 7; i++) { - const d = new Date(base) - d.setDate(base.getDate() + i) - res.push(d.toLocaleDateString(undefined, { weekday: 'short' })) - } - return res +function getLocalizedWeekdayNames(timeZone = DEFAULT_TZ) { + const monday = makeTZDate(2025, 0, 6, timeZone) // a Monday + return Array.from({ length: 7 }, (_, i) => + new Intl.DateTimeFormat(undefined, { weekday: 'short', timeZone }).format( + dateFns.addDays(monday, i), + ), + ) } -/** - * Get the locale's first day of the week (0=Sunday, 1=Monday, etc.) - * @returns {number} First day of the week (0-6) - */ function getLocaleFirstDay() { - try { - return new Intl.Locale(navigator.language).weekInfo.firstDay % 7 - } catch { - return 1 // Default to Monday if locale info not available - } + const day = new Intl.Locale(navigator.language).weekInfo?.firstDay ?? 1 + return day % 7 } -/** - * Get the locale's weekend days as an array of booleans (Sunday=index 0) - * @returns {Array} Array where true indicates a weekend day - */ function getLocaleWeekendDays() { - try { - const localeWeekend = new Intl.Locale(navigator.language).weekInfo.weekend - const dayidx = new Set(localeWeekend) - return Array.from({ length: 7 }, (_, i) => dayidx.has(i || 7)) - } catch { - return [true, false, false, false, false, false, true] // Default to Saturday/Sunday weekend - } + const wk = new Set(new Intl.Locale(navigator.language).weekInfo?.weekend ?? [6, 7]) + return Array.from({ length: 7 }, (_, i) => wk.has(1 + ((i + 6) % 7))) } -/** - * Reorder a 7-element array based on the first day of the week - * @param {Array} days - Array of 7 elements (Sunday=index 0) - * @param {number} firstDay - First day of the week (0=Sunday, 1=Monday, etc.) - * @returns {Array} Reordered array - */ function reorderByFirstDay(days, firstDay) { return Array.from({ length: 7 }, (_, i) => days[(i + firstDay) % 7]) } -/** - * Get localized month name - * @param {number} idx - Month index (0-11) - * @param {boolean} short - Whether to return short name - * @returns {string} Localized month name - */ -function getLocalizedMonthName(idx, short = false) { - const d = new Date(2025, idx, 1) - return d.toLocaleDateString(undefined, { month: short ? 'short' : 'long' }) +function getLocalizedMonthName(idx, short = false, timeZone = DEFAULT_TZ) { + const d = makeTZDate(2025, idx, 1, timeZone) + return new Intl.DateTimeFormat(undefined, { month: short ? 'short' : 'long', timeZone }).format(d) } -/** - * Format a date range for display - * @param {Date} startDate - Start date - * @param {Date} endDate - End date - * @returns {string} Formatted date range string - */ -function formatDateRange(startDate, endDate) { - if (toLocalString(startDate) === toLocalString(endDate)) return toLocalString(startDate) - const startISO = toLocalString(startDate) - const endISO = toLocalString(endDate) - const [sy, sm] = startISO.split('-') - const [ey, em, ed] = endISO.split('-') - if (sy === ey && sm === em) return `${startISO}/${ed}` - if (sy === ey) return `${startISO}/${em}-${ed}` - return `${startISO}/${endISO}` +function formatDateRange(startDate, endDate, timeZone = DEFAULT_TZ) { + const a = toLocalString(startDate, timeZone) + const b = toLocalString(endDate, timeZone) + if (a === b) return a + const [ay, am] = a.split('-') + const [by, bm, bd] = b.split('-') + if (ay === by && am === bm) return `${a}/${bd}` + if (ay === by) return `${a}/${bm}-${bd}` + return `${a}/${b}` } -/** - * Compute lunar phase symbol for the four main phases on a given date. - * Returns one of: 🌑 (new), 🌓 (first quarter), 🌕 (full), 🌗 (last quarter), or '' otherwise. - * Uses an approximate algorithm with a fixed epoch. - */ function lunarPhaseSymbol(date) { - // Reference new moon: 2000-01-06 18:14 UTC (J2000 era), often used in approximations - const ref = Date.UTC(2000, 0, 6, 18, 14, 0) - const synodic = 29.530588853 // days - // Use UTC noon of given date to reduce timezone edge effects - const dUTC = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0) - const daysSince = (dUTC - ref) / DAY_MS - const phase = (((daysSince / synodic) % 1) + 1) % 1 + // Reference new moon (J2000 era) used for approximate phase calculations + const ref = UTCDate(2000, 0, 6, 18, 14, 0) + const obs = new Date(date) + obs.setHours(12, 0, 0, 0) + const synodic = 29.530588853 // mean synodic month length in days + const daysSince = dateFns.differenceInMinutes(obs, ref) / 60 / 24 + const phase = (((daysSince / synodic) % 1) + 1) % 1 // normalize to [0,1) const phases = [ - { t: 0.0, s: '🌑' }, // New Moon + { t: 0.0, s: '🌑' }, // New { t: 0.25, s: '🌓' }, // First Quarter - { t: 0.5, s: '🌕' }, // Full Moon + { t: 0.5, s: '🌕' }, // Full { t: 0.75, s: '🌗' }, // Last Quarter ] - // threshold in days from exact phase to still count for this date - const thresholdDays = 0.5 // ±12 hours + const thresholdDays = 0.5 // within ~12h of exact phase for (const p of phases) { let delta = Math.abs(phase - p.t) - if (delta > 0.5) delta = 1 - delta + if (delta > 0.5) delta = 1 - delta // wrap shortest arc if (delta * synodic <= thresholdDays) return p.s } return '' } -// Export all functions and constants +// Exports ----------------------------------------------------------------- +/** + * Format date as short localized string (e.g., "Jan 15") + */ +function formatDateShort(date) { + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }).replace(/, /, ' ') +} + +/** + * Format date as long localized string with optional year (e.g., "Mon Jan 15" or "Mon Jan 15, 2025") + */ +function formatDateLong(date, includeYear = false) { + const opts = { + weekday: 'short', + month: 'short', + day: 'numeric', + ...(includeYear ? { year: 'numeric' } : {}), + } + return date.toLocaleDateString(undefined, opts) +} + +/** + * Format date as today string (e.g., "Monday\nJanuary 15") + */ +function formatTodayString(date) { + const formatted = date + .toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) + .replace(/,? /, '\n') + return formatted.charAt(0).toUpperCase() + formatted.slice(1) +} + export { + // constants monthAbbr, - DAY_MS, - WEEK_MS, - isoWeekInfo, + MIN_YEAR, + MAX_YEAR, + DEFAULT_TZ, + // core tz helpers + makeTZDate, toLocalString, fromLocalString, + // recurrence + getMondayOfISOWeek, mondayIndex, + getOccurrenceIndex, + getOccurrenceDate, + getVirtualOccurrenceEndDate, + // formatting & localization pad, daysInclusive, addDaysStr, @@ -217,5 +368,14 @@ export { reorderByFirstDay, getLocalizedMonthName, formatDateRange, + formatDateShort, + formatDateLong, + formatTodayString, lunarPhaseSymbol, + // iso helpers re-export + getISOWeek, + getISOWeekYear, + // constructors + TZDate, + UTCDate, } diff --git a/src/utils/holidays.js b/src/utils/holidays.js new file mode 100644 index 0000000..e774fbd --- /dev/null +++ b/src/utils/holidays.js @@ -0,0 +1,179 @@ +// holidays.js — Holiday utilities using date-holidays package +import Holidays from 'date-holidays' + +let holidaysInstance = null +let currentCountry = null +let currentState = null +let currentRegion = null +let holidayCache = new Map() +let yearCache = new Map() + +/** + * Initialize holidays for a specific country/region + * @param {string} country - Country code (e.g., 'US', 'GB', 'DE') + * @param {string} [state] - State/province code (e.g., 'CA' for California) + * @param {string} [region] - Region code + */ +export function initializeHolidays(country, state = null, region = null) { + if (!country) { + console.warn('No country provided for holiday initialization') + holidaysInstance = null + return false + } + + try { + holidaysInstance = new Holidays(country, state, region) + currentCountry = country + currentState = state + currentRegion = region + + holidayCache.clear() + yearCache.clear() + + return true + } catch (error) { + console.warn('Failed to initialize holidays for', country, state, region, error) + holidaysInstance = null + return false + } +} + +/** + * Get holidays for a specific year + * @param {number} year - The year to get holidays for + * @returns {Array} Array of holiday objects + */ +export function getHolidaysForYear(year) { + if (!holidaysInstance) { + return [] + } + + if (yearCache.has(year)) { + return yearCache.get(year) + } + + try { + const holidays = holidaysInstance.getHolidays(year) + yearCache.set(year, holidays) + return holidays + } catch (error) { + console.warn('Failed to get holidays for year', year, error) + return [] + } +} + +/** + * Get holiday for a specific date + * @param {string|Date} date - Date in YYYY-MM-DD format or Date object + * @returns {Object|null} Holiday object or null if no holiday + */ +export function getHolidayForDate(date) { + if (!holidaysInstance) { + return null + } + + const cacheKey = typeof date === 'string' ? date : date.toISOString().split('T')[0] + if (holidayCache.has(cacheKey)) { + return holidayCache.get(cacheKey) + } + + try { + let dateObj + if (typeof date === 'string') { + const [year, month, day] = date.split('-').map(Number) + dateObj = new Date(year, month - 1, day) + } else { + dateObj = date + } + + const year = dateObj.getFullYear() + const holidays = getHolidaysForYear(year) + + const holiday = holidays.find((h) => { + const holidayDate = new Date(h.date) + return ( + holidayDate.getFullYear() === dateObj.getFullYear() && + holidayDate.getMonth() === dateObj.getMonth() && + holidayDate.getDate() === dateObj.getDate() + ) + }) + + const result = holiday || null + holidayCache.set(cacheKey, result) + + return result + } catch (error) { + console.warn('Failed to get holiday for date', date, error) + return null + } +} + +/** + * Check if a date is a holiday + * @param {string|Date} date - Date in YYYY-MM-DD format or Date object + * @returns {boolean} True if the date is a holiday + */ +export function isHoliday(date) { + return getHolidayForDate(date) !== null +} + +/** + * Get available countries for holidays + * @returns {Array} Array of country codes + */ +export function getAvailableCountries() { + try { + const holidays = new Holidays() + const countries = holidays.getCountries() + + // The getCountries method might return an object, convert to array of keys + if (countries && typeof countries === 'object') { + return Array.isArray(countries) ? countries : Object.keys(countries) + } + + return ['US', 'GB', 'DE', 'FR', 'CA', 'AU'] // Fallback + } catch (error) { + console.warn('Failed to get available countries', error) + return ['US', 'GB', 'DE', 'FR', 'CA', 'AU'] // Fallback to common countries + } +} + +/** + * Get available states/regions for a country + * @param {string} country - Country code + * @returns {Array} Array of state/region codes + */ +export function getAvailableStates(country) { + try { + if (!country) return [] + + const holidays = new Holidays() + const states = holidays.getStates(country) + + // The getStates method might return an object, convert to array of keys + if (states && typeof states === 'object') { + return Array.isArray(states) ? states : Object.keys(states) + } + + return [] + } catch (error) { + console.warn('Failed to get available states for', country, error) + return [] + } +} + +/** + * Get holiday configuration info + * @returns {Object} Current holiday configuration + */ +export function getHolidayConfig() { + return { + country: currentCountry, + state: currentState, + region: currentRegion, + initialized: !!holidaysInstance, + } +} + +// Initialize with US holidays by default +initializeHolidays('US')