Recurrent deletion bugfixes.

This commit is contained in:
Leo Vasanko 2025-08-22 21:08:14 -06:00
parent 1257fba211
commit 4529d0c412
4 changed files with 330 additions and 382 deletions

View File

@ -142,9 +142,59 @@ function createWeek(virtualWeek) {
let monthToLabel = null
let labelYear = null
// Precollect unique repeating base events once (avoid nested loops for each day)
const repeatingBases = []
const seen = new Set()
for (const [, list] of calendarStore.events) {
for (const ev of list) {
if (ev.isRepeating && !seen.has(ev.id)) {
seen.add(ev.id)
repeatingBases.push(ev)
}
}
}
for (let i = 0; i < 7; i++) {
const dateStr = toLocalString(cur)
const eventsForDay = calendarStore.events.get(dateStr) || []
const storedEvents = calendarStore.events.get(dateStr) || []
// Build day events starting with stored (base/spanning) then virtual occurrences
const dayEvents = [...storedEvents]
for (const base of repeatingBases) {
// Skip if the base itself already on this date (already in storedEvents)
if (dateStr >= base.startDate && dateStr <= base.endDate) continue
if (calendarStore.occursOnDate(base, dateStr)) {
// Determine occurrence index (0 = first repeat after base) for weekly / monthly
let recurrenceIndex = 0
try {
if (base.repeat === 'weeks') {
const pattern = base.repeatWeekdays || []
const baseDate = new Date(base.startDate + 'T00:00:00')
const target = new Date(dateStr + 'T00:00:00')
let matched = -1
const cur = new Date(baseDate)
while (cur < target && matched < 100000) {
cur.setDate(cur.getDate() + 1)
if (pattern[cur.getDay()]) matched++
}
if (cur.toDateString() === target.toDateString()) recurrenceIndex = matched
} else if (base.repeat === 'months') {
const baseDate = new Date(base.startDate + 'T00:00:00')
const target = new Date(dateStr + 'T00:00:00')
const diffMonths =
(target.getFullYear() - baseDate.getFullYear()) * 12 +
(target.getMonth() - baseDate.getMonth())
recurrenceIndex = diffMonths // matches existing monthly logic semantics
}
} catch {}
dayEvents.push({
...base,
id: base.id + '_v_' + dateStr,
startDate: dateStr,
endDate: dateStr,
_recurrenceIndex: recurrenceIndex,
})
}
}
const dow = cur.getDay()
const isFirst = cur.getDate() === 1
@ -177,7 +227,7 @@ function createWeek(virtualWeek) {
selection.value.dayCount > 0 &&
dateStr >= selection.value.startDate &&
dateStr <= addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
events: eventsForDay,
events: dayEvents,
})
cur.setDate(cur.getDate() + 1)
}

View File

