From 8e926c0a21d8efd8df4d00fb4f054a6c0345c86a Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 24 Aug 2025 08:52:28 -0600 Subject: [PATCH] Cleanup date and recurrence calculations. --- src/stores/CalendarStore.js | 406 ++++++++---------------------------- src/utils/date.js | 63 ++++++ 2 files changed, 152 insertions(+), 317 deletions(-) diff --git a/src/stores/CalendarStore.js b/src/stores/CalendarStore.js index 1bde058..38fd544 100644 --- a/src/stores/CalendarStore.js +++ b/src/stores/CalendarStore.js @@ -5,9 +5,10 @@ import { getLocaleWeekendDays, getMondayOfISOWeek, getOccurrenceIndex, + getOccurrenceDate, DEFAULT_TZ, } from '@/utils/date' -import { differenceInCalendarDays, addDays, addMonths } from 'date-fns' +import { differenceInCalendarDays, addDays } from 'date-fns' import { initializeHolidays, getHolidayForDate, @@ -235,249 +236,100 @@ export const useCalendarStore = defineStore('calendar', { }, deleteEvent(eventId) { + console.log('Deleting event', eventId) this.events.delete(eventId) }, - deleteSingleOccurrence(ctx) { - const { baseId, occurrenceIndex } = ctx + // Remove the first (base) occurrence of a repeating event by shifting anchor forward + deleteFirstOccurrence(baseId) { + console.log('Deleting first occurrence', baseId) const base = this.getEventById(baseId) - if (!base || !base.isRepeating) return - // WEEKLY SERIES ------------------------------------------------------ - if (base.repeat === 'weeks') { - // Special case: deleting the first occurrence (index 0) should shift the series forward - if (occurrenceIndex === 0) { - const baseStart = fromLocalString(base.startDate, DEFAULT_TZ) - const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ) - const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart)) - const pattern = base.repeatWeekdays || [] - if (!pattern.some(Boolean)) { - // No pattern to continue -> delete whole series - this.deleteEvent(baseId) - return - } - const interval = base.repeatInterval || 1 - const WEEK_MS = 7 * 86400000 - const baseBlockStart = getMondayOfISOWeek(baseStart) - const isAligned = (d) => { - const blk = getMondayOfISOWeek(d) - const diff = Math.floor((blk - baseBlockStart) / WEEK_MS) - return diff % interval === 0 - } - let probe = new Date(baseStart) - let safety = 0 - let found = null - while (safety < 5000) { - probe = addDays(probe, 1) - if (pattern[probe.getDay()] && isAligned(probe)) { - found = new Date(probe) - break - } - safety++ - } - if (!found) { - // Nothing after first -> delete series - this.deleteEvent(baseId) - return - } - // Adjust repeat count - if (base.repeatCount !== 'unlimited') { - const rc = parseInt(base.repeatCount, 10) - if (!isNaN(rc)) { - const newRc = rc - 1 - if (newRc <= 0) { - this.deleteEvent(baseId) - return - } - base.repeatCount = String(newRc) - } - } - const newEnd = addDays(found, spanDays) - base.startDate = toLocalString(found, DEFAULT_TZ) - base.endDate = toLocalString(newEnd, DEFAULT_TZ) - base.isSpanning = base.startDate < base.endDate - this.events.set(base.id, base) - return - } - 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 sequential occurrenceIndex (base=0, first repeat=1) - const baseStart = fromLocalString(base.startDate, DEFAULT_TZ) - const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ) - if (occurrenceIndex === 0) { - targetDate = baseStart - } else { - let cur = new Date(baseEnd) - cur = addDays(cur, 1) - let found = 0 - let safety = 0 - const WEEK_MS = 7 * 86400000 - const baseBlockStart = getMondayOfISOWeek(baseStart) - function isAligned(d) { - const blk = getMondayOfISOWeek(d) - 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 = addDays(cur, 1) - safety++ - } - targetDate = cur - } - } - if (!targetDate) return - - // Count occurrences BEFORE target (always include the base occurrence as first) - const baseStart = fromLocalString(base.startDate, DEFAULT_TZ) - const baseBlockStart = getMondayOfISOWeek(baseStart) - const WEEK_MS = 7 * 86400000 - function isAligned(d) { - const block = getMondayOfISOWeek(d) - 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 = addDays(probe, 1) // start counting AFTER base - let safety2 = 0 - while (probe < targetDate && safety2 < 50000) { - if (pattern[probe.getDay()] && isAligned(probe)) countBefore++ - probe = addDays(probe, 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 = addDays(continuationStart, 1) - if (pattern[continuationStart.getDay()] && isAligned(continuationStart)) { - foundNext = true - break - } - searchSafety++ - } - if (!foundNext) return // no remaining occurrences - - const spanDays = differenceInCalendarDays( - fromLocalString(base.endDate, DEFAULT_TZ), - fromLocalString(base.startDate, DEFAULT_TZ), - ) - const nextStartStr = toLocalString(continuationStart, DEFAULT_TZ) - const nextEnd = addDays(continuationStart, spanDays) - const nextEndStr = toLocalString(nextEnd, DEFAULT_TZ) - this.createEvent({ - title: base.title, - startDate: nextStartStr, - endDate: nextEndStr, - colorId: base.colorId, - repeat: 'weeks', - repeatInterval: interval, - repeatCount: remainingCount, - repeatWeekdays: base.repeatWeekdays, - }) + if (!base) return + if (!base.isRepeating) { + // Simple (non-repeating) event: delete entirely + this.deleteEvent(baseId) return } - // MONTHLY SERIES ----------------------------------------------------- - if (base.repeat === 'months') { - if (occurrenceIndex === 0) { - const baseStart = fromLocalString(base.startDate, DEFAULT_TZ) - const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ) - const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart)) - const interval = base.repeatInterval || 1 - const targetMonthIndex = baseStart.getMonth() + interval - const targetYear = baseStart.getFullYear() + Math.floor(targetMonthIndex / 12) - const targetMonth = targetMonthIndex % 12 - const daysInTarget = new Date(targetYear, targetMonth + 1, 0).getDate() - const dom = Math.min(baseStart.getDate(), daysInTarget) - const newStart = new Date(targetYear, targetMonth, dom) - if (base.repeatCount !== 'unlimited') { - const rc = parseInt(base.repeatCount, 10) - if (!isNaN(rc)) { - const newRc = rc - 1 - if (newRc <= 0) { - this.deleteEvent(baseId) - return - } - base.repeatCount = String(newRc) - } - } - const newEnd = addDays(newStart, spanDays) - base.startDate = toLocalString(newStart, DEFAULT_TZ) - base.endDate = toLocalString(newEnd, DEFAULT_TZ) - base.isSpanning = base.startDate < base.endDate - this.events.set(base.id, base) - return - } - const interval = base.repeatInterval || 1 - // Sequential index: base=0, first repeat=1 - if (occurrenceIndex <= 0) return // base deletion handled elsewhere - // Count prior occurrences to KEEP (indices 0 .. occurrenceIndex-1) => occurrenceIndex total - const originalCountRaw = base.repeatCount - const priorOccurrences = occurrenceIndex - this._terminateRepeatSeriesAtIndex(baseId, priorOccurrences) - // Compute span days for multi‑day events - const spanDays = differenceInCalendarDays( - fromLocalString(base.endDate, DEFAULT_TZ), - fromLocalString(base.startDate, DEFAULT_TZ), - ) - // Remaining occurrences after deletion - let remainingCount = 'unlimited' - 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 - remainingCount = String(rem) - } - } - // Next occurrence after deleted one is at (occurrenceIndex + 1)*interval months from base - const baseStart = fromLocalString(base.startDate, DEFAULT_TZ) - const nextStart = addMonths(baseStart, (occurrenceIndex + 1) * interval) - const nextEnd = addDays(nextStart, spanDays) - const nextStartStr = toLocalString(nextStart, DEFAULT_TZ) - const nextEndStr = toLocalString(nextEnd, DEFAULT_TZ) - this.createEvent({ - title: base.title, - startDate: nextStartStr, - endDate: nextEndStr, - colorId: base.colorId, - repeat: 'months', - repeatInterval: interval, - repeatCount: remainingCount, - }) + const numericCount = + base.repeatCount === 'unlimited' ? Infinity : parseInt(base.repeatCount, 10) + if (numericCount <= 1) { + // Only one occurrence (or invalid count) -> delete event + this.deleteEvent(baseId) + return } + // Get the next occurrence start date (index 1) + const nextStartStr = getOccurrenceDate(base, 1, DEFAULT_TZ) + if (!nextStartStr) { + // No next occurrence; remove event + this.deleteEvent(baseId) + return + } + const oldStart = fromLocalString(base.startDate, DEFAULT_TZ) + const oldEnd = fromLocalString(base.endDate, DEFAULT_TZ) + const durationDays = Math.max(0, differenceInCalendarDays(oldEnd, oldStart)) + const newEndStr = toLocalString( + addDays(fromLocalString(nextStartStr, DEFAULT_TZ), durationDays), + DEFAULT_TZ, + ) + // Mutate existing event instead of delete+recreate so references remain stable + base.startDate = nextStartStr + base.endDate = newEndStr + if (numericCount !== Infinity) { + base.repeatCount = String(Math.max(1, numericCount - 1)) + } + this.events.set(baseId, { ...base, isSpanning: base.startDate < base.endDate }) + }, + + // Delete a specific occurrence (not the first) from a repeating series, splitting into two + deleteSingleOccurrence(ctx) { + console.log('DeletesingleOccurrence') + const { baseId, occurrenceIndex } = ctx || {} + if (occurrenceIndex === undefined || occurrenceIndex === null) return + const base = this.getEventById(baseId) + if (!base) return + if (!base.isRepeating) { + // Single non-repeating event deletion + if (occurrenceIndex === 0) this.deleteEvent(baseId) + return + } + if (occurrenceIndex === 0) { + // Delegate to specialized first-occurrence deletion + this.deleteFirstOccurrence(baseId) + return + } + // Save copy before truncation for computing next occurrence date + const snapshot = { ...base } + // Cap original series to occurrences before the deleted one + base.repeatCount = occurrenceIndex + const nextStartStr = getOccurrenceDate(snapshot, occurrenceIndex + 1, DEFAULT_TZ) + console.log('Deleting single', occurrenceIndex, nextStartStr) + if (!nextStartStr) return // no continuation + const durationDays = Math.max( + 0, + differenceInCalendarDays( + fromLocalString(snapshot.endDate), + fromLocalString(snapshot.startDate), + ), + ) + const newEndStr = toLocalString(addDays(fromLocalString(nextStartStr), durationDays)) + const originalNumeric = + snapshot.repeatCount === 'unlimited' ? Infinity : parseInt(snapshot.repeatCount, 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, + endDate: newEndStr, + colorId: snapshot.colorId, + repeat: snapshot.repeat, + repeatInterval: snapshot.repeatInterval, + repeatCount: remainingCount, + repeatWeekdays: snapshot.repeatWeekdays, + }) }, deleteFromOccurrence(ctx) { @@ -494,86 +346,6 @@ export const useCalendarStore = defineStore('calendar', { this._terminateRepeatSeriesAtIndex(baseId, keptTotal) }, - deleteFirstOccurrence(baseId) { - const base = this.getEventById(baseId) - if (!base || !base.isRepeating) return - const oldStart = fromLocalString(base.startDate, DEFAULT_TZ) - const oldEnd = fromLocalString(base.endDate, DEFAULT_TZ) - const spanDays = Math.max(0, differenceInCalendarDays(oldEnd, oldStart)) - - let newStartDate = null - - if (base.repeat === 'weeks') { - const pattern = base.repeatWeekdays || [] - if (!pattern.some(Boolean)) { - // No valid pattern -> delete series - this.deleteEvent(baseId) - return - } - const interval = base.repeatInterval || 1 - const baseBlockStart = getMondayOfISOWeek(oldStart) - const WEEK_MS = 7 * 86400000 - const isAligned = (d) => { - const block = getMondayOfISOWeek(d) - const diff = Math.floor((block - baseBlockStart) / WEEK_MS) - return diff % interval === 0 - } - // search forward for next valid weekday respecting interval alignment - let probe = new Date(oldStart) - let safety = 0 - while (safety < 5000) { - probe = addDays(probe, 1) - if (pattern[probe.getDay()] && isAligned(probe)) { - newStartDate = new Date(probe) - break - } - safety++ - } - } else if (base.repeat === 'months') { - const interval = base.repeatInterval || 1 - const y = oldStart.getFullYear() - const m = oldStart.getMonth() - const targetMonthIndex = m + interval - const targetYear = y + Math.floor(targetMonthIndex / 12) - const targetMonth = targetMonthIndex % 12 - const daysInTargetMonth = new Date(targetYear, targetMonth + 1, 0).getDate() - const dom = Math.min(oldStart.getDate(), daysInTargetMonth) - newStartDate = new Date(targetYear, targetMonth, dom) - } else { - // Unsupported repeat type - this.deleteEvent(baseId) - return - } - - if (!newStartDate) { - // No continuation; deleting first removes series - this.deleteEvent(baseId) - return - } - - // Decrement repeatCount if limited - if (base.repeatCount !== 'unlimited') { - const rc = parseInt(base.repeatCount, 10) - if (!isNaN(rc)) { - const newRc = rc - 1 - if (newRc <= 0) { - // After removing first occurrence there are none left - this.deleteEvent(baseId) - return - } - base.repeatCount = String(newRc) - } - } - - const newEndDate = addDays(newStartDate, spanDays) - base.startDate = toLocalString(newStartDate, DEFAULT_TZ) - base.endDate = toLocalString(newEndDate, DEFAULT_TZ) - base.isSpanning = base.startDate < base.endDate - // Persist updated base event - this.events.set(base.id, base) - return base.id - }, - // Adjust start/end range of a base event (non-generated) and reindex occurrences setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto' } = {}) { const snapshot = this.events.get(eventId) diff --git a/src/utils/date.js b/src/utils/date.js index 0ec09dc..885460d 100644 --- a/src/utils/date.js +++ b/src/utils/date.js @@ -139,6 +139,68 @@ function getOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) { return null } +// Reverse lookup: given a recurrence index (0-based) return the occurrence start date string. +// Returns null if the index is out of range or the event is not repeating. +function getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) { + if (!event?.isRepeating || event.repeat !== 'weeks') return null + if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null + if (event.repeatCount !== 'unlimited' && occurrenceIndex >= event.repeatCount) return null + const pattern = event.repeatWeekdays || [] + if (!pattern.some(Boolean)) return null + const interval = event.repeatInterval || 1 + const baseStart = fromLocalString(event.startDate, timeZone) + if (occurrenceIndex === 0) return toLocalString(baseStart, timeZone) + const baseWeekMonday = getMondayOfISOWeek(baseStart, timeZone) + const baseDow = dateFns.getDay(baseStart) + // Sorted list of active weekday indices + const patternDays = [] + for (let d = 0; d < 7; d++) if (pattern[d]) patternDays.push(d) + // First (possibly partial) week: only pattern days >= baseDow and >= baseStart date + const firstWeekDates = [] + for (const d of patternDays) { + if (d < baseDow) continue + const date = dateFns.addDays(baseWeekMonday, d) + if (date < baseStart) continue + firstWeekDates.push(date) + } + const F = firstWeekDates.length + if (occurrenceIndex < F) { + return toLocalString(firstWeekDates[occurrenceIndex], timeZone) + } + const remaining = occurrenceIndex - F + const P = patternDays.length + if (P === 0) return null + // Determine aligned week group (k >= 1) in which the remaining-th occurrence lies + const k = Math.floor(remaining / P) + 1 // 1-based aligned week count after base week + const indexInWeek = remaining % P + const dow = patternDays[indexInWeek] + const occurrenceDate = dateFns.addDays(baseWeekMonday, k * interval * 7 + dow) + return toLocalString(occurrenceDate, timeZone) +} + +function getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) { + if (!event?.isRepeating || event.repeat !== 'months') return null + if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null + if (event.repeatCount !== 'unlimited' && occurrenceIndex >= event.repeatCount) return null + const interval = event.repeatInterval || 1 + const baseStart = fromLocalString(event.startDate, timeZone) + const targetMonthOffset = occurrenceIndex * interval + const monthDate = dateFns.addMonths(baseStart, targetMonthOffset) + // Adjust day for shorter months (clamp like forward logic) + const baseDay = dateFns.getDate(baseStart) + const daysInTargetMonth = dateFns.getDaysInMonth(monthDate) + const day = Math.min(baseDay, daysInTargetMonth) + const actual = makeTZDate(dateFns.getYear(monthDate), dateFns.getMonth(monthDate), day, timeZone) + return toLocalString(actual, timeZone) +} + +function getOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) { + if (!event?.isRepeating || event.repeat === 'none') return null + if (event.repeat === 'weeks') return getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone) + if (event.repeat === 'months') return getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone) + return null +} + function getVirtualOccurrenceEndDate(event, occurrenceStartDate, timeZone = DEFAULT_TZ) { const baseStart = fromLocalString(event.startDate, timeZone) const baseEnd = fromLocalString(event.endDate, timeZone) @@ -237,6 +299,7 @@ export { getMondayOfISOWeek, mondayIndex, getOccurrenceIndex, + getOccurrenceDate, getVirtualOccurrenceEndDate, // formatting & localization pad,