Major new version #2

Merged
LeoVasanko merged 86 commits from vol002 into main 2025-08-26 05:58:24 +01:00
7 changed files with 249 additions and 270 deletions
Showing only changes of commit 02442f5135 - Show all commits

View File

@ -15,9 +15,9 @@ const handleCreateEvent = (eventData) => {
} }
} }
const handleEditEvent = (eventInstanceId) => { const handleEditEvent = (eventClickPayload) => {
if (eventDialog.value) { if (eventDialog.value) {
eventDialog.value.openEditDialog(eventInstanceId) eventDialog.value.openEditDialog(eventClickPayload)
} }
} }

View File

@ -6,7 +6,7 @@ const props = defineProps({
const emit = defineEmits(['event-click']) const emit = defineEmits(['event-click'])
const handleEventClick = (eventId) => { const handleEventClick = (eventId) => {
emit('event-click', eventId) emit('event-click', { id: eventId, instanceId: eventId, occurrenceIndex: 0 })
} }
</script> </script>

View File

@ -144,54 +144,90 @@ function createWeek(virtualWeek) {
// Precollect unique repeating base events once (avoid nested loops for each day) // Precollect unique repeating base events once (avoid nested loops for each day)
const repeatingBases = [] const repeatingBases = []
const seen = new Set() if (calendarStore.events) {
for (const [, list] of calendarStore.events) { for (const ev of calendarStore.events.values()) {
for (const ev of list) { if (ev.isRepeating) repeatingBases.push(ev)
if (ev.isRepeating && !seen.has(ev.id)) {
seen.add(ev.id)
repeatingBases.push(ev)
}
} }
} }
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
const dateStr = toLocalString(cur) const dateStr = toLocalString(cur)
const storedEvents = calendarStore.events.get(dateStr) || [] const storedEvents = []
const idSet = calendarStore.dates.get(dateStr)
if (idSet) {
// Support Set or Array; ignore unexpected shapes
if (idSet instanceof Set) {
idSet.forEach((id) => {
const ev = calendarStore.events.get(id)
if (ev) storedEvents.push(ev)
})
} else if (Array.isArray(idSet)) {
for (const id of idSet) {
const ev = calendarStore.events.get(id)
if (ev) storedEvents.push(ev)
}
} else if (typeof idSet === 'object' && idSet !== null) {
// If mistakenly hydrated as plain object {id:true,...}
for (const id of Object.keys(idSet)) {
const ev = calendarStore.events.get(id)
if (ev) storedEvents.push(ev)
}
}
}
// Build day events starting with stored (base/spanning) then virtual occurrences // Build day events starting with stored (base/spanning) then virtual occurrences
const dayEvents = [...storedEvents] const dayEvents = [...storedEvents]
for (const base of repeatingBases) { for (const base of repeatingBases) {
// Skip if the base itself already on this date (already in storedEvents) // Skip if the base itself already on this date (already in storedEvents)
if (dateStr >= base.startDate && dateStr <= base.endDate) continue if (dateStr >= base.startDate && dateStr <= base.endDate) continue
if (calendarStore.occursOnDate(base, dateStr)) { if (calendarStore.occursOnDate(base, dateStr)) {
// Determine occurrence index (0 = first repeat after base) for weekly / monthly // Determine sequential occurrence index: base event = 0, first repeat = 1, etc.
let recurrenceIndex = 0 let recurrenceIndex = 0
try { try {
if (base.repeat === 'weeks') { if (base.repeat === 'weeks') {
const pattern = base.repeatWeekdays || [] const pattern = base.repeatWeekdays || []
const baseDate = new Date(base.startDate + 'T00:00:00') const interval = base.repeatInterval || 1
const baseStart = new Date(base.startDate + 'T00:00:00')
const baseEnd = new Date(base.endDate + 'T00:00:00')
const target = new Date(dateStr + 'T00:00:00') const target = new Date(dateStr + 'T00:00:00')
let matched = -1 const WEEK_MS = 7 * 86400000
const cur = new Date(baseDate) const baseBlockStart = new Date(baseStart)
while (cur < target && matched < 100000) { baseBlockStart.setDate(baseStart.getDate() - baseStart.getDay())
cur.setDate(cur.getDate() + 1) function isAligned(d) {
if (pattern[cur.getDay()]) matched++ const blk = new Date(d)
blk.setDate(d.getDate() - d.getDay())
const diff = Math.floor((blk - baseBlockStart) / WEEK_MS)
return diff % interval === 0
} }
if (cur.toDateString() === target.toDateString()) recurrenceIndex = matched // Count valid occurrences after base end and before target
let count = 0
const cursor = new Date(baseEnd)
cursor.setDate(cursor.getDate() + 1)
while (cursor < target) {
if (pattern[cursor.getDay()] && isAligned(cursor)) count++
cursor.setDate(cursor.getDate() + 1)
}
// Target itself is guaranteed valid (occursOnDate passed), so its index is count+1
recurrenceIndex = count + 1
} else if (base.repeat === 'months') { } else if (base.repeat === 'months') {
const baseDate = new Date(base.startDate + 'T00:00:00') const baseStart = new Date(base.startDate + 'T00:00:00')
const target = new Date(dateStr + 'T00:00:00') const target = new Date(dateStr + 'T00:00:00')
const interval = base.repeatInterval || 1
const diffMonths = const diffMonths =
(target.getFullYear() - baseDate.getFullYear()) * 12 + (target.getFullYear() - baseStart.getFullYear()) * 12 +
(target.getMonth() - baseDate.getMonth()) (target.getMonth() - baseStart.getMonth())
recurrenceIndex = diffMonths // matches existing monthly logic semantics // diffMonths should be multiple of interval; sequential index = diffMonths/interval
recurrenceIndex = diffMonths / interval
}
} catch {
recurrenceIndex = 0
} }
} catch {}
dayEvents.push({ dayEvents.push({
...base, ...base,
id: base.id + '_v_' + dateStr, id: base.id + '_v_' + dateStr,
startDate: dateStr, startDate: dateStr,
endDate: dateStr, endDate: dateStr,
_recurrenceIndex: recurrenceIndex, _recurrenceIndex: recurrenceIndex,
_baseId: base.id,
}) })
} }
} }
@ -399,8 +435,8 @@ const handleDayTouchEnd = (dateStr) => {
} }
} }
const handleEventClick = (eventInstanceId) => { const handleEventClick = (payload) => {
emit('edit-event', eventInstanceId) emit('edit-event', payload)
} }
// Handle year change emitted from CalendarHeader: scroll to computed target position // Handle year change emitted from CalendarHeader: scroll to computed target position