@ -155,6 +155,8 @@ function openEditDialog(eventInstanceId) {
let occurrenceIndex = 0
let weekday = 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
@ -162,10 +164,57 @@ function openEditDialog(eventInstanceId) {
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)
if (!event) return
// Derive occurrence date for repeat occurrences (occurrenceIndex > 0 means not the base)
// Derive occurrence date for repeat occurrences if not already determined above
if (
!occurrenceDate &&
event.isRepeating &&
((event.repeat === 'weeks' && occurrenceIndex >= 0) ||
(event.repeat === 'months' && occurrenceIndex > 0))
@ -173,8 +222,6 @@ function openEditDialog(eventInstanceId) {
if (event.repeat === 'weeks' && occurrenceIndex >= 0) {
const repeatWeekdaysLocal = event.repeatWeekdays || []
const baseDate = new Date(event.startDate + 'T00:00:00')
// occurrenceIndex counts prior occurrences AFTER base;
// For occurrenceIndex = 0 we want first matching day after base.
let cur = new Date(baseDate)
let matched = -1
let safety = 0

View File

@ -5,6 +5,7 @@
:key="span.id"
class="event-span"
:class="[`event-color-${span.colorId}`]"
:data-recurrence="span._recurrenceIndex != null ? span._recurrenceIndex : undefined"
:style="{
gridColumn: `${span.startIdx + 1} / ${span.endIdx + 2}`,
gridRow: `${span.row}`,
@ -24,54 +25,81 @@
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useCalendarStore } from '@/stores/CalendarStore'
import { toLocalString, fromLocalString, daysInclusive, addDaysStr } from '@/utils/date'
import { daysInclusive, addDaysStr } from '@/utils/date'
const props = defineProps({
week: {
type: Object,
required: true,
},
week: { type: Object, required: true },
})
const emit = defineEmits(['event-click'])
const store = useCalendarStore()
// Local drag state
// Drag state
const dragState = ref(null)
const justDragged = ref(false)
// (legacy helpers removed)
// Consolidate already-provided day.events into contiguous spans (no recurrence generation)
const eventSpans = computed(() => {
const weekEvents = new Map()
props.week.days.forEach((day, dayIndex) => {
day.events.forEach((ev) => {
const key = ev.id
if (!weekEvents.has(key)) {
weekEvents.set(key, { ...ev, startIdx: dayIndex, endIdx: dayIndex })
} else {
const ref = weekEvents.get(key)
ref.endIdx = Math.max(ref.endIdx, dayIndex)
}
})
})
const arr = Array.from(weekEvents.values())
arr.sort((a, b) => {
const spanA = a.endIdx - a.startIdx
const spanB = b.endIdx - b.startIdx
if (spanA !== spanB) return spanB - spanA
if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx
return String(a.id).localeCompare(String(b.id))
})
// Assign non-overlapping rows
const rowsLastEnd = []
arr.forEach((ev) => {
let row = 0
while (row < rowsLastEnd.length && !(ev.startIdx > rowsLastEnd[row])) row++
if (row === rowsLastEnd.length) rowsLastEnd.push(-1)
rowsLastEnd[row] = ev.endIdx
ev.row = row + 1
})
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
}
// Handle event click
function handleEventClick(span) {
if (justDragged.value) return
// Emit the actual span id (may include repeat suffix) so edit dialog knows occurrence context
emit('event-click', span.id)
}
// Handle event pointer down for dragging
function handleEventPointerDown(span, event) {
// Don't start drag if clicking on resize handle
if (event.target.classList.contains('resize-handle')) return
event.stopPropagation()
// Do not preventDefault here to allow click unless drag threshold is passed
// Get the date under the pointer
const hit = getDateUnderPointer(event.clientX, event.clientY, event.currentTarget)
const anchorDate = hit ? hit.date : span.startDate
const baseId = extractBaseId(span.id)
const isVirtual = baseId !== span.id
startLocalDrag(
{
id: span.id,
id: baseId,
originalId: span.id,
isVirtual,
mode: 'move',
pointerStartX: event.clientX,
pointerStartY: event.clientY,
anchorDate,
anchorDate: span.startDate,
startDate: span.startDate,
endDate: span.endDate,
},
@ -79,13 +107,15 @@ function handleEventPointerDown(span, event) {
)
}
// Handle resize handle pointer down
function handleResizePointerDown(span, mode, event) {
event.stopPropagation()
// Start drag from the current edge; anchorDate not needed for resize
const baseId = extractBaseId(span.id)
const isVirtual = baseId !== span.id
startLocalDrag(
{
id: span.id,
id: baseId,
originalId: span.id,
isVirtual,
mode,
pointerStartX: event.clientX,
pointerStartY: event.clientY,
@ -97,94 +127,6 @@ function handleResizePointerDown(span, mode, event) {
)
}
// Get date under pointer coordinates
function getDateUnderPointer(clientX, clientY, targetEl) {
// First try to find a day cell directly under the pointer
let element = document.elementFromPoint(clientX, clientY)
// If we hit an event element, temporarily hide it and try again
const hiddenElements = []
while (element && element.classList.contains('event-span')) {
element.style.pointerEvents = 'none'
hiddenElements.push(element)
element = document.elementFromPoint(clientX, clientY)
}
// Restore pointer events for hidden elements
hiddenElements.forEach((el) => (el.style.pointerEvents = 'auto'))
if (element) {
// Look for a day cell with data-date attribute
const dayElement = element.closest('[data-date]')
if (dayElement && dayElement.dataset.date) {
return { date: dayElement.dataset.date }
}
// Also check if we're over a week element and can calculate position
const weekElement = element.closest('.week-row')
if (weekElement) {
const rect = weekElement.getBoundingClientRect()
const relativeX = clientX - rect.left
const dayWidth = rect.width / 7
const dayIndex = Math.floor(Math.max(0, Math.min(6, relativeX / dayWidth)))
const daysGrid = weekElement.querySelector('.days-grid')
if (daysGrid && daysGrid.children[dayIndex]) {
const dayEl = daysGrid.children[dayIndex]
const date = dayEl?.dataset?.date
if (date) return { date }
}
}
}
// Fallback: try to find the week overlay and calculate position
const overlayEl = targetEl?.closest('.week-overlay')
const weekElement = overlayEl ? overlayEl.parentElement : null
if (!weekElement) {
// If we're outside this week, try to find any week element under the pointer
const allWeekElements = document.querySelectorAll('.week-row')
let bestWeek = null
let bestDistance = Infinity
for (const week of allWeekElements) {
const rect = week.getBoundingClientRect()
if (clientY >= rect.top && clientY <= rect.bottom) {
const distance = Math.abs(clientY - (rect.top + rect.height / 2))
if (distance < bestDistance) {
bestDistance = distance
bestWeek = week
}
}
}
if (bestWeek) {
const rect = bestWeek.getBoundingClientRect()
const relativeX = clientX - rect.left
const dayWidth = rect.width / 7
const dayIndex = Math.floor(Math.max(0, Math.min(6, relativeX / dayWidth)))
const daysGrid = bestWeek.querySelector('.days-grid')
if (daysGrid && daysGrid.children[dayIndex]) {
const dayEl = daysGrid.children[dayIndex]
const date = dayEl?.dataset?.date
if (date) return { date }
}
}
return null
}
const rect = weekElement.getBoundingClientRect()
const relativeX = clientX - rect.left
const dayWidth = rect.width / 7
const dayIndex = Math.floor(Math.max(0, Math.min(6, relativeX / dayWidth)))
if (props.week.days[dayIndex]) {
return { date: props.week.days[dayIndex].date }
}
return null
}
// Local drag handling
function startLocalDrag(init, evt) {
const spanDays = daysInclusive(init.startDate, init.endDate)
@ -292,249 +234,11 @@ function normalizeDateOrder(aStr, bStr) {
}
function applyRangeDuringDrag(st, startDate, endDate) {
let ev = store.getEventById(st.id)
let isRepeatOccurrence = false
let baseId = st.id
let repeatIndex = 0
let grabbedWeekday = null
// If not found (repeat occurrences aren't stored) parse synthetic id
if (!ev && typeof st.id === 'string' && st.id.includes('_repeat_')) {
const [bid, suffix] = st.id.split('_repeat_')
baseId = bid
ev = store.getEventById(baseId)
if (ev) {
const parts = suffix.split('_')
repeatIndex = parseInt(parts[0], 10) || 0
grabbedWeekday = parts.length > 1 ? parseInt(parts[1], 10) : null
isRepeatOccurrence = repeatIndex >= 0
// If dragging a virtual occurrence, map to base move without changing recurrence pattern mid-series.
// We disallow resizing individual virtual occurrences; treat as move of whole series anchor.
if (st.isVirtual && st.mode !== 'move') return
store.setEventRange(st.id, startDate, endDate, { mode: st.mode })
}
}
if (!ev) return
const mode = st.mode === 'resize-left' || st.mode === 'resize-right' ? st.mode : 'move'
if (isRepeatOccurrence) {
if (repeatIndex === 0) {
store.setEventRange(baseId, startDate, endDate, { mode })
} else {
if (!st.splitNewBaseId) {
const newId = store.splitRepeatSeries(
baseId,
repeatIndex,
startDate,
endDate,
grabbedWeekday,
)
if (newId) {
st.splitNewBaseId = newId
st.id = newId
st.startDate = startDate
st.endDate = endDate
}
} else {
store.setEventRange(st.splitNewBaseId, startDate, endDate, { mode })
}
}
} else {
store.setEventRange(st.id, startDate, endDate, { mode })
}
}
// Calculate event spans for this week
const eventSpans = computed(() => {
const spans = []
const weekEvents = new Map()
// Collect stored base events
props.week.days.forEach((day, dayIndex) => {
day.events.forEach((ev) => {
if (!weekEvents.has(ev.id)) {
weekEvents.set(ev.id, { ...ev, startIdx: dayIndex, endIdx: dayIndex })
} else weekEvents.get(ev.id).endIdx = dayIndex
})
})
// Generate virtual repeats numerically
const weekStart = fromLocalString(props.week.days[0].date)
const weekEnd = fromLocalString(props.week.days[6].date)
const weekStartTime = weekStart.getTime()
const weekEndTime = weekEnd.getTime()
const DAY_MS = 86400000
// All repeating base events
const baseEvents = []
const seen = new Set()
for (const [, list] of store.events) {
for (const ev of list) {
if (ev.isRepeating && !seen.has(ev.id)) {
seen.add(ev.id)
baseEvents.push(ev)
}
}
}
for (const base of baseEvents) {
if (!base.isRepeating || base.repeat === 'none') continue
const baseStart = fromLocalString(base.startDate)
const baseEnd = fromLocalString(base.endDate)
const spanDays = Math.floor((baseEnd - baseStart) / DAY_MS)
const maxOccurrences =
base.repeatCount === 'unlimited' ? Infinity : parseInt(base.repeatCount, 10)
if (maxOccurrences === 0) continue
if (base.repeat === 'weeks') {
const pattern = base.repeatWeekdays || []
const interval = base.repeatInterval || 1
if (!pattern.some(Boolean)) continue
// Align base block start to week (Sunday=0)
const baseBlockStart = new Date(baseStart)
baseBlockStart.setDate(baseStart.getDate() - baseStart.getDay())
// Search window
const searchStart = new Date(weekStart)
searchStart.setDate(searchStart.getDate() - 7) // one block back for early-week carries
const searchEnd = new Date(weekEnd)
searchEnd.setDate(searchEnd.getDate() + 7) // one block forward for late-week upcoming
const startBlocks = Math.floor((searchStart - baseBlockStart) / (7 * DAY_MS))
const endBlocks = Math.floor((searchEnd - baseBlockStart) / (7 * DAY_MS))
for (let b = Math.max(0, startBlocks); b <= endBlocks; b++) {
if (b % interval !== 0) continue
const blockStart = new Date(baseBlockStart)
blockStart.setDate(baseBlockStart.getDate() + b * 7)
for (let dow = 0; dow < 7; dow++) {
if (!pattern[dow]) continue
const cand = new Date(blockStart)
cand.setDate(blockStart.getDate() + dow)
if (cand < baseStart) continue
const isBase = cand.getTime() === baseStart.getTime()
const candStartTime = cand.getTime()
const candEndTime = candStartTime + spanDays * DAY_MS
const overlaps = candStartTime <= weekEndTime && candEndTime >= weekStartTime
if (!isBase && overlaps) {
let occIdx = 0
const cursor = new Date(baseStart)
while (cursor < cand && occIdx < maxOccurrences) {
const weeksFromBase = Math.floor((cursor - baseBlockStart) / (7 * DAY_MS))
if (
weeksFromBase % interval === 0 &&
pattern[cursor.getDay()] &&
cursor.getTime() !== baseStart.getTime()
) {
occIdx++
}
cursor.setDate(cursor.getDate() + 1)
}
if (occIdx >= maxOccurrences && isFinite(maxOccurrences)) break
const occStartStr = toLocalString(cand)
const occEnd = new Date(cand)
occEnd.setDate(occEnd.getDate() + spanDays)
const occEndStr = toLocalString(occEnd)
let startIdx = -1
let endIdx = -1
props.week.days.forEach((d, idx) => {
if (startIdx === -1 && d.date >= occStartStr && d.date <= occEndStr) startIdx = idx
if (d.date >= occStartStr && d.date <= occEndStr) endIdx = idx
})
const id = `${base.id}_repeat_${occIdx}_${cand.getDay()}`
if ((startIdx !== -1 || endIdx !== -1) && !weekEvents.has(id)) {
weekEvents.set(id, {
...base,
id,
startDate: occStartStr,
endDate: occEndStr,
isRepeatOccurrence: true,
repeatIndex: occIdx,
startIdx: startIdx === -1 ? 0 : startIdx,
endIdx: endIdx === -1 ? 6 : endIdx,
})
}
}
}
}
} else if (base.repeat === 'months') {
const interval = base.repeatInterval || 1
const baseDay = baseStart.getDate()
const startMonthIndex = baseStart.getFullYear() * 12 + baseStart.getMonth()
const endMonthIndex = weekEnd.getFullYear() * 12 + weekEnd.getMonth()
for (let mi = startMonthIndex; mi <= endMonthIndex + 1; mi++) {
// +1 to catch overlap spilling in
const diff = mi - startMonthIndex
if (diff === 0) continue // base occurrence already stored
if (diff % interval !== 0) continue
if (diff > maxOccurrences && isFinite(maxOccurrences)) break
const y = Math.floor(mi / 12)
const m = mi % 12
const daysInMonth = new Date(y, m + 1, 0).getDate()
const dom = Math.min(baseDay, daysInMonth)
const cand = new Date(y, m, dom)
if (cand < baseStart) continue
const candEnd = new Date(cand)
const spanDays = Math.floor((baseEnd - baseStart) / DAY_MS)
candEnd.setDate(candEnd.getDate() + spanDays)
const candStartStr = toLocalString(cand)
const candEndStr = toLocalString(candEnd)
const overlaps = cand.getTime() <= weekEndTime && candEnd.getTime() >= weekStartTime
if (!overlaps) continue
let startIdx = -1
let endIdx = -1
props.week.days.forEach((d, idx) => {
if (startIdx === -1 && d.date >= candStartStr && d.date <= candEndStr) startIdx = idx
if (d.date >= candStartStr && d.date <= candEndStr) endIdx = idx
})
if (startIdx === -1 && endIdx === -1) continue
const id = `${base.id}_repeat_${diff}`
if (!weekEvents.has(id)) {
weekEvents.set(id, {
...base,
id,
startDate: candStartStr,
endDate: candEndStr,
isRepeatOccurrence: true,
repeatIndex: diff,
startIdx: startIdx === -1 ? 0 : startIdx,
endIdx: endIdx === -1 ? 6 : endIdx,
})
}
}
}
}
// Convert to array and sort
const eventArray = Array.from(weekEvents.values())
eventArray.sort((a, b) => {
// Sort by span length (longer first)
const spanA = a.endIdx - a.startIdx
const spanB = b.endIdx - b.startIdx
if (spanA !== spanB) return spanB - spanA
// Then by start position
if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx
// Then by start time if available
const timeA = a.startTime ? timeToMinutes(a.startTime) : 0
const timeB = b.startTime ? timeToMinutes(b.startTime) : 0
if (timeA !== timeB) return timeA - timeB
// Fallback to ID
return String(a.id).localeCompare(String(b.id))
})
// Assign rows to avoid overlaps
const rowsLastEnd = []
eventArray.forEach((event) => {
let placedRow = 0
while (placedRow < rowsLastEnd.length && !(event.startIdx > rowsLastEnd[placedRow])) {
placedRow++
}
if (placedRow === rowsLastEnd.length) {
rowsLastEnd.push(-1)
}
rowsLastEnd[placedRow] = event.endIdx
event.row = placedRow + 1
})
return eventArray
})
function timeToMinutes(timeStr) {
if (!timeStr) return 0

View File

@ -25,6 +25,77 @@ export const useCalendarStore = defineStore('calendar', {
},
actions: {
// Determine if a repeating event occurs on a specific date (YYYY-MM-DD) without iterating all occurrences.
occursOnDate(event, dateStr) {
if (!event || !event.isRepeating || event.repeat === 'none') return false
// Quick bounds: event cannot occur before its base start
if (dateStr < event.startDate) return false
// For multi-day spanning events, we treat start date as anchor; UI handles span painting separately.
if (event.repeat === 'weeks') {
const pattern = event.repeatWeekdays || []
if (!pattern.some(Boolean)) return false
// Day of week must match
const d = fromLocalString(dateStr)
const dow = d.getDay()
if (!pattern[dow]) return false
// Compute week distance blocks respecting interval by counting ISO weeks since anchor Monday of base.
const baseStart = fromLocalString(event.startDate)
// If date is before base anchor weekday match, ensure anchor alignment
// Count days since base start; ensure that number of matching weekdays encountered equals occurrence index < repeatCount
// Optimized approach: approximate max occurrences cap first.
const interval = event.repeatInterval || 1
// Check if date resides in a week block that aligns with interval
const baseBlockStart = new Date(baseStart)
baseBlockStart.setDate(baseStart.getDate() - baseStart.getDay())
const currentBlockStart = new Date(d)
currentBlockStart.setDate(d.getDate() - d.getDay())
const WEEK_MS = 7 * 86400000
const blocksDiff = Math.floor((currentBlockStart - baseBlockStart) / WEEK_MS)
if (blocksDiff < 0 || blocksDiff % interval !== 0) return false
// Count occurrences up to this date to enforce repeatCount finite limits
if (event.repeatCount !== 'unlimited') {
const targetTime = d.getTime()
let occs = 0
const cursor = new Date(baseStart)
const limit = parseInt(event.repeatCount, 10)
const safetyLimit = Math.min(limit + 1, 10000)
while (cursor.getTime() <= targetTime && occs < safetyLimit) {
if (pattern[cursor.getDay()]) {
if (cursor.getTime() === targetTime) {
// This is the occurrence. Validate occs < limit
return occs < limit
}
occs++
}
cursor.setDate(cursor.getDate() + 1)
}
return false
}
return true
} else if (event.repeat === 'months') {
const baseStart = fromLocalString(event.startDate)
const d = fromLocalString(dateStr)
const diffMonths =
(d.getFullYear() - baseStart.getFullYear()) * 12 + (d.getMonth() - baseStart.getMonth())
if (diffMonths < 0) return false
const interval = event.repeatInterval || 1
if (diffMonths % interval !== 0) return false
// Check day match (clamped for shorter months). Base day might exceed target month length.
const baseDay = baseStart.getDate()
const daysInMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate()
const effectiveDay = Math.min(baseDay, daysInMonth)
if (d.getDate() !== effectiveDay) return false
if (event.repeatCount !== 'unlimited') {
const limit = parseInt(event.repeatCount, 10)
if (isNaN(limit)) return false
// Base is occurrence 0; diffMonths/interval gives occurrence index
const occurrenceIndex = diffMonths / interval
return occurrenceIndex < limit
}
return true
}
return false
},
updateCurrentDate() {
const d = new Date()
this.now = d.toISOString()
@ -133,28 +204,96 @@ export const useCalendarStore = defineStore('calendar', {
if (!base || !base.isRepeating) return
// WEEKLY SERIES ------------------------------------------------------
if (base.repeat === 'weeks') {
// Strategy: split series around the target occurrence, omitting it.
const remaining =
base.repeatCount === 'unlimited'
? 'unlimited'
: String(Math.max(0, parseInt(base.repeatCount, 10) - (occurrenceIndex + 1)))
// Keep occurrences before the deleted one
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
if (remaining === '0') return
// Find date of next occurrence (first after deleted)
const startDate = new Date(base.startDate + 'T00:00:00')
let idx = 0
let cur = new Date(startDate)
while (idx <= occurrenceIndex && idx < 10000) {
const interval = base.repeatInterval || 1
const pattern = base.repeatWeekdays || []
if (!pattern.some(Boolean)) return
// Preserve original count before any truncation
const originalCountRaw = base.repeatCount
// Determine target occurrence date
let targetDate = null
if (ctx.occurrenceDate instanceof Date) {
targetDate = new Date(
ctx.occurrenceDate.getFullYear(),
ctx.occurrenceDate.getMonth(),
ctx.occurrenceDate.getDate(),
)
} else {
// Fallback: derive from occurrenceIndex (legacy path)
const baseStart = new Date(base.startDate + 'T00:00:00')
let cur = new Date(baseStart)
let matched = -1
let safety = 0
while (matched < occurrenceIndex && safety < 20000) {
cur.setDate(cur.getDate() + 1)
if (base.repeatWeekdays && base.repeatWeekdays[cur.getDay()]) idx++
const blockStart = new Date(cur)
blockStart.setDate(cur.getDate() - cur.getDay())
const baseBlockStart = new Date(baseStart)
baseBlockStart.setDate(baseStart.getDate() - baseStart.getDay())
const WEEK_MS = 7 * 86400000
const blocksDiff = Math.floor((blockStart - baseBlockStart) / WEEK_MS)
const aligned = blocksDiff % interval === 0
if (aligned && pattern[cur.getDay()]) matched++
safety++
}
const nextStartStr = toLocalString(cur)
// Preserve multiday span if any
targetDate = cur
}
if (!targetDate) return
// Count occurrences BEFORE target (always include the base occurrence as first)
const baseStart = new Date(base.startDate + 'T00:00:00')
const baseBlockStart = new Date(baseStart)
baseBlockStart.setDate(baseStart.getDate() - baseStart.getDay())
const WEEK_MS = 7 * 86400000
function isAligned(d) {
const block = new Date(d)
block.setDate(d.getDate() - d.getDay())
const diff = Math.floor((block - baseBlockStart) / WEEK_MS)
return diff % interval === 0
}
// Start with 1 (base occurrence) if target is after base; if target IS base (should not happen here) leave 0
let countBefore = targetDate > baseStart ? 1 : 0
let probe = new Date(baseStart)
probe.setDate(probe.getDate() + 1) // start counting AFTER base
let safety2 = 0
while (probe < targetDate && safety2 < 50000) {
if (pattern[probe.getDay()] && isAligned(probe)) countBefore++
probe.setDate(probe.getDate() + 1)
safety2++
}
// Terminate original series to keep only occurrences before target
this._terminateRepeatSeriesAtIndex(baseId, countBefore)
// Calculate remaining occurrences for new series using ORIGINAL total
let remainingCount = 'unlimited'
if (originalCountRaw !== 'unlimited') {
const originalTotal = parseInt(originalCountRaw, 10)
if (!isNaN(originalTotal)) {
const rem = originalTotal - countBefore - 1 // kept + deleted
if (rem <= 0) return // nothing left to continue
remainingCount = String(rem)
}
}
// Continuation starts at NEXT valid occurrence (matching weekday & aligned block)
let continuationStart = new Date(targetDate)
let searchSafety = 0
let foundNext = false
while (searchSafety < 50000) {
continuationStart.setDate(continuationStart.getDate() + 1)
if (pattern[continuationStart.getDay()] && isAligned(continuationStart)) {
foundNext = true
break
}
searchSafety++
}
if (!foundNext) return // no remaining occurrences
const spanDays = Math.round(
(fromLocalString(base.endDate) - fromLocalString(base.startDate)) / (24 * 60 * 60 * 1000),
)
const nextEnd = new Date(fromLocalString(nextStartStr))
const nextStartStr = toLocalString(continuationStart)
const nextEnd = new Date(continuationStart)
nextEnd.setDate(nextEnd.getDate() + spanDays)
const nextEndStr = toLocalString(nextEnd)
this.createEvent({
@ -163,7 +302,8 @@ export const useCalendarStore = defineStore('calendar', {
endDate: nextEndStr,
colorId: base.colorId,
repeat: 'weeks',
repeatCount: remaining,
repeatInterval: interval,
repeatCount: remainingCount,
repeatWeekdays: base.repeatWeekdays,
})
return
@ -175,6 +315,7 @@ export const useCalendarStore = defineStore('calendar', {
if (occurrenceIndex <= 0) return // base itself handled elsewhere
if (occurrenceIndex % interval !== 0) return // should not happen (synthetic id only for valid occurrences)
// Count prior occurrences (including base) before the deleted one
const originalCountRaw = base.repeatCount
const priorOccurrences = Math.floor((occurrenceIndex - 1) / interval) + 1
// Truncate base series to keep only priorOccurrences
this._terminateRepeatSeriesAtIndex(baseId, priorOccurrences)
@ -184,8 +325,8 @@ export const useCalendarStore = defineStore('calendar', {
)
// Remaining occurrences after deletion
let remainingCount = 'unlimited'
if (base.repeatCount !== 'unlimited') {
const total = parseInt(base.repeatCount, 10)
if (originalCountRaw !== 'unlimited') {
const total = parseInt(originalCountRaw, 10)
if (!isNaN(total)) {
const rem = total - priorOccurrences - 1 // subtract kept + deleted
if (rem <= 0) return // nothing left
@ -214,7 +355,13 @@ export const useCalendarStore = defineStore('calendar', {
deleteFromOccurrence(ctx) {
const { baseId, occurrenceIndex } = ctx
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
const base = this.getEventById(baseId)
if (!base || !base.isRepeating) return
// We want to keep occurrences up to and including the selected one; that becomes new repeatCount.
// occurrenceIndex here represents the number of repeats AFTER the base (weekly: 0 = first repeat; monthly: diffMonths)
// Total kept occurrences = base (1) + occurrenceIndex
const keptTotal = 1 + Math.max(0, occurrenceIndex)
this._terminateRepeatSeriesAtIndex(baseId, keptTotal)
},
deleteFirstOccurrence(baseId) {