Skip to content

Leave out of a split's new series what the calendar shows nothing for - #99

Open
Timtam wants to merge 2 commits into
mainfrom
fix/split-tail-rule
Open

Timtam wants to merge 2 commits into
mainfrom
fix/split-tail-rule

Conversation

@Timtam

@Timtam Timtam commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Fourth PR of the arc "exceptions when splitting a series" (decision 134): the new series leaves out what the calendar shows nothing for. Decisions 141 and 150.

Why

When a series was split, the new series (the tail) got only the master's exceptions. That was wrong in two ways:

  • Google keeps a deleted occurrence as a cancelled row and lists no exception for it. CalDAV can do the same with a STATUS:CANCELLED override. On a split, those deleted occurrences came back in the new series, with an invitation.
  • Exchange lists the slot of every occurrence changed in Outlook among its exceptions, next to the deleted ones. A tail in another calendar left each of those changed occurrences out, and the truncate dropped them from the head too, so they vanished from both halves.

The carry had the same blind spot. firstOccurrenceFrom read only the master's exceptions, so it went wrong in two cases:

  • It cut an Exchange copy a week late when the copy's cut-day occurrence had been changed in Outlook, and that day kept its old content.
  • It anchored a Google copy on an occurrence Google keeps deleted, and the new part brought that occurrence back.

The change

  • deletedSlots(master, rows) (shared/seriesSplit.ts) returns the slots the calendar shows nothing for:

    • a master's exception with no live row of the series in its slot;
    • every cancelled row's slot.

    A live row means the occurrence was changed, not deleted. Each slot comes once and in order. An exception keeps its own spelling; a row's slot is written as the instant it names.

  • The split's new series gets exactly those slots from the cut on. A series written whole (no head) keeps its id and its rows, so nothing changes there.

  • firstOccurrenceFrom(master, from, rows) reads the series with its deleted slots, and anchors a copy by its place in the series (141).

  • Both carry dialogs read the copy's rows once, before the anchor, and hand the same rows to the anchor and to the plan. A copy without a rule is not read.

  • sameSlot (shared/recurrence.ts) matches slots: exactly for a timed series, by day for a series of days. expandAll uses it, and so does the split, so the views and the split cannot drift apart.

Not here:

  • Google reach (decision 150). The cache holds Google rows only about a year ahead, so a Google occurrence deleted further out still comes back. There is no question before a split; the limit is named in the tutorial and TODO.md. A later PR will read those deletions from Google itself.
  • PR 3b (split out of this one): a tail that switches between all-day and timed, and a changed rule. Both keep exceptions that hit the wrong day or no occurrence at all. Whether deletions should move along when "this and all following" moves the date is an open question for Toni.
  • Writing the exceptions. EWS still writes no exceptions on create (140), and Google drops UTC EXDATEs on a zoned series (137). These are the adapter PRs to come, and until they land, the provider may not keep the right set this PR sends.

Docs:

  • The tutorial (en and de) says deleted occurrences stay deleted and changed ones stay, and names the Google limit.
  • TODO.md marks the two defects done and adds 3b.

Checks

  • Desktop and mobile tsc and eslint are clean.
  • vitest: 2207 passed, locally and under TZ=UTC.
  • New tests:
    • deletedSlots:
      • a cancelled row is a deletion without any exception;
      • an exception with a live row is a changed occurrence, and without the row it is a deletion in its own spelling;
      • each slot is named once, in order, whatever its spelling;
      • other series' rows are ignored;
      • an all-day day matches whatever hour it was spelled at.
    • Plan:
      • the tail leaves out a Google cancelled row, also years ahead;
      • it keeps an occurrence changed in Outlook;
      • a slot named by both an exception and a cancelled row is left out once;
      • a plain exception is still left out, and nothing before the cut.
    • Anchor:
      • an occurrence changed in Outlook on the cut day is found at its slot;
      • a Google-deleted one is passed over;
      • other series' rows are ignored.
    • Carry harness: rows are read first, and a copy changed in Outlook on the cut day is cut there (UNTIL=20260824T075959Z).
    • Desktop carry dialog: a copy with a Google-deleted cut-day occurrence gets its new part a week later, and the rows are read once.
  • Red proofs, 7 of 7 red, tree restored byte for byte, each run time-limited:
    • a cancelled row not counted as a deletion;
    • a changed occurrence read as deleted;
    • the tail taken from the master's exceptions alone;
    • the anchor taken without the rows;
    • the carry anchored before its rows are read;
    • a slot named twice;
    • a day matched as an exact instant.
  • Not covered by a test: the phone carry dialog (no runner). It mirrors the desktop line for line.

