Fix monthly repeats on events with day 29-31 not showing up on shorter months. Improve event repeat handling performance.

This commit is contained in:
Leo Vasanko 2025-08-22 19:23:31 -06:00
parent 47976eef88
commit 26b2e983ed
2 changed files with 158 additions and 155 deletions

View File

@ -44,131 +44,7 @@ const store = useCalendarStore()
const dragState = ref(null)
const justDragged = ref(false)
// Generate repeat occurrences for a specific date
function generateRepeatOccurrencesForDate(targetDateStr) {
const occurrences = []
// Get all events from the store and check for repeating ones
for (const [, eventList] of store.events) {
for (const baseEvent of eventList) {
if (!baseEvent.isRepeating || baseEvent.repeat === 'none') {
continue
}
const targetDate = new Date(fromLocalString(targetDateStr))
const baseStartDate = new Date(fromLocalString(baseEvent.startDate))
const baseEndDate = new Date(fromLocalString(baseEvent.endDate))
const spanDays = Math.floor((baseEndDate - baseStartDate) / (24 * 60 * 60 * 1000))
if (baseEvent.repeat === 'weeks') {
const repeatWeekdays = baseEvent.repeatWeekdays
if (targetDate < baseStartDate) continue
const maxOccurrences =
baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10)
if (maxOccurrences === 0) continue
const interval = baseEvent.repeatInterval || 1
const msPerDay = 24 * 60 * 60 * 1000
// Determine if targetDate lies within some occurrence span. We look backwards up to spanDays to find a start day.
let occStart = null
for (let back = 0; back <= spanDays; back++) {
const cand = new Date(targetDate)
cand.setDate(cand.getDate() - back)
if (cand < baseStartDate) break
const daysDiff = Math.floor((cand - baseStartDate) / msPerDay)
const weeksDiff = Math.floor(daysDiff / 7)
if (weeksDiff % interval !== 0) continue
if (repeatWeekdays[cand.getDay()]) {
// candidate start must produce span covering targetDate
const candEnd = new Date(cand)
candEnd.setDate(candEnd.getDate() + spanDays)
if (targetDate <= candEnd) {
occStart = cand
break
}
}
}
if (!occStart) continue
// Skip base occurrence if this is within its span (base already physically stored)
if (occStart.getTime() === baseStartDate.getTime()) continue
// Compute occurrence index (number of previous start days)
let occIdx = 0
const cursor = new Date(baseStartDate)
while (cursor < occStart && occIdx < maxOccurrences) {
const cDaysDiff = Math.floor((cursor - baseStartDate) / msPerDay)
const cWeeksDiff = Math.floor(cDaysDiff / 7)
if (cWeeksDiff % interval === 0 && repeatWeekdays[cursor.getDay()]) occIdx++
cursor.setDate(cursor.getDate() + 1)
}
if (occIdx >= maxOccurrences) continue
const occEnd = new Date(occStart)
occEnd.setDate(occStart.getDate() + spanDays)
const occStartStr = toLocalString(occStart)
const occEndStr = toLocalString(occEnd)
occurrences.push({
...baseEvent,
id: `${baseEvent.id}_repeat_${occIdx}_${occStart.getDay()}`,
startDate: occStartStr,
endDate: occEndStr,
isRepeatOccurrence: true,
repeatIndex: occIdx,
})
continue
} else {
// Handle other repeat types (months)
let intervalsPassed = 0
const timeDiff = targetDate - baseStartDate
if (baseEvent.repeat === 'months') {
intervalsPassed =
(targetDate.getFullYear() - baseStartDate.getFullYear()) * 12 +
(targetDate.getMonth() - baseStartDate.getMonth())
} else {
continue
}
const interval = baseEvent.repeatInterval || 1
if (intervalsPassed < 0 || intervalsPassed % interval !== 0) continue
// Check a few occurrences around the target date
const maxOccurrences =
baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10)
if (maxOccurrences === 0) continue
const i = intervalsPassed
if (i >= maxOccurrences) continue
const currentStart = new Date(baseStartDate)
currentStart.setMonth(baseStartDate.getMonth() + i)
const currentEnd = new Date(currentStart)
currentEnd.setDate(currentStart.getDate() + spanDays)
// If target day lies within base (i===0) we skip because base is stored already
if (i === 0) {
// only skip if targetDate within base span
if (targetDate >= baseStartDate && targetDate <= baseEndDate) continue
}
const currentStartStr = toLocalString(currentStart)
const currentEndStr = toLocalString(currentEnd)
if (currentStartStr <= targetDateStr && targetDateStr <= currentEndStr) {
occurrences.push({
...baseEvent,
id: `${baseEvent.id}_repeat_${i}`,
startDate: currentStartStr,
endDate: currentEndStr,
isRepeatOccurrence: true,
repeatIndex: i,
})
}
}
}
}
return occurrences
}
// Extract original event ID from repeat occurrence ID
function getOriginalEventId(eventId) {
if (typeof eventId === 'string' && eventId.includes('_repeat_')) {
return eventId.split('_repeat_')[0]
}
return eventId
}
// (legacy helpers removed)
// Handle event click
function handleEventClick(span) {
@ -470,38 +346,159 @@ const eventSpans = computed(() => {
const spans = []
const weekEvents = new Map()
// Collect events from all days in this week, including repeat occurrences
// Collect stored base events
props.week.days.forEach((day, dayIndex) => {
// Get base events for this day
day.events.forEach((event) => {
if (!weekEvents.has(event.id)) {
weekEvents.set(event.id, {
...event,
startIdx: dayIndex,
endIdx: dayIndex,
})
} else {
const existing = weekEvents.get(event.id)
existing.endIdx = dayIndex
}
})
// Generate repeat occurrences for this day
const repeatOccurrences = generateRepeatOccurrencesForDate(day.date)
repeatOccurrences.forEach((event) => {
if (!weekEvents.has(event.id)) {
weekEvents.set(event.id, {
...event,
startIdx: dayIndex,
endIdx: dayIndex,
})
} else {
const existing = weekEvents.get(event.id)
existing.endIdx = 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) => {

View File

@ -193,8 +193,14 @@ export const useCalendarStore = defineStore('calendar', {
}
}
} else if (base.repeat === 'months') {
newStart = new Date(oldStart)
newStart.setMonth(newStart.getMonth() + 1)
// Advance one month, clamping to last day if necessary
const o = oldStart
const nextMonthIndex = o.getMonth() + 1
const y = o.getFullYear() + Math.floor(nextMonthIndex / 12)
const m = nextMonthIndex % 12
const daysInTargetMonth = new Date(y, m + 1, 0).getDate()
const dom = Math.min(o.getDate(), daysInTargetMonth)
newStart = new Date(y, m, dom)
} else {
// Unknown pattern: delete entire series
this.deleteEvent(baseId)