Skip to content

Cancelled and moved single changes ring where the views show them (PR 7a) - #117

Merged
Timtam merged 7 commits into
mainfrom
fix/reminders-honour-overrides
Oct 6, 2026
Merged

Timtam merged 7 commits into
mainfrom
fix/reminders-honour-overrides

Conversation

@Timtam

@Timtam Timtam commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

PR 7a of the arc "exceptions when splitting a series": cancelled and moved single changes now ring where the views show them (decisions 209 and 214).

Why

The reminders (host-core, used by both desktop and phone) expanded every series on its own and applied none of its single changes ({series}::rid::{slot} rows). The views drop a series' occurrence wherever such a row stands in for it (expandAll). The contract table recorded the difference as surfacesDiffer on five rows.

  • A cancelled occurrence kept ringing at its slot. On Google every deleted occurrence is such a cancelled row, so this happens there today. With PR 7 (decision 202: a new Google series cancels its deleted occurrences as instances), every deleted occurrence of a new series would have rung too.
  • A moved occurrence rang twice: at its old slot through the series, and at its new time through its own row. This applies to CalDAV and Google. Exchange already lists a moved occurrence's original slot among the series' exceptions (adapter-ews to_event).

What changes

  • override_slots collects, per series, the slots its override rows stand in for. Cancelled and moved rows both count, and the slot is read with cal_core::split_override_id.
  • without_overridden_slots adds those slots to the series' exceptions before it expands. The expansion then matches them as it matches its own exceptions: exactly on a timed series, and by the day they name on a series of days (decision 95).
  • A slot that does not read is left alone, as the views leave it.
  • A cancelled row still rings nowhere. A whole cancelled series stays silent, as intended.

Contract

shared/contracts/eventOccurrences.json: five rows now agree and lose their own reminders answer:

  • a-moved-occurrence-stands-in-for-its-slot
  • a-cancelled-occurrence-removes-its-slot
  • a-moved-occurrence-of-a-zoned-series-after-the-change
  • an-override-slot-in-another-spelling
  • an-override-listed-before-its-series

They move from DIFFERING to AGREEING in the Rust contract, and the contract runner folds the slots the same way. The TS anti-silence list names the two override rows it did not name yet.

Tests

  • New test: a_single_change_takes_its_slot_out_of_the_series runs through event_triggers with a weekly series, a cancelled occurrence, and a moved occurrence whose slot is spelled with an offset. Only the series' other occurrences and the moved one at its new time ring.
  • Red proof: without the fold, the new test and every_row_holds_for_the_reminders both fail.
  • Gates: fmt, ts-types, clippy, cargo test --workspace, tsc desktop and mobile, eslint, bridges, and vitest (2377) pass.

First check (90dd625)

Three finders, two skeptics per finding, and a completeness critic. One finding mattered, plus doc corrections:

Exchange all-day single changes (decision 215).

  • The bug. EWS reports OriginalStart as midnight in the mailbox's zone. The series' exceptions were already re-anchored to the local midnight of that day, but the override id kept the raw instant.
  • The effect. Read by the nearest day, it named the neighbouring day wherever the mailbox's zone lies more than twelve hours from the device's: a New York mailbox on a device in Tokyo, or a UTC-midnight series in Auckland's summer. The views already hid that day, and the fold here would have silenced its reminder.
  • The fix. adapter-ews now mints the id from the anchored instant (override_slot). Writing finds the exception by either spelling (names_override), so an older id still resolves.
  • The cost. Cache generation 7 re-reads every account once, so cached rows carry the new ids. The second check below handles what the device-dependent slot changed.
  • Red proof: with the raw slot in the id, the new EWS test fails.

Doc corrections.

  • The contract table's header and the TODO note no longer say the reminders apply no single changes.
  • The TODO count of differing rows is corrected: 13 today, 22 at the pin.
  • The claim about rings no longer names Exchange.
  • ews.md documents the slot.

Runs:

  • gates are green;
  • vitest in UTC: 2377 passed;
  • the second vitest run hit the known load-flaky quickAddOfferCalendar file (9 failures), which passes alone 10/10. This round changed no TypeScript, and the existing task covers that flake.

Second check (6a6a16c)

The anchored slot is this device's local midnight. Six findings followed from that, and decision 216 fixes them:

  • Deleting a day of an all-day Exchange series. delete_series_occurrence now reads the server's slot as the read anchors it before it compares. Before, deleting, cancelling or moving a day failed beyond six hours from the mailbox's zone, and a move left a duplicate. Plain occurrences had the same failure before 215. The target is never re-read, so a midnight east of UTC+12 keeps its day.
  • A device that moved zone. The events token names the device's zone besides the zone translation. A device that moved reads the folder again, and its ids, starts and exceptions follow. names_override stays exact: a slot from another zone is refused, never read as a guessed day.
  • Colour and meeting rows. In cal_core::plan_repairs, a row bound to one occurrence follows its override when that override is minted again. It is never promoted to the series, which used to recolour every other occurrence. This also covers CalDAV and Google remints.
  • Sound. A single change without its own sound rings with its series' sound, the key the desktop writes.

