473 lines
16 KiB
JavaScript
473 lines
16 KiB
JavaScript
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
|
|
},
|
|
},
|
|
},
|
|
})
|