Stop calling every event a series

A one-time event opened for editing said "this is a recurring event —
changes apply to the whole series", and deleting one offered to delete
all occurrences of an event that has exactly one.

Three places asked whether an event had a baseEventId and took that for
recurrence. It isn't: the calendar loads its range with expandRecurrences,
and Stalwart puts a baseEventId on everything it returns that way, a
one-off pointing at itself included. The mock never sets the field at
all, which is why this only showed up against a real server.

They now share isRecurring(), which asks about recurrence rules, and
treats a base that is some other event as an occurrence of a series too
— so an expanded instance that travels without its rules is still
described honestly on the way to being deleted.

Fixes #25
This commit is contained in:
2026-08-25 07:01:23 -07:00
parent 0b922e175f
commit 8b22ea9aab
5 changed files with 47 additions and 6 deletions
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { isRecurring } from "@/store/calendar";
import type { CalendarEvent } from "@/jmap/types";
/**
* `CalendarEvent/query` runs with `expandRecurrences`, and Stalwart puts a
* `baseEventId` on everything it returns that way — one-off events included.
* Treating that as proof of a series told people editing a plain event that
* their changes applied to the whole series, and offered to delete "all
* occurrences" of an event that has exactly one.
*/
const ev = (p: Partial<CalendarEvent>): CalendarEvent => ({ id: "ev1", "@type": "Event", uid: "u1", calendarIds: { c1: true }, start: "2026-08-25T09:00:00", duration: "PT30M", ...p } as CalendarEvent);
describe("isRecurring", () => {
it("does not call a one-off event a series just because it has a baseEventId", () => {
expect(isRecurring(ev({ baseEventId: "ev1" }))).toBe(false);
expect(isRecurring(ev({}))).toBe(false);
});
it("still recognises an occurrence whose base is another event", () => {
expect(isRecurring(ev({ id: "ev1_2", baseEventId: "ev1" }))).toBe(true);
});
it("recognises a series by its recurrence rules", () => {
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);
});
});
+14
View File
@@ -297,6 +297,20 @@ export const useCalendar = create<CalendarState>((set, get) => ({
},
}));
/**
* Whether an event is part of a series.
*
* Not the same question as "does it have a baseEventId": `CalendarEvent/query`
* runs with `expandRecurrences`, and Stalwart sets `baseEventId` on every event
* it returns that way — a one-off event included, pointing at itself. Recurrence
* rules are what make a series, so those are the question; a base that is some
* *other* event means this is one occurrence of one, whether or not the expanded
* instance carried the rules along with it.
*/
export function isRecurring(ev: CalendarEvent): boolean {
return Boolean(ev.recurrenceRules?.length || ev.excludedRecurrenceRules?.length || (ev.baseEventId && ev.baseEventId !== ev.id));
}
export function toInstance(e: CalendarEvent, calendars: Record<Id, Calendar>): EventInstance | null {
const allDay = Boolean(e.showWithoutTime);
let start: Date;