calendar/src/stores/CalendarStore.js

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