🐛(cal) fix display of recurring events with exceptions

This commit is contained in:
Sylvain Zimmer
2026-05-29 15:43:50 +02:00
parent 3f200ed04d
commit 15adb1cc2c
3 changed files with 132 additions and 1 deletions
@@ -31,6 +31,41 @@ export function getEventEnd(event: IcsEvent | undefined): Date | undefined {
return undefined;
}
/**
* Of two VEVENTs sharing a UID, return the one that best represents the
* series: prefer the master (no RECURRENCE-ID — it carries the RRULE), and
* otherwise the earlier start.
*/
function pickSeriesRepresentative(a: IcsEvent, b: IcsEvent): IcsEvent {
const aIsMaster = !a.recurrenceId;
const bIsMaster = !b.recurrenceId;
if (aIsMaster !== bIsMaster) return aIsMaster ? a : b;
const aStart = a.start?.date?.getTime() ?? Infinity;
const bStart = b.start?.date?.getTime() ?? Infinity;
return aStart <= bStart ? a : b;
}
/**
* Collapse a calendar's VEVENTs into one entry per recurrence set.
*
* A recurring invite is transmitted as several VEVENTs sharing a single UID:
* a master event carrying the RRULE plus one VEVENT per modified occurrence,
* each tagged with RECURRENCE-ID. Rendering them verbatim shows the same
* meeting several times over. Keep one representative per UID — the master
* when present (it describes the whole series), otherwise the earliest
* occurrence. First-seen order is preserved, and UID-less events (which can't
* be grouped) are each kept as-is.
*/
export function collapseRecurringEvents(events: IcsEvent[]): IcsEvent[] {
const byKey = new Map<string, IcsEvent>();
events.forEach((event, index) => {
const key = event.uid || `__no-uid-${index}`;
const existing = byKey.get(key);
byKey.set(key, existing ? pickSeriesRepresentative(existing, event) : event);
});
return [...byKey.values()];
}
export { TextHelper } from "@/features/utils/text-helper";
/**
@@ -15,6 +15,7 @@ import {
formatRecurrenceRule,
getAttendeeStatusInfo,
createContactFromAttendee,
collapseRecurringEvents,
} from "./calendar-helper";
// Simple pass-through translation mock
@@ -358,6 +359,94 @@ describe("createContactFromAttendee", () => {
});
});
// ─── collapseRecurringEvents ─────────────────────────────────────────────────
describe("collapseRecurringEvents", () => {
it("should collapse a recurring series (master + overrides sharing a UID) into one event", () => {
// Mirrors a real "modified recurring meeting" REQUEST: one master with
// an RRULE plus three RECURRENCE-ID overrides, all sharing the UID.
const uid = "series-001";
const master = makeEvent({
summary: "Standup",
uid,
start: { date: new Date("2026-04-23T10:00:00Z") },
recurrenceRule: { frequency: "WEEKLY", interval: 2 },
});
const overrides = [
"2026-04-23T10:00:00Z",
"2026-05-07T10:00:00Z",
"2026-05-21T10:00:00Z",
].map((iso) =>
makeEvent({
summary: "Standup",
uid,
start: { date: new Date(iso) },
recurrenceId: { value: { date: new Date(iso) } },
}),
);
const result = collapseRecurringEvents([master, ...overrides]);
expect(result).toHaveLength(1);
// The master is kept (it carries the RRULE describing the whole series).
expect(result[0].recurrenceRule?.frequency).toBe("WEEKLY");
expect(result[0].recurrenceId).toBeUndefined();
});
it("should keep events with different UIDs as separate cards", () => {
const events = [
makeEvent({ summary: "Meeting A", uid: "a-001" }),
makeEvent({ summary: "Meeting B", uid: "b-001" }),
];
const result = collapseRecurringEvents(events);
expect(result).toHaveLength(2);
expect(result.map((e) => e.summary)).toEqual(["Meeting A", "Meeting B"]);
});
it("should preserve first-seen order", () => {
const events = [
makeEvent({ summary: "First", uid: "1" }),
makeEvent({ summary: "Second", uid: "2" }),
makeEvent({ summary: "First again", uid: "1" }),
];
const result = collapseRecurringEvents(events);
expect(result.map((e) => e.uid)).toEqual(["1", "2"]);
});
it("should not group UID-less events together", () => {
const events = [
makeEvent({ summary: "No UID A", uid: "" }),
makeEvent({ summary: "No UID B", uid: "" }),
];
const result = collapseRecurringEvents(events);
expect(result).toHaveLength(2);
});
it("should pick the earliest occurrence when no master is present", () => {
const uid = "no-master-001";
const later = makeEvent({
summary: "Later",
uid,
start: { date: new Date("2026-05-21T10:00:00Z") },
recurrenceId: { value: { date: new Date("2026-05-21T10:00:00Z") } },
});
const earlier = makeEvent({
summary: "Earlier",
uid,
start: { date: new Date("2026-04-23T10:00:00Z") },
recurrenceId: { value: { date: new Date("2026-04-23T10:00:00Z") } },
});
const result = collapseRecurringEvents([later, earlier]);
expect(result).toHaveLength(1);
expect(result[0].summary).toBe("Earlier");
});
it("should return an empty array for no events", () => {
expect(collapseRecurringEvents([])).toEqual([]);
});
});
// ─── ICS round-trip tests (generate → string → parse) ───────────────────────
describe("ICS round-trip with ts-ics", () => {
@@ -25,6 +25,7 @@ import {
formatRecurrenceRule,
getAttendeeStatusInfo,
createContactFromAttendee,
collapseRecurringEvents,
} from "./calendar-helper";
import { cleanEventForDisplay } from "./event-display";
import { CalendarSelect } from "./calendar-select";
@@ -703,7 +704,13 @@ export const CalendarInvite = ({
// Set default calendar when calendars load
const effectiveCalendarId = selectedCalendarId ?? (calendars.length > 0 ? calendars[0].id : null);
const events = calendar?.events ?? [];
// A recurring invite arrives as a master VEVENT plus one VEVENT per
// modified occurrence, all sharing a UID. Collapse them so the series
// renders as a single card instead of one card per occurrence.
const events = useMemo(
() => collapseRecurringEvents(calendar?.events ?? []),
[calendar],
);
const isCancellation = calendar?.method === "CANCEL";
// Conflict detection for the first event