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 {