Red proofs: each of the four is caught when sabotaged.

Runs: all gates pass, including vitest 2377 in both runs.

Third check (f36b11a)

Four findings were confirmed. They all came from the 12-hour sample, which reads a midnight east of UTC+12 as the day before, so decision 217 replaces the guess:

  • The zone the day is read in. An all-day Exchange item's instants are midnights in its own zone. all_day_zone takes the item's start zone, read the way its series' zone is read, and UTC for a series made without one. all_day_anchor reads the day in that zone. The start, the exceptions, single changes' rows and their id slot all go through it. Only where Exchange names no readable zone is the day still sampled.
  • Deleting a day. delete_series_occurrence compares the server's slot as read in the series' zone, so a neighbour, a whole day away, never comes within the tolerance. With 216, deleting one day of a daily all-day series in an Auckland mailbox in New Zealand's summer deleted the next day. Without a zone, the raw instants are compared, as before 216, and the delete aborts rather than trust a sampled day.
  • Repairs across zones. plan_repairs reads an all-day start's day twelve hours into it. A row signed in Berlin now finds its day after the device moved to New York, instead of missing it or taking the day before.
  • Docs. A misplaced test doc comment is back on its test. DESIGN §14.4 and the sound module now name the series fallback.

Red proofs:

  • the anchor ignoring the zone;
  • the delete comparing raw instants;
  • the delete sampling the day;
  • the repair reading the UTC date.

Each one fails.

Runs: all gates pass, including vitest 2377 in both runs.

Fourth check (c05e3b5)

Five findings were confirmed, and all are fixed:

  • Relabelled items. all_day_anchor samples the day twelve hours into it in the zone Exchange names, instead of taking its exact date there.
    • Why: after Aperio's own write of an Outlook all-day item (UTC midnights, no zone), Exchange keeps the old zone's midnights and labels them UTC (live round 3, T2). Read by the exact date, such an item jumped back a day right after its drag, and its reminders rang a day early.
    • The sample holds for a label up to twelve hours off, and for any offset the zone has, Auckland's summer included.
  • An exception's before copy. It is read through a shape without zones, so it now takes its series' zone, as its row does (WriteTarget::series_zone). Before, an untouched all-day slot counted as moved, and a rename wrote it back.
  • Repair key. cal_core::starts_the_same reads an all-day day 13:45 into the instant. That gives every zone in (−10:15, +13:45] its own day: New Zealand's summer, the Chatham Islands and London keep their days across zones and at every change of clocks. Read twelve hours in, New Zealand's summer named the day before, and its first summer day shared a key with the last winter day.
  • Docs. The repair's doc comment, TODO (the London item is now fixed) and ews.md are updated.

Red proofs: each of these is caught:

  • reading the exact date;
  • the before copy without its series' zone;
  • repairs read twelve hours in.

Runs: all gates pass, including vitest 2377 in both runs.

Fifth check (08a544a)

One doc typo was confirmed and fixed: a repair test's comment gave the wrong time for Berlin's January midnight. Two split findings were taken as well:

  • One window. all_day_anchor now samples 13:45 into the day, the window cal-core's anchor repair reads days in. That is exact for a midnight the named zone gives, and the intended day for a relabelled item from any zone in (−10:15, +13:45]. Twelve hours still read an Auckland item dragged in New Zealand's summer as the day before. Red proof: at twelve hours, the Auckland relabel test fails.
  • A skipped midnight. On a device zone that skips midnight that day, the first hour after it is used, as the views place the day, instead of the raw instant.

Noted in TODO, not changed: only the phone stores a sound for one occurrence, under that occurrence's id. A re-minted id loses that sound.

Runs: all gates pass.

Sixth check (2593a71)

No code findings. Two comments still said "twelve hours"; both now say 13:45, as the code reads.

Docs

TODO: a PR 7a entry, and the contract note that lists where the surfaces differ is corrected.

🤖 Generated with Claude Code

Timtam and others added 7 commits October 5, 2026 20:07
… 7a, 209, 214)

The reminders expanded every series on its own and applied none of its
single changes. A cancelled occurrence kept ringing at its slot (Google keeps
every deleted occurrence as such a row, so with PR 7 every deleted
occurrence of a new series would have rung), and a moved one rang twice: at
its old slot through the series and at its new time through its own row,
on CalDAV, Google and Exchange alike.

Now every `{series}::rid::{slot}` row takes its slot out of its series before
it expands (override_slots, without_overridden_slots), as the views drop it
(expandAll): matched exactly on a timed series, by the day it names on a
series of days. A slot that does not read is left alone. A cancelled row
still rings nowhere; a whole cancelled series stays silent.

eventOccurrences.json: five rows the reminders differed on now agree and move
to AGREEING; the contract runner folds the slots the same way. A direct test
through event_triggers. Red: without the fold, both fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… by its day (215)

EWS reports an occurrence's OriginalStart as midnight in the mailbox's zone.
The series' own exceptions were already re-anchored to the local midnight of
that day, but the override id kept the raw instant. Read by the day it is
nearest to, as the views and now the reminders read an all-day slot, it
named the neighbouring day wherever the mailbox's zone lies more than twelve
hours from the device's (a New York mailbox in Tokyo, or a UTC-midnight
series in Auckland's summer): the views hid that day already, and the fold of
#117 would have silenced its reminder.

