[#318] implement SEQUENCE field for ICS events (#362)

- Add SEQUENCE property to ICS generation (starts at 1 for new events)
- Increment SEQUENCE on event updates (modal, drag & drop, resize)
- Handle SEQUENCE independently for recurring event instances
- Add comprehensive tests for SEQUENCE generation and incrementation
- Ensure proper event ordering for remote calendar participants

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Benoit TELLIER
2025-11-27 08:58:31 +01:00
committed by GitHub
parent d8c931e02e
commit 65c26f7627
6 changed files with 114 additions and 0 deletions
@@ -302,6 +302,7 @@ describe("CalendarApp integration", () => {
end: new Date("2025-11-14T11:31:00.000Z").toISOString(),
class: "PUBLIC",
partstat: "ACCEPTED",
sequence: 2,
organizer: {
cn: "Alice",
cal_address: "alice@example.com",
@@ -380,6 +381,9 @@ describe("CalendarApp integration", () => {
expect(normalAttendee).toBeTruthy();
expect(normalAttendee?.partstat).toBe("ACCEPTED");
expect(normalAttendee?.role).toBe("REQ-PARTICIPANT");
// Verify SEQUENCE is incremented
expect(updatedEvent?.sequence).toBe(3); // 2 + 1
});
it("changes normal attendee to need action on time update and no organizer changes", async () => {
@@ -432,6 +436,9 @@ describe("CalendarApp integration", () => {
expect(normalAttendee).toBeTruthy();
expect(normalAttendee?.partstat).toBe("NEEDS-ACTION");
expect(normalAttendee?.role).toBe("REQ-PARTICIPANT");
// Verify SEQUENCE is incremented
expect(updatedEvent?.sequence).toBe(3); // 2 + 1
});
it("update event attendees on drag", async () => {
const mockDispatch = jest.fn();
@@ -490,6 +497,9 @@ describe("CalendarApp integration", () => {
(a: any) => a.cal_address === "bob@example.com"
);
expect(normal?.partstat).toBe("NEEDS-ACTION");
// Verify SEQUENCE is incremented
expect(updatedEvent?.sequence).toBe(3); // 2 + 1
});
it("update event attendees on resize", async () => {
@@ -549,6 +559,9 @@ describe("CalendarApp integration", () => {
(a: any) => a.cal_address === "bob@example.com"
);
expect(normal?.partstat).toBe("NEEDS-ACTION");
// Verify SEQUENCE is incremented
expect(updatedEvent?.sequence).toBe(3); // 2 + 1
});
});
});
@@ -837,6 +837,99 @@ describe("calendarEventToJCal", () => {
])
);
});
it("includes SEQUENCE field when present in event", () => {
const mockEvent = {
uid: "event-with-sequence",
title: "Event with Sequence",
start: "2025-07-23T08:00:00.000Z",
end: "2025-07-23T09:00:00.000Z",
timezone: "Europe/Paris",
allday: false,
sequence: 3,
attendee: [],
};
const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent);
const [vevent] = result[2];
const props = vevent[1];
const sequenceProp = props.find((p: any[]) => p[0] === "sequence");
expect(sequenceProp).toBeDefined();
expect(sequenceProp).toEqual(["sequence", {}, "integer", 3]);
});
it("defaults SEQUENCE to 1 when not present in event", () => {
const mockEvent = {
uid: "event-without-sequence",
title: "Event without Sequence",
start: "2025-07-23T08:00:00.000Z",
end: "2025-07-23T09:00:00.000Z",
timezone: "Europe/Paris",
allday: false,
attendee: [],
};
const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent);
const [vevent] = result[2];
const props = vevent[1];
const sequenceProp = props.find((p: any[]) => p[0] === "sequence");
expect(sequenceProp).toBeDefined();
expect(sequenceProp).toEqual(["sequence", {}, "integer", 1]);
});
it("includes SEQUENCE in recurring event", () => {
const mockEvent = {
uid: "recurring-event-sequence",
title: "Recurring Event with Sequence",
start: "2025-07-23T08:00:00.000Z",
end: "2025-07-23T09:00:00.000Z",
timezone: "Europe/Paris",
allday: false,
sequence: 2,
repetition: { freq: "DAILY", interval: 1 },
attendee: [],
};
const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent);
const [vevent] = result[2];
const props = vevent[1];
const sequenceProp = props.find((p: any[]) => p[0] === "sequence");
expect(sequenceProp).toBeDefined();
expect(sequenceProp).toEqual(["sequence", {}, "integer", 2]);
});
it("includes SEQUENCE in event exception (with recurrence-id)", () => {
const mockEvent = {
uid: "event-exception-sequence",
title: "Event Exception with Sequence",
start: "2025-07-23T08:00:00.000Z",
end: "2025-07-23T09:00:00.000Z",
timezone: "Europe/Paris",
allday: false,
sequence: 5,
recurrenceId: "2025-07-23T08:00:00Z",
attendee: [],
};
const result = calendarEventToJCal(mockEvent as unknown as CalendarEvent);
const [vevent] = result[2];
const props = vevent[1];
const sequenceProp = props.find((p: any[]) => p[0] === "sequence");
expect(sequenceProp).toBeDefined();
expect(sequenceProp).toEqual(["sequence", {}, "integer", 5]);
// Also verify recurrence-id is present
const recurrenceIdProp = props.find((p: any[]) => p[0] === "recurrence-id");
expect(recurrenceIdProp).toBeDefined();
});
});
describe("combineMasterDateWithFormTime", () => {
@@ -177,6 +177,7 @@ export const createEventHandlers = (props: EventHandlersProps) => {
...event,
start: computedNewStart.toISOString(),
end: computedNewEnd.toISOString(),
sequence: (event.sequence ?? 1) + 1,
} as CalendarEvent,
true
);
@@ -202,6 +203,7 @@ export const createEventHandlers = (props: EventHandlersProps) => {
...master,
start: computedNewStart.toISOString(),
end: computedNewEnd.toISOString(),
sequence: (master.sequence ?? 1) + 1,
},
})
);
@@ -254,6 +256,7 @@ export const createEventHandlers = (props: EventHandlersProps) => {
...event,
start: computedNewStart.toISOString(),
end: computedNewEnd.toISOString(),
sequence: (event.sequence ?? 1) + 1,
} as CalendarEvent,
true
);
@@ -280,6 +283,7 @@ export const createEventHandlers = (props: EventHandlersProps) => {
...master,
start: computedNewStart.toISOString(),
end: computedNewEnd.toISOString(),
sequence: (master.sequence ?? 1) + 1,
},
})
);
+1
View File
@@ -733,6 +733,7 @@ function EventPopover({
},
],
transp: busy,
sequence: 1,
color: targetCalendar?.color,
alarm: { trigger: alarm, action: "EMAIL" },
x_openpass_videoconference: meetingLink || undefined,
+2
View File
@@ -604,6 +604,7 @@ function EventUpdateModal({
organizer: organizer,
timezone,
transp: busy,
sequence: (event.sequence ?? 1) + 1,
color: targetCalendar?.color,
alarm: { trigger: alarm, action: "EMAIL" },
x_openpass_videoconference: meetingLink || undefined,
@@ -700,6 +701,7 @@ function EventUpdateModal({
...newEvent,
uid: newEventUID,
URL: `/calendars/${newCalId || calId}/${newEventUID}.ics`,
sequence: 1, // New event with new UID starts at sequence 1
};
// STEP 3: Persist new event to server
+1
View File
@@ -297,6 +297,7 @@ export function makeVevent(
formatDateToICal(new Date(event.start), event.allday ?? false, tzid),
],
["class", {}, "text", event.class ?? "PUBLIC"],
["sequence", {}, "integer", event.sequence ?? 1],
[
"x-openpaas-videoconference",
{},