Major new version (#2)
Release Notes Architecture - Component refactor: removed monoliths (`Calendar.vue`, `CalendarGrid.vue`); expanded granular view + header/control components. - Dialog system introduced (`BaseDialog`, `SettingsDialog`). State & Data - Store redesigned: Map-based events + recurrence map; mutation counters. - Local persistence + undo/redo history (custom plugins). Date & Holidays - Migrated all date logic to `date-fns` (+ tz). - Added national holiday support (toggle + loading utilities). Recurrence & Events - Consolidated recurrence handling; fixes for monthly edge days (29–31), annual, multi‑day, and complex weekly repeats. - Reliable splitting/moving/resizing/deletion of repeating and multi‑day events. Interaction & UX - Double‑tap to create events; improved drag (multi‑day + position retention). - Scroll & inertial/momentum navigation; year change via numeric scroller. - Movable event dialog; live settings application. Performance - Progressive / virtual week rendering, reduced off‑screen buffer. - Targeted repaint strategy; minimized full re-renders. Plugins Added - History, undo normalization, persistence, scroll manager, virtual weeks. Styling & Layout - Responsive + compact layout refinements; header restructured. - Simplified visual elements (removed dots/overflow text); holiday styling adjustments. Reliability / Fixes - Numerous recurrence, deletion, orientation/rotation, and event indexing corrections. - Cross-browser fallback (Firefox week info). Dependencies Added - date-fns, date-fns-tz, date-holidays, pinia-plugin-persistedstate. Net Change - 28 files modified; ~4.4K insertions / ~2.2K deletions (major refactor + feature set).
This commit is contained in:
@@ -2,76 +2,113 @@ import { defineStore } from 'pinia'
|
||||
import {
|
||||
toLocalString,
|
||||
fromLocalString,
|
||||
getLocaleFirstDay,
|
||||
getLocaleWeekendDays,
|
||||
getMondayOfISOWeek,
|
||||
getOccurrenceDate,
|
||||
DEFAULT_TZ,
|
||||
} from '@/utils/date'
|
||||
|
||||
/**
|
||||
* Calendar configuration can be overridden via window.calendarConfig:
|
||||
*
|
||||
* window.calendarConfig = {
|
||||
* firstDay: 0, // 0=Sunday, 1=Monday, etc. (default: 1)
|
||||
* firstDay: 'auto', // Use locale detection
|
||||
* weekendDays: [true, false, false, false, false, false, true], // Custom weekend
|
||||
* weekendDays: 'auto' // Use locale detection (default)
|
||||
* }
|
||||
*/
|
||||
|
||||
const MIN_YEAR = 1900
|
||||
const MAX_YEAR = 2100
|
||||
|
||||
// Helper function to determine first day with config override support
|
||||
function getConfiguredFirstDay() {
|
||||
// Check for environment variable or global config
|
||||
const configOverride = window?.calendarConfig?.firstDay
|
||||
if (configOverride !== undefined) {
|
||||
return configOverride === 'auto' ? getLocaleFirstDay() : Number(configOverride)
|
||||
}
|
||||
// Default to Monday (1) instead of locale
|
||||
return 1
|
||||
}
|
||||
|
||||
// Helper function to determine weekend days with config override support
|
||||
function getConfiguredWeekendDays() {
|
||||
// Check for environment variable or global config
|
||||
const configOverride = window?.calendarConfig?.weekendDays
|
||||
if (configOverride !== undefined) {
|
||||
return configOverride === 'auto' ? getLocaleWeekendDays() : configOverride
|
||||
}
|
||||
// Default to locale-based weekend days
|
||||
return getLocaleWeekendDays()
|
||||
}
|
||||
import { differenceInCalendarDays, addDays } from 'date-fns'
|
||||
import { initializeHolidays, getAvailableCountries, getAvailableStates } from '@/utils/holidays'
|
||||
|
||||
export const useCalendarStore = defineStore('calendar', {
|
||||
state: () => ({
|
||||
today: toLocalString(new Date()),
|
||||
now: new Date(),
|
||||
events: new Map(), // Map of date strings to arrays of events
|
||||
weekend: getConfiguredWeekendDays(),
|
||||
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: 1000,
|
||||
min_year: MIN_YEAR,
|
||||
max_year: MAX_YEAR,
|
||||
first_day: getConfiguredFirstDay(),
|
||||
select_days: 14,
|
||||
first_day: 1,
|
||||
holidays: {
|
||||
enabled: true,
|
||||
country: 'auto',
|
||||
state: null,
|
||||
region: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
getters: {
|
||||
// Basic configuration getters
|
||||
minYear: () => MIN_YEAR,
|
||||
maxYear: () => MAX_YEAR,
|
||||
},
|
||||
|
||||
actions: {
|
||||
updateCurrentDate() {
|
||||
this.now = new Date()
|
||||
const today = toLocalString(this.now)
|
||||
if (this.today !== today) {
|
||||
this.today = today
|
||||
}
|
||||
_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
|
||||
},
|
||||
|
||||
// Event management
|
||||
generateId() {
|
||||
try {
|
||||
if (window.crypto && typeof window.crypto.randomUUID === 'function') {
|
||||
@@ -81,357 +118,308 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
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
|
||||
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,
|
||||
endDate: eventData.endDate,
|
||||
days,
|
||||
colorId:
|
||||
eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate),
|
||||
eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.startDate),
|
||||
startTime: singleDay ? eventData.startTime || '09:00' : null,
|
||||
durationMinutes: singleDay ? eventData.durationMinutes || 60 : null,
|
||||
repeat:
|
||||
(eventData.repeat === 'weekly'
|
||||
? 'weeks'
|
||||
: eventData.repeat === 'monthly'
|
||||
? 'months'
|
||||
: eventData.repeat) || 'none',
|
||||
repeatInterval: eventData.repeatInterval || 1,
|
||||
repeatCount: eventData.repeatCount || 'unlimited',
|
||||
repeatWeekdays: eventData.repeatWeekdays,
|
||||
isRepeating: eventData.repeat && eventData.repeat !== 'none',
|
||||
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,
|
||||
}
|
||||
|
||||
const startDate = new Date(fromLocalString(event.startDate))
|
||||
const endDate = new Date(fromLocalString(event.endDate))
|
||||
|
||||
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
||||
const dateStr = toLocalString(d)
|
||||
if (!this.events.has(dateStr)) {
|
||||
this.events.set(dateStr, [])
|
||||
}
|
||||
this.events.get(dateStr).push({ ...event, isSpanning: startDate < endDate })
|
||||
}
|
||||
// No physical expansion; repeats are virtual
|
||||
this.events.set(event.id, { ...event, isSpanning: event.days > 1 })
|
||||
this.notifyEventsChanged()
|
||||
return event.id
|
||||
},
|
||||
|
||||
getEventById(id) {
|
||||
for (const [, list] of this.events) {
|
||||
const found = list.find((e) => e.id === id)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
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))
|
||||
|
||||
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
||||
const dateStr = toLocalString(d)
|
||||
const dayEvents = this.events.get(dateStr) || []
|
||||
for (const event of dayEvents) {
|
||||
if (event.colorId >= 0 && event.colorId < 8) {
|
||||
colorCounts[event.colorId]++
|
||||
}
|
||||
}
|
||||
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 colorId = 1; colorId < 8; colorId++) {
|
||||
if (colorCounts[colorId] < minCount) {
|
||||
minCount = colorCounts[colorId]
|
||||
selectedColor = colorId
|
||||
for (let c = 1; c < 8; c++) {
|
||||
if (colorCounts[c] < minCount) {
|
||||
minCount = colorCounts[c]
|
||||
selectedColor = c
|
||||
}
|
||||
}
|
||||
|
||||
return selectedColor
|
||||
},
|
||||
|
||||
deleteEvent(eventId) {
|
||||
const datesToCleanup = []
|
||||
for (const [dateStr, eventList] of this.events) {
|
||||
const eventIndex = eventList.findIndex((event) => event.id === eventId)
|
||||
if (eventIndex !== -1) {
|
||||
eventList.splice(eventIndex, 1)
|
||||
if (eventList.length === 0) {
|
||||
datesToCleanup.push(dateStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
datesToCleanup.forEach((dateStr) => this.events.delete(dateStr))
|
||||
},
|
||||
|
||||
deleteSingleOccurrence(ctx) {
|
||||
const { baseId, occurrenceIndex } = ctx
|
||||
const base = this.getEventById(baseId)
|
||||
if (!base || base.repeat !== 'weekly') return
|
||||
if (!base || base.repeat !== 'weeks') return
|
||||
// Strategy: clone pattern into two: reduce base repeatCount to exclude this occurrence; create new series for remainder excluding this one
|
||||
// Simpler: convert base to explicit exception by creating a one-off non-repeating event? For now: create exception by inserting a blocking dummy? Instead just split by shifting repeatCount logic: decrement repeatCount and create a new series starting after this single occurrence.
|
||||
// Implementation (approx): terminate at occurrenceIndex; create a new series starting next occurrence with remaining occurrences.
|
||||
const remaining =
|
||||
base.repeatCount === 'unlimited'
|
||||
? 'unlimited'
|
||||
: String(Math.max(0, parseInt(base.repeatCount, 10) - (occurrenceIndex + 1)))
|
||||
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
|
||||
if (remaining === '0') return
|
||||
// Find date of next occurrence
|
||||
const startDate = new Date(base.startDate + 'T00:00:00')
|
||||
let idx = 0
|
||||
let cur = new Date(startDate)
|
||||
while (idx <= occurrenceIndex && idx < 10000) {
|
||||
cur.setDate(cur.getDate() + 1)
|
||||
if (base.repeatWeekdays[cur.getDay()]) idx++
|
||||
}
|
||||
const nextStartStr = toLocalString(cur)
|
||||
this.createEvent({
|
||||
title: base.title,
|
||||
startDate: nextStartStr,
|
||||
endDate: nextStartStr,
|
||||
colorId: base.colorId,
|
||||
repeat: 'weeks',
|
||||
repeatCount: remaining,
|
||||
repeatWeekdays: base.repeatWeekdays,
|
||||
})
|
||||
},
|
||||
|
||||
deleteFromOccurrence(ctx) {
|
||||
const { baseId, occurrenceIndex } = ctx
|
||||
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
|
||||
this.events.delete(eventId)
|
||||
this.notifyEventsChanged()
|
||||
},
|
||||
|
||||
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') {
|
||||
newStart = new Date(oldStart)
|
||||
newStart.setMonth(newStart.getMonth() + 1)
|
||||
} else {
|
||||
// Unknown pattern: delete entire series
|
||||
if (!base) return
|
||||
if (!base.recur) {
|
||||
this.deleteEvent(baseId)
|
||||
return
|
||||
}
|
||||
|
||||
if (!newStart) {
|
||||
// No subsequent occurrence -> delete entire series
|
||||
const numericCount =
|
||||
base.recur.count === 'unlimited' ? Infinity : parseInt(base.recur.count, 10)
|
||||
if (numericCount <= 1) {
|
||||
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 nextStartStr = getOccurrenceDate(base, 1, DEFAULT_TZ)
|
||||
if (!nextStartStr) {
|
||||
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()
|
||||
},
|
||||
|
||||
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'
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
if (occurrenceIndex === 0) {
|
||||
this.deleteFirstOccurrence(baseId)
|
||||
return
|
||||
}
|
||||
const snapshot = { ...base }
|
||||
snapshot.recur = snapshot.recur ? { ...snapshot.recur } : null
|
||||
base.recur.count = occurrenceIndex
|
||||
const nextStartStr = getOccurrenceDate(snapshot, occurrenceIndex + 1, DEFAULT_TZ)
|
||||
if (!nextStartStr) return
|
||||
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,
|
||||
}
|
||||
}
|
||||
newRepeatWeekdays = rotatedWeekdays
|
||||
}
|
||||
}
|
||||
|
||||
const newId = this.createEvent({
|
||||
title: base.title,
|
||||
startDate,
|
||||
endDate,
|
||||
colorId: base.colorId,
|
||||
repeat: base.repeat,
|
||||
repeatCount: newRepeatCount,
|
||||
repeatWeekdays: newRepeatWeekdays,
|
||||
: null,
|
||||
})
|
||||
return newId
|
||||
this.notifyEventsChanged()
|
||||
},
|
||||
|
||||
_snapshotBaseEvent(eventId) {
|
||||
// Return a shallow snapshot of any instance for metadata
|
||||
for (const [, eventList] of this.events) {
|
||||
const e = eventList.find((x) => x.id === eventId)
|
||||
if (e) return { ...e }
|
||||
deleteFromOccurrence(ctx) {
|
||||
const { baseId, occurrenceIndex } = ctx
|
||||
const base = this.getEventById(baseId)
|
||||
if (!base || !base.recur) return
|
||||
if (occurrenceIndex === 0) {
|
||||
this.deleteEvent(baseId)
|
||||
return
|
||||
}
|
||||
return null
|
||||
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
|
||||
this.notifyEventsChanged()
|
||||
},
|
||||
|
||||
_removeEventFromAllDatesById(eventId) {
|
||||
for (const [dateStr, list] of this.events) {
|
||||
for (let i = list.length - 1; i >= 0; i--) {
|
||||
if (list[i].id === eventId) {
|
||||
list.splice(i, 1)
|
||||
}
|
||||
}
|
||||
if (list.length === 0) this.events.delete(dateStr)
|
||||
}
|
||||
},
|
||||
|
||||
_addEventToDateRangeWithId(eventId, baseData, startDate, endDate) {
|
||||
const s = fromLocalString(startDate)
|
||||
const e = fromLocalString(endDate)
|
||||
const multi = startDate < endDate
|
||||
const payload = {
|
||||
...baseData,
|
||||
id: eventId,
|
||||
startDate,
|
||||
endDate,
|
||||
isSpanning: multi,
|
||||
}
|
||||
// Normalize single-day time fields
|
||||
if (!multi) {
|
||||
if (!payload.startTime) payload.startTime = '09:00'
|
||||
if (!payload.durationMinutes) payload.durationMinutes = 60
|
||||
} else {
|
||||
payload.startTime = null
|
||||
payload.durationMinutes = null
|
||||
}
|
||||
const cur = new Date(s)
|
||||
while (cur <= e) {
|
||||
const dateStr = toLocalString(cur)
|
||||
if (!this.events.has(dateStr)) this.events.set(dateStr, [])
|
||||
this.events.get(dateStr).push({ ...payload })
|
||||
cur.setDate(cur.getDate() + 1)
|
||||
}
|
||||
},
|
||||
|
||||
// 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._findEventInAnyList(eventId)
|
||||
setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto', rotatePattern = true } = {}) {
|
||||
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)),
|
||||
)
|
||||
|
||||
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') {
|
||||
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
|
||||
snapshot.days = finalDurationDays + 1
|
||||
if (
|
||||
mode === 'move' &&
|
||||
snapshot.isRepeating &&
|
||||
snapshot.repeat === 'weeks' &&
|
||||
Array.isArray(snapshot.repeatWeekdays)
|
||||
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) {
|
||||
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
|
||||
snapshot.recur.weekdays = this._rotateWeekdayPattern(snapshot.recur.weekdays, shift)
|
||||
}
|
||||
}
|
||||
// Reindex
|
||||
this._removeEventFromAllDatesById(eventId)
|
||||
this._addEventToDateRangeWithId(eventId, snapshot, snapshot.startDate, snapshot.endDate)
|
||||
// no expansion
|
||||
this.events.set(eventId, { ...snapshot, isSpanning: snapshot.days > 1 })
|
||||
this.notifyEventsChanged()
|
||||
},
|
||||
|
||||
// Split a repeating series at a given occurrence index; returns new series id
|
||||
splitRepeatSeries(baseId, occurrenceIndex, newStartStr, newEndStr, _grabbedWeekday) {
|
||||
const base = this._findEventInAnyList(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)
|
||||
splitMoveVirtualOccurrence(baseId, occurrenceDateStr, newStartStr, newEndStr) {
|
||||
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
|
||||
}
|
||||
if (occurrenceDate <= baseStart) {
|
||||
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)
|
||||
// Compute new series repeatCount (remaining occurrences starting at occurrenceIndex)
|
||||
let newSeriesCount = 'unlimited'
|
||||
if (originalCountRaw !== 'unlimited') {
|
||||
const originalNum = parseInt(originalCountRaw, 10)
|
||||
@@ -440,64 +428,54 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
newSeriesCount = String(Math.max(1, remaining))
|
||||
}
|
||||
}
|
||||
const newId = this.createEvent({
|
||||
return this.createEvent({
|
||||
title: base.title,
|
||||
startDate: newStartStr,
|
||||
endDate: newEndStr,
|
||||
days: base.days,
|
||||
colorId: base.colorId,
|
||||
repeat: base.repeat,
|
||||
repeatInterval: base.repeatInterval,
|
||||
repeatCount: newSeriesCount,
|
||||
repeatWeekdays: base.repeatWeekdays,
|
||||
recur: base.recur
|
||||
? {
|
||||
freq: base.recur.freq,
|
||||
interval: base.recur.interval,
|
||||
count: newSeriesCount,
|
||||
weekdays: base.recur.weekdays ? [...base.recur.weekdays] : null,
|
||||
}
|
||||
: null,
|
||||
})
|
||||
return newId
|
||||
},
|
||||
|
||||
_reindexBaseEvent(eventId, snapshot, startDate, endDate) {
|
||||
if (!snapshot) return
|
||||
this._removeEventFromAllDatesById(eventId)
|
||||
this._addEventToDateRangeWithId(eventId, snapshot, startDate, endDate)
|
||||
},
|
||||
|
||||
_terminateRepeatSeriesAtIndex(baseId, index) {
|
||||
// Cap repeatCount to 'index' total occurrences (0-based index means index occurrences before split)
|
||||
for (const [, list] of this.events) {
|
||||
for (const ev of list) {
|
||||
if (ev.id === baseId && ev.isRepeating) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
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()
|
||||
},
|
||||
|
||||
_findEventInAnyList(eventId) {
|
||||
for (const [, eventList] of this.events) {
|
||||
const found = eventList.find((e) => e.id === eventId)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
},
|
||||
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
|
||||
},
|
||||
},
|
||||
|
||||
_addEventToDateRange(event) {
|
||||
const startDate = fromLocalString(event.startDate)
|
||||
const endDate = fromLocalString(event.endDate)
|
||||
const cur = new Date(startDate)
|
||||
|
||||
while (cur <= endDate) {
|
||||
const dateStr = toLocalString(cur)
|
||||
if (!this.events.has(dateStr)) {
|
||||
this.events.set(dateStr, [])
|
||||
}
|
||||
this.events.get(dateStr).push({ ...event, isSpanning: event.startDate < event.endDate })
|
||||
cur.setDate(cur.getDate() + 1)
|
||||
}
|
||||
},
|
||||
|
||||
// NOTE: legacy dynamic getEventById for synthetic occurrences removed.
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user