diff --git a/__test__/features/Calendars/CalendarSlice.test.tsx b/__test__/features/Calendars/CalendarSlice.test.tsx
index 6698a04..6ae6171 100644
--- a/__test__/features/Calendars/CalendarSlice.test.tsx
+++ b/__test__/features/Calendars/CalendarSlice.test.tsx
@@ -512,5 +512,72 @@ describe("CalendarSlice", () => {
);
expect(state.list.c1.events.e1.uid).toBe("e1");
});
+
+ it("getEventAsync.fulfilled doesnt create new events when there are already event with base UID", () => {
+ const baseUid = "recurring-event-base";
+ const existingEvent = {
+ uid: `${baseUid}/20240115`,
+ title: "Existing Recurring Event",
+ recurrenceId: null,
+ } as unknown as CalendarEvent;
+
+ const newEventInstance = {
+ uid: baseUid,
+ title: "Fetched Master Event",
+ recurrenceId: "20240115",
+ } as CalendarEvent;
+
+ const stateWithEvent = {
+ ...initialState,
+ list: {
+ c1: {
+ id: "c1",
+ events: { [`${baseUid}/20240115`]: existingEvent },
+ } as unknown as Calendar,
+ },
+ };
+
+ const payload = { calId: "c1", event: newEventInstance };
+ const state = reducer(
+ stateWithEvent,
+ getEventAsync.fulfilled(payload, "req", newEventInstance)
+ );
+
+ // Should still only have the base event, not the instance
+ expect(Object.keys(state.list.c1.events)).toHaveLength(1);
+ expect(state.list.c1.events[`${baseUid}/20240115`]).toBeDefined();
+ expect(state.list.c1.events[baseUid]).toBeUndefined();
+ });
+
+ it("getEventAsync.fulfilled create new event when there isn't any event with base UID", () => {
+ const eventUid = "new-event-uid";
+ const newEvent = {
+ uid: eventUid,
+ title: "New Event",
+ recurrenceId: null,
+ } as unknown as CalendarEvent;
+
+ const stateWithoutEvent = {
+ ...initialState,
+ list: {
+ c1: {
+ id: "c1",
+ events: {},
+ } as unknown as Calendar,
+ },
+ };
+
+ const payload = { calId: "c1", event: newEvent };
+ const state = reducer(
+ stateWithoutEvent,
+ getEventAsync.fulfilled(payload, "req", newEvent)
+ );
+
+ // Should create the new event
+ expect(Object.keys(state.list.c1.events)).toHaveLength(1);
+ expect(state.list.c1.events[eventUid]).toBeDefined();
+ expect(state.list.c1.events[eventUid].uid).toBe(eventUid);
+ expect(state.list.c1.events[eventUid].title).toBe("New Event");
+ });
});
});
diff --git a/__test__/features/Events/EventRepetition.test.tsx b/__test__/features/Events/EventRepetition.test.tsx
index d04f73b..727cefc 100644
--- a/__test__/features/Events/EventRepetition.test.tsx
+++ b/__test__/features/Events/EventRepetition.test.tsx
@@ -699,7 +699,6 @@ describe("Edit Recurring Event in Full Display", () => {
);
fireEvent.click(screen.getByRole("button", { name: "common.moreOptions" }));
- preview.debug();
expect(screen.getByText("event.repeat.repeatEvery")).toBeInTheDocument();
expect(screen.getByText("event.repeat.end.label")).toBeInTheDocument();
@@ -1191,7 +1190,6 @@ describe("Event URL handling for recurring events", () => {
await new Promise((resolve) => setTimeout(resolve, 600));
});
- preview.debug();
await waitFor(() => {
expect(moveEventSpy).toHaveBeenCalled();
});
diff --git a/__test__/features/Events/EventUpdateModal.recurring.test.tsx b/__test__/features/Events/EventUpdateModal.recurring.test.tsx
new file mode 100644
index 0000000..a33ef31
--- /dev/null
+++ b/__test__/features/Events/EventUpdateModal.recurring.test.tsx
@@ -0,0 +1,522 @@
+import * as CalendarApi from "@/features/Calendars/CalendarApi";
+import * as eventThunks from "@/features/Calendars/services";
+import * as EventApi from "@/features/Events/EventApi";
+import { CalendarEvent } from "@/features/Events/EventsTypes";
+import EventUpdateModal from "@/features/Events/EventUpdateModal";
+import { act, fireEvent, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderWithProviders } from "../../utils/Renderwithproviders";
+
+jest.mock("@/features/Events/EventApi");
+jest.mock("@/features/Calendars/CalendarApi");
+
+describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => {
+ const mockOnClose = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.restoreAllMocks();
+ sessionStorage.clear();
+ });
+
+ const baseUID = "recurring-event-base";
+ const calId = "667037022b752d0026472254/cal1";
+
+ const preloadedState = {
+ user: {
+ userData: {
+ sub: "test",
+ email: "test@test.com",
+ sid: "test-sid",
+ openpaasId: "667037022b752d0026472254",
+ },
+ organiserData: {
+ cn: "test",
+ cal_address: "test@test.com",
+ },
+ },
+ calendars: {
+ list: {
+ [calId]: {
+ id: calId,
+ name: "Test Calendar",
+ color: "#FF0000",
+ events: {},
+ ownerEmails: ["test@test.com"],
+ },
+ },
+ pending: false,
+ },
+ };
+
+ describe("Master Event Display", () => {
+ it("should fetch and display master event when editing 'all events' of a recurring series", async () => {
+ // Given a recurring series with modified instances
+ const masterEvent = {
+ uid: baseUID,
+ title: "Master Event Title",
+ description: "Master Description",
+ calId,
+ start: "2025-01-15T10:00:00.000Z",
+ end: "2025-01-15T11:00:00.000Z",
+ repetition: { freq: "daily", interval: 1 },
+ allday: false,
+ timezone: "America/New_York",
+ organizer: { cn: "test", cal_address: "test@test.com" },
+ attendee: [{ cn: "test", cal_address: "test@test.com" }],
+ URL: `/calendars/${calId}/${baseUID}.ics`,
+ } as CalendarEvent;
+
+ // Instance that was moved to different time
+ const modifiedInstance = {
+ ...masterEvent,
+ uid: `${baseUID}/20250116`,
+ title: "Modified Instance Title", // Different title
+ start: "2025-01-16T14:00:00.000Z", // Different time (2PM instead of 10AM)
+ end: "2025-01-16T15:00:00.000Z",
+ };
+
+ const stateWithRecurringEvent = {
+ ...preloadedState,
+ calendars: {
+ ...preloadedState.calendars,
+ list: {
+ [calId]: {
+ ...preloadedState.calendars.list[calId],
+ events: {
+ [baseUID]: masterEvent,
+ [`${baseUID}/20250116`]: modifiedInstance,
+ },
+ },
+ },
+ },
+ };
+
+ // Mock getEvent to return master event
+ const mockGetEvent = jest
+ .spyOn(EventApi, "getEvent")
+ .mockResolvedValue(masterEvent);
+
+ renderWithProviders(
+ ,
+ stateWithRecurringEvent
+ );
+
+ // Wait for master event to be fetched
+ await waitFor(() => {
+ expect(mockGetEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ uid: baseUID, // Should fetch base UID, not instance
+ }),
+ true
+ );
+ });
+
+ // Should display master event data, NOT modified instance data
+ await waitFor(() => {
+ const titleInput = screen.getByDisplayValue("Master Event Title");
+ expect(titleInput).toBeInTheDocument();
+ });
+
+ // Should NOT show modified instance title
+ expect(
+ screen.queryByDisplayValue("Modified Instance Title")
+ ).not.toBeInTheDocument();
+
+ // Verify timezone dropdown shows master timezone
+ await waitFor(() => {
+ expect(screen.getByDisplayValue(/New York/i)).toBeDefined();
+ });
+ });
+
+ it("should use master event directly if clicked event is already the master", async () => {
+ const masterEvent = {
+ uid: baseUID, // No recurrence-id
+ title: "Master Event",
+ calId,
+ start: "2025-01-15T10:00:00.000Z",
+ end: "2025-01-15T11:00:00.000Z",
+ repetition: { freq: "weekly", interval: 1 },
+ allday: false,
+ organizer: { cn: "test", cal_address: "test@test.com" },
+ URL: `/calendars/${calId}/${baseUID}.ics`,
+ };
+
+ const stateWithMaster = {
+ ...preloadedState,
+ calendars: {
+ ...preloadedState.calendars,
+ list: {
+ [calId]: {
+ ...preloadedState.calendars.list[calId],
+ events: {
+ [baseUID]: masterEvent,
+ },
+ },
+ },
+ },
+ };
+
+ const mockGetEvent = jest.spyOn(EventApi, "getEvent");
+
+ renderWithProviders(
+ ,
+ stateWithMaster
+ );
+
+ await waitFor(() => {
+ expect(screen.getByDisplayValue("Master Event")).toBeInTheDocument();
+ });
+
+ // Should NOT fetch from API since we already have master
+ expect(mockGetEvent).not.toHaveBeenCalled();
+ });
+
+ it("should fallback to instance data if master event fetch fails", async () => {
+ const instance = {
+ uid: `${baseUID}/20250115`,
+ title: "Instance Event",
+ calId,
+ start: "2025-01-15T10:00:00.000Z",
+ end: "2025-01-15T11:00:00.000Z",
+ repetition: { freq: "daily", interval: 1 },
+ allday: false,
+ organizer: { cn: "test", cal_address: "test@test.com" },
+ URL: `/calendars/${calId}/${baseUID}.ics`,
+ } as CalendarEvent;
+
+ const stateWithInstance = {
+ ...preloadedState,
+ calendars: {
+ ...preloadedState.calendars,
+ list: {
+ [calId]: {
+ ...preloadedState.calendars.list[calId],
+ events: {
+ [`${baseUID}/20250115`]: instance,
+ },
+ },
+ },
+ },
+ };
+
+ // Mock getEvent to fail
+ jest
+ .spyOn(EventApi, "getEvent")
+ .mockRejectedValue(new Error("Network error"));
+
+ const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
+
+ renderWithProviders(
+ ,
+ stateWithInstance
+ );
+
+ // Should still display the instance data as fallback
+ await waitFor(() => {
+ expect(screen.getByDisplayValue("Instance Event")).toBeInTheDocument();
+ });
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ "Failed to fetch master event:",
+ expect.any(Error)
+ );
+
+ consoleErrorSpy.mockRestore();
+ });
+ });
+
+ describe("Update All Events - Using Base UID", () => {
+ it("should use base UID (not instance UID) when updating series with property changes", async () => {
+ const masterEvent = {
+ uid: baseUID,
+ title: "Weekly Meeting",
+ description: "Original description",
+ calId,
+ start: "2025-01-15T10:00:00.000Z",
+ end: "2025-01-15T11:00:00.000Z",
+ repetition: { freq: "weekly", interval: 1 },
+ allday: false,
+ organizer: { cn: "test", cal_address: "test@test.com" },
+ URL: `/calendars/${calId}/${baseUID}.ics`,
+ } as CalendarEvent;
+
+ const instance1 = {
+ ...masterEvent,
+ uid: `${baseUID}/20250115`,
+ };
+
+ const instance2 = {
+ ...masterEvent,
+ uid: `${baseUID}/20250122`,
+ start: "2025-01-22T10:00:00.000Z",
+ end: "2025-01-22T11:00:00.000Z",
+ };
+
+ const stateWithSeries = {
+ ...preloadedState,
+ calendars: {
+ ...preloadedState.calendars,
+ list: {
+ [calId]: {
+ ...preloadedState.calendars.list[calId],
+ events: {
+ [baseUID]: masterEvent,
+ [`${baseUID}/20250115`]: instance1,
+ [`${baseUID}/20250122`]: instance2,
+ },
+ },
+ },
+ },
+ };
+
+ // Mock getEvent to return master
+ jest.spyOn(EventApi, "getEvent").mockResolvedValue(masterEvent);
+ const updateSeriesAsyncSpy = jest.spyOn(eventThunks, "updateSeriesAsync");
+
+ renderWithProviders(
+ ,
+ stateWithSeries
+ );
+
+ await waitFor(() => {
+ expect(screen.getByDisplayValue("Weekly Meeting")).toBeInTheDocument();
+ });
+
+ // Change only the title (property change, not recurrence rules)
+ const titleInput = screen.getByDisplayValue("Weekly Meeting");
+ fireEvent.change(titleInput, { target: { value: "Updated Meeting" } });
+
+ const saveButton = screen.getByRole("button", { name: "actions.save" });
+
+ await act(async () => {
+ fireEvent.click(saveButton);
+ });
+
+ // Verify updateSeriesAsync was dispatched with base UID
+ await waitFor(() => {
+ expect(updateSeriesAsyncSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({
+ uid: "recurring-event-base",
+ recurrenceId: undefined,
+ }),
+ removeOverrides: false,
+ })
+ );
+ });
+ });
+
+ it("should use base UID when updating series with recurrence rule changes", async () => {
+ const masterEvent = {
+ uid: baseUID,
+ title: "Daily Standup",
+ calId,
+ start: "2025-01-15T09:00:00.000Z",
+ end: "2025-01-15T09:30:00.000Z",
+ repetition: { freq: "daily", interval: 1 }, // Daily
+ allday: false,
+ timezone: "America/New_York",
+ organizer: { cn: "test", cal_address: "test@test.com" },
+ URL: `/calendars/${calId}/${baseUID}.ics`,
+ } as CalendarEvent;
+
+ const instance1 = {
+ ...masterEvent,
+ uid: `${baseUID}/20250115`,
+ };
+
+ const instance2 = {
+ ...masterEvent,
+ uid: `${baseUID}/20250116`,
+ start: "2025-01-16T09:00:00.000Z",
+ end: "2025-01-16T09:30:00.000Z",
+ };
+
+ const stateWithSeries = {
+ ...preloadedState,
+ calendars: {
+ ...preloadedState.calendars,
+ list: {
+ [calId]: {
+ ...preloadedState.calendars.list[calId],
+ events: {
+ [baseUID]: masterEvent,
+ [`${baseUID}/20250115`]: instance1,
+ [`${baseUID}/20250116`]: instance2,
+ },
+ },
+ },
+ },
+ };
+
+ jest.spyOn(EventApi, "getEvent").mockResolvedValue(masterEvent);
+ jest.spyOn(CalendarApi, "getCalendar").mockResolvedValue({
+ _embedded: { "dav:item": [] },
+ } as any);
+
+ const { store } = renderWithProviders(
+ ,
+ stateWithSeries
+ );
+
+ await waitFor(() => {
+ expect(screen.getByDisplayValue("Daily Standup")).toBeInTheDocument();
+ });
+
+ // Click More Options to access repeat settings
+ const moreOptionsButton = screen.getByText("common.moreOptions");
+ fireEvent.click(moreOptionsButton);
+
+ await waitFor(() => {
+ const repeatCheckbox = screen.getByLabelText("event.form.repeat");
+ expect(repeatCheckbox).toBeInTheDocument();
+ expect(repeatCheckbox).toBeChecked();
+ });
+
+ // Change from daily to weekly
+ const frequencySelect = screen.getByText("event.repeat.frequency.days");
+ fireEvent.mouseDown(frequencySelect);
+ const weeklyOption = screen.getByRole("option", {
+ name: "event.repeat.frequency.weeks",
+ });
+ fireEvent.click(weeklyOption);
+
+ const saveButton = screen.getByRole("button", { name: "actions.save" });
+
+ await act(async () => {
+ fireEvent.click(saveButton);
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ });
+
+ // Verify all old instances were removed from store
+ await waitFor(() => {
+ const state = store.getState();
+ const calendar = state.calendars.list[calId];
+
+ expect(calendar.events[baseUID]).toBeUndefined();
+ expect(calendar.events[`${baseUID}/20250115`]).toBeUndefined();
+ expect(calendar.events[`${baseUID}/20250116`]).toBeUndefined();
+ });
+ });
+ });
+
+ describe("Master Event Date Preservation", () => {
+ it("should preserve master event date when updating time in 'all events' mode", async () => {
+ // Master event starts on Jan 15 at 10:00 AM
+ const masterEvent = {
+ uid: baseUID,
+ title: "Daily Standup",
+ calId,
+ start: "2025-01-15T10:00:00.000Z",
+ end: "2025-01-15T10:30:00.000Z",
+ repetition: { freq: "daily", interval: 1 },
+ allday: false,
+ timezone: "UTC",
+ organizer: { cn: "test", cal_address: "test@test.com" },
+ URL: `/calendars/${calId}/${baseUID}.ics`,
+ } as CalendarEvent;
+
+ // Instance on Jan 17 (different date)
+ const instance = {
+ ...masterEvent,
+ uid: `${baseUID}/20250117`,
+ start: "2025-01-17T10:00:00.000Z",
+ end: "2025-01-17T10:30:00.000Z",
+ };
+
+ const stateWithSeries = {
+ ...preloadedState,
+ calendars: {
+ ...preloadedState.calendars,
+ list: {
+ [calId]: {
+ ...preloadedState.calendars.list[calId],
+ events: {
+ [baseUID]: masterEvent,
+ [`${baseUID}/20250117`]: instance,
+ },
+ },
+ },
+ },
+ };
+
+ jest.spyOn(EventApi, "getEvent").mockResolvedValue(masterEvent);
+ jest
+ .spyOn(EventApi, "putEvent")
+ .mockResolvedValue({ status: 201 } as any);
+
+ const updateSeriesAsyncSpy = jest.spyOn(eventThunks, "updateSeriesAsync");
+
+ renderWithProviders(
+ ,
+ stateWithSeries
+ );
+
+ await waitFor(() => {
+ expect(screen.getByDisplayValue("Daily Standup")).toBeInTheDocument();
+ });
+
+ // Change time to 2:00 PM (14:00)
+ const input = screen.getByTestId("start-time-input");
+ await userEvent.click(input);
+ await userEvent.clear(input);
+ await userEvent.type(input, "14:00{enter}");
+
+ const saveButton = screen.getByRole("button", { name: "actions.save" });
+
+ await act(async () => {
+ fireEvent.click(saveButton);
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ });
+
+ // Verify the API was called with master date (Jan 15) + new time (14:00)
+ await waitFor(() => {
+ expect(updateSeriesAsyncSpy).toHaveBeenCalled();
+ const callArgs = updateSeriesAsyncSpy.mock.calls[0][0];
+ const updatedEvent = callArgs.event;
+
+ // Should preserve Jan 15 date (master), not Jan 17 (clicked instance)
+ expect(updatedEvent.start).toContain("2025-01-15");
+ // But with new time 14:00 (in UTC, this is 14:00)
+ expect(updatedEvent.start).toContain("14:00");
+ });
+ });
+ });
+});
diff --git a/__test__/features/Events/EventUpdateModal.test.tsx b/__test__/features/Events/EventUpdateModal.test.tsx
index a11d738..502fdfc 100644
--- a/__test__/features/Events/EventUpdateModal.test.tsx
+++ b/__test__/features/Events/EventUpdateModal.test.tsx
@@ -1,6 +1,6 @@
-import * as eventUtils from "@/components/Event/utils/eventUtils";
import * as CalendarApi from "@/features/Calendars/CalendarApi";
import * as EventApi from "@/features/Events/EventApi";
+import { CalendarEvent } from "@/features/Events/EventsTypes";
import EventUpdateModal from "@/features/Events/EventUpdateModal";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "../../utils/Renderwithproviders";
@@ -257,6 +257,9 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
status: 201,
url: `/calendars/${calId}/new-event.ics`,
} as any);
+ const mockGetMasterEvent = jest
+ .spyOn(EventApi, "getEvent")
+ .mockResolvedValue(masterEvent);
jest.spyOn(CalendarApi, "getCalendar").mockResolvedValue({
_embedded: {
"dav:item": [],
@@ -274,28 +277,37 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
stateWithRecurringEvent
);
- // Wait for component to load
+ // Wait for master event to be fetched AND form to be initialized
await waitFor(() => {
- expect(screen.getByDisplayValue("Recurring Meeting")).toBeInTheDocument();
+ expect(mockGetMasterEvent).toHaveBeenCalled();
});
- // Uncheck repeat checkbox
- const repeatCheckbox = screen.getByLabelText("event.form.repeat");
- expect(repeatCheckbox).toBeChecked();
+ // Wait for the repeat checkbox to be checked (indicating form is initialized)
+ const repeatCheckbox = await waitFor(() => {
+ const checkbox = screen.getByLabelText("event.form.repeat");
+ expect(checkbox).toBeChecked();
+ return checkbox;
+ });
+ // Now uncheck repeat checkbox
await act(async () => {
fireEvent.click(repeatCheckbox);
});
- expect(repeatCheckbox).not.toBeChecked();
+ // Wait for checkbox to be unchecked
+ await waitFor(() => {
+ expect(repeatCheckbox).not.toBeChecked();
+ });
// Click Save button
const saveButton = screen.getByRole("button", { name: "actions.save" });
await act(async () => {
fireEvent.click(saveButton);
- // Wait for the 500ms delay in the code
- await new Promise((resolve) => setTimeout(resolve, 600));
+ });
+
+ await waitFor(() => {
+ expect(mockPutEvent).toHaveBeenCalled();
});
// Verify API calls - should delete all instances
@@ -346,7 +358,7 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
organizer: { cn: "test", cal_address: "test@test.com" },
attendee: [{ cn: "test", cal_address: "test@test.com" }],
URL: `/calendars/${calId}/${baseUID}.ics`,
- };
+ } as CalendarEvent;
const instance1 = {
...masterEvent,
@@ -369,17 +381,17 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
},
};
- // Mock deleteEvent to fail
- jest
+ // Mock deleteEvent to fail with a non-404 error
+ const mockDeleteEvent = jest
.spyOn(EventApi, "deleteEvent")
.mockRejectedValue(new Error("Network error"));
const mockPutEvent = jest
.spyOn(EventApi, "putEvent")
.mockResolvedValue({ status: 201 } as any);
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
-
- // Mock refreshCalendars
- jest.spyOn(eventUtils, "refreshCalendars").mockResolvedValue(undefined);
+ const mockGetMasterEvent = jest
+ .spyOn(EventApi, "getEvent")
+ .mockResolvedValue(masterEvent);
const { store } = renderWithProviders(
{
stateWithRecurringEvent
);
+ // Wait for master event to be fetched
+ await waitFor(() => {
+ expect(mockGetMasterEvent).toHaveBeenCalled();
+ });
+
// Wait for component to load
await waitFor(() => {
expect(screen.getByDisplayValue("Recurring Meeting")).toBeInTheDocument();
});
- // Uncheck repeat checkbox and save
- const repeatCheckbox = screen.getByLabelText("event.form.repeat");
+ // Wait for repeat checkbox to be checked
+ const repeatCheckbox = await waitFor(() => {
+ const checkbox = screen.getByLabelText("event.form.repeat");
+ expect(checkbox).toBeChecked();
+ return checkbox;
+ });
+ // Uncheck repeat checkbox and save
await act(async () => {
fireEvent.click(repeatCheckbox);
});
+ // Wait for checkbox to be unchecked
+ await waitFor(() => {
+ expect(repeatCheckbox).not.toBeChecked();
+ });
+
const saveButton = screen.getByRole("button", { name: "actions.save" });
await act(async () => {
fireEvent.click(saveButton);
- await new Promise((resolve) => setTimeout(resolve, 600));
});
+ // Wait for delete to be attempted
+ await waitFor(
+ () => {
+ expect(mockDeleteEvent).toHaveBeenCalled();
+ },
+ { timeout: 3000 }
+ );
+
// Verify error was logged for failed deletion
await waitFor(
() => {
@@ -523,7 +557,6 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
await act(async () => {
fireEvent.click(saveButton);
- await new Promise((resolve) => setTimeout(resolve, 600));
});
// Verify onCloseAll was called to close both preview and update modals
diff --git a/__test__/features/Events/eventUtils.test.ts b/__test__/features/Events/eventUtils.test.ts
index 1240e88..b3c5627 100644
--- a/__test__/features/Events/eventUtils.test.ts
+++ b/__test__/features/Events/eventUtils.test.ts
@@ -55,7 +55,7 @@ describe("parseCalendarEvent", () => {
expect(result.transp).toBe("OPAQUE");
expect(result.class).toBe("PUBLIC");
expect(result.x_openpass_videoconference).toBe("https://meet.link");
- expect(result.timezone).toBe("Etc/UTC");
+ expect(result.timezone).not.toBeDefined();
expect(result.organizer).toEqual({
cn: "Alice",
@@ -117,7 +117,7 @@ describe("parseCalendarEvent", () => {
expect(result.transp).toBe("OPAQUE");
expect(result.class).toBe("PUBLIC");
expect(result.x_openpass_videoconference).toBe("https://meet.link");
- expect(result.timezone).toBe("Etc/UTC");
+ expect(result.timezone).not.toBeDefined();
expect(result.organizer).toEqual({
cn: "Alice",
@@ -210,7 +210,6 @@ describe("parseCalendarEvent", () => {
"/calendars/test.ics"
);
expect(result.end).toBeDefined();
- expect(result.timezone).toBeDefined();
const endDate = new Date(result.end ?? "");
const startDate = new Date(result.start);
expect(endDate.getTime() - startDate.getTime()).toBe(60 * 60 * 1000);
@@ -229,7 +228,6 @@ describe("parseCalendarEvent", () => {
"/calendars/test.ics"
);
expect(result.end).toBeDefined();
- expect(result.timezone).toBeDefined();
const endDate = new Date(result.end ?? "");
const startDate = new Date(result.start);
expect(endDate.getTime() - startDate.getTime()).toBe(30 * 60 * 1000);
@@ -332,7 +330,6 @@ describe("parseCalendarEvent", () => {
expect(result.start).toBe("2025-07-18T09:00:00Z");
expect(result.end).toBe("2025-07-18T10:00:00Z");
- expect(result.timezone).toBe("Etc/UTC");
});
it("preserves all-day event dates without conversion", () => {
@@ -356,15 +353,6 @@ describe("parseCalendarEvent", () => {
});
describe("calendarEventToJCal", () => {
- beforeAll(() => {
- jest.mock("ical.js", () => ({
- Timezone: jest.fn().mockImplementation(({ component, tzid }) => ({
- component: {
- jCal: [`vtimezone`, [], [["tzid", {}, "text", tzid]]],
- },
- })),
- }));
- });
it("should convert a CalendarEvent to JCal format", () => {
const mockEvent = {
uid: "event-123",
@@ -1165,8 +1153,7 @@ describe("detectRecurringEventChanges", () => {
end: "2025-10-15T10:00",
},
null,
- mockResolveTimezone,
- mockFormatDateTime
+ mockResolveTimezone
);
expect(result.timeChanged).toBe(true);
@@ -1192,8 +1179,7 @@ describe("detectRecurringEventChanges", () => {
end: "2025-10-15T08:00",
},
null,
- mockResolveTimezone,
- mockFormatDateTime
+ mockResolveTimezone
);
expect(result.timeChanged).toBe(false);
@@ -1218,8 +1204,7 @@ describe("detectRecurringEventChanges", () => {
end: "2025-10-15T08:00",
},
null,
- mockResolveTimezone,
- mockFormatDateTime
+ mockResolveTimezone
);
expect(result.timezoneChanged).toBe(true);
@@ -1249,8 +1234,7 @@ describe("detectRecurringEventChanges", () => {
end: "2025-10-15T08:00",
},
null,
- resolveWithAlias,
- mockFormatDateTime
+ resolveWithAlias
);
expect(result.timezoneChanged).toBe(false);
@@ -1274,8 +1258,7 @@ describe("detectRecurringEventChanges", () => {
end: "2025-10-15T08:00",
},
null,
- mockResolveTimezone,
- mockFormatDateTime
+ mockResolveTimezone
);
expect(result.repetitionRulesChanged).toBe(true);
@@ -1299,8 +1282,7 @@ describe("detectRecurringEventChanges", () => {
end: "2025-10-15T08:00",
},
null,
- mockResolveTimezone,
- mockFormatDateTime
+ mockResolveTimezone
);
expect(result.repetitionRulesChanged).toBe(true);
@@ -1332,8 +1314,7 @@ describe("detectRecurringEventChanges", () => {
end: "2025-10-15T08:00",
},
null,
- mockResolveTimezone,
- mockFormatDateTime
+ mockResolveTimezone
);
expect(result.repetitionRulesChanged).toBe(true);
@@ -1357,8 +1338,7 @@ describe("detectRecurringEventChanges", () => {
end: "2025-10-15T10:00",
},
null,
- mockResolveTimezone,
- mockFormatDateTime
+ mockResolveTimezone
);
expect(result.timeChanged).toBe(true);
diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts
index 403bd1c..bb75005 100644
--- a/src/features/Calendars/CalendarSlice.ts
+++ b/src/features/Calendars/CalendarSlice.ts
@@ -193,9 +193,30 @@ const CalendarSlice = createSlice({
events: {},
} as Calendar;
}
-
- state.list[action.payload.calId].events[action.payload.event.uid] =
- action.payload.event;
+ if (
+ Object.keys(state.list[action.payload.calId].events).find(
+ (eventId: string) => {
+ const eventIdBase = extractEventBaseUuid(eventId);
+ return eventIdBase === action.payload.event.uid;
+ }
+ )
+ ) {
+ Object.keys(state.list[action.payload.calId].events)
+ .filter((eventKey) => {
+ const baseUid = extractEventBaseUuid(eventKey);
+ return baseUid === action.payload.event.uid;
+ })
+ .forEach((eventKey) => {
+ state.list[action.payload.calId].events[eventKey] = {
+ ...state.list[action.payload.calId].events[eventKey],
+ repetition: action.payload.event.repetition,
+ timezone: action.payload.event.timezone,
+ };
+ });
+ } else {
+ state.list[action.payload.calId].events[action.payload.event.uid] =
+ action.payload.event;
+ }
}
)
.addCase(moveEventAsync.fulfilled, (state) => {
diff --git a/src/features/Events/EventApi.ts b/src/features/Events/EventApi.ts
index e3cd26c..c74eb09 100644
--- a/src/features/Events/EventApi.ts
+++ b/src/features/Events/EventApi.ts
@@ -1,6 +1,5 @@
import { api } from "@/utils/apiUtils";
-import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
-import { convertEventDateTimeToISO, resolveTimezoneId } from "@/utils/timezone";
+import { resolveTimezoneId, convertEventDateTimeToISO } from "@/utils/timezone";
import { TIMEZONES } from "@/utils/timezone-data";
import ICAL from "ical.js";
import { CalDavItem } from "../Calendars/api/types";
@@ -203,17 +202,72 @@ export const deleteEventInstance = async (
event: CalendarEvent,
calOwnerEmail?: string
) => {
- const seriesEvent = await getEvent(
- {
- ...event,
- uid: extractEventBaseUuid(event.uid),
- },
- true
- );
- seriesEvent.exdates = [...(seriesEvent.exdates || []), event.start];
- delete seriesEvent.recurrenceId;
+ // Get all VEVENTs (master + overrides) from the series
+ const vevents = await getAllRecurrentEvent(event);
- return putEvent(seriesEvent, calOwnerEmail);
+ // Find the master VEVENT
+ const masterIndex = vevents.findIndex(
+ ([, props]: [string, any[]]) =>
+ !props.find(([k]: string[]) => k.toLowerCase() === "recurrence-id")
+ );
+
+ if (masterIndex === -1) {
+ throw new Error("No master VEVENT found for this series");
+ }
+
+ const exdateValue = event.recurrenceId || event.start;
+ const seriesEvent = parseCalendarEvent(vevents[masterIndex][1], {}, "", "");
+ const masterProps = vevents[masterIndex][1];
+
+ // Check if this date is already in EXDATE (avoid duplicates)
+ const normalizeRecurrenceId = (id: string) => (id ?? "").replace(/Z$/, "");
+ const isDuplicate = masterProps.some((prop: any[]) => {
+ if (prop[0].toLowerCase() === "exdate" && prop[3]) {
+ return (
+ normalizeRecurrenceId(prop[3]) === normalizeRecurrenceId(exdateValue)
+ );
+ }
+ return false;
+ });
+
+ if (!isDuplicate) {
+ // Add new EXDATE property as a separate entry
+ const valueType = seriesEvent.allday ? "date" : "date-time";
+ masterProps.push(["exdate", {}, valueType, exdateValue]);
+ }
+
+ // Update the master VEVENT with the new properties
+ vevents[masterIndex][1] = masterProps;
+
+ // Remove the override instance if it exists (in case it was an override being deleted)
+ const filteredVevents = vevents.filter(([, props]: [string, any[]]) => {
+ const recurrenceIdProp = props.find(
+ ([k]: string[]) => k.toLowerCase() === "recurrence-id"
+ );
+ if (!recurrenceIdProp) return true; // Keep master
+ return (
+ normalizeRecurrenceId(recurrenceIdProp[3]) !==
+ normalizeRecurrenceId(event.recurrenceId ?? "")
+ ); // Remove matching override
+ });
+
+ // Build the updated jCal with all VEVENTs and timezone
+ const timezoneData = TIMEZONES.zones[seriesEvent.timezone];
+ const vtimezone = makeTimezone(timezoneData, seriesEvent);
+
+ const newJCal = [
+ "vcalendar",
+ [],
+ [...filteredVevents, vtimezone.component.jCal],
+ ];
+
+ return api(`dav${event.URL}`, {
+ method: "PUT",
+ body: JSON.stringify(newJCal),
+ headers: {
+ "content-type": "text/calendar; charset=utf-8",
+ },
+ });
};
export const updateSeriesPartstat = async (
diff --git a/src/features/Events/EventUpdateModal.tsx b/src/features/Events/EventUpdateModal.tsx
index 55ccd86..c89344c 100644
--- a/src/features/Events/EventUpdateModal.tsx
+++ b/src/features/Events/EventUpdateModal.tsx
@@ -1,19 +1,16 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { updateAttendeesAfterTimeChange } from "@/components/Calendar/handlers/eventHandlers";
-import { updateTempCalendar } from "@/components/Calendar/utils/calendarUtils";
import { ResponsiveDialog } from "@/components/Dialog";
import EventFormFields from "@/components/Event/EventFormFields";
import { addDays } from "@/components/Event/utils/dateRules";
import { formatDateTimeInTimezone } from "@/components/Event/utils/dateTimeFormatters";
import { convertFormDateTimeToISO } from "@/components/Event/utils/dateTimeHelpers";
-import { refreshCalendars } from "@/components/Event/utils/eventUtils";
import {
moveEventAsync,
putEventAsync,
updateEventInstanceAsync,
updateSeriesAsync,
} from "@/features/Calendars/services";
-import { getCalendarRange } from "@/utils/dateUtils";
import {
buildEventFormTempData,
clearEventFormTempData,
@@ -45,10 +42,7 @@ import { Calendar } from "../Calendars/CalendarTypes";
import { userAttendee } from "../User/models/attendee";
import { deleteEvent, getEvent, putEvent } from "./EventApi";
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
-import {
- combineMasterDateWithFormTime,
- detectRecurringEventChanges,
-} from "./eventUtils";
+import { detectRecurringEventChanges } from "./eventUtils";
function EventUpdateModal({
eventId,
@@ -75,31 +69,8 @@ function EventUpdateModal({
(state) => state.calendars.list[calId]?.events[eventId]
);
- // State for fresh event data
- const [freshEvent, setFreshEvent] = useState(null);
-
// Use fresh data if available, otherwise use eventData from props, otherwise use cached data
- const event = freshEvent || eventData || cachedEvent;
-
- useEffect(() => {
- setFreshEvent(null);
- }, [eventId, calId]);
-
- // Fetch fresh event data when modal opens
- useEffect(() => {
- if (open && cachedEvent && !eventData) {
- const fetchFreshData = async () => {
- try {
- const freshData = await getEvent(cachedEvent);
- setFreshEvent(freshData);
- } catch (err) {
- // Keep using cached data if API fails
- }
- };
-
- fetchFreshData();
- }
- }, [open, cachedEvent, eventData]);
+ const event = eventData || cachedEvent;
const user = useAppSelector((state) => state.user);
@@ -186,53 +157,112 @@ function EventUpdateModal({
// Track when restoring from error to prevent other useEffects from overriding restored data
const isRestoringFromErrorRef = useRef(false);
+ // State to hold the master event when editing "all events"
+ const [masterEvent, setMasterEvent] = useState(null);
+ const [isLoadingMasterEvent, setIsLoadingMasterEvent] = useState(false);
+
+ // Fetch master event when editing "all events" of a recurring series
+ useEffect(() => {
+ if (!event || !open || typeOfAction !== "all") {
+ setMasterEvent(null);
+ return;
+ }
+
+ const isRecurringEvent = !!event.repetition?.freq;
+ if (!isRecurringEvent) {
+ setMasterEvent(null);
+ return;
+ }
+
+ // Check if this is an instance (has recurrence-id)
+ const [baseUID, recurrenceId] = event.uid.split("/");
+ if (!recurrenceId) {
+ // This is already the master event
+ setMasterEvent(event);
+ return;
+ }
+
+ // Fetch the master event
+ const fetchMasterEvent = async () => {
+ setIsLoadingMasterEvent(true);
+ try {
+ const masterEventToFetch = {
+ ...event,
+ uid: baseUID, // Use base UID to get master event
+ };
+ const fetchedMasterEvent = await getEvent(masterEventToFetch, true);
+ setMasterEvent(fetchedMasterEvent);
+ } catch (err: any) {
+ console.error("Failed to fetch master event:", err);
+ // Fallback to using the clicked instance
+ setMasterEvent(event);
+ } finally {
+ setIsLoadingMasterEvent(false);
+ }
+ };
+
+ fetchMasterEvent();
+ }, [event, open, typeOfAction]);
+
// Initialize form state when event data is available
useEffect(() => {
// Skip if restoring from error - data already restored
if (isRestoringFromErrorRef.current) {
return;
}
+
+ // Skip if still loading master event
+ if (isLoadingMasterEvent) {
+ return;
+ }
+
if (event && open) {
+ // Use master event for "all" action, otherwise use the clicked event
+ const eventToDisplay =
+ typeOfAction === "all" && masterEvent ? masterEvent : event;
+
// Reset validation errors when modal opens
setShowValidationErrors(false);
// Editing existing event - populate fields with event data
- setTitle(event.title ?? "");
- setDescription(event.description ?? "");
- setLocation(event.location ?? "");
+ setTitle(eventToDisplay.title ?? "");
+ setDescription(eventToDisplay.description ?? "");
+ setLocation(eventToDisplay.location ?? "");
// Handle all-day events properly
- const isAllDay = event.allday ?? false;
+ const isAllDay = eventToDisplay.allday ?? false;
setAllDay(isAllDay);
// Get event's original timezone
- const eventTimezone = event.timezone
- ? resolveTimezone(event.timezone)
+ const eventTimezone = eventToDisplay.timezone
+ ? resolveTimezone(eventToDisplay.timezone)
: resolveTimezone(browserDefaultTimeZone);
// Format dates based on all-day status
- if (event.start) {
+ if (eventToDisplay.start) {
if (isAllDay) {
// For all-day events, use date format (YYYY-MM-DD)
- const startDate = new Date(event.start);
+ const startDate = new Date(eventToDisplay.start);
setStart(startDate.toISOString().split("T")[0]);
} else {
// For timed events, format in the event's original timezone
- setStart(formatDateTimeInTimezone(event.start, eventTimezone));
+ setStart(
+ formatDateTimeInTimezone(eventToDisplay.start, eventTimezone)
+ );
}
} else {
setStart("");
}
- if (event.end) {
+ if (eventToDisplay.end) {
if (isAllDay) {
// For all-day events, use date format (YYYY-MM-DD)
- const endDate = new Date(event.end);
+ const endDate = new Date(eventToDisplay.end);
endDate.setDate(endDate.getDate() - 1);
setEnd(endDate.toISOString().split("T")[0]);
} else {
// For timed events, format in the event's original timezone
- setEnd(formatDateTimeInTimezone(event.end, eventTimezone));
+ setEnd(formatDateTimeInTimezone(eventToDisplay.end, eventTimezone));
}
} else {
setEnd("");
@@ -242,9 +272,10 @@ function EventUpdateModal({
setCalendarid(calId);
// Handle repetition properly - check both current event and base event
- const baseEventId = extractEventBaseUuid(event.uid);
+ const baseEventId = extractEventBaseUuid(eventToDisplay.uid);
const baseEvent = calendarsList[calId]?.events[baseEventId];
- const repetitionSource = event.repetition || baseEvent?.repetition;
+ const repetitionSource =
+ eventToDisplay.repetition || baseEvent?.repetition;
if (repetitionSource && repetitionSource.freq) {
const repetitionData: RepetitionObject = {
@@ -262,44 +293,57 @@ function EventUpdateModal({
}
setAttendees(
- event.attendee
- ? event.attendee.filter(
+ eventToDisplay.attendee
+ ? eventToDisplay.attendee.filter(
(a: userAttendee) =>
- a.cal_address !== event.organizer?.cal_address
+ a.cal_address !== eventToDisplay.organizer?.cal_address
)
: []
);
- setAlarm(event.alarm?.trigger ?? "");
- setEventClass(event.class ?? "PUBLIC");
- setBusy(event.transp ?? "OPAQUE");
+ setAlarm(eventToDisplay.alarm?.trigger ?? "");
+ setEventClass(eventToDisplay.class ?? "PUBLIC");
+ setBusy(eventToDisplay.transp ?? "OPAQUE");
- if (event.timezone) {
- const resolvedTimezone = resolveTimezone(event.timezone);
+ if (eventToDisplay.timezone) {
+ const resolvedTimezone = resolveTimezone(eventToDisplay.timezone);
setTimezone(resolvedTimezone);
} else {
const browserTz = resolveTimezone(browserDefaultTimeZone);
setTimezone(browserTz);
}
- setHasVideoConference(event.x_openpass_videoconference ? true : false);
- setMeetingLink(event.x_openpass_videoconference || null);
- setNewCalId(event.calId || calId);
+ setHasVideoConference(
+ eventToDisplay.x_openpass_videoconference ? true : false
+ );
+ setMeetingLink(eventToDisplay.x_openpass_videoconference || null);
+ setNewCalId(eventToDisplay.calId || calId);
// Update description to include video conference footer if exists
- if (event.x_openpass_videoconference && event.description) {
- const hasVideoFooter = event.description.includes("Visio:");
+ if (
+ eventToDisplay.x_openpass_videoconference &&
+ eventToDisplay.description
+ ) {
+ const hasVideoFooter = eventToDisplay.description.includes("Visio:");
if (!hasVideoFooter) {
setDescription(
addVideoConferenceToDescription(
- event.description,
- event.x_openpass_videoconference
+ eventToDisplay.description,
+ eventToDisplay.x_openpass_videoconference
)
);
} else {
- setDescription(event.description);
+ setDescription(eventToDisplay.description);
}
}
}
- }, [open, event, calId, userPersonalCalendars, calendarsList]);
+ }, [
+ open,
+ event,
+ calId,
+ userPersonalCalendars,
+ calendarsList,
+ masterEvent,
+ isLoadingMasterEvent,
+ ]);
// Helper to close modal(s) - use onCloseAll if available to close preview modal too
const closeModal = () => {
@@ -316,7 +360,6 @@ function EventUpdateModal({
closeModal();
setShowValidationErrors(false);
resetAllStateToDefault();
- setFreshEvent(null);
initializedKeyRef.current = null;
};
@@ -467,19 +510,14 @@ function EventUpdateModal({
return instances;
};
- // When editing "all events" of a recurring series, fetch master event to get original start time
+ // When editing "all events" of a recurring series, use the master event we already fetched
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) {
- // API failed - restore form data and mark as error
+ // We already have the master event from state
+ masterEventData = masterEvent;
+
+ if (!masterEventData) {
+ // This shouldn't happen, but handle it gracefully
const formDataToSave = saveCurrentFormData();
const errorFormData = {
...formDataToSave,
@@ -488,7 +526,7 @@ function EventUpdateModal({
saveEventFormDataToTemp("update", errorFormData);
showErrorNotification(
- err?.message || "Failed to fetch event data. Please try again."
+ "Failed to load master event data. Please try again."
);
// Dispatch eventModalError to reopen modal
@@ -505,58 +543,42 @@ function EventUpdateModal({
let startDate: string;
let endDate: string;
- // For "all events" update, use master event's DATE but apply user's TIME from form
- if (masterEventData && typeOfAction === "all") {
- const combined = combineMasterDateWithFormTime(
- masterEventData,
- start,
- end,
- timezone,
- allday,
- formatDateTimeInTimezone
+ // For single events or "solo" edits, use the edited dates from form
+ if (allday) {
+ // For all-day events, use date format (YYYY-MM-DD)
+ // Extract date string directly to avoid timezone conversion issues
+ const startDateOnly = (start || "").split("T")[0];
+ const endDateOnlyUI = (end || start || "").split("T")[0];
+ // API needs end date = UI end date + 1 day
+ const endDateOnlyAPI = addDays(endDateOnlyUI, 1);
+ // Parse date string and create Date at UTC midnight to avoid timezone offset issues
+ const [startYear, startMonth, startDay] = startDateOnly
+ .split("-")
+ .map(Number);
+ const [endYear, endMonth, endDay] = endDateOnlyAPI.split("-").map(Number);
+ const startDateObj = new Date(
+ Date.UTC(startYear, startMonth - 1, startDay, 0, 0, 0, 0)
);
- startDate = combined.startDate;
- endDate = combined.endDate;
+ const endDateObj = new Date(
+ Date.UTC(endYear, endMonth - 1, endDay, 0, 0, 0, 0)
+ );
+ startDate = startDateObj.toISOString();
+ endDate = endDateObj.toISOString();
} else {
- // For single events or "solo" edits, use the edited dates from form
- if (allday) {
- // For all-day events, use date format (YYYY-MM-DD)
- // Extract date string directly to avoid timezone conversion issues
- const startDateOnly = (start || "").split("T")[0];
- const endDateOnlyUI = (end || start || "").split("T")[0];
- // API needs end date = UI end date + 1 day
- const endDateOnlyAPI = addDays(endDateOnlyUI, 1);
- // Parse date string and create Date at UTC midnight to avoid timezone offset issues
- const [startYear, startMonth, startDay] = startDateOnly
- .split("-")
- .map(Number);
- const [endYear, endMonth, endDay] = endDateOnlyAPI
- .split("-")
- .map(Number);
- const startDateObj = new Date(
- Date.UTC(startYear, startMonth - 1, startDay, 0, 0, 0, 0)
- );
- const endDateObj = new Date(
- Date.UTC(endYear, endMonth - 1, endDay, 0, 0, 0, 0)
- );
- startDate = startDateObj.toISOString();
- endDate = endDateObj.toISOString();
+ // For timed events
+ startDate = convertFormDateTimeToISO(start, timezone);
+ // In normal mode, only override end date when the end date field is not shown and end date is same as start date
+ const startDateOnly = (start || "").split("T")[0];
+ const endDateOnly = (end || "").split("T")[0];
+ if (!showMore && !hasEndDateChanged && startDateOnly === endDateOnly) {
+ const endTimeOnly = end.includes("T")
+ ? end.split("T")[1]?.slice(0, 5) || "00:00"
+ : "00:00";
+ const endDateTime = `${startDateOnly}T${endTimeOnly}`;
+ endDate = convertFormDateTimeToISO(endDateTime, timezone);
} else {
- // For timed events
- startDate = convertFormDateTimeToISO(start, timezone);
- // In normal mode, only override end date when the end date field is not shown and end date is same as start date
- const startDateOnly = (start || "").split("T")[0];
- const endDateOnly = (end || "").split("T")[0];
- if (!showMore && !hasEndDateChanged && startDateOnly === endDateOnly) {
- const endTimeOnly = end.includes("T")
- ? end.split("T")[1]?.slice(0, 5) || "00:00"
- : "00:00";
- const endDateTime = `${startDateOnly}T${endTimeOnly}`;
- endDate = convertFormDateTimeToISO(endDateTime, timezone);
- } else {
- // Extended mode or end date explicitly shown in normal mode or end date differs from start date: use actual end datetime
- endDate = convertFormDateTimeToISO(end, timezone);
- }
+ // Extended mode or end date explicitly shown in normal mode or end date differs from start date: use actual end datetime
+ endDate = convertFormDateTimeToISO(end, timezone);
}
}
@@ -680,6 +702,7 @@ function EventUpdateModal({
uid: newEventUID,
URL: `/calendars/${newCalId || calId}/${newEventUID}.ics`,
sequence: 1, // New event with new UID starts at sequence 1
+ recurrenceId: undefined,
};
// STEP 3: Persist new event to server
@@ -700,7 +723,6 @@ function EventUpdateModal({
// Reset all state to default values only on successful save
resetAllStateToDefault();
- setFreshEvent(null);
initializedKeyRef.current = null;
} catch (err: any) {
// API failed - restore form data and mark as error
@@ -801,8 +823,7 @@ function EventUpdateModal({
event,
{ repetition, timezone, allday, start, end },
masterEventData,
- resolveTimezone,
- formatDateTimeInTimezone
+ resolveTimezone
);
const repetitionRulesChanged = changes.repetitionRulesChanged;
@@ -834,11 +855,18 @@ function EventUpdateModal({
// STEP 1: Remove ALL old instances from UI (including solo overrides)
removeSeriesInstancesFromUI();
- // STEP 2: Update series on server with removeOverrides=true (await to ensure it completes)
+ // STEP 2: Update series on server with removeOverrides=true
+ // IMPORTANT: Use base event UID (master), not instance UID with recurrence-id
+ const masterEventForUpdate = {
+ ...newEvent,
+ uid: baseUID, // Use base UID for updating the master
+ recurrenceId: undefined, // Don't send recurrence-id for master update
+ };
+
const result = await dispatch(
updateSeriesAsync({
cal: targetCalendar,
- event: { ...newEvent, recurrenceId },
+ event: masterEventForUpdate,
removeOverrides: true,
})
);
@@ -865,12 +893,13 @@ function EventUpdateModal({
}
}
- // Clear cache after reload
+ // Clear cache after successful update
dispatch(clearFetchCache(calId));
// Clear temp data on successful save
clearEventFormTempData("update");
} catch (seriesError) {
+ // Restore instances on error
restoreSeriesInstancesFromSnapshot();
throw seriesError;
}
@@ -904,10 +933,17 @@ function EventUpdateModal({
});
// Update server in background with removeOverrides=false
+ // IMPORTANT: Use base event UID (master), not instance UID with recurrence-id
+ const masterEventForUpdate = {
+ ...newEvent,
+ uid: baseUID, // Use base UID for updating the master
+ recurrenceId: undefined, // Don't send recurrence-id for master update
+ };
+
const result = await dispatch(
updateSeriesAsync({
cal: targetCalendar,
- event: { ...newEvent, recurrenceId },
+ event: masterEventForUpdate,
removeOverrides: false,
})
);
@@ -1049,7 +1085,6 @@ function EventUpdateModal({
// Reset all state to default values only on successful save (after all branches)
clearEventFormTempData("update");
resetAllStateToDefault();
- setFreshEvent(null);
initializedKeyRef.current = null;
} catch (error: any) {
// Handle errors for all branches
diff --git a/src/features/Events/eventUtils.ts b/src/features/Events/eventUtils.ts
index b6e2c5c..116a0e0 100644
--- a/src/features/Events/eventUtils.ts
+++ b/src/features/Events/eventUtils.ts
@@ -14,9 +14,6 @@ function inferTimezoneFromValue(
value: string
): string | undefined {
if (!params) {
- if (typeof value === "string" && value.endsWith("Z")) {
- return "Etc/UTC";
- }
return undefined;
}
@@ -29,11 +26,6 @@ function inferTimezoneFromValue(
return resolved;
}
}
-
- if (typeof value === "string" && value.endsWith("Z")) {
- return "Etc/UTC";
- }
-
return undefined;
}
@@ -186,8 +178,7 @@ export function parseCalendarEvent(
event.error = `missing crucial event param in calendar ${calendarid} `;
}
- const eventTimezone = event.timezone || "Etc/UTC";
- event.timezone = eventTimezone;
+ const eventTimezone = event.timezone;
if (!event.end) {
const start = event.start ? new Date(event.start) : new Date();
@@ -516,52 +507,38 @@ export function detectRecurringEventChanges(
end: string;
},
masterEventData: CalendarEvent | null,
- resolveTimezone: (tz: string) => string,
- formatDateTimeInTimezone: (iso: string, tz: string) => string
+ resolveTimezone: (tz: string) => string
): {
timeChanged: boolean;
timezoneChanged: boolean;
repetitionRulesChanged: boolean;
} {
- const oldRepetition = normalizeRepetition(oldEvent.repetition);
- const newRepetition = normalizeRepetition(newData.repetition);
-
- const oldTimezone = normalizeTimezone(oldEvent.timezone, resolveTimezone);
- const newTimezone = normalizeTimezone(newData.timezone, resolveTimezone);
+ const oldTimezone = resolveTimezone(oldEvent.timezone || "UTC");
+ const newTimezone = resolveTimezone(newData.timezone || "UTC");
const timezoneChanged = oldTimezone !== newTimezone;
- // Check if TIME changed (compare time portion only, not date)
- const extractTimeFromForm = (localDateTimeStr: string) => {
- if (!localDateTimeStr) return null;
- const timePart = localDateTimeStr.includes("T")
- ? localDateTimeStr.split("T")[1]
- : localDateTimeStr.substring(11);
- return timePart?.substring(0, 5); // HH:MM
- };
+ // Use master event as the source of truth for the "old" times,
+ // falling back to the clicked instance if master isn't available.
+ const oldStart = masterEventData?.start || oldEvent.start;
+ const oldEnd = masterEventData?.end || oldEvent.end;
- const extractTimeFromISO = (isoString: string | undefined, tz: string) => {
- 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 || oldEvent.start;
- const masterOldEnd = masterEventData?.end || oldEvent.end;
-
- const formStartTime = extractTimeFromForm(newData.start);
- const formEndTime = extractTimeFromForm(newData.end);
-
- const oldStartTime = extractTimeFromISO(masterOldStart, newData.timezone);
- const oldEndTime = extractTimeFromISO(masterOldEnd, newData.timezone);
+ // Parse old times (ISO strings from the server) into the event's timezone
+ // and extract HH:mm for comparison.
+ const oldStartTime = moment.tz(oldStart, oldTimezone).format("HH:mm");
+ const oldEndTime = moment.tz(oldEnd, oldTimezone).format("HH:mm");
+ // Parse new times from the form. These may be either:
+ // - local datetime strings like "2025-01-15T10:00" (from the form)
+ // - ISO strings like "2025-01-15T10:00:00.000Z" (if pre-converted)
+ // moment.tz with a format avoids ambiguous parsing in both cases.
+ const newStartTime = moment.tz(newData.start, newTimezone).format("HH:mm");
+ const newEndTime = moment.tz(newData.end, newTimezone).format("HH:mm");
const timeChanged =
- formStartTime !== oldStartTime || formEndTime !== oldEndTime;
+ oldStartTime !== newStartTime || oldEndTime !== newEndTime;
const repetitionRulesChanged =
- JSON.stringify(oldRepetition) !== JSON.stringify(newRepetition) ||
+ JSON.stringify(normalizeRepetition(oldEvent.repetition)) !==
+ JSON.stringify(normalizeRepetition(newData.repetition)) ||
timezoneChanged ||
oldEvent.allday !== newData.allday ||
timeChanged;