From af47923478cf6ab0c17cd61c86e90bcb355cb884 Mon Sep 17 00:00:00 2001 From: lenhanphung Date: Tue, 14 Oct 2025 12:46:32 +0700 Subject: [PATCH] fix: recurring events edit all instances issues - Fix master event detection in getEvent() to find VEVENT without recurrence-id - Add removeOverrides parameter to updateSeries() to control override deletion - Fetch master event when editing 'all events' to preserve original start date - Combine master's date with form's time when updating all instances - Detect time changes separately from date changes to preserve optimistic updates - Only remove overrides when date/time/timezone/repeat rules change - Keep overrides when only properties (title, description, etc) change Fixes: 1. Old UI not cleared when editing all events 2. Start date not preserved when editing from different instance 3. Solo overrides incorrectly removed when only properties changed 4. Optimistic updates lost when editing recurring events --- src/features/Calendars/CalendarSlice.ts | 6 +- src/features/Events/EventApi.ts | 43 +++++- src/features/Events/EventUpdateModal.tsx | 160 ++++++++++++++++++++--- 3 files changed, 180 insertions(+), 29 deletions(-) diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index 3385d50..738bc13 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -295,9 +295,9 @@ export const updateEventInstanceAsync = createAsyncThunk< export const updateSeriesAsync = createAsyncThunk< void, - { cal: Calendars; event: CalendarEvent } ->("calendars/updateSeries", async ({ cal, event }) => { - await updateSeries(event, cal.ownerEmails?.[0]); + { cal: Calendars; event: CalendarEvent; removeOverrides?: boolean } +>("calendars/updateSeries", async ({ cal, event, removeOverrides = true }) => { + await updateSeries(event, cal.ownerEmails?.[0], removeOverrides); }); export const createCalendarAsync = createAsyncThunk< diff --git a/src/features/Events/EventApi.ts b/src/features/Events/EventApi.ts index df77f03..c28c3a6 100644 --- a/src/features/Events/EventApi.ts +++ b/src/features/Events/EventApi.ts @@ -15,8 +15,27 @@ export async function getEvent(event: CalendarEvent, isMaster?: boolean) { const eventical = ICAL.parse(eventData); + let targetVevent; + if (isMaster) { + // Find master VEVENT (the one without recurrence-id) + const vevents = eventical[2].filter( + ([name]: string[]) => name === "vevent" + ); + targetVevent = vevents.find( + ([, props]: [string, any[]]) => + !props.find(([k]: string[]) => k.toLowerCase() === "recurrence-id") + ); + if (!targetVevent) { + // Fallback to first VEVENT if no master found + targetVevent = eventical[2][1]; + } + } else { + // For non-master, use first VEVENT as before + targetVevent = eventical[2][1]; + } + const eventjson = parseCalendarEvent( - eventical[2][1][1], + targetVevent[1], event.color ?? "", event.calId, event.URL @@ -109,7 +128,8 @@ export const deleteEventInstance = async ( export const updateSeries = async ( event: CalendarEvent, - calOwnerEmail?: string + calOwnerEmail?: string, + removeOverrides: boolean = true ) => { const vevents = await getAllRecurrentEvent(event); const masterIndex = vevents.findIndex( @@ -119,7 +139,7 @@ export const updateSeries = async ( if (masterIndex === -1) { throw new Error("No master VEVENT found for this series"); } - const rrule = vevents[0][1].find(([k]: string[]) => k === "rrule"); + const rrule = vevents[masterIndex][1].find(([k]: string[]) => k === "rrule"); const tzid = event.timezone; @@ -128,12 +148,25 @@ export const updateSeries = async ( if (!newRrule) { updatedMaster[1].push(rrule); } - vevents[masterIndex] = updatedMaster; const timezoneData = TIMEZONES.zones[event.timezone]; const vtimezone = makeTimezone(timezoneData, event); - const newJCal = ["vcalendar", [], [...vevents, vtimezone.component.jCal]]; + let finalVevents; + if (removeOverrides) { + // When date/time/timezone/repeat rules changed, remove all override instances + finalVevents = [updatedMaster]; + } else { + // When only properties changed, keep override instances + vevents[masterIndex] = updatedMaster; + finalVevents = vevents; + } + + const newJCal = [ + "vcalendar", + [], + [...finalVevents, vtimezone.component.jCal], + ]; return api(`dav${event.URL}`, { method: "PUT", body: JSON.stringify(newJCal), diff --git a/src/features/Events/EventUpdateModal.tsx b/src/features/Events/EventUpdateModal.tsx index 924d717..92446de 100644 --- a/src/features/Events/EventUpdateModal.tsx +++ b/src/features/Events/EventUpdateModal.tsx @@ -317,18 +317,86 @@ function EventUpdateModal({ return; } + // Handle recurrence instances + const [baseUID, recurrenceId] = event.uid.split("/"); + + // Check if this is a recurring event + const isRecurringEvent = !!event.repetition?.freq; + + // When editing "all events" of a recurring series, fetch master event to get original start time + let masterEventData: CalendarEvent | null = null; + if (isRecurringEvent && typeOfAction === "all") { + try { + // Fetch master event using base UID (without recurrence-id) + const masterEventToFetch = { + ...event, + uid: baseUID, // Use base UID to get master event + }; + const masterEvent = await getEvent(masterEventToFetch, true); + masterEventData = masterEvent; + } catch (err: any) { + console.error("Failed to fetch master event:", err); + showErrorNotification("Failed to fetch event data. Please try again."); + return; + } + } + // Handle start and end dates based on all-day status let startDate: string; let endDate: string; - if (allday) { - // For all-day events, use date format (YYYY-MM-DD) - startDate = new Date(start).toISOString().split("T")[0]; - endDate = new Date(end).toISOString().split("T")[0]; + // For "all events" update, use master event's DATE but apply user's TIME from form + if (masterEventData && typeOfAction === "all") { + if (allday) { + // For all-day events, use date from master event + startDate = new Date(masterEventData.start).toISOString().split("T")[0]; + if (masterEventData.end) { + endDate = new Date(masterEventData.end).toISOString().split("T")[0]; + } else { + endDate = startDate; + } + } else { + // For timed events: combine master's date with form's time (both in event timezone) + // Format master's start in event timezone to get proper date + const masterFormattedStart = formatDateTimeInTimezone( + masterEventData.start, + timezone + ); + const masterFormattedEnd = masterEventData.end + ? formatDateTimeInTimezone(masterEventData.end, timezone) + : masterFormattedStart; + + // Extract date portion from master (YYYY-MM-DD) + const masterDatePart = masterFormattedStart.split("T")[0]; + const masterEndDatePart = masterFormattedEnd.split("T")[0]; + + // Extract time portion from form input (HH:MM or HH:MM:SS) + const formTimePart = start.includes("T") + ? start.split("T")[1] + : start.substring(11); + const formEndTimePart = end.includes("T") + ? end.split("T")[1] + : end.substring(11); + + // Combine master's date + form's time + const combinedStartStr = `${masterDatePart}T${formTimePart}`; + const combinedEndStr = `${masterEndDatePart}T${formEndTimePart}`; + + // Parse and convert to ISO (assume local timezone matches event timezone for form input) + startDate = new Date(combinedStartStr).toISOString(); + endDate = new Date(combinedEndStr).toISOString(); + } } else { - // For timed events, use full datetime - startDate = new Date(start).toISOString(); - endDate = new Date(end).toISOString(); + // For single events or "solo" edits, use the edited dates from form + if (allday) { + // For all-day events, use date format (YYYY-MM-DD) + startDate = new Date(start).toISOString().split("T")[0]; + endDate = new Date(end).toISOString().split("T")[0]; + } else { + // For timed events, use full datetime + startDate = new Date(start).toISOString(); + endDate = new Date(end).toISOString(); + } } const newEvent: CalendarEvent = { @@ -354,9 +422,6 @@ function EventUpdateModal({ x_openpass_videoconference: meetingLink || undefined, }; - // Handle recurrence instances - const [, recurrenceId] = event.uid.split("/"); - // Special case: When converting recurring event to non-recurring if ( recurrenceId && @@ -506,21 +571,71 @@ function EventUpdateModal({ const newTimezone = normalizeTimezone(timezone); const timezoneChanged = oldTimezone !== newTimezone; + // Check if TIME changed (compare time portion only, not date) + // We need to compare against master event's time, not current instance's time + const extractTimeFromForm = (localDateTimeStr: string) => { + // Form input is local datetime string like "2025-10-15T09:00" + // Extract just the time portion + if (!localDateTimeStr) return null; + const timePart = localDateTimeStr.includes("T") + ? localDateTimeStr.split("T")[1] + : localDateTimeStr.substring(11); + return timePart?.substring(0, 5); // HH:MM + }; + + const extractTimeFromISO = ( + isoString: string | undefined, + tz: string + ) => { + // Format ISO datetime in event timezone and extract time + if (!isoString) return null; + const formatted = formatDateTimeInTimezone(isoString, tz); + const timePart = formatted.includes("T") + ? formatted.split("T")[1] + : formatted.substring(11); + return timePart?.substring(0, 5); // HH:MM + }; + + const masterOldStart = masterEventData?.start || event.start; + const masterOldEnd = masterEventData?.end || event.end; + + // Extract time from form input (local time) + const formStartTime = extractTimeFromForm(start); + const formEndTime = extractTimeFromForm(end); + + // Extract time from master event (in event timezone) + const oldStartTime = extractTimeFromISO(masterOldStart, timezone); + const oldEndTime = extractTimeFromISO(masterOldEnd, timezone); + + const timeChanged = + formStartTime !== oldStartTime || formEndTime !== oldEndTime; + const repetitionRulesChanged = JSON.stringify(oldRepetition) !== JSON.stringify(newRepetition) || timezoneChanged || - event.allday !== allday; + event.allday !== allday || + timeChanged; if (repetitionRulesChanged) { - // Repetition rules changed - need server to recalculate instances - dispatch( + // Date/time or repetition rules changed - remove all overrides and refresh + + // STEP 1: Remove ALL old instances from UI (including solo overrides) + Object.keys(targetCalendar.events).forEach((eventId) => { + if (eventId.split("/")[0] === baseUID) { + dispatch(removeEvent({ calendarUid: calId, eventUid: eventId })); + } + }); + + // STEP 2: Update series on server with removeOverrides=true (await to ensure it completes) + await dispatch( updateSeriesAsync({ cal: targetCalendar, event: { ...newEvent, recurrenceId }, + removeOverrides: true, }) - ); + ).unwrap(); - // Fetch to get new instances with correct timing + // STEP 3: Fetch to get new instances with correct timing const calendarRange = getCalendarRange(new Date(start)); await refreshCalendars( dispatch, @@ -531,7 +646,9 @@ function EventUpdateModal({ // Clear cache after reload dispatch(clearFetchCache(calId)); } else { - // Only properties changed - use optimistic update + // Only properties changed - use optimistic update and keep overrides + + // Store old instances for rollback const oldInstances: Record = {}; Object.keys(targetCalendar.events) .filter((eventId) => eventId.split("/")[0] === baseUID) @@ -539,6 +656,7 @@ function EventUpdateModal({ oldInstances[eventId] = { ...targetCalendar.events[eventId] }; }); + // Optimistic update: Apply new properties to all instances immediately Object.keys(oldInstances).forEach((eventId) => { const instance = oldInstances[eventId]; @@ -550,11 +668,8 @@ function EventUpdateModal({ title: newEvent.title, description: newEvent.description, location: newEvent.location, - allday: newEvent.allday, - repetition: newEvent.repetition, class: newEvent.class, transp: newEvent.transp, - timezone: newEvent.timezone, attendee: newEvent.attendee, alarm: newEvent.alarm, x_openpass_videoconference: @@ -564,10 +679,12 @@ function EventUpdateModal({ ); }); + // Update server in background with removeOverrides=false dispatch( updateSeriesAsync({ cal: targetCalendar, event: { ...newEvent, recurrenceId }, + removeOverrides: false, }) ) .unwrap() @@ -575,7 +692,8 @@ function EventUpdateModal({ // Clear cache to ensure navigation shows updated data dispatch(clearFetchCache(calId)); }) - .catch((error) => { + .catch((error: any) => { + // Rollback: Restore old instances on error Object.values(oldInstances).forEach((oldEvent) => { dispatch(updateEventLocal({ calId, event: oldEvent })); }); @@ -606,7 +724,7 @@ function EventUpdateModal({ // Clear cache to ensure navigation to other weeks works dispatch(clearFetchCache(calId)); }) - .catch((error) => { + .catch((error: any) => { showErrorNotification("Failed to create recurring event."); }); } else {