800 lines
28 KiB
JavaScript
800 lines
28 KiB
JavaScript
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
|
||
})
|
||
},
|
||
},
|
||
},
|
||
})
|