Resolve the base event id in the calendar store, not at the call sites

Closes #133.

`updateEvent`, `destroyEvent` and `rsvp` took an id and sent it. The
`baseEventId ?? id` that made them hit the series lived at four call
sites instead, and every one of them happened to be right.

That was backstopped by the server until now. Through 0.16.19 a synthetic
id reaching `destroy` came back as "Deleting synthetic ids is not yet
supported" and the user saw a toast. 0.16.20 accepts it and removes one
date instead, reporting success under a dialog that said "Delete all
occurrences?" - so a forgotten `??` became silent data loss rather than
an error.

The three methods now take the event and a required `scope`, and there is
exactly one place that turns an event into an id. A caller that wants the
series cannot get an occurrence by forgetting anything; a caller that
wants one occurrence has to say so.

`rsvp` takes the event rather than an id for the same reason, and no
longer looks it up: its patch is `participants/{key}/participationStatus`,
which is one of the pointers 0.16.20 *allows* on an occurrence, so aimed
at an instance it would quietly mean "only that day".

`findByUid` says in a comment that its query omits `expandRecurrences` on
purpose, since InviteCard hands the result straight to `destroyEvent`.
This commit is contained in:
2026-08-30 21:06:56 -07:00
parent 12157f47bf
commit 373822c2ae
6 changed files with 235 additions and 20 deletions
@@ -60,14 +60,13 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
const { inst } = ctx;
const ev = inst.event;
const baseId = ev.baseEventId ?? ev.id;
const canEdit = inst.calendar?.myRights.mayWriteAll || inst.calendar?.myRights.mayWriteOwn || !inst.calendar;
const currentCat = categoryOf(ev, categories);
const participants = Object.keys(ev.participants ?? {}).length;
const patch = async (p: Record<string, unknown>, msg: string) => {
try {
await cal.updateEvent(baseId, p, false);
await cal.updateEvent(ev, p, false, "series");
toast.success(msg);
} catch (err) {
toast.error((err as Error).message);
@@ -92,7 +91,7 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
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);
await cal.destroyEvent(ev, participants > 1, "series");
toast.success("Event deleted");
} catch (err) {
toast.error((err as Error).message);
+3 -1
View File
@@ -178,7 +178,9 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
const patch: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) patch[k] = v === undefined ? null : v;
if (Object.keys(ev.calendarIds)[0] !== calendarId) patch.calendarIds = { [calendarId]: true };
await cal.updateEvent(ev.id, patch, invites);
// `ev` is the master: EventEditor resolves `baseEventId` when it opens
// on an occurrence, so the whole series is what this form edits.
await cal.updateEvent(ev, patch, invites, "series");
toast.success("Event updated");
} else {
const clean: Record<string, unknown> = {};
+2 -3
View File
@@ -22,7 +22,6 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
const myStatus = myKeys.length ? ev.participants?.[myKeys[0]!]?.participationStatus : undefined;
const isOrganizer = ev.isOrigin !== false && (!participants.length || participants.some(([k, p]) => p.roles?.owner && myKeys.includes(k)));
const canEdit = inst.calendar?.myRights.mayWriteAll || (inst.calendar?.myRights.mayWriteOwn && isOrganizer) || !inst.calendar;
const baseId = ev.baseEventId ?? ev.id;
const location = Object.values(ev.locations ?? {})[0];
const vloc = Object.values(ev.virtualLocations ?? {})[0];
const alerts = Object.values(ev.alerts ?? {});
@@ -34,7 +33,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
if (!ok) return;
setBusy(true);
try {
await cal.destroyEvent(baseId, participants.length > 1);
await cal.destroyEvent(ev, participants.length > 1, "series");
toast.success("Event deleted");
onClose();
} catch (err) {
@@ -47,7 +46,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
const rsvp = async (status: "accepted" | "tentative" | "declined") => {
setBusy(true);
try {
await cal.rsvp(baseId, status);
await cal.rsvp(ev, status);
toast.success("Response sent");
onClose();
} catch (err) {