- adapter-ews: override_slot mints the id from the anchored instant on an
  all-day series; names_override lets writing find the exception by either
  spelling, so an id minted before still resolves.
- Cache generation 7 re-reads every account once, so cached rows carry the
  new ids. A colour set on an all-day Exchange single change is lost once.
- The contract table's header and the TODO note no longer say the reminders
  apply no single changes; Exchange never rang twice for a moved occurrence
  (its series already listed the slot), so the claim names CalDAV and Google.
- ews.md documents the slot.

Red: with the raw slot in the id, the new test fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ne (216)

The anchored slot of 215 is this device's local midnight. What that changed:

- adapter-ews delete_series_occurrence reads the server's slot of an all-day
  series as the read anchors it before it compares. Deleting, cancelling or
  moving one day failed beyond six hours from the mailbox's zone (for plain
  occurrences before 215 too); a move left a duplicate. The target itself is
  never re-read, so a midnight east of UTC+12 keeps its day.
- The events token names the device's zone besides the zone translation, so
  a device that moved reads the folder again and its ids, starts and
  exceptions follow. names_override stays exact: a slot from another zone is
  refused, never read as a guessed day.
- cal-core plan_repairs: a row bound to one occurrence follows its override
  when that is minted again, and is never promoted to the series, which
  recoloured every other occurrence. Helps CalDAV and Google remints too.
- host-core SoundPrefs: a single change without a sound of its own rings with
  its series' (the key the desktop writes).
- TODO and ews.md say so.

Red: each of the four is caught when sabotaged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… zone (217)

An all-day Exchange item's instants are midnights in its own zone. The
12-hour sample that recovered the day reads a midnight east of UTC+12 as the
day before, and the delete of 216 trusted it: in an Auckland mailbox in New
Zealand's summer, deleting one day of a daily all-day series deleted the
next. Now:

- adapter-ews all_day_zone: the item's start zone, read as its series' zone
  is (read_series_zone), UTC for a series made without one. all_day_anchor
  reads the day in it; only where Exchange names no zone Aperio can read is
  the day sampled. The start, the exceptions, single changes' rows and their
  id slot all read it.
- delete_series_occurrence compares the server's slot as read in the series'
  zone, so a neighbour, a whole day away, never comes within the tolerance.
  Without a zone it compares the raw instants, as before 216, and aborts
  rather than trust a sampled day.
- cal-core plan_repairs reads an all-day start's day twelve hours into it, so
  a row signed in Berlin finds its day after the device moved to New York
  instead of missing it or taking the day before.
- The misplaced test doc comment is back on its test; DESIGN §14.4 and the
  sound module name the series fallback; ews.md and TODO describe 217.

Red: the anchor ignoring the zone, the delete comparing raw instants, the
delete sampling the day, and the repair reading the UTC date are each caught.

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

- adapter-ews all_day_anchor samples the day twelve hours into it in the
  zone Exchange names, not by its exact date there. After Aperio's own write
  of an Outlook all-day item (UTC midnights, no zone) Exchange keeps the old
  zone's midnights and labels them UTC (live round 3, T2): read by the exact
  date, the item jumped back a day right after its drag and its reminders
  rang a day early. The sample holds for a label up to twelve hours off and
  for any offset the zone has, Auckland's summer included.
- An exception's before copy, read through a shape without zones, takes its
  series' zone as its row does (WriteTarget::series_zone); read without one,
  an untouched all-day slot counted as moved and a rename wrote it back.
- cal-core starts_the_same reads an all-day day 13:45 into the instant: its
  own day for any zone in (-10:15, +13:45], so New Zealand's summer, the
  Chatham Islands and London keep their days across zones and at every
  change of clocks; twelve hours in, New Zealand's summer named the day
  before and its first summer day shared a key with the last winter day.
- Docs: the repair's doc comment, TODO (the London item is fixed), ews.md.

Red: the exact date, the before copy without its series' zone, and repairs
read twelve hours in are each caught.

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

- adapter-ews all_day_anchor samples 13:45 into the day, the window cal-core's
  anchor repair reads days in: exact for a midnight the named zone gives, and
  the intended day for a relabelled item from any zone in (-10:15, +13:45].
  Twelve hours still read an Auckland item dragged in New Zealand's summer
  as the day before.
- A device zone that skips midnight that day gets the first hour after it,
  as the doc said and as the views place the day, not the raw instant.
- The new repair test's comment names Berlin's January midnight right.
- TODO names the window, and the open phone-only per-occurrence sound key
  that a re-minted id loses.

Red: twelve hours in, the Auckland relabel is read a day early.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The delete path's comment and the relabelled-item test's doc name the
sample 13:45 into the day, as the code reads it since the fifth check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Timtam
Timtam merged commit f35de4a into main Oct 6, 2026
20 of 21 checks passed
@Timtam
Timtam deleted the fix/reminders-honour-overrides branch October 6, 2026 05:41
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