import { defineStore } from 'pinia' import { toLocalString, fromLocalString, getLocaleWeekendDays, getMondayOfISOWeek, DEFAULT_TZ, } from '@/utils/date' import { differenceInCalendarDays, addDays } from 'date-fns' import { initializeHolidays, getAvailableCountries, getAvailableStates } from '@/utils/holidays' export const useCalendarStore = defineStore('calendar', { state: () => ({ today: toLocalString(new Date(), DEFAULT_TZ), now: new Date().toISOString(), events: new Map(), // 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: 14, first_day: 1, holidays: { enabled: true, country: 'auto', state: null, region: null, }, }, }), actions: { _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 }, generateId() { try { if (window.crypto && typeof window.crypto.randomUUID === 'function') { return window.crypto.randomUUID() } } catch {} return 'e-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36) }, notifyEventsChanged() {}, touchEvents() { this.notifyEventsChanged() }, createEvent(eventData) { 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, days, colorId: eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.startDate), startTime: singleDay ? eventData.startTime || '09:00' : null, durationMinutes: singleDay ? eventData.durationMinutes || 60 : null, 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, } this.events.set(event.id, { ...event, isSpanning: event.days > 1 }) this.notifyEventsChanged() return event.id }, getEventById(id) { return this.events.get(id) || null }, selectEventColorId(startDateStr, endDateStr) { const colorCounts = [0, 0, 0, 0, 0, 0, 0, 0] 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 c = 1; c < 8; c++) { if (colorCounts[c] < minCount) { minCount = colorCounts[c] selectedColor = c } } return selectedColor }, deleteEvent(eventId) { this.events.delete(eventId) this.notifyEventsChanged() }, deleteFirstOccurrence(baseId) { const base = this.getEventById(baseId) if (!base) return if (!base.recur) { this.deleteEvent(baseId) return } const numericCount = base.recur.count === 'unlimited' ? Infinity : parseInt(base.recur.count, 10) if (numericCount <= 1) { 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() }, 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 } if (occurrenceIndex === 0) { this.deleteFirstOccurrence(baseId) return } const snapshot = { ...base } snapshot.recur = snapshot.recur ? { ...snapshot.recur } : null if (base.recur.count === occurrenceIndex + 1) { base.recur.count = occurrenceIndex return } base.recur.count = occurrenceIndex 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, } : null, }) this.notifyEventsChanged() }, deleteFromOccurrence(ctx) { const { baseId, occurrenceIndex } = ctx const base = this.getEventById(baseId) if (!base || !base.recur) return if (occurrenceIndex === 0) { this.deleteEvent(baseId) return } this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex) this.notifyEventsChanged() }, setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto', rotatePattern = true } = {}) { const snapshot = this.events.get(eventId) if (!snapshot) return 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') finalDurationDays = proposedDurationDays snapshot.startDate = newStartStr snapshot.days = finalDurationDays + 1 if ( 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) { snapshot.recur.weekdays = this._rotateWeekdayPattern(snapshot.recur.weekdays, shift) } } this.events.set(eventId, { ...snapshot, isSpanning: snapshot.days > 1 }) this.notifyEventsChanged() }, splitMoveVirtualOccurrence(baseId, occurrenceDateStr, newStartStr, newEndStr, occurrenceIndex) { 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 } // Use occurrenceIndex when provided to detect first occurrence (n == 0) if (occurrenceIndex === 0 || occurrenceDate.getTime() === baseStart.getTime()) { 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) let newSeriesCount = 'unlimited' if (originalCountRaw !== 'unlimited') { const originalNum = parseInt(originalCountRaw, 10) if (!isNaN(originalNum)) { const remaining = originalNum - occurrenceIndex newSeriesCount = String(Math.max(1, remaining)) } } return this.createEvent({ title: base.title, startDate: newStartStr, days: base.days, colorId: base.colorId, recur: base.recur ? { freq: base.recur.freq, interval: base.recur.interval, count: newSeriesCount, weekdays: base.recur.weekdays ? [...base.recur.weekdays] : null, } : null, }) }, _terminateRepeatSeriesAtIndex(baseId, 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() }, }, 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 }, }, }, })