import { defineStore } from 'pinia' import { toLocalString, fromLocalString, getLocaleWeekendDays, getMondayOfISOWeek, getOccurrenceDate, 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(), // 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: 14, first_day: 1, holidays: { enabled: true, country: 'auto', state: null, region: null, }, }, }), actions: { _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() { // 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 const event = { id: this.generateId(), title: eventData.title, startDate: eventData.startDate, endDate: eventData.endDate, colorId: eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate), startTime: singleDay ? eventData.startTime || '09:00' : null, durationMinutes: singleDay ? eventData.durationMinutes || 60 : null, repeat: ['weeks', 'months'].includes(eventData.repeat) ? eventData.repeat : 'none', repeatInterval: eventData.repeatInterval || 1, repeatCount: eventData.repeatCount || 'unlimited', repeatWeekdays: eventData.repeatWeekdays, isRepeating: eventData.repeat && eventData.repeat !== 'none', } this.events.set(event.id, { ...event, isSpanning: event.startDate < event.endDate }) 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 = fromLocalString(ev.endDate) 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.isRepeating) { this.deleteEvent(baseId) return } const numericCount = base.repeatCount === 'unlimited' ? Infinity : parseInt(base.repeatCount, 10) if (numericCount <= 1) { this.deleteEvent(baseId) return } const nextStartStr = getOccurrenceDate(base, 1, DEFAULT_TZ) if (!nextStartStr) { this.deleteEvent(baseId) return } const oldStart = fromLocalString(base.startDate, DEFAULT_TZ) const oldEnd = fromLocalString(base.endDate, DEFAULT_TZ) const durationDays = Math.max(0, differenceInCalendarDays(oldEnd, oldStart)) const newEndStr = toLocalString( addDays(fromLocalString(nextStartStr, DEFAULT_TZ), durationDays), DEFAULT_TZ, ) base.startDate = nextStartStr base.endDate = newEndStr if (numericCount !== Infinity) base.repeatCount = String(Math.max(1, numericCount - 1)) this.events.set(baseId, { ...base, isSpanning: base.startDate < base.endDate }) this.notifyEventsChanged() }, deleteSingleOccurrence(ctx) { const { baseId, occurrenceIndex } = ctx || {} if (occurrenceIndex == null) return const base = this.getEventById(baseId) if (!base) return if (!base.isRepeating) { if (occurrenceIndex === 0) this.deleteEvent(baseId) return } if (occurrenceIndex === 0) { this.deleteFirstOccurrence(baseId) return } const snapshot = { ...base } base.repeatCount = occurrenceIndex const nextStartStr = getOccurrenceDate(snapshot, occurrenceIndex + 1, DEFAULT_TZ) if (!nextStartStr) return const durationDays = Math.max( 0, differenceInCalendarDays( fromLocalString(snapshot.endDate), fromLocalString(snapshot.startDate), ), ) const newEndStr = toLocalString(addDays(fromLocalString(nextStartStr), durationDays)) const originalNumeric = snapshot.repeatCount === 'unlimited' ? Infinity : parseInt(snapshot.repeatCount, 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, endDate: newEndStr, colorId: snapshot.colorId, repeat: snapshot.repeat, repeatInterval: snapshot.repeatInterval, repeatCount: remainingCount, repeatWeekdays: snapshot.repeatWeekdays, }) this.notifyEventsChanged() }, deleteFromOccurrence(ctx) { const { baseId, occurrenceIndex } = ctx const base = this.getEventById(baseId) if (!base || !base.isRepeating) return if (occurrenceIndex === 0) { this.deleteEvent(baseId) return } this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex) this.notifyEventsChanged() }, setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto' } = {}) { const snapshot = this.events.get(eventId) if (!snapshot) return const prevStart = fromLocalString(snapshot.startDate, DEFAULT_TZ) const prevEnd = fromLocalString(snapshot.endDate, DEFAULT_TZ) const prevDurationDays = Math.max(0, differenceInCalendarDays(prevEnd, prevStart)) 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.endDate = toLocalString( addDays(fromLocalString(newStartStr, DEFAULT_TZ), finalDurationDays), DEFAULT_TZ, ) if ( mode === 'move' && snapshot.isRepeating && snapshot.repeat === 'weeks' && Array.isArray(snapshot.repeatWeekdays) ) { 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 } } this.events.set(eventId, { ...snapshot, isSpanning: snapshot.startDate < snapshot.endDate }) this.notifyEventsChanged() }, splitMoveVirtualOccurrence(baseId, occurrenceDateStr, newStartStr, newEndStr) { const base = this.events.get(baseId) if (!base || !base.isRepeating) return const originalCountRaw = base.repeatCount const occurrenceDate = fromLocalString(occurrenceDateStr, DEFAULT_TZ) const baseStart = fromLocalString(base.startDate, DEFAULT_TZ) if (occurrenceDate <= baseStart) { this.setEventRange(baseId, newStartStr, newEndStr, { mode: 'move' }) return } let keptOccurrences = 0 if (base.repeat === 'weeks') { const interval = base.repeatInterval || 1 const pattern = base.repeatWeekdays || [] 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.repeat === 'months') { const diffMonths = (occurrenceDate.getFullYear() - baseStart.getFullYear()) * 12 + (occurrenceDate.getMonth() - baseStart.getMonth()) const interval = base.repeatInterval || 1 if (diffMonths <= 0 || diffMonths % interval !== 0) return keptOccurrences = diffMonths } else { return } this._terminateRepeatSeriesAtIndex(baseId, keptOccurrences) 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 repeatWeekdays = base.repeatWeekdays if (base.repeat === 'weeks' && Array.isArray(base.repeatWeekdays)) { const origWeekday = occurrenceDate.getDay() const newWeekday = fromLocalString(newStartStr, DEFAULT_TZ).getDay() const shift = newWeekday - origWeekday if (shift !== 0) { const rotated = [false, false, false, false, false, false, false] for (let i = 0; i < 7; i++) { if (base.repeatWeekdays[i]) { let ni = (i + shift) % 7 if (ni < 0) ni += 7 rotated[ni] = true } } repeatWeekdays = rotated } } this.createEvent({ title: base.title, startDate: newStartStr, endDate: newEndStr, colorId: base.colorId, repeat: base.repeat, repeatInterval: base.repeatInterval, repeatCount: remainingCount, repeatWeekdays, }) this.notifyEventsChanged() }, splitRepeatSeries(baseId, occurrenceIndex, newStartStr, newEndStr) { const base = this.events.get(baseId) if (!base || !base.isRepeating) return null const originalCountRaw = base.repeatCount 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, endDate: newEndStr, colorId: base.colorId, repeat: base.repeat, repeatInterval: base.repeatInterval, repeatCount: newSeriesCount, repeatWeekdays: base.repeatWeekdays, }) }, _terminateRepeatSeriesAtIndex(baseId, index) { const ev = this.events.get(baseId) if (!ev || !ev.isRepeating) return 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)) } 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) { return 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 }) }, }, }, })