Review round

Two lenses (providers; logic): 5 confirmed, two pairs of them the same finding; 2 refuted. Fixed here:

  • Medium, the tutorial overpromised. It said deleted occurrences stay deleted in the new series. Aperio leaves them out, but only CalDAV keeps that today. Exchange writes no exceptions on create (140), and Google drops them on a series with a time of day (137). The tutorial (en and de) now says so, beside the Google one-year limit.
  • Low, the cut's comment was wrong. It said the truncate drops the head's rows after the cut. An all-day series on Google or CalDAV keeps them, because the adapters skip that cleanup for days, so a changed all-day occurrence after the cut shows twice. This was already so before this PR; it now also covers a day that carries an EXDATE and an override together. The comment and TODO.md name the gap. It is to be fixed at the truncate, not in this rule: keeping the exception would take Exchange's changed occurrences out of both halves again.
  • Low, performance. sameSlot asked the core for the series clock on every comparison, where expandAll had asked once per series. A year of a daily series against sixty rows became thousands of WASM or FFI calls. slotMatcher answers once per series, expandAll and deletedSlots use it, and a test counts the calls.

Checks for the round:

  • desktop and mobile tsc and eslint clean;
  • vitest 2208 passed, locally and under TZ=UTC;
  • red proofs, 2 of 2 red: expandAll asking per comparison, and deletedSlots asking per comparison. The second one first stayed green, because the test made deletedSlots compare nothing; it now has exceptions and cancelled rows.

Phone

No native change.

🤖 Generated with Claude Code

Timtam and others added 2 commits September 26, 2026 16:23
A split gave the new series only the master's exceptions. Google keeps a
deleted occurrence as a cancelled row and lists no exception for it
(CalDAV can do the same with a cancelled override), so the occurrence came
back in the new series; and Exchange lists the slot of every occurrence
changed in Outlook among the exceptions, so a tail left those out while
the truncate dropped them from the head.

- `deletedSlots(master, rows)` names the slots the calendar shows nothing
  for: a master exception with no live row in its slot, and every
  cancelled row's slot. A live row is a changed occurrence, not a deletion.
- The cut's new series gets exactly those slots from the cut on; a series
  written whole keeps its id and rows as before.
- `firstOccurrenceFrom(master, from, rows)` reads the series with its
  deleted slots, so a copy is cut at its place in the series (decision
  141): an occurrence changed in Outlook on the cut day is there, one
  Google deleted is not. Both carry dialogs read the copy's rows once,
  before the anchor, and hand them to the anchor and the plan.
- `sameSlot` is the one slot rule (`expandAll` and the split use it).
- Google's rows reach about a year ahead (decision 150: named, no
  question); the tutorial (en, de) and TODO.md say so. The tail's switch
  between all-day and timed, and a changed rule, are PR 3b.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The tutorial (en, de) promised deleted occurrences stay deleted in the
  new series. Aperio leaves them out, but not every calendar keeps that
  yet: Exchange writes no exceptions on create (140) and Google drops them
  on a series with a time of day (137). It now says so, beside the Google
  one-year limit.
- The cut's comment said the truncate drops the head's rows after the cut.
  An all-day series on Google or CalDAV keeps them (the adapters skip that
  cleanup for days), so a changed all-day occurrence after the cut shows
  twice; said in the comment and in TODO.md, to be fixed at the truncate.
- `sameSlot` asked the core for the series clock on every comparison,
  where `expandAll` used to ask once per series: a year of a daily series
  against sixty rows became thousands of WASM or FFI calls. `slotMatcher`
  answers once per series; `expandAll` and `deletedSlots` use it, and a
  test counts the calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant