Calendar: edit and delete a single occurrence

Closes #132.

Stalwart 0.16.20 accepts a synthetic id on `CalendarEvent/set`, writing a
`recurrenceOverrides` entry rather than touching the series, so editing
one date of a recurring event is now something the server does and this
does too.

Editing asks the scope *before* the form opens, because it decides which
event the form is even about: a form populated from the master shows the
series' start date, so editing Wednesday's standup would have offered to
move Monday's. Deleting asks in place of the old confirm.

The patch is narrowed rather than posted hopefully. 0.16.20 sorts
per-occurrence properties into three groups and only one is honest: ten
are refused with `invalidProperties`, twelve more are dropped from the
patch while the response still reports success, and the rest are applied.
That silent middle group is how #26 reached a live server - a successful
response is not evidence anything was written - so `occurrencePatch`
throws on the first group, reports the second to the caller, and the
editor leaves out the five it always sends. A patch that would be
entirely dropped is not sent at all.

The refusal for an occurrence of a this-and-future change offers the
series instead of a bare error toast. Nothing here writes one of those,
but an event synced from another client can carry one.

Two things the scope prompt cost, both worth knowing. A dialog is queued
in a store the moment it is asked for, so it outlives the effect that
asked: without a ref guard a remount queues a second prompt the first
answer cannot retract. And gating the *answer* on the effect's cleanup
flag is worse - StrictMode runs mount, cleanup, mount, so the flag is
already set by the time anyone clicks and the editor never opens.

The mock expands recurrences for the first time, which is what makes any
of this developable. It hands out synthetic ids for everything including
one-offs, gives occurrences a `recurrenceId` and no rule, and reproduces
the refusals - including the silent drops, since a mock that applied them
would let a client that sends them look correct everywhere but a real
server.
This commit is contained in:
2026-08-30 21:35:01 -07:00
parent 6ec2304fc2
commit dd8998f178
11 changed files with 923 additions and 54 deletions
+69 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import { eventIdForScope, isOccurrence, useCalendar } from "@/store/calendar";
import { CalendarSetError, eventIdForScope, isOccurrence, isThisAndFutureRefusal, occurrencePatch, OccurrenceScopeError, useCalendar } from "@/store/calendar";
import type { CalendarEvent, JmapSession } from "@/jmap/types";
/**
@@ -161,3 +161,71 @@ describe("rsvp", () => {
await expect(useCalendar.getState().rsvp(OCCURRENCE, "accepted")).rejects.toThrow(/not a participant/i);
});
});
describe("occurrencePatch", () => {
it("lets through what one date will actually take", () => {
const { patch, dropped } = occurrencePatch({ title: "Just today", color: "#f00" });
expect(patch).toEqual({ title: "Just today", color: "#f00" });
expect(dropped).toEqual([]);
});
it("throws on a property the server refuses outright", () => {
// Loud is correct here: moving one occurrence to another calendar is not
// something the user can be quietly given a different answer to.
expect(() => occurrencePatch({ calendarIds: { c2: true } })).toThrow(OccurrenceScopeError);
expect(() => occurrencePatch({ useDefaultAlerts: false })).toThrow(/whole series/i);
});
it("removes an inherited property and reports it, rather than letting it vanish", () => {
// The server would take this patch, drop `privacy`, and answer "updated".
// Anything that believes the response believes the change landed.
const { patch, dropped } = occurrencePatch({ title: "x", privacy: "private", recurrenceRule: null });
expect(patch).toEqual({ title: "x" });
expect(dropped).toEqual(["privacy", "recurrenceRule"]);
});
it("judges a pointer patch on its first token, as the server does", () => {
expect(occurrencePatch({ "participants/me/participationStatus": "accepted" }).patch)
.toEqual({ "participants/me/participationStatus": "accepted" });
expect(occurrencePatch({ "participants/me/calendarAddress": "mailto:x@y" }).dropped)
.toEqual(["participants/me/calendarAddress"]);
});
});
describe("updateEvent, per occurrence", () => {
it("narrows the patch before sending it and reports what it kept back", async () => {
const calls = server();
const dropped = await useCalendar.getState().updateEvent(OCCURRENCE, { title: "Just today", privacy: "private" }, false, "occurrence");
expect(calls[0]!.update).toEqual({ iaaaaas: { title: "Just today" } });
expect(dropped).toEqual(["privacy"]);
});
it("sends nothing at all when a patch is entirely inherited", async () => {
// A request that could only be a no-op is worse than no request: the
// response would say "updated" and mean nothing by it.
const calls = server();
const dropped = await useCalendar.getState().updateEvent(OCCURRENCE, { privacy: "private" }, false, "occurrence");
expect(calls).toEqual([]);
expect(dropped).toEqual(["privacy"]);
});
it("leaves a series patch exactly as the caller wrote it", async () => {
const calls = server();
await useCalendar.getState().updateEvent(OCCURRENCE, { privacy: "private", useDefaultAlerts: false }, false, "series");
expect(calls[0]!.update!.i).toEqual({ privacy: "private", useDefaultAlerts: false });
});
});
describe("isThisAndFutureRefusal", () => {
it("recognises the refusal worth offering the series for", () => {
expect(isThisAndFutureRefusal(new CalendarSetError({
type: "invalidProperties",
description: "Occurrences of a this-and-future change cannot be modified individually.",
}))).toBe(true);
});
it("does not claim an unrelated refusal", () => {
expect(isThisAndFutureRefusal(new CalendarSetError({ type: "forbidden", description: "Nope." }))).toBe(false);
expect(isThisAndFutureRefusal(new Error("Occurrences of a this-and-future change"))).toBe(false);
});
});