Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
formatRecurrenceRule,
getAttendeeStatusInfo,
createContactFromAttendee,
collapseRecurringEvents,
} from "./calendar-helper";

// Simple pass-through translation mock
Expand Down Expand Up @@ -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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
formatRecurrenceRule,
getAttendeeStatusInfo,
createContactFromAttendee,
collapseRecurringEvents,
} from "./calendar-helper";
import { cleanEventForDisplay } from "./event-display";
import { CalendarSelect } from "./calendar-select";
Expand Down Expand Up @@ -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
Expand Down