From 1257fba2119becd6550a38676b6c76ddc147cfc2 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 22 Aug 2025 20:34:56 -0600 Subject: [PATCH] Recurrent event handling bugfixes. --- src/components/EventDialog.vue | 50 +++++++++----- src/stores/CalendarStore.js | 115 +++++++++++++++++++++++---------- 2 files changed, 115 insertions(+), 50 deletions(-) diff --git a/src/components/EventDialog.vue b/src/components/EventDialog.vue index 21ca90a..81a334f 100644 --- a/src/components/EventDialog.vue +++ b/src/components/EventDialog.vue @@ -44,7 +44,7 @@ const fallbackWeekdays = computed(() => { return fallback }) -// Repeat mapping uses 'weeks' | 'months' | 'none' directly (legacy 'weekly'/'monthly' accepted on load) +// Repeat mapping uses 'weeks' | 'months' | 'none' directly const repeat = computed({ get() { if (!recurrenceEnabled.value) return 'none' @@ -56,8 +56,8 @@ const repeat = computed({ return } recurrenceEnabled.value = true - if (val === 'weeks' || val === 'weekly') recurrenceFrequency.value = 'weeks' - else if (val === 'months' || val === 'monthly') recurrenceFrequency.value = 'months' + if (val === 'weeks') recurrenceFrequency.value = 'weeks' + else if (val === 'months') recurrenceFrequency.value = 'months' }, }) @@ -164,18 +164,31 @@ function openEditDialog(eventInstanceId) { } const event = calendarStore.getEventById(baseId) if (!event) return - // Derive occurrence date if weekly occurrence - if (weekday != null) { - // Recompute occurrence date: iterate days accumulating selected weekdays - const repeatWeekdaysLocal = event.repeatWeekdays - let idx = 0 - let cur = new Date(event.startDate + 'T00:00:00') - while (idx < occurrenceIndex && idx < 10000) { - // safety bound - cur.setDate(cur.getDate() + 1) - if (repeatWeekdaysLocal[cur.getDay()]) idx++ + // Derive occurrence date for repeat occurrences (occurrenceIndex > 0 means not the base) + if ( + event.isRepeating && + ((event.repeat === 'weeks' && occurrenceIndex >= 0) || + (event.repeat === 'months' && occurrenceIndex > 0)) + ) { + 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 + while (matched < occurrenceIndex && safety < 10000) { + cur.setDate(cur.getDate() + 1) + if (repeatWeekdaysLocal[cur.getDay()]) matched++ + safety++ + } + occurrenceDate = cur + } else if (event.repeat === 'months') { + const cur = new Date(event.startDate + 'T00:00:00') + cur.setMonth(cur.getMonth() + occurrenceIndex) + occurrenceDate = cur } - occurrenceDate = cur } dialogMode.value = 'edit' editingEventId.value = baseId @@ -188,8 +201,13 @@ function openEditDialog(eventInstanceId) { recurrenceOccurrences.value = rc === 'unlimited' ? 0 : parseInt(rc, 10) || 0 colorId.value = event.colorId eventSaved.value = false - if (event.isRepeating && occurrenceIndex >= 0 && weekday != null) { - occurrenceContext.value = { baseId, occurrenceIndex, weekday, occurrenceDate } + // Build occurrence context: treat any occurrenceIndex > 0 as a specific occurrence (weekday only relevant for weekly) + if (event.isRepeating) { + if (event.repeat === 'weeks' && weekday != null && occurrenceIndex >= 0) { + occurrenceContext.value = { baseId, occurrenceIndex, weekday, occurrenceDate } + } else if (event.repeat === 'months' && occurrenceIndex > 0) { + occurrenceContext.value = { baseId, occurrenceIndex, weekday: null, occurrenceDate } + } } showDialog.value = true diff --git a/src/stores/CalendarStore.js b/src/stores/CalendarStore.js index 741bc5a..7f16cba 100644 --- a/src/stores/CalendarStore.js +++ b/src/stores/CalendarStore.js @@ -55,12 +55,8 @@ export const useCalendarStore = defineStore('calendar', { eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate), 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', + // Normalized repeat value: only 'weeks', 'months', or 'none' + repeat: ['weeks', 'months'].includes(eventData.repeat) ? eventData.repeat : 'none', repeatInterval: eventData.repeatInterval || 1, repeatCount: eventData.repeatCount || 'unlimited', repeatWeekdays: eventData.repeatWeekdays, @@ -134,35 +130,86 @@ export const useCalendarStore = defineStore('calendar', { 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++ + 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) { + cur.setDate(cur.getDate() + 1) + if (base.repeatWeekdays && base.repeatWeekdays[cur.getDay()]) idx++ + } + const nextStartStr = toLocalString(cur) + // Preserve multi‑day span if any + const spanDays = Math.round( + (fromLocalString(base.endDate) - fromLocalString(base.startDate)) / (24 * 60 * 60 * 1000), + ) + const nextEnd = new Date(fromLocalString(nextStartStr)) + nextEnd.setDate(nextEnd.getDate() + spanDays) + const nextEndStr = toLocalString(nextEnd) + this.createEvent({ + title: base.title, + startDate: nextStartStr, + endDate: nextEndStr, + colorId: base.colorId, + repeat: 'weeks', + repeatCount: remaining, + repeatWeekdays: base.repeatWeekdays, + }) + return + } + // MONTHLY SERIES ----------------------------------------------------- + if (base.repeat === 'months') { + const interval = base.repeatInterval || 1 + // occurrenceIndex is the diff in months from base (1-based for first recurrence) + 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 priorOccurrences = Math.floor((occurrenceIndex - 1) / interval) + 1 + // Truncate base series to keep only priorOccurrences + this._terminateRepeatSeriesAtIndex(baseId, priorOccurrences) + // Compute span days for multi‑day events + const spanDays = Math.round( + (fromLocalString(base.endDate) - fromLocalString(base.startDate)) / (24 * 60 * 60 * 1000), + ) + // Remaining occurrences after deletion + let remainingCount = 'unlimited' + if (base.repeatCount !== 'unlimited') { + const total = parseInt(base.repeatCount, 10) + if (!isNaN(total)) { + const rem = total - priorOccurrences - 1 // subtract kept + deleted + if (rem <= 0) return // nothing left + remainingCount = String(rem) + } + } + // Next occurrence after deleted one is at occurrenceIndex + interval months from base + const baseStart = fromLocalString(base.startDate) + const nextStart = new Date(baseStart) + nextStart.setMonth(nextStart.getMonth() + occurrenceIndex + interval) + const nextEnd = new Date(nextStart) + nextEnd.setDate(nextEnd.getDate() + spanDays) + const nextStartStr = toLocalString(nextStart) + const nextEndStr = toLocalString(nextEnd) + this.createEvent({ + title: base.title, + startDate: nextStartStr, + endDate: nextEndStr, + colorId: base.colorId, + repeat: 'months', + repeatInterval: interval, + repeatCount: remainingCount, + }) } - 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) {