View File

@ -3,10 +3,18 @@ import CalendarDay from './CalendarDay.vue'
import EventOverlay from './EventOverlay.vue' import EventOverlay from './EventOverlay.vue'
const props = defineProps({ const props = defineProps({
week: Object week: Object,
}) })
const emit = defineEmits(['day-mousedown', 'day-mouseenter', 'day-mouseup', 'day-touchstart', 'day-touchmove', 'day-touchend', 'event-click']) const emit = defineEmits([
'day-mousedown',
'day-mouseenter',
'day-mouseup',
'day-touchstart',
'day-touchmove',
'day-touchend',
'event-click',
])
const handleDayMouseDown = (dateStr) => { const handleDayMouseDown = (dateStr) => {
emit('day-mousedown', dateStr) emit('day-mousedown', dateStr)
@ -32,16 +40,13 @@ const handleDayTouchEnd = (dateStr) => {
emit('day-touchend', dateStr) emit('day-touchend', dateStr)
} }
const handleEventClick = (eventId) => { const handleEventClick = (payload) => {
emit('event-click', eventId) emit('event-click', payload)
} }
</script> </script>
<template> <template>
<div <div class="week-row" :style="{ top: `${props.week.top}px` }">
class="week-row"
:style="{ top: `${props.week.top}px` }"
>
<div class="week-label">W{{ props.week.weekNumber }}</div> <div class="week-label">W{{ props.week.weekNumber }}</div>
<div class="days-grid"> <div class="days-grid">
<CalendarDay <CalendarDay
@ -56,10 +61,7 @@ const handleEventClick = (eventId) => {
@touchend="handleDayTouchEnd(day.date)" @touchend="handleDayTouchEnd(day.date)"
@event-click="handleEventClick" @event-click="handleEventClick"
/> />
<EventOverlay <EventOverlay :week="props.week" @event-click="handleEventClick" />
:week="props.week"
@event-click="handleEventClick"
/>
</div> </div>
</div> </div>
</template> </template>

View File

@ -149,90 +149,56 @@ function openCreateDialog(selectionData = null) {
}) })
} }
function openEditDialog(eventInstanceId) { function openEditDialog(payload) {
occurrenceContext.value = null occurrenceContext.value = null
let baseId = eventInstanceId if (!payload) return
let occurrenceIndex = 0 // Payload expected: { id: baseId, instanceId, occurrenceIndex }
const baseId = payload.id
let occurrenceIndex = payload.occurrenceIndex || 0
let weekday = null let weekday = null
let occurrenceDate = null let occurrenceDate = null
// Support legacy synthetic id pattern: baseId_repeat_<index>[_<weekday>]
if (typeof eventInstanceId === 'string' && eventInstanceId.includes('_repeat_')) {
const [bid, suffix] = eventInstanceId.split('_repeat_')
baseId = bid
const parts = suffix.split('_')
occurrenceIndex = parseInt(parts[0], 10) || 0
if (parts.length > 1) weekday = parseInt(parts[1], 10)
}
// Support new virtual id pattern: baseId_v_YYYY-MM-DD
else if (typeof eventInstanceId === 'string' && eventInstanceId.includes('_v_')) {
const splitIndex = eventInstanceId.lastIndexOf('_v_')
if (splitIndex !== -1) {
baseId = eventInstanceId.slice(0, splitIndex)
const dateStr = eventInstanceId.slice(splitIndex + 3)
occurrenceDate = new Date(dateStr + 'T00:00:00')
// Derive occurrenceIndex based on event's repeat pattern
const eventForIndex = calendarStore.getEventById(baseId)
if (eventForIndex?.isRepeating) {
if (eventForIndex.repeat === 'weeks') {
const pattern = eventForIndex.repeatWeekdays || []
const baseDate = new Date(eventForIndex.startDate + 'T00:00:00')
// Count matching weekdays after base until reaching occurrenceDate
let cur = new Date(baseDate)
let matched = -1 // first match after base increments this to 0
while (cur < occurrenceDate && matched < 100000) {
cur.setDate(cur.getDate() + 1)
if (pattern[cur.getDay()]) matched++
}
if (cur.toDateString() === occurrenceDate.toDateString()) {
occurrenceIndex = matched
weekday = occurrenceDate.getDay()
} else {
// Fallback: treat as base click if something went wrong
occurrenceIndex = 0
weekday = null
occurrenceDate = null
}
} else if (eventForIndex.repeat === 'months') {
const baseDate = new Date(eventForIndex.startDate + 'T00:00:00')
const diffMonths =
(occurrenceDate.getFullYear() - baseDate.getFullYear()) * 12 +
(occurrenceDate.getMonth() - baseDate.getMonth())
const interval = eventForIndex.repeatInterval || 1
// occurrenceIndex for monthly logic: diff in months (first after base is 1 * interval)
if (diffMonths > 0 && diffMonths % interval === 0) {
occurrenceIndex = diffMonths // matches store deletion expectation
} else {
occurrenceDate = null
}
}
}
}
}
const event = calendarStore.getEventById(baseId) const event = calendarStore.getEventById(baseId)
if (!event) return if (!event) return
// Derive occurrence date for repeat occurrences if not already determined above // Compute occurrenceDate based on occurrenceIndex rather than parsing instanceId
if ( if (event.isRepeating) {
!occurrenceDate &&
event.isRepeating &&
((event.repeat === 'weeks' && occurrenceIndex >= 0) ||
(event.repeat === 'months' && occurrenceIndex > 0))
) {
if (event.repeat === 'weeks' && occurrenceIndex >= 0) { if (event.repeat === 'weeks' && occurrenceIndex >= 0) {
const repeatWeekdaysLocal = event.repeatWeekdays || [] const pattern = event.repeatWeekdays || []
const baseDate = new Date(event.startDate + 'T00:00:00') const baseStart = new Date(event.startDate + 'T00:00:00')
let cur = new Date(baseDate) const baseEnd = new Date(event.endDate + 'T00:00:00')
let matched = -1 if (occurrenceIndex === 0) {
let safety = 0 occurrenceDate = baseStart
while (matched < occurrenceIndex && safety < 10000) { weekday = baseStart.getDay()
} else {
// Count valid repeat occurrences (pattern + interval alignment) AFTER the base span
const interval = event.repeatInterval || 1
const WEEK_MS = 7 * 86400000
const baseBlockStart = new Date(baseStart)
baseBlockStart.setDate(baseStart.getDate() - baseStart.getDay())
function isAligned(d) {
const blk = new Date(d)
blk.setDate(d.getDate() - d.getDay())
const diff = Math.floor((blk - baseBlockStart) / WEEK_MS)
return diff % interval === 0
}
let cur = new Date(baseEnd)
cur.setDate(cur.getDate() + 1)
let found = 0 // number of repeat occurrences found so far
let safety = 0
while (found < occurrenceIndex && safety < 20000) {
if (pattern[cur.getDay()] && isAligned(cur)) {
found++
if (found === occurrenceIndex) break
}
cur.setDate(cur.getDate() + 1) cur.setDate(cur.getDate() + 1)
if (repeatWeekdaysLocal[cur.getDay()]) matched++
safety++ safety++
} }
occurrenceDate = cur occurrenceDate = cur
} else if (event.repeat === 'months') { weekday = cur.getDay()
const cur = new Date(event.startDate + 'T00:00:00') }
} else if (event.repeat === 'months' && occurrenceIndex >= 0) {
const baseDate = new Date(event.startDate + 'T00:00:00')
const cur = new Date(baseDate)
cur.setMonth(cur.getMonth() + occurrenceIndex) cur.setMonth(cur.getMonth() + occurrenceIndex)
occurrenceDate = cur occurrenceDate = cur
} }
@ -250,7 +216,7 @@ function openEditDialog(eventInstanceId) {
eventSaved.value = false eventSaved.value = false
// Build occurrence context: treat any occurrenceIndex > 0 as a specific occurrence (weekday only relevant for weekly) // Build occurrence context: treat any occurrenceIndex > 0 as a specific occurrence (weekday only relevant for weekly)
if (event.isRepeating) { if (event.isRepeating) {
if (event.repeat === 'weeks' && weekday != null && occurrenceIndex >= 0) { if (event.repeat === 'weeks' && occurrenceIndex >= 0) {
occurrenceContext.value = { baseId, occurrenceIndex, weekday, occurrenceDate } occurrenceContext.value = { baseId, occurrenceIndex, weekday, occurrenceDate }
} else if (event.repeat === 'months' && occurrenceIndex > 0) { } else if (event.repeat === 'months' && occurrenceIndex > 0) {
occurrenceContext.value = { baseId, occurrenceIndex, weekday: null, occurrenceDate } occurrenceContext.value = { baseId, occurrenceIndex, weekday: null, occurrenceDate }
@ -282,9 +248,8 @@ function updateEventInStore() {
// For simple property updates (title, color, repeat), update all instances directly // For simple property updates (title, color, repeat), update all instances directly
// This avoids the expensive remove/re-add cycle // This avoids the expensive remove/re-add cycle
for (const [, eventList] of calendarStore.events) { if (calendarStore.events?.has(editingEventId.value)) {
for (const event of eventList) { const event = calendarStore.events.get(editingEventId.value)
if (event.id === editingEventId.value) {
event.title = title.value event.title = title.value
event.colorId = colorId.value event.colorId = colorId.value
event.repeat = repeat.value event.repeat = repeat.value
@ -295,8 +260,6 @@ function updateEventInStore() {
event.isRepeating = recurrenceEnabled.value && repeat.value !== 'none' event.isRepeating = recurrenceEnabled.value && repeat.value !== 'none'
} }
} }
}
}
function saveEvent() { function saveEvent() {
if (editingEventId.value) { if (editingEventId.value) {

View File

@ -5,7 +5,8 @@
:key="span.id" :key="span.id"
class="event-span" class="event-span"
:class="[`event-color-${span.colorId}`]" :class="[`event-color-${span.colorId}`]"
:data-recurrence="span._recurrenceIndex != null ? span._recurrenceIndex : undefined" :data-id="span.id"
:data-n="span._recurrenceIndex != null ? span._recurrenceIndex : 0"
:style="{ :style="{
gridColumn: `${span.startIdx + 1} / ${span.endIdx + 2}`, gridColumn: `${span.startIdx + 1} / ${span.endIdx + 2}`,
gridRow: `${span.row}`, gridRow: `${span.row}`,
@ -74,23 +75,26 @@ const eventSpans = computed(() => {
return arr return arr
}) })
function extractBaseId(eventId) {
if (typeof eventId !== 'string') return eventId
if (eventId.includes('_repeat_')) return eventId.split('_repeat_')[0]
if (eventId.includes('_v_')) return eventId.slice(0, eventId.lastIndexOf('_v_'))
return eventId
}
function handleEventClick(span) { function handleEventClick(span) {
if (justDragged.value) return if (justDragged.value) return
emit('event-click', span.id) // Emit composite payload: base id (without virtual marker), instance id, occurrence index (data-n)
const idStr = span.id
const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_')
const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr
emit('event-click', {
id: baseId,
instanceId: span.id,
occurrenceIndex: span._recurrenceIndex != null ? span._recurrenceIndex : 0,
})
} }
function handleEventPointerDown(span, event) { function handleEventPointerDown(span, event) {
if (event.target.classList.contains('resize-handle')) return if (event.target.classList.contains('resize-handle')) return
event.stopPropagation() event.stopPropagation()
const baseId = extractBaseId(span.id) const idStr = span.id
const isVirtual = baseId !== span.id const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_')
const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr
const isVirtual = hasVirtualMarker
startLocalDrag( startLocalDrag(
{ {
id: baseId, id: baseId,
@ -109,8 +113,10 @@ function handleEventPointerDown(span, event) {
function handleResizePointerDown(span, mode, event) { function handleResizePointerDown(span, mode, event) {
event.stopPropagation() event.stopPropagation()
const baseId = extractBaseId(span.id) const idStr = span.id
const isVirtual = baseId !== span.id const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_')
const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr
const isVirtual = hasVirtualMarker
startLocalDrag( startLocalDrag(
{ {
id: baseId, id: baseId,

View File

@ -8,7 +8,8 @@ export const useCalendarStore = defineStore('calendar', {
state: () => ({ state: () => ({
today: toLocalString(new Date()), today: toLocalString(new Date()),
now: new Date().toISOString(), // store as ISO string now: new Date().toISOString(), // store as ISO string
events: new Map(), // Map of date strings to arrays of events events: new Map(), // id -> event object (primary)
dates: new Map(), // dateStr -> Set of event ids
weekend: getLocaleWeekendDays(), weekend: getLocaleWeekendDays(),
config: { config: {
select_days: 1000, select_days: 1000,
@ -134,41 +135,25 @@ export const useCalendarStore = defineStore('calendar', {
isRepeating: eventData.repeat && eventData.repeat !== 'none', isRepeating: eventData.repeat && eventData.repeat !== 'none',
} }
const startDate = new Date(fromLocalString(event.startDate)) this.events.set(event.id, { ...event, isSpanning: event.startDate < event.endDate })
const endDate = new Date(fromLocalString(event.endDate)) this._indexEventDates(event.id)
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
return event.id return event.id
}, },
getEventById(id) { getEventById(id) {
for (const [, list] of this.events) { return this.events.get(id) || null
const found = list.find((e) => e.id === id)
if (found) return found
}
return null
}, },
selectEventColorId(startDateStr, endDateStr) { selectEventColorId(startDateStr, endDateStr) {
const colorCounts = [0, 0, 0, 0, 0, 0, 0, 0] const colorCounts = [0, 0, 0, 0, 0, 0, 0, 0]
const startDate = new Date(fromLocalString(startDateStr)) const startDate = new Date(fromLocalString(startDateStr))
const endDate = new Date(fromLocalString(endDateStr)) const endDate = new Date(fromLocalString(endDateStr))
// Count events whose ranges overlap at least one day in selected span
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { for (const ev of this.events.values()) {
const dateStr = toLocalString(d) const evStart = fromLocalString(ev.startDate)
const dayEvents = this.events.get(dateStr) || [] const evEnd = fromLocalString(ev.endDate)
for (const event of dayEvents) { if (evEnd < startDate || evStart > endDate) continue
if (event.colorId >= 0 && event.colorId < 8) { if (ev.colorId >= 0 && ev.colorId < 8) colorCounts[ev.colorId]++
colorCounts[event.colorId]++
}
}
} }
let minCount = colorCounts[0] let minCount = colorCounts[0]
@ -185,17 +170,15 @@ export const useCalendarStore = defineStore('calendar', {
}, },
deleteEvent(eventId) { deleteEvent(eventId) {
const datesToCleanup = [] if (!this.events.has(eventId)) return
for (const [dateStr, eventList] of this.events) { // Remove id from all date sets
const eventIndex = eventList.findIndex((event) => event.id === eventId) for (const [dateStr, set] of this.dates) {
if (eventIndex !== -1) { if (set.has(eventId)) {
eventList.splice(eventIndex, 1) set.delete(eventId)
if (eventList.length === 0) { if (set.size === 0) this.dates.delete(dateStr)
datesToCleanup.push(dateStr)
} }
} }
} this.events.delete(eventId)
datesToCleanup.forEach((dateStr) => this.events.delete(dateStr))
}, },
deleteSingleOccurrence(ctx) { deleteSingleOccurrence(ctx) {
@ -219,25 +202,36 @@ export const useCalendarStore = defineStore('calendar', {
ctx.occurrenceDate.getDate(), ctx.occurrenceDate.getDate(),
) )
} else { } else {
// Fallback: derive from occurrenceIndex (legacy path) // Fallback: derive from sequential occurrenceIndex (base=0, first repeat=1)
const baseStart = new Date(base.startDate + 'T00:00:00') const baseStart = new Date(base.startDate + 'T00:00:00')
let cur = new Date(baseStart) const baseEnd = new Date(base.endDate + 'T00:00:00')
let matched = -1 if (occurrenceIndex === 0) {
let safety = 0 targetDate = baseStart
while (matched < occurrenceIndex && safety < 20000) { } else {
let cur = new Date(baseEnd)
cur.setDate(cur.getDate() + 1) cur.setDate(cur.getDate() + 1)
const blockStart = new Date(cur) let found = 0
blockStart.setDate(cur.getDate() - cur.getDay()) let safety = 0
const WEEK_MS = 7 * 86400000
const baseBlockStart = new Date(baseStart) const baseBlockStart = new Date(baseStart)
baseBlockStart.setDate(baseStart.getDate() - baseStart.getDay()) baseBlockStart.setDate(baseStart.getDate() - baseStart.getDay())
const WEEK_MS = 7 * 86400000 function isAligned(d) {
const blocksDiff = Math.floor((blockStart - baseBlockStart) / WEEK_MS) const blk = new Date(d)
const aligned = blocksDiff % interval === 0 blk.setDate(d.getDate() - d.getDay())
if (aligned && pattern[cur.getDay()]) matched++ 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++ safety++
} }
targetDate = cur targetDate = cur
} }
}
if (!targetDate) return if (!targetDate) return
// Count occurrences BEFORE target (always include the base occurrence as first) // Count occurrences BEFORE target (always include the base occurrence as first)
@ -311,13 +305,11 @@ export const useCalendarStore = defineStore('calendar', {
// MONTHLY SERIES ----------------------------------------------------- // MONTHLY SERIES -----------------------------------------------------
if (base.repeat === 'months') { if (base.repeat === 'months') {
const interval = base.repeatInterval || 1 const interval = base.repeatInterval || 1
// occurrenceIndex is the diff in months from base (1-based for first recurrence) // Sequential index: base=0, first repeat=1
if (occurrenceIndex <= 0) return // base itself handled elsewhere if (occurrenceIndex <= 0) return // base deletion handled elsewhere
if (occurrenceIndex % interval !== 0) return // should not happen (synthetic id only for valid occurrences) // Count prior occurrences to KEEP (indices 0 .. occurrenceIndex-1) => occurrenceIndex total
// Count prior occurrences (including base) before the deleted one
const originalCountRaw = base.repeatCount const originalCountRaw = base.repeatCount
const priorOccurrences = Math.floor((occurrenceIndex - 1) / interval) + 1 const priorOccurrences = occurrenceIndex
// Truncate base series to keep only priorOccurrences
this._terminateRepeatSeriesAtIndex(baseId, priorOccurrences) this._terminateRepeatSeriesAtIndex(baseId, priorOccurrences)
// Compute span days for multiday events // Compute span days for multiday events
const spanDays = Math.round( const spanDays = Math.round(
@ -333,10 +325,10 @@ export const useCalendarStore = defineStore('calendar', {
remainingCount = String(rem) remainingCount = String(rem)
} }
} }
// Next occurrence after deleted one is at occurrenceIndex + interval months from base // Next occurrence after deleted one is at (occurrenceIndex + 1)*interval months from base
const baseStart = fromLocalString(base.startDate) const baseStart = fromLocalString(base.startDate)
const nextStart = new Date(baseStart) const nextStart = new Date(baseStart)
nextStart.setMonth(nextStart.getMonth() + occurrenceIndex + interval) nextStart.setMonth(nextStart.getMonth() + (occurrenceIndex + 1) * interval)
const nextEnd = new Date(nextStart) const nextEnd = new Date(nextStart)
nextEnd.setDate(nextEnd.getDate() + spanDays) nextEnd.setDate(nextEnd.getDate() + spanDays)
const nextStartStr = toLocalString(nextStart) const nextStartStr = toLocalString(nextStart)
@ -477,49 +469,43 @@ export const useCalendarStore = defineStore('calendar', {
}, },
_snapshotBaseEvent(eventId) { _snapshotBaseEvent(eventId) {
// Return a shallow snapshot of any instance for metadata const ev = this.events.get(eventId)
for (const [, eventList] of this.events) { return ev ? { ...ev } : null
const e = eventList.find((x) => x.id === eventId)
if (e) return { ...e }
}
return null
}, },
_removeEventFromAllDatesById(eventId) { _removeEventFromAllDatesById(eventId) {
for (const [dateStr, list] of this.events) { for (const [dateStr, set] of this.dates) {
for (let i = list.length - 1; i >= 0; i--) { if (set.has(eventId)) {
if (list[i].id === eventId) { set.delete(eventId)
list.splice(i, 1) if (set.size === 0) this.dates.delete(dateStr)
} }
} }
if (list.length === 0) this.events.delete(dateStr)
}
}, },
_addEventToDateRangeWithId(eventId, baseData, startDate, endDate) { _addEventToDateRangeWithId(eventId, baseData, startDate, endDate) {
const s = fromLocalString(startDate) // Update base data
const e = fromLocalString(endDate) this.events.set(eventId, {
const multi = startDate < endDate
const payload = {
...baseData, ...baseData,
id: eventId, id: eventId,
startDate, startDate,
endDate, endDate,
isSpanning: multi, isSpanning: startDate < endDate,
} })
// Normalize single-day time fields this._indexEventDates(eventId)
if (!multi) { },
if (!payload.startTime) payload.startTime = '09:00'
if (!payload.durationMinutes) payload.durationMinutes = 60 _indexEventDates(eventId) {
} else { const ev = this.events.get(eventId)
payload.startTime = null if (!ev) return
payload.durationMinutes = null // remove old date references first
} for (const [, set] of this.dates) set.delete(eventId)
const s = fromLocalString(ev.startDate)
const e = fromLocalString(ev.endDate)
const cur = new Date(s) const cur = new Date(s)
while (cur <= e) { while (cur <= e) {
const dateStr = toLocalString(cur) const dateStr = toLocalString(cur)
if (!this.events.has(dateStr)) this.events.set(dateStr, []) if (!this.dates.has(dateStr)) this.dates.set(dateStr, new Set())
this.events.get(dateStr).push({ ...payload }) this.dates.get(dateStr).add(ev.id)
cur.setDate(cur.getDate() + 1) cur.setDate(cur.getDate() + 1)
} }
}, },
@ -528,7 +514,7 @@ export const useCalendarStore = defineStore('calendar', {
// Adjust start/end range of a base event (non-generated) and reindex occurrences // Adjust start/end range of a base event (non-generated) and reindex occurrences
setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto' } = {}) { setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto' } = {}) {
const snapshot = this._findEventInAnyList(eventId) const snapshot = this.events.get(eventId)
if (!snapshot) return if (!snapshot) return
// Calculate current duration in days (inclusive) // Calculate current duration in days (inclusive)
const prevStart = new Date(fromLocalString(snapshot.startDate)) const prevStart = new Date(fromLocalString(snapshot.startDate))
@ -588,7 +574,7 @@ export const useCalendarStore = defineStore('calendar', {
// Split a repeating series at a specific occurrence date and move that occurrence (and future) to new range // Split a repeating series at a specific occurrence date and move that occurrence (and future) to new range
splitMoveVirtualOccurrence(baseId, occurrenceDateStr, newStartStr, newEndStr) { splitMoveVirtualOccurrence(baseId, occurrenceDateStr, newStartStr, newEndStr) {
const base = this._findEventInAnyList(baseId) const base = this.events.get(baseId)
if (!base || !base.isRepeating) return if (!base || !base.isRepeating) return
const originalCountRaw = base.repeatCount const originalCountRaw = base.repeatCount
const spanDays = Math.max( const spanDays = Math.max(
@ -680,7 +666,7 @@ export const useCalendarStore = defineStore('calendar', {
// Split a repeating series at a given occurrence index; returns new series id // Split a repeating series at a given occurrence index; returns new series id
splitRepeatSeries(baseId, occurrenceIndex, newStartStr, newEndStr, _grabbedWeekday) { splitRepeatSeries(baseId, occurrenceIndex, newStartStr, newEndStr, _grabbedWeekday) {
const base = this._findEventInAnyList(baseId) const base = this.events.get(baseId)
if (!base || !base.isRepeating) return null if (!base || !base.isRepeating) return null
// Capture original repeatCount BEFORE truncation // Capture original repeatCount BEFORE truncation
const originalCountRaw = base.repeatCount const originalCountRaw = base.repeatCount
@ -715,42 +701,21 @@ export const useCalendarStore = defineStore('calendar', {
}, },
_terminateRepeatSeriesAtIndex(baseId, index) { _terminateRepeatSeriesAtIndex(baseId, index) {
// Cap repeatCount to 'index' total occurrences (0-based index means index occurrences before split) const ev = this.events.get(baseId)
for (const [, list] of this.events) { if (!ev || !ev.isRepeating) return
for (const ev of list) {
if (ev.id === baseId && ev.isRepeating) {
if (ev.repeatCount === 'unlimited') { if (ev.repeatCount === 'unlimited') {
ev.repeatCount = String(index) ev.repeatCount = String(index)
} else { } else {
const rc = parseInt(ev.repeatCount, 10) const rc = parseInt(ev.repeatCount, 10)
if (!isNaN(rc)) ev.repeatCount = String(Math.min(rc, index)) if (!isNaN(rc)) ev.repeatCount = String(Math.min(rc, index))
} }
}
}
}
}, },
_findEventInAnyList(eventId) { // _findEventInAnyList removed (direct map access)
for (const [, eventList] of this.events) {
const found = eventList.find((e) => e.id === eventId)
if (found) return found
}
return null
},
_addEventToDateRange(event) { _addEventToDateRange(event) {
const startDate = fromLocalString(event.startDate) this.events.set(event.id, { ...event, isSpanning: event.startDate < event.endDate })
const endDate = fromLocalString(event.endDate) this._indexEventDates(event.id)
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. // NOTE: legacy dynamic getEventById for synthetic occurrences removed.
@ -758,15 +723,22 @@ export const useCalendarStore = defineStore('calendar', {
persist: { persist: {
key: 'calendar-store', key: 'calendar-store',
storage: localStorage, storage: localStorage,
paths: ['today', 'events', 'config'], // Persist new structures; keep legacy 'events' only for transitional restore if needed
paths: ['today', 'config', 'events', 'dates'],
serializer: { serializer: {
serialize(value) { serialize(value) {
return JSON.stringify(value, (_k, v) => return JSON.stringify(value, (_k, v) => {
v instanceof Map ? { __map: true, data: [...v] } : v, if (v instanceof Map) return { __map: true, data: [...v] }
) if (v instanceof Set) return { __set: true, data: [...v] }
return v
})
}, },
deserialize(value) { deserialize(value) {
return JSON.parse(value, (_k, v) => (v && v.__map ? new Map(v.data) : v)) 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
})
}, },
}, },
}, },