diff --git a/web/src/jmap/types.ts b/web/src/jmap/types.ts index cf47bd6..b25cee1 100644 --- a/web/src/jmap/types.ts +++ b/web/src/jmap/types.ts @@ -679,6 +679,8 @@ export interface JSCalendarEvent { recurrenceId?: LocalDate; recurrenceIdTimeZone?: string; recurrenceRules?: JSCalendarRecurrenceRule[]; + /** Stalwart 0.16 stores a single rule under this name instead of the array above. */ + recurrenceRule?: JSCalendarRecurrenceRule; excludedRecurrenceRules?: JSCalendarRecurrenceRule[]; recurrenceOverrides?: Record | null>; excluded?: boolean; diff --git a/web/src/store/__tests__/recurrence.test.ts b/web/src/store/__tests__/recurrence.test.ts index f6e0ae2..603685a 100644 --- a/web/src/store/__tests__/recurrence.test.ts +++ b/web/src/store/__tests__/recurrence.test.ts @@ -19,9 +19,15 @@ describe("isRecurring", () => { // and a base that is a different id. Neither makes it a series. expect(isRecurring(ev({ id: "eaaaaai", baseEventId: "i" }))).toBe(false); }); - it("recognises a series by its recurrence rules", () => { + it("recognises a series by its rule, under either name", () => { expect(isRecurring(ev({ recurrenceRules: [{ "@type": "RecurrenceRule", frequency: "weekly" }] }))).toBe(true); - expect(isRecurring(ev({ baseEventId: "ev1", recurrenceRules: [{ "@type": "RecurrenceRule", frequency: "daily" }] }))).toBe(true); expect(isRecurring(ev({ excludedRecurrenceRules: [{ "@type": "RecurrenceRule", frequency: "monthly" }] }))).toBe(true); + // Stalwart 0.16 keeps a single rule under the singular name. + expect(isRecurring(ev({ recurrenceRule: { "@type": "RecurrenceRule", frequency: "weekly", count: 3 } }))).toBe(true); + }); + it("recognises an occurrence, which arrives with no rule of its own", () => { + // A live 0.16.19 expands a weekly series into instances like this: an id + // per occurrence, a recurrenceId, and no rule attached. + expect(isRecurring(ev({ id: "iaaaaas", recurrenceId: "2030-03-11T10:00:00" }))).toBe(true); }); }); diff --git a/web/src/store/calendar.ts b/web/src/store/calendar.ts index 04d2b8f..e9f5138 100644 --- a/web/src/store/calendar.ts +++ b/web/src/store/calendar.ts @@ -300,15 +300,22 @@ export const useCalendar = create((set, get) => ({ /** * Whether an event is part of a series. * - * Not the same question as "does it have a baseEventId", nor "is that base some - * other event". `CalendarEvent/query` runs with `expandRecurrences`, and Stalwart - * hands back an instance id for everything it returns that way — a one-off event - * included, whose own id (`eaaaaai`) differs from its base (`i`), verified - * against a live 0.16.19. Recurrence rules are what make a series, so those are - * what we ask about. + * Three things had to be checked against a live 0.16.19 to get this right, none + * of which the mock reproduces: + * + * - `baseEventId` says nothing. `CalendarEvent/query` runs with + * `expandRecurrences`, and a one-off comes back as id `eaaaaai` over base + * `i` — an instance id of its own, and a base that is a different id. + * - The rules say nothing on an instance. An occurrence of a weekly series + * arrives with no rule attached at all; only the master carries one. + * - Stalwart names that rule `recurrenceRule`, singular, not the RFC 8984 + * `recurrenceRules` array. + * + * What an occurrence does carry is a `recurrenceId`, and a one-off never has + * one. Master or occurrence, that is what makes this a series. */ export function isRecurring(ev: CalendarEvent): boolean { - return Boolean(ev.recurrenceRules?.length || ev.excludedRecurrenceRules?.length); + return Boolean(ev.recurrenceRule || ev.recurrenceRules?.length || ev.excludedRecurrenceRules?.length || ev.recurrenceId); } export function toInstance(e: CalendarEvent, calendars: Record): EventInstance | null {