calendar/src/stores/CalendarStore.js
2025-08-23 14:03:48 -06:00

767 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { defineStore } from 'pinia'
import {
toLocalString,
fromLocalString,
getLocaleWeekendDays,
getMondayOfISOWeek,
getOccurrenceIndex,
getWeeklyOccurrenceIndex,
getMonthlyOccurrenceIndex,
getVirtualOccurrenceEndDate,
occursOnOrSpansDate,
} from '@/utils/date'
import {
initializeHolidays,
getHolidayForDate,
isHoliday,
getAvailableCountries,
getAvailableStates,
getHolidayConfig,
} 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() {
try {
const countries = getAvailableCountries()
return Array.isArray(countries) ? countries : ['US', 'GB', 'DE', 'FR', 'CA', 'AU']
} catch (error) {
console.warn('Failed to get available countries:', error)
return ['US', 'GB', 'DE', 'FR', 'CA', 'AU']
}
},
getAvailableStates(country) {
try {
const states = getAvailableStates(country)
return Array.isArray(states) ? states : []
} catch (error) {
console.warn('Failed to get available states for', country, error)
return []
}
},
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') {
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') {
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 multiday 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
// We want to keep occurrences up to and including the selected one; that becomes new repeatCount.
// occurrenceIndex here represents the number of repeats AFTER the base (weekly: 0 = first repeat; monthly: diffMonths)
// Total kept occurrences = base (1) + occurrenceIndex
const keptTotal = 1 + Math.max(0, occurrenceIndex)
this._terminateRepeatSeriesAtIndex(baseId, keptTotal)
},
deleteFirstOccurrence(baseId) {
const base = this.getEventById(baseId)
if (!base || !base.isRepeating) return
const oldStart = new Date(fromLocalString(base.startDate))
const oldEnd = new Date(fromLocalString(base.endDate))
const spanDays = Math.round((oldEnd - oldStart) / (24 * 60 * 60 * 1000))
let newStart = null
if (base.repeat === 'weeks' && base.repeatWeekdays) {
const probe = new Date(oldStart)
for (let i = 0; i < 14; i++) {
// search ahead up to 2 weeks
probe.setDate(probe.getDate() + 1)
if (base.repeatWeekdays[probe.getDay()]) {
newStart = new Date(probe)
break
}
}
} else if (base.repeat === 'months') {
// Advance one month, clamping to last day if necessary
const o = oldStart
const nextMonthIndex = o.getMonth() + 1
const y = o.getFullYear() + Math.floor(nextMonthIndex / 12)
const m = nextMonthIndex % 12
const daysInTargetMonth = new Date(y, m + 1, 0).getDate()
const dom = Math.min(o.getDate(), daysInTargetMonth)
newStart = new Date(y, m, dom)
} else {
// Unknown pattern: delete entire series
this.deleteEvent(baseId)
return
}
if (!newStart) {
// No subsequent occurrence -> delete entire series
this.deleteEvent(baseId)
return
}
if (base.repeatCount !== 'unlimited') {
const rc = parseInt(base.repeatCount, 10)
if (!isNaN(rc)) {
const newRc = Math.max(0, 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)
// old occurrence expansion removed (series handled differently now)
const originalRepeatCount = base.repeatCount
// Always cap original series at the split occurrence index (occurrences 0..index-1)
// Keep its weekday pattern unchanged.
this._terminateRepeatSeriesAtIndex(baseId, index)
let newRepeatCount = 'unlimited'
if (originalRepeatCount !== 'unlimited') {
const originalCount = parseInt(originalRepeatCount, 10)
if (!isNaN(originalCount)) {
const remaining = originalCount - index
// remaining occurrences go to new series; ensure at least 1 (the dragged occurrence itself)
newRepeatCount = remaining > 0 ? String(remaining) : '1'
}
} else {
// Original was unlimited: original now capped, new stays unlimited
newRepeatCount = 'unlimited'
}
// Handle weekdays for weekly repeats
let newRepeatWeekdays = base.repeatWeekdays
if (base.repeat === 'weeks' && base.repeatWeekdays) {
const newStartDate = new Date(fromLocalString(startDate))
let dayShift = 0
if (grabbedWeekday != null) {
// Rotate so that the grabbed weekday maps to the new start weekday
dayShift = newStartDate.getDay() - grabbedWeekday
} else {
// Fallback: rotate by difference between new and original start weekday
const originalStartDate = new Date(fromLocalString(base.startDate))
dayShift = newStartDate.getDay() - originalStartDate.getDay()
}
if (dayShift !== 0) {
const rotatedWeekdays = [false, false, false, false, false, false, false]
for (let i = 0; i < 7; i++) {
if (base.repeatWeekdays[i]) {
let nd = (i + dayShift) % 7
if (nd < 0) nd += 7
rotatedWeekdays[nd] = true
}
}
newRepeatWeekdays = rotatedWeekdays
}
}
const newId = this.createEvent({
title: base.title,
startDate,
endDate,
colorId: base.colorId,
repeat: base.repeat,
repeatCount: newRepeatCount,
repeatWeekdays: newRepeatWeekdays,
})
return newId
},
_snapshotBaseEvent(eventId) {
const ev = this.events.get(eventId)
return ev ? { ...ev } : null
},
// expandRepeats removed: no physical occurrence expansion
// 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
})
},
},
},
})