{{ props.day.displayText }}
{{ props.day.lunarPhase }} - -
-
-
@@ -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 @@
-
-
- +{{ props.day.events.length - 3 }}
-
+
+
+ {{ props.day.holiday.name }}
+
-
- {{ calendarStore.viewYear }}
-
- {{ day }}
-
-
-
-
-
-
-
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 @@
-
-
-
-
-
-
-
-
Calendar
-
-
- {{ todayString }}
-
-
-
-
-
-
- {{ week.monthLabel?.text }}
+
+
+
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)
+ }
}
-
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ monthWeek.monthLabel?.text || ''
+ }}
+
+
+
+
@@ -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 @@
-
-
- } 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')
W{{ props.week.weekNumber }}
-
-
+
-
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 @@
-
+
+
+ {{ dialogMode === 'create' ? 'Create Event' : 'Edit Event'
+ }} · {{ headerDateShort }}
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+ {{ recurrenceSummary }}
+
+ until {{ formattedFinalOccurrence }}
+
+ Does not recur
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 }}
W{{ weekNumber }}
-
-
+
+
@@ -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
{{ monthLabel.name }} '{{ monthLabel.year }}