import { defineStore } from 'pinia' import { toLocalString, fromLocalString, getLocaleWeekendDays, getMondayOfISOWeek, getOccurrenceIndex, } from '@/utils/date' import { initializeHolidays, getHolidayForDate, isHoliday, getAvailableCountries, getAvailableStates, } from '@/utils/holidays' const MIN_YEAR = 1900 const MAX_YEAR = 2100 export const useCalendarStore = defineStore('calendar', { state: () => ({ today: toLocalString(new Date()), now: new Date().toISOString(), events: new Map(), weekend: getLocaleWeekendDays(), _holidayConfigSignature: null, _holidaysInitialized: false, config: { select_days: 1000, min_year: MIN_YEAR, max_year: MAX_YEAR, first_day: 1, holidays: { enabled: true, country: 'auto', state: null, region: null, }, }, }), getters: { // Basic configuration getters minYear: () => MIN_YEAR, maxYear: () => MAX_YEAR, }, actions: { // Initialize holidays based on current config initializeHolidaysFromConfig() { if (!this.config.holidays.enabled) { return false } let country = this.config.holidays.country if (country === 'auto') { const locale = navigator.language || navigator.languages?.[0] if (!locale) return false const parts = locale.split('-') if (parts.length < 2) return false country = parts[parts.length - 1].toUpperCase() } if (country) { return this.initializeHolidays( country, this.config.holidays.state, this.config.holidays.region, ) } return false }, occursOnDate(event, dateStr) { return getOccurrenceIndex(event, dateStr) !== null }, updateCurrentDate() { const d = new Date() this.now = d.toISOString() const today = toLocalString(d) if (this.today !== today) { this.today = today } }, // Holiday management initializeHolidays(country, state = null, region = null) { let actualCountry = country if (country === 'auto') { const locale = navigator.language || navigator.languages?.[0] if (!locale) return false const parts = locale.split('-') if (parts.length < 2) return false actualCountry = parts[parts.length - 1].toUpperCase() } 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 } let actualCountry = this.config.holidays.country if (this.config.holidays.country === 'auto') { const locale = navigator.language || navigator.languages?.[0] if (!locale) return false const parts = locale.split('-') if (parts.length < 2) return false actualCountry = parts[parts.length - 1].toUpperCase() } const configSignature = `${actualCountry}-${this.config.holidays.state}-${this.config.holidays.region}-${this.config.holidays.enabled}` if (this._holidayConfigSignature !== configSignature || !this._holidaysInitialized) { const success = initializeHolidays( actualCountry, this.config.holidays.state, this.config.holidays.region, ) if (success) { this._holidayConfigSignature = configSignature this._holidaysInitialized = true } return success } return this._holidaysInitialized }, getHolidayForDate(dateStr) { if (!this._ensureHolidaysInitialized()) { return null } return getHolidayForDate(dateStr) }, isHoliday(dateStr) { if (!this._ensureHolidaysInitialized()) { return false } return isHoliday(dateStr) }, 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') { return window.crypto.randomUUID() } } catch {} return 'e-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36) }, 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, // Normalized repeat value: only 'weeks', 'months', or 'none' 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 }) 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 = new Date(fromLocalString(startDateStr)) const endDate = new Date(fromLocalString(endDateStr)) // Count events whose ranges overlap at least one day in selected span 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 colorId = 1; colorId < 8; colorId++) { if (colorCounts[colorId] < minCount) { minCount = colorCounts[colorId] selectedColor = colorId } } return selectedColor }, deleteEvent(eventId) { this.events.delete(eventId) }, deleteSingleOccurrence(ctx) { const { baseId, occurrenceIndex } = ctx const base = this.getEventById(baseId) if (!base || !base.isRepeating) return // WEEKLY SERIES ------------------------------------------------------ if (base.repeat === 'weeks') { // Special case: deleting the first occurrence (index 0) should shift the series forward if (occurrenceIndex === 0) { const baseStart = fromLocalString(base.startDate) const baseEnd = fromLocalString(base.endDate) const spanDays = Math.max(0, Math.round((baseEnd - baseStart) / (24 * 60 * 60 * 1000))) const pattern = base.repeatWeekdays || [] if (!pattern.some(Boolean)) { // No pattern to continue -> delete whole series this.deleteEvent(baseId) return } const interval = base.repeatInterval || 1 const WEEK_MS = 7 * 86400000 const baseBlockStart = getMondayOfISOWeek(baseStart) const isAligned = (d) => { const blk = getMondayOfISOWeek(d) const diff = Math.floor((blk - baseBlockStart) / WEEK_MS) return diff % interval === 0 } const probe = new Date(baseStart) let safety = 0 let found = null while (safety < 5000) { probe.setDate(probe.getDate() + 1) if (pattern[probe.getDay()] && isAligned(probe)) { found = new Date(probe) break } safety++ } if (!found) { // Nothing after first -> delete series this.deleteEvent(baseId) return } // Adjust repeat count if (base.repeatCount !== 'unlimited') { const rc = parseInt(base.repeatCount, 10) if (!isNaN(rc)) { const newRc = rc - 1 if (newRc <= 0) { this.deleteEvent(baseId) return } base.repeatCount = String(newRc) } } const newEnd = new Date(found) newEnd.setDate(newEnd.getDate() + spanDays) base.startDate = toLocalString(found) base.endDate = toLocalString(newEnd) base.isSpanning = base.startDate < base.endDate this.events.set(base.id, base) return } const interval = base.repeatInterval || 1 const pattern = base.repeatWeekdays || [] if (!pattern.some(Boolean)) return // Preserve original count before any truncation const originalCountRaw = base.repeatCount // Determine target occurrence date let targetDate = null if (ctx.occurrenceDate instanceof Date) { targetDate = new Date( ctx.occurrenceDate.getFullYear(), ctx.occurrenceDate.getMonth(), ctx.occurrenceDate.getDate(), ) } else { // Fallback: derive from sequential occurrenceIndex (base=0, first repeat=1) const baseStart = new Date(base.startDate + 'T00:00:00') const baseEnd = new Date(base.endDate + 'T00:00:00') if (occurrenceIndex === 0) { targetDate = baseStart } else { let cur = new Date(baseEnd) cur.setDate(cur.getDate() + 1) let found = 0 let safety = 0 const WEEK_MS = 7 * 86400000 const baseBlockStart = getMondayOfISOWeek(baseStart) function isAligned(d) { const blk = getMondayOfISOWeek(d) const diff = Math.floor((blk - baseBlockStart) / WEEK_MS) return diff % interval === 0 } while (found < occurrenceIndex && safety < 50000) { if (pattern[cur.getDay()] && isAligned(cur)) { found++ if (found === occurrenceIndex) break } cur.setDate(cur.getDate() + 1) safety++ } targetDate = cur } } if (!targetDate) return // Count occurrences BEFORE target (always include the base occurrence as first) const baseStart = new Date(base.startDate + 'T00:00:00') const baseBlockStart = getMondayOfISOWeek(baseStart) const WEEK_MS = 7 * 86400000 function isAligned(d) { const block = getMondayOfISOWeek(d) const diff = Math.floor((block - baseBlockStart) / WEEK_MS) return diff % interval === 0 } // Start with 1 (base occurrence) if target is after base; if target IS base (should not happen here) leave 0 let countBefore = targetDate > baseStart ? 1 : 0 let probe = new Date(baseStart) probe.setDate(probe.getDate() + 1) // start counting AFTER base let safety2 = 0 while (probe < targetDate && safety2 < 50000) { if (pattern[probe.getDay()] && isAligned(probe)) countBefore++ probe.setDate(probe.getDate() + 1) safety2++ } // Terminate original series to keep only occurrences before target this._terminateRepeatSeriesAtIndex(baseId, countBefore) // Calculate remaining occurrences for new series using ORIGINAL total let remainingCount = 'unlimited' if (originalCountRaw !== 'unlimited') { const originalTotal = parseInt(originalCountRaw, 10) if (!isNaN(originalTotal)) { const rem = originalTotal - countBefore - 1 // kept + deleted if (rem <= 0) return // nothing left to continue remainingCount = String(rem) } } // Continuation starts at NEXT valid occurrence (matching weekday & aligned block) let continuationStart = new Date(targetDate) let searchSafety = 0 let foundNext = false while (searchSafety < 50000) { continuationStart.setDate(continuationStart.getDate() + 1) if (pattern[continuationStart.getDay()] && isAligned(continuationStart)) { foundNext = true break } searchSafety++ } if (!foundNext) return // no remaining occurrences const spanDays = Math.round( (fromLocalString(base.endDate) - fromLocalString(base.startDate)) / (24 * 60 * 60 * 1000), ) const nextStartStr = toLocalString(continuationStart) const nextEnd = new Date(continuationStart) nextEnd.setDate(nextEnd.getDate() + spanDays) const nextEndStr = toLocalString(nextEnd) this.createEvent({ title: base.title, startDate: nextStartStr, endDate: nextEndStr, colorId: base.colorId, repeat: 'weeks', repeatInterval: interval, repeatCount: remainingCount, repeatWeekdays: base.repeatWeekdays, }) return } // MONTHLY SERIES ----------------------------------------------------- if (base.repeat === 'months') { if (occurrenceIndex === 0) { const baseStart = fromLocalString(base.startDate) const baseEnd = fromLocalString(base.endDate) const spanDays = Math.max(0, Math.round((baseEnd - baseStart) / (24 * 60 * 60 * 1000))) const interval = base.repeatInterval || 1 const targetMonthIndex = baseStart.getMonth() + interval const targetYear = baseStart.getFullYear() + Math.floor(targetMonthIndex / 12) const targetMonth = targetMonthIndex % 12 const daysInTarget = new Date(targetYear, targetMonth + 1, 0).getDate() const dom = Math.min(baseStart.getDate(), daysInTarget) const newStart = new Date(targetYear, targetMonth, dom) if (base.repeatCount !== 'unlimited') { const rc = parseInt(base.repeatCount, 10) if (!isNaN(rc)) { const newRc = rc - 1 if (newRc <= 0) { this.deleteEvent(baseId) return } base.repeatCount = String(newRc) } } const newEnd = new Date(newStart) newEnd.setDate(newEnd.getDate() + spanDays) base.startDate = toLocalString(newStart) base.endDate = toLocalString(newEnd) base.isSpanning = base.startDate < base.endDate this.events.set(base.id, base) return } const interval = base.repeatInterval || 1 // Sequential index: base=0, first repeat=1 if (occurrenceIndex <= 0) return // base deletion handled elsewhere // Count prior occurrences to KEEP (indices 0 .. occurrenceIndex-1) => occurrenceIndex total const originalCountRaw = base.repeatCount const priorOccurrences = occurrenceIndex this._terminateRepeatSeriesAtIndex(baseId, priorOccurrences) // Compute span days for multi‑day events const spanDays = Math.round( (fromLocalString(base.endDate) - fromLocalString(base.startDate)) / (24 * 60 * 60 * 1000), ) // Remaining occurrences after deletion let remainingCount = 'unlimited' if (originalCountRaw !== 'unlimited') { const total = parseInt(originalCountRaw, 10) if (!isNaN(total)) { const rem = total - priorOccurrences - 1 // subtract kept + deleted if (rem <= 0) return // nothing left remainingCount = String(rem) } } // Next occurrence after deleted one is at (occurrenceIndex + 1)*interval months from base const baseStart = fromLocalString(base.startDate) const nextStart = new Date(baseStart) nextStart.setMonth(nextStart.getMonth() + (occurrenceIndex + 1) * interval) const nextEnd = new Date(nextStart) nextEnd.setDate(nextEnd.getDate() + spanDays) const nextStartStr = toLocalString(nextStart) const nextEndStr = toLocalString(nextEnd) this.createEvent({ title: base.title, startDate: nextStartStr, endDate: nextEndStr, colorId: base.colorId, repeat: 'months', repeatInterval: interval, repeatCount: remainingCount, }) } }, deleteFromOccurrence(ctx) { const { baseId, occurrenceIndex } = ctx const base = this.getEventById(baseId) if (!base || !base.isRepeating) return // Special case: if deleting from the base occurrence (index 0), delete the entire series if (occurrenceIndex === 0) { this.deleteEvent(baseId) return } const keptTotal = occurrenceIndex this._terminateRepeatSeriesAtIndex(baseId, keptTotal) }, deleteFirstOccurrence(baseId) { const base = this.getEventById(baseId) if (!base || !base.isRepeating) return const oldStart = fromLocalString(base.startDate) const oldEnd = fromLocalString(base.endDate) const spanDays = Math.max(0, Math.round((oldEnd - oldStart) / (24 * 60 * 60 * 1000))) let newStartDate = null if (base.repeat === 'weeks') { const pattern = base.repeatWeekdays || [] if (!pattern.some(Boolean)) { // No valid pattern -> delete series this.deleteEvent(baseId) return } const interval = base.repeatInterval || 1 const baseBlockStart = getMondayOfISOWeek(oldStart) const WEEK_MS = 7 * 86400000 const isAligned = (d) => { const block = getMondayOfISOWeek(d) const diff = Math.floor((block - baseBlockStart) / WEEK_MS) return diff % interval === 0 } // search forward for next valid weekday respecting interval alignment const probe = new Date(oldStart) let safety = 0 while (safety < 5000) { probe.setDate(probe.getDate() + 1) if (pattern[probe.getDay()] && isAligned(probe)) { newStartDate = new Date(probe) break } safety++ } } else if (base.repeat === 'months') { const interval = base.repeatInterval || 1 const y = oldStart.getFullYear() const m = oldStart.getMonth() const targetMonthIndex = m + interval const targetYear = y + Math.floor(targetMonthIndex / 12) const targetMonth = targetMonthIndex % 12 const daysInTargetMonth = new Date(targetYear, targetMonth + 1, 0).getDate() const dom = Math.min(oldStart.getDate(), daysInTargetMonth) newStartDate = new Date(targetYear, targetMonth, dom) } else { // Unsupported repeat type this.deleteEvent(baseId) return } if (!newStartDate) { // No continuation; deleting first removes series this.deleteEvent(baseId) return } // Decrement repeatCount if limited if (base.repeatCount !== 'unlimited') { const rc = parseInt(base.repeatCount, 10) if (!isNaN(rc)) { const newRc = rc - 1 if (newRc <= 0) { // After removing first occurrence there are none left this.deleteEvent(baseId) return } base.repeatCount = String(newRc) } } const newEndDate = new Date(newStartDate) newEndDate.setDate(newEndDate.getDate() + spanDays) base.startDate = toLocalString(newStartDate) base.endDate = toLocalString(newEndDate) base.isSpanning = base.startDate < base.endDate // Persist updated base event this.events.set(base.id, base) return base.id }, // Adjust start/end range of a base event (non-generated) and reindex occurrences setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto' } = {}) { 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)), ) let finalDurationDays = prevDurationDays 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 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 } } // Update the event directly this.events.set(eventId, { ...snapshot, startDate: snapshot.startDate, endDate: snapshot.endDate, isSpanning: snapshot.startDate < snapshot.endDate, }) }, // Split a repeating series at a specific occurrence date and move that occurrence (and future) to new range splitMoveVirtualOccurrence(baseId, occurrenceDateStr, newStartStr, newEndStr) { const base = this.events.get(baseId) if (!base || !base.isRepeating) return const originalCountRaw = base.repeatCount const spanDays = Math.max( 0, Math.round( (fromLocalString(base.endDate) - fromLocalString(base.startDate)) / (24 * 60 * 60 * 1000), ), ) const occurrenceDate = fromLocalString(occurrenceDateStr) const baseStart = fromLocalString(base.startDate) if (occurrenceDate <= baseStart) { // Moving the base itself: just move entire series this.setEventRange(baseId, newStartStr, newEndStr, { mode: 'move' }) return } let keptOccurrences = 0 // number of occurrences BEFORE the moved one 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 } const cursor = new Date(baseStart) while (cursor < occurrenceDate) { if (pattern[cursor.getDay()] && isAligned(cursor)) keptOccurrences++ cursor.setDate(cursor.getDate() + 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 // invalid occurrence keptOccurrences = diffMonths // base is occurrence 0; we keep all before diffMonths } else { // Unsupported repeat type return } // Truncate original series to keptOccurrences this._terminateRepeatSeriesAtIndex(baseId, keptOccurrences) // Compute remaining occurrences count 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) } } // Determine repeat-specific adjustments let repeatWeekdays = base.repeatWeekdays if (base.repeat === 'weeks' && Array.isArray(base.repeatWeekdays)) { // Rotate pattern so that the moved occurrence weekday stays active relative to new anchor const origWeekday = occurrenceDate.getDay() const newWeekday = fromLocalString(newStartStr).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 } } // Create continuation series starting at newStartStr this.createEvent({ title: base.title, startDate: newStartStr, endDate: newEndStr, colorId: base.colorId, repeat: base.repeat, repeatInterval: base.repeatInterval, repeatCount: remainingCount, repeatWeekdays, }) }, // Split a repeating series at a given occurrence index; returns new series id splitRepeatSeries(baseId, occurrenceIndex, newStartStr, newEndStr, _grabbedWeekday) { const base = this.events.get(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) this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex) // Compute new series repeatCount (remaining occurrences starting at 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)) } } const newId = this.createEvent({ title: base.title, startDate: newStartStr, endDate: newEndStr, colorId: base.colorId, repeat: base.repeat, repeatInterval: base.repeatInterval, repeatCount: newSeriesCount, repeatWeekdays: base.repeatWeekdays, }) return newId }, _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)) } }, // _findEventInAnyList removed (direct map access) // NOTE: legacy dynamic getEventById for synthetic occurrences removed. }, persist: { key: 'calendar-store', storage: localStorage, // Persist only events map, no dates indexing 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 }) }, }, }, })