mirror of
https://github.com/suitenumerique/messages.git
synced 2026-08-17 21:25:41 +02:00
🐛(cal) fix display of recurring events with exceptions
This commit is contained in:
+35
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
+89
@@ -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", () => {
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user