calendar/src/stores/CalendarStore.js
2025-08-24 21:59:56 -06:00

445 lines
15 KiB
JavaScript

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
})
},
},
},
})