From 8b22ea9aab2e9fa921e87e9cea7f809beed33aba Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 25 Aug 2026 07:01:23 -0700 Subject: [PATCH] Stop calling every event a series MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/store/__tests__/recurrence.test.ts | 27 +++++++++++++++++++ web/src/store/calendar.ts | 14 ++++++++++ .../views/calendar/CalendarContextMenu.tsx | 4 +-- web/src/views/calendar/EventEditor.tsx | 4 +-- web/src/views/calendar/EventPopover.tsx | 4 +-- 5 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 web/src/store/__tests__/recurrence.test.ts diff --git a/web/src/store/__tests__/recurrence.test.ts b/web/src/store/__tests__/recurrence.test.ts new file mode 100644 index 0000000..d48a955 --- /dev/null +++ b/web/src/store/__tests__/recurrence.test.ts @@ -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 => ({ 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); + }); +}); diff --git a/web/src/store/calendar.ts b/web/src/store/calendar.ts index 9513180..d74967b 100644 --- a/web/src/store/calendar.ts +++ b/web/src/store/calendar.ts @@ -297,6 +297,20 @@ export const useCalendar = create((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): EventInstance | null { const allDay = Boolean(e.showWithoutTime); let start: Date; diff --git a/web/src/views/calendar/CalendarContextMenu.tsx b/web/src/views/calendar/CalendarContextMenu.tsx index e5af553..a4d179b 100644 --- a/web/src/views/calendar/CalendarContextMenu.tsx +++ b/web/src/views/calendar/CalendarContextMenu.tsx @@ -1,7 +1,7 @@ import { Calendar as CalIcon, CalendarDays, Copy, ExternalLink, Palette, Pencil, Plus, Tag, Trash2, X } from "lucide-react"; import { useLocation } from "wouter"; import type { CalendarEvent } from "@/jmap/types"; -import { useCalendar, type EventInstance } from "@/store/calendar"; +import { useCalendar, isRecurring, type EventInstance } from "@/store/calendar"; import { useSettings } from "@/store/settings"; import { formatDayMonth } from "@/lib/datetime"; import { MenuItem, MenuSep, MenuTitle, Popover, type Anchor } from "@/ui/popover"; @@ -89,7 +89,7 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }: }; const del = async () => { onClose(); - const recurring = Boolean(ev.recurrenceRules?.length || ev.baseEventId); + const recurring = isRecurring(ev); if (!(await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", confirmLabel: "Delete", danger: true }))) return; try { await cal.destroyEvent(baseId, participants > 1); diff --git a/web/src/views/calendar/EventEditor.tsx b/web/src/views/calendar/EventEditor.tsx index 83e1e8f..af9c765 100644 --- a/web/src/views/calendar/EventEditor.tsx +++ b/web/src/views/calendar/EventEditor.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { Plus, Trash2, Users } from "lucide-react"; import type { BusyPeriod, CalendarEvent, EmailAddress, JSCalendarAlert, JSCalendarParticipant, JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types"; -import { useCalendar, myParticipantKeys } from "@/store/calendar"; +import { useCalendar, myParticipantKeys, isRecurring } from "@/store/calendar"; import { useSettings } from "@/store/settings"; import { useSession } from "@/store/session"; import { useContacts } from "@/store/contacts"; @@ -202,7 +202,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE return ( }>
- {init.event?.baseEventId &&
This is a recurring event — changes apply to the whole series.
} + {ev && isRecurring(ev) &&
This is a recurring event — changes apply to the whole series.
}
setTitle(e.target.value)} />
{allDay ? ( diff --git a/web/src/views/calendar/EventPopover.tsx b/web/src/views/calendar/EventPopover.tsx index 9c4c42e..160d338 100644 --- a/web/src/views/calendar/EventPopover.tsx +++ b/web/src/views/calendar/EventPopover.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { AlignLeft, Bell, Calendar as CalIcon, Check, Clock, HelpCircle, Link2, MapPin, Pencil, Repeat, Trash2, Users, X, Mail } from "lucide-react"; -import { useCalendar, myParticipantKeys, type EventInstance } from "@/store/calendar"; +import { useCalendar, myParticipantKeys, isRecurring, type EventInstance } from "@/store/calendar"; import { Popover, type Anchor } from "@/ui/popover"; import { confirmDialog } from "@/ui/dialog"; import { toast } from "@/ui/toast"; @@ -29,7 +29,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns const openCompose = useCompose((s) => s.open); const del = async () => { - const recurring = Boolean(ev.recurrenceRules?.length || ev.baseEventId); + const recurring = isRecurring(ev); const ok = await confirmDialog({ title: recurring ? "Delete all occurrences?" : "Delete this event?", message: recurring ? "This will delete the entire series." : undefined, confirmLabel: "Delete", danger: true }); if (!ok) return; setBusy(true);