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:
2025-08-26 05:58:24 +01:00
parent 018b9ecc55
commit 9e3f7ddd57
28 changed files with 4467 additions and 2209 deletions

View File

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