diff --git a/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/calendar-helper.tsx b/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/calendar-helper.tsx index 39c34ca1a..d2874947d 100644 --- a/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/calendar-helper.tsx +++ b/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/calendar-helper.tsx @@ -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(); + 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"; /** diff --git a/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/index.test.ts b/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/index.test.ts index 32da2d286..3cf0b006c 100644 --- a/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/index.test.ts +++ b/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/index.test.ts @@ -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", () => { diff --git a/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/index.tsx b/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/index.tsx index f11084815..35798296a 100644 --- a/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/index.tsx +++ b/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/index.tsx @@ -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