From 39d266e9b582d84628a79fac69b0bcfbe34ce5be Mon Sep 17 00:00:00 2001 From: Clas Wen Date: Thu, 25 Sep 2025 22:47:05 +0800 Subject: [PATCH] Fix recurring event duplication bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a recurring event has a modified occurrence, the calendar was showing both the original and modified versions. This happened because the code didn't properly handle RECURRENCE-ID exceptions in iCal data. Changes: - Modified extractScheduleEventsInRange() to collect exception events first and filter them from recurring patterns - Updated expandEventOccurrences() to skip dates that have exceptions - Applied same fix to extractScheduleEvents() for proper date range filtering Fixes issue where modified recurring events appeared twice in the UI. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/pages/calendar/Schedule.tsx | 84 +++++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 5 deletions(-) diff --git a/src/pages/calendar/Schedule.tsx b/src/pages/calendar/Schedule.tsx index 52d758d..e7fc3a3 100644 --- a/src/pages/calendar/Schedule.tsx +++ b/src/pages/calendar/Schedule.tsx @@ -28,7 +28,39 @@ const extractScheduleEventsInRange = ( rangeEnd: ICAL.Time, ): ScheduleEvent[] => { const vevents = icalComp.getAllSubcomponents("vevent") - return vevents + + // First, collect all exception events (events with RECURRENCE-ID) + const exceptions = new Map>() // uid -> set of recurrence-id strings + const exceptionEvents: ScheduleEvent[] = [] + + vevents.forEach((vevent) => { + const recurrenceId = vevent.getFirstPropertyValue("recurrence-id") + if (recurrenceId) { + const uid = vevent.getFirstPropertyValue("uid") + const event = new ICAL.Event(vevent) + + // Check if exception is in range + if (event.startDate.compare(rangeEnd) <= 0 && event.endDate.compare(rangeStart) >= 0) { + exceptionEvents.push({ + start: event.startDate.toJSDate(), + end: event.endDate.toJSDate(), + summary: event.summary, + description: event.description, + recurrenceId: recurrenceId.toString(), + }) + } + + // Track exception for filtering recurring events + if (!exceptions.has(uid)) { + exceptions.set(uid, new Set()) + } + exceptions.get(uid)!.add(recurrenceId.toString()) + } + }) + + // Process regular and recurring events + const regularEvents = vevents + .filter(vevent => !vevent.getFirstPropertyValue("recurrence-id")) .flatMap((vevent) => { const event = new ICAL.Event(vevent) if (!event.isRecurring()) { @@ -41,8 +73,11 @@ const extractScheduleEventsInRange = ( recurrenceId: undefined, }] } - return expandEventOccurrences(event, rangeStart, rangeEnd) + const eventExceptions = exceptions.get(event.uid) || new Set() + return expandEventOccurrences(event, rangeStart, rangeEnd, eventExceptions) }) + + return [...regularEvents, ...exceptionEvents] .sort((a, b) => b.start.getTime() - a.start.getTime()) } @@ -50,6 +85,7 @@ const expandEventOccurrences = ( event: ICAL.Event, rangeStart: ICAL.Time, rangeEnd: ICAL.Time, + exceptions?: Set, ): ScheduleEvent[] => { const occurrences: ScheduleEvent[] = [] const iterator = event.iterator() @@ -58,6 +94,10 @@ const expandEventOccurrences = ( if (!next) break if (next.compare(rangeEnd) > 0) break if (next.compare(rangeStart) < 0) continue + + // Skip this occurrence if it has an exception + if (exceptions && exceptions.has(next.toString())) continue + const details = event.getOccurrenceDetails(next) occurrences.push({ start: details.startDate.toJSDate(), @@ -73,8 +113,40 @@ const expandEventOccurrences = ( const extractScheduleEvents = (icalComp: ICAL.Component): ScheduleEvent[] => { const vevents = icalComp.getAllSubcomponents("vevent") const rangeEnd = ICAL.Time.fromDateString("2026-01-01") + const rangeStart = ICAL.Time.fromDateString("2020-01-01") - return vevents + // First, collect all exception events (events with RECURRENCE-ID) + const exceptions = new Map>() // uid -> set of recurrence-id strings + const exceptionEvents: ScheduleEvent[] = [] + + vevents.forEach((vevent) => { + const recurrenceId = vevent.getFirstPropertyValue("recurrence-id") + if (recurrenceId) { + const uid = vevent.getFirstPropertyValue("uid") + const event = new ICAL.Event(vevent) + + // Check if exception is in range + if (event.startDate.compare(rangeEnd) <= 0 && event.endDate.compare(rangeStart) >= 0) { + exceptionEvents.push({ + start: event.startDate.toJSDate(), + end: event.endDate.toJSDate(), + summary: event.summary, + description: event.description, + recurrenceId: recurrenceId.toString(), + }) + } + + // Track exception for filtering recurring events + if (!exceptions.has(uid)) { + exceptions.set(uid, new Set()) + } + exceptions.get(uid)!.add(recurrenceId.toString()) + } + }) + + // Process regular and recurring events + const regularEvents = vevents + .filter(vevent => !vevent.getFirstPropertyValue("recurrence-id")) .flatMap((vevent) => { const event = new ICAL.Event(vevent) if (event.iterator().complete) { @@ -84,11 +156,13 @@ const extractScheduleEvents = (icalComp: ICAL.Component): ScheduleEvent[] => { summary: event.summary, description: event.description, recurrenceId: event.uid || event.startDate.toString(), - }] } - return expandEventOccurrences(event, rangeEnd) + const eventExceptions = exceptions.get(event.uid) || new Set() + return expandEventOccurrences(event, rangeStart, rangeEnd, eventExceptions) }) + + return [...regularEvents, ...exceptionEvents] .sort((a, b) => b.start.getTime() - a.start.getTime()) }