Someone drags one chip on a calendar grid. That gesture is a single mouse-up. What has to happen behind it is one of three completely different writes, and which one is correct depends on a question the drag itself cannot answer: did you mean this Tuesday, or every Tuesday?
I hit this building the calendar in ToDowl, where events come from Google Calendar over its REST API and from iCloud over CalDAV. Same UI, same drag, two protocols that disagree about almost everything. Here is what the three scopes actually mean once you get past the dialog.
"This one" is not an edit, it is a second event
The instinct is to think of a recurring event as a row you can update. It is not. It is a rule, plus a list of exceptions to that rule.
Moving one occurrence leaves the rule alone and adds an exception. In iCalendar that exception is a second VEVENT carrying the same UID and a RECURRENCE-ID naming the instance it replaces:
BEGIN:VEVENT
UID:5f6a...@todowl
RRULE:FREQ=WEEKLY;BYDAY=TU
DTSTART;TZID=Europe/Istanbul:20260901T100000
END:VEVENT
BEGIN:VEVENT
UID:5f6a...@todowl
RECURRENCE-ID;TZID=Europe/Istanbul:20260908T100000
DTSTART;TZID=Europe/Istanbul:20260908T140000
END:VEVENT
The second block replaces the September 8 occurrence. The failure mode here is quiet and it cost me an afternoon: if the RECURRENCE-ID does not match a generated instance exactly, byte for byte in the same time zone form, it replaces nothing. It becomes a separate event sitting on top of the original, and the user sees the same meeting twice. No error, no 400. Just a duplicate.
Google's API hides the iCalendar underneath, but the model is identical. You do not PATCH the series, you PATCH the instance, and Google gives instances their own event ids for exactly that reason.
"All of them" is the only one that looks like a normal update
Change the master's DTSTART and every occurrence moves with it. This is the one case where the mental model of "a row you update" holds.
Worth saying out loud because it is easy to over-engineer: no exception handling, no new ids, one write.
"This and all following" does not exist in either protocol
Neither iCalendar nor the Google Calendar API has an operation for it. What both Apple Calendar and Google Calendar do internally, and what you have to do too, is a split:
- End the old series just before the instance you touched, with an
UNTILon itsRRULE. - Create a new series, with a new UID, starting at the new time.
So the single most innocent-looking option in the dialog is the one that turns into two writes and permanently forks the event's history. If a user picks it three times, they have four series where they think they have one. That is not a bug, it is how the format works, but it does mean the "following" branch is the one worth testing hardest.
The read path decides how hard the write path is
Before any of this can work, the client has to be able to name one occurrence. We hand out event ids in this shape:
-
uidfor a one-off event -
uid::<key>for one instance of a series, where the key is the instance's ownYYYY-MM-DDfor all-day events or epoch milliseconds for timed ones
The client never parses it. It sends back whatever it was given, plus a scope, and the server splits the id. Keeping the instance key derivable from the instance itself, rather than from an index into a list, is what makes the endpoint stateless: two people can be looking at the same shared calendar and the third occurrence for one of them is not the third occurrence for the other.
The scope type is shared between the Google and the CalDAV code rather than duplicated:
export type CalDavScope = RecurrenceScope;
export { parseRecurrenceScope as parseCalDavScope } from './write.js';
Two parallel copies would drift the first time a fourth option shows up, and the drift would look like one provider silently ignoring a scope the other honours. That is a miserable bug to reproduce.
Read immediately before you write
Every edit re-fetches the object from the server and then writes conditionally on the ETag that read returned. Nothing about where an event lives is cached between requests.
That sounds paranoid until you remember the account is also on a phone. Between the moment we listed a calendar and the moment someone finished dragging, the same event may have been moved, deleted, or turned into an override by Apple Calendar on an iPhone. A stale ETag turns that into a 412 and a retry. No ETag turns it into silently clobbering the phone's change.
Not everything on the grid may be dragged
Half of "editing a calendar" is deciding what is not editable, and saying so before the drag starts rather than snapping the chip back on a 403:
export function eventIsEditable(accessRole, event): boolean {
if (!calendarIsWritable(accessRole)) return false;
if (event.locked) return false;
if (event.eventType === 'birthday') return false;
const isInvitation = (event.attendees ?? []).length > 0;
const selfOrganised = event.organizer?.self ?? event.creator?.self ?? false;
if (isInvitation && !selfOrganised && !event.guestsCanModify) return false;
return true;
}
Holiday feeds and shared calendars come back from Google as reader, and no scope makes those writable. Birthdays Google generates from Contacts are not events you can edit at all. And an invitation you are not the organiser of is refused unless the organiser set guestsCanModify, which most people never do.
Each of those looked like an edge case when I read the API docs. Every one of them showed up in the first week of real accounts.
What I would tell myself at the start
The calendar is not the hard part. The recurrence rules are not really the hard part either, since a library will expand them for you. The hard part is that one gesture maps to three writes, two of which change the shape of the data rather than its contents, and the protocol gives you no error when you get it wrong.
If you are building this: write the "this one" case first, then immediately write a test that reads the calendar back and asserts the occurrence count did not go up. That single assertion would have caught the duplicate override on day one instead of day three.
Top comments (0)