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;