From 37a969d152bdea4c4764139fb262e9b938aaa422 Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Mon, 5 Oct 2026 20:07:44 +0200 Subject: [PATCH 1/7] Cancelled and moved single changes ring where the views show them (PR 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 --- TODO.md | 21 +++- crates/host-core/src/reminders.rs | 112 +++++++++++++++++---- shared/contracts/eventOccurrences.json | 101 +------------------ src/intl/eventOccurrences.contract.test.ts | 2 + 4 files changed, 117 insertions(+), 119 deletions(-) diff --git a/TODO.md b/TODO.md index eb7105fa..9b366a18 100644 --- a/TODO.md +++ b/TODO.md @@ -1945,12 +1945,13 @@ Siehe DESIGN §4.2. Leerzeichen (host-core trimmt, Intl nicht) oder in Kleinbuchstaben (Intl nimmt ihn, chrono-tz nicht); eine Zeitumstellung um Mitternacht (Santiago: die Ansichten schieben 00:30 auf 01:30, die Erinnerungen lassen den Tag aus - und enden einen Tag später); Einzeländerungen, auch an einer zonierten Serie - nach der Umstellung (die Erinnerungen wenden keine an, der alte Platz - erinnert weiter); eine abgesagte Serie (die Erinnerungen überspringen sie, + und enden einen Tag später); eine abgesagte Serie (die Erinnerungen überspringen sie, gewollt); und die Kappe der Erinnerungen bei 500 Vorkommen. Auf beiden Seiten gleich und gepinnt: die Sommerzeit-Lücke (vorwärts um die Lückenlänge), die - Überlappung (die frühere Lesung), zonierte Serien an den Bereichsrändern, ein + Überlappung (die frühere Lesung), Einzeländerungen, abgesagt oder verschoben, + auch an einer zonierten Serie nach der Umstellung (seit 214 nehmen sie auch + in den Erinnerungen ihren Platz aus der Serie), zonierte Serien an den + Bereichsrändern, ein zoniertes UNTIL mit `Z` über eine Umstellung, WKST, ganztägige Serien ohne Zone (steppen in UTC, nach der Umstellung um 23:00 am Vortag), das `T235959Z` des Editors auf einer ganztägigen Serie östlich von UTC (behielt @@ -2458,6 +2459,18 @@ Siehe DESIGN §4.2. zeigt als das Gerät (New York 20 Uhr = Berlin 2 Uhr): Die Regel wird auf der alten Uhr gelesen, der ganztägige Beginn auf den Tagen des Geräts; die neue Serie kann neben ihrer Regel beginnen. Selten, nicht behandelt. + - ✅ **Abgesagte und verschobene Einzeltermine klingeln richtig** (PR 7a, + 209, 214): Die Erinnerungen klappten jede Serie für sich aus und wandten + ihre Einzeländerungen nicht an. Ein abgesagtes Vorkommen klingelte + weiter (bei Google ist jede Löschung so eine Zeile; mit PR 7 hätte das + jeden gelöschten Termin eines neuen Serienteils getroffen), ein + verschobenes zweimal: am alten Platz und zur neuen Zeit, bei CalDAV, + Google und Exchange. Jetzt nimmt jede Zeile `{Serie}::rid::{Platz}` ihren + Platz aus der Serie (`override_slots`), wie die Ansichten + (`expandAll`): genau bei Uhrzeit, nach dem Tag bei ganztägig; ein + Platz, der sich nicht lesen lässt, bleibt. Fünf Vertragszeilen in + `eventOccurrences.json` sind jetzt einig; die ganze abgesagte Serie + bleibt still (gewollt). Desktop und Handy gleich (host-core). ↻ im Test. - ✅ **Google liest jede Schreibweise einer Löschung** (PR 6, 205): Andere Apps schreiben gelöschte Vorkommen als `EXDATE`-Zeilen in eine Google-Serie, meist als Wanduhr in der Zone (`EXDATE;TZID=…`), wie auch diff --git a/crates/host-core/src/reminders.rs b/crates/host-core/src/reminders.rs index f87bbc16..6a108e87 100644 --- a/crates/host-core/src/reminders.rs +++ b/crates/host-core/src/reminders.rs @@ -945,6 +945,7 @@ fn event_triggers( window_end: DateTime, day_start: NaiveTime, ) -> Vec { + let overridden = override_slots(events.iter().map(|ev| ev.id.as_str())); let mut out = Vec::new(); for ev in events { // A cancelled meeting never nags — skip it unconditionally, regardless @@ -970,12 +971,16 @@ fn event_triggers( // hasn't started yet) is still useful on app start; one for // an event already ended isn't. let duration = (ev.end - ev.start).max(ChronoDuration::zero()); + let recurrence = ev + .recurrence + .as_ref() + .map(|rec| without_overridden_slots(rec, overridden.get(ev.id.as_str()))); out.extend(occurrence_triggers( &ev.id, ItemKind::Event, &ev.title, ev.start, - ev.recurrence.as_ref(), + recurrence.as_ref(), &effective, duration, window_start, @@ -989,6 +994,37 @@ fn event_triggers( out } +/// Per series, the slots its single changes stand in for: every override row +/// (`{series}::rid::{slot}`) takes its slot out of the series, cancelled or +/// moved (decision 214), as the views drop it (`expandAll` in +/// `shared/recurrence.ts`). A cancelled one then rings nowhere — the row is +/// skipped as every cancelled event is — and a moved one only at its new +/// time, where its own row rings. A slot that does not read is left alone, +/// as the views leave it: never hide what cannot be placed. +fn override_slots<'a>(ids: impl Iterator) -> HashMap<&'a str, Vec>> { + let mut slots: HashMap<&str, Vec>> = HashMap::new(); + for id in ids { + if let Ok(Some((series, slot))) = cal_core::split_override_id(id) { + slots.entry(series).or_default().push(slot); + } + } + slots +} + +/// A series' recurrence with the slots its single changes stand in for as +/// deletions too, which the expansion matches as it matches its own: exactly +/// on a timed series, by the day they name on a series of days. +fn without_overridden_slots( + rec: &EventRecurrence, + slots: Option<&Vec>>, +) -> EventRecurrence { + let mut rec = rec.clone(); + if let Some(slots) = slots { + rec.exceptions.extend(slots.iter().copied()); + } + rec +} + /// The single primitive every event-trigger emission path funnels /// through. Given an item's master start + (optional) recurrence /// spec + reminders + occurrence duration, produce every Trigger @@ -3161,6 +3197,39 @@ mod tests { ); } + /// Decision 214: a single change takes its slot out of its series, as the + /// views drop it. A deleted occurrence (Google keeps every deletion as such + /// a cancelled row) rang at its slot, and a moved one rang twice — at its + /// slot through the series and at its new time through its own row. + #[test] + fn a_single_change_takes_its_slot_out_of_the_series() { + let at = |d: u32, m: u32, h: u32| Utc.with_ymd_and_hms(2026, m, d, h, 0, 0).unwrap(); + let mut series = make_event(vec![rel(15)]); + series.id = "cal|s".into(); + series.start = at(19, 5, 9); + series.end = at(19, 5, 10); + series.recurrence = Some(EventRecurrence { + rrule: "FREQ=WEEKLY;BYDAY=TU".into(), + exceptions: Vec::new(), + tzid: None, + }); + let mut deleted = make_event(vec![rel(15)]); + deleted.id = "cal|s::rid::2026-05-26T09:00:00Z".into(); + deleted.start = at(26, 5, 9); + deleted.end = at(26, 5, 10); + deleted.cancelled = true; + let mut moved = make_event(vec![rel(15)]); + // The slot in another spelling names the same instant. + moved.id = "cal|s::rid::2026-06-02T11:00:00+02:00".into(); + moved.start = at(2, 6, 14); + moved.end = at(2, 6, 15); + + let triggers = ev_triggers(&[moved, series, deleted], &[], at(18, 5, 0), at(10, 6, 0)); + let mut rung: Vec> = triggers.iter().map(|t| t.start).collect(); + rung.sort(); + assert_eq!(rung, vec![at(19, 5, 9), at(2, 6, 14), at(9, 6, 9)]); + } + #[test] fn all_day_reminder_reads_the_offset_as_whole_days() { // "1 week before" (10080 min) → 7 days before at the day-start time. @@ -3885,11 +3954,6 @@ mod tests { "an-until-before-the-start", "an-exception-a-millisecond-off-keeps-the-occurrence", "a-daily-series-across-a-change-at-midnight", - "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", "a-cancelled-series", "a-long-daily-series-in-a-wide-range", ]; @@ -3900,8 +3964,16 @@ mod tests { /// answer of its own again. And the rows of a series of days' UNTIL, /// decided on 2026-10-05 (decision 201): both read it on the day clock /// by its digits, a date as the whole day (`until_on_day_clock` here, - /// `untilOnClock` in the views). Named, like `DIFFERING`. + /// `untilOnClock` in the views). And the rows of a single change, + /// decided on 2026-10-05 (decision 214): cancelled or moved, it takes + /// its slot out of its series on both (`override_slots` here, + /// `expandAll` in the views). Named, like `DIFFERING`. const AGREEING: &[&str] = &[ + "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", "an-all-day-series-west-of-utc-until-its-local-day", "a-date-only-until-on-an-all-day-series-east-of-utc", "the-editors-until-on-an-all-day-series-east-of-utc", @@ -3920,7 +3992,7 @@ mod tests { ]; #[test] - fn the_zone_rows_agree_between_views_and_reminders() { + fn the_decided_rows_agree_between_views_and_reminders() { let t = table(); let cases = t["cases"].as_array().expect("cases"); for name in AGREEING { @@ -3931,7 +4003,7 @@ mod tests { assert!( case.get("reminders").is_none() && case.get("surfacesDiffer").is_none(), "{name} records a reminders answer of its own, but views and reminders \ - read a zone through one rule (cal_core::series_clock, decision 26a)", + read it through one rule (decisions 26a, 201, 214)", ); } } @@ -3968,31 +4040,33 @@ mod tests { .expect("an RFC 3339 instant") } - /// Every event on its own, as `event_triggers` expands it: a cancelled event - /// is skipped before anything expands, and an override is just another event. - /// Sorted by instant, then input index — the order the table records. + /// Every event as `event_triggers` expands it: a cancelled event is skipped + /// before anything expands, and every override takes its slot out of its + /// series (`override_slots`, decision 214). Sorted by instant, then input + /// index — the order the table records. fn reminder_answer(input: &Value, device: RruleTz) -> Vec<(DateTime, usize)> { let lo = instant(&input["range"]["start"]); let hi = instant(&input["range"]["end"]); + let events = input["events"].as_array().expect("events"); + let overridden = + override_slots(events.iter().map(|ev| ev["id"].as_str().expect("an id"))); let mut rows = Vec::new(); - for (i, ev) in input["events"] - .as_array() - .expect("events") - .iter() - .enumerate() - { + for (i, ev) in events.iter().enumerate() { if ev["cancelled"].as_bool() == Some(true) { continue; } let start = instant(&ev["start"]); let starts = match ev["recurrence"].as_object() { Some(rec) => { - let exceptions: Vec> = rec["exceptions"] + let mut exceptions: Vec> = rec["exceptions"] .as_array() .expect("exceptions") .iter() .map(instant) .collect(); + if let Some(slots) = overridden.get(ev["id"].as_str().expect("an id")) { + exceptions.extend(slots.iter().copied()); + } expand_occurrences( start, rec["rrule"].as_str().expect("a rule"), diff --git a/shared/contracts/eventOccurrences.json b/shared/contracts/eventOccurrences.json index eb3df135..fea84a11 100644 --- a/shared/contracts/eventOccurrences.json +++ b/shared/contracts/eventOccurrences.json @@ -2788,8 +2788,7 @@ }, { "name": "a-moved-occurrence-stands-in-for-its-slot", - "note": "The override’s id names the series and the slot it replaces. The views drop the master’s slot and show the override at its new time. The reminders expand each event on its own and apply no override: the master’s slot fires as well as the override.", - "surfacesDiffer": true, + "note": "The override’s id names the series and the slot it replaces. The views drop the master’s slot and show the override at its new time, and so do the reminders since decision 214: every override takes its slot out of its series before it expands. Before, the reminders fired at the master’s slot as well.", "input": { "events": [ { @@ -2826,30 +2825,11 @@ "event": 0, "start": "2026-06-02T09:00:00.000Z" } - ], - "reminders": [ - { - "event": 0, - "start": "2026-05-19T09:00:00.000Z" - }, - { - "event": 0, - "start": "2026-05-26T09:00:00.000Z" - }, - { - "event": 1, - "start": "2026-05-26T14:00:00.000Z" - }, - { - "event": 0, - "start": "2026-06-02T09:00:00.000Z" - } ] }, { "name": "a-cancelled-occurrence-removes-its-slot", - "note": "The views drop the master’s slot and the cancelled override both. The reminders skip the cancelled override and still fire at the master’s slot.", - "surfacesDiffer": true, + "note": "The views drop the master’s slot and the cancelled override both. The reminders skip the cancelled override and, since decision 214, take its slot out of the series too, so a deleted occurrence no longer rings. On Google every deleted occurrence is such a row.", "input": { "events": [ { @@ -2883,26 +2863,11 @@ "event": 0, "start": "2026-06-02T09:00:00.000Z" } - ], - "reminders": [ - { - "event": 0, - "start": "2026-05-19T09:00:00.000Z" - }, - { - "event": 0, - "start": "2026-05-26T09:00:00.000Z" - }, - { - "event": 0, - "start": "2026-06-02T09:00:00.000Z" - } ] }, { "name": "a-moved-occurrence-of-a-zoned-series-after-the-change", - "note": "Monday 09:00 Europe/Berlin from summer; CalDAV names the slot of the November 2 occurrence by its winter instant. The views drop that slot and show the override at 12:00 UTC. The reminders apply no override: the November 2 slot fires as well as the override.", - "surfacesDiffer": true, + "note": "Monday 09:00 Europe/Berlin from summer; CalDAV names the slot of the November 2 occurrence by its winter instant. The views drop that slot and show the override at 12:00 UTC, and so do the reminders since decision 214.", "input": { "events": [ { @@ -2940,24 +2905,6 @@ "event": 1, "start": "2026-11-02T12:00:00Z" } - ], - "reminders": [ - { - "event": 0, - "start": "2026-10-19T07:00:00.000Z" - }, - { - "event": 0, - "start": "2026-10-26T08:00:00.000Z" - }, - { - "event": 0, - "start": "2026-11-02T08:00:00.000Z" - }, - { - "event": 1, - "start": "2026-11-02T12:00:00.000Z" - } ] }, { @@ -3031,8 +2978,7 @@ }, { "name": "an-override-slot-in-another-spelling", - "note": "The slot is written with an offset instead of Z and matched as an instant. The reminders apply no override: the master’s slot fires as well as the override.", - "surfacesDiffer": true, + "note": "The slot is written with an offset instead of Z and matched as an instant, by the views and, since decision 214, by the reminders.", "input": { "events": [ { @@ -3069,24 +3015,6 @@ "event": 0, "start": "2026-06-02T09:00:00.000Z" } - ], - "reminders": [ - { - "event": 0, - "start": "2026-05-19T09:00:00.000Z" - }, - { - "event": 0, - "start": "2026-05-26T09:00:00.000Z" - }, - { - "event": 1, - "start": "2026-05-26T14:00:00.000Z" - }, - { - "event": 0, - "start": "2026-06-02T09:00:00.000Z" - } ] }, { @@ -3178,8 +3106,7 @@ }, { "name": "an-override-listed-before-its-series", - "note": "The order of the list does not matter for which slot is dropped. The reminders apply no override: the master’s slot fires as well as the override.", - "surfacesDiffer": true, + "note": "The order of the list does not matter for which slot is dropped, for the views or, since decision 214, the reminders.", "input": { "events": [ { @@ -3216,24 +3143,6 @@ "event": 1, "start": "2026-06-02T09:00:00.000Z" } - ], - "reminders": [ - { - "event": 1, - "start": "2026-05-19T09:00:00.000Z" - }, - { - "event": 1, - "start": "2026-05-26T09:00:00.000Z" - }, - { - "event": 0, - "start": "2026-05-26T14:00:00.000Z" - }, - { - "event": 1, - "start": "2026-06-02T09:00:00.000Z" - } ] }, { diff --git a/src/intl/eventOccurrences.contract.test.ts b/src/intl/eventOccurrences.contract.test.ts index 37590d31..401e8a39 100644 --- a/src/intl/eventOccurrences.contract.test.ts +++ b/src/intl/eventOccurrences.contract.test.ts @@ -135,6 +135,8 @@ describe('eventOccurrences contract (views)', () => { 'a-zoned-series-on-both-range-ends-into-summer', 'a-zoned-occurrence-just-past-the-range-end', 'a-moved-occurrence-of-a-zoned-series-after-the-change', + 'an-override-slot-in-another-spelling', + 'an-override-listed-before-its-series', 'every-other-week-with-the-week-starting-on-sunday', 'a-plain-event-at-the-same-instant-as-an-occurrence', 'a-zone-with-surrounding-space', From 90dd6252c54784dc2be25452307b9382753637f5 Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Mon, 5 Oct 2026 20:46:35 +0200 Subject: [PATCH 2/7] First check of #117: an Exchange all-day single change names its slot 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 --- TODO.md | 28 +++++-- crates/adapter-ews/src/api.rs | 8 +- crates/adapter-ews/src/mapping.rs | 84 ++++++++++++++++++- crates/host-core/src/cache/mod.rs | 7 +- shared/contracts/eventOccurrences.json | 6 +- .../content/docs/developers/adapters/ews.md | 9 ++ 6 files changed, 126 insertions(+), 16 deletions(-) diff --git a/TODO.md b/TODO.md index 9b366a18..48592e80 100644 --- a/TODO.md +++ b/TODO.md @@ -1925,15 +1925,18 @@ Siehe DESIGN §4.2. eigene Regel) wird erst nach dem Pin mit gemessener WASM-Größe gewählt. ↳ **Schritt 1 (Pin): gebaut.** `shared/contracts/eventOccurrences.json` (dort, weil host-core die Tabelle liest und nur von dort einbetten darf), - 78 Zeilen: Regeln (auch WKST, BYMONTH mit BYDAY, BYMONTHDAY=-1), Bereich + heute 88 Zeilen: Regeln (auch WKST, BYMONTH mit BYDAY, BYMONTHDAY=-1), Bereich (auch zonierte Serien genau an den Rändern), UNTIL (auch das `T235959Z` des Editors und ein zoniertes UNTIL über eine Zeitumstellung), Ausnahmen, Zonen, ganztägig, Einzeländerungen, Größe. `expect` ist die Antwort der Ansichten - (`expandAll`); wo die Erinnerungen anders antworten — jeder Termin einzeln, - wie `event_triggers` ausrollt —, trägt die Zeile `reminders`. Verglichen + (`expandAll`); wo die Erinnerungen anders antworten — so wie + `event_triggers` ausrollt, seit 214 jede Einzeländerung mit ihrem Platz aus + ihrer Serie genommen —, trägt die Zeile `reminders`. Verglichen werden Zeitpunkte, sortiert nach Zeitpunkt und Position; die Reihenfolge von - `expandAll` (nach dem Text des Starts) zählt nicht. **22 Zeilen weichen ab**, - jede eine Entscheidung für den Port: UNTIL als reines Datum, ohne `Z` oder + `expandAll` (nach dem Text des Starts) zählt nicht. Beim Pin wichen **22 + Zeilen** ab (heute 13, benannt in `DIFFERING` in `reminders.rs`; die Zonen + seit 26a, das UNTIL einer Serie von Tagen seit 201 und die + Einzeländerungen seit 214 sind einig), jede eine Entscheidung für den Port: UNTIL als reines Datum, ohne `Z` oder vor dem Start (die rrule-Kiste nimmt die Regel nicht an, die Erinnerungen behalten nur den Starttermin; rrule.js liest ohne Zone ein Datum als 00:00 UTC und eine Uhrzeit ohne `Z` als UTC, bei einer zonierten Serie beides als @@ -2464,13 +2467,22 @@ Siehe DESIGN §4.2. ihre Einzeländerungen nicht an. Ein abgesagtes Vorkommen klingelte weiter (bei Google ist jede Löschung so eine Zeile; mit PR 7 hätte das jeden gelöschten Termin eines neuen Serienteils getroffen), ein - verschobenes zweimal: am alten Platz und zur neuen Zeit, bei CalDAV, - Google und Exchange. Jetzt nimmt jede Zeile `{Serie}::rid::{Platz}` ihren + verschobenes zweimal: am alten Platz und zur neuen Zeit, bei CalDAV und + Google (Exchange trug den alten Platz schon in den Ausnahmen der Serie). + Jetzt nimmt jede Zeile `{Serie}::rid::{Platz}` ihren Platz aus der Serie (`override_slots`), wie die Ansichten (`expandAll`): genau bei Uhrzeit, nach dem Tag bei ganztägig; ein Platz, der sich nicht lesen lässt, bleibt. Fünf Vertragszeilen in `eventOccurrences.json` sind jetzt einig; die ganze abgesagte Serie - bleibt still (gewollt). Desktop und Handy gleich (host-core). ↻ im Test. + bleibt still (gewollt). Desktop und Handy gleich (host-core). Dazu 215: + Exchange nannte den Platz einer ganztägigen Einzeländerung als + Mitternacht in der Zone des Postfachs; lag das Gerät mehr als zwölf + Stunden davon, lasen Ansichten (schon vorher) und nun auch Erinnerungen + den Nachbartag. Der Adapter verankert den Platz jetzt wie die Ausnahme + der Serie (`override_slot`, lokale Mitternacht des Tages), Schreiben + findet beide Schreibweisen (`names_override`), Cache-Generation 7 lädt + einmal neu; eine Farbe an einer ganztägigen Exchange-Einzeländerung geht + dabei einmal verloren. ↻ im Test. - ✅ **Google liest jede Schreibweise einer Löschung** (PR 6, 205): Andere Apps schreiben gelöschte Vorkommen als `EXDATE`-Zeilen in eine Google-Serie, meist als Wanduhr in der Zone (`EXDATE;TZID=…`), wie auch diff --git a/crates/adapter-ews/src/api.rs b/crates/adapter-ews/src/api.rs index 5f643eec..408f9830 100644 --- a/crates/adapter-ews/src/api.rs +++ b/crates/adapter-ews/src/api.rs @@ -1180,10 +1180,14 @@ async fn resolve_override_target( )]); let response = client.post_soap(envelope).await?; let items = crate::mapping::parse_get_calendar_items_response(&response)?; + // The slot as the id names it (`override_slot`): the local midnight of the + // day on an all-day series, and the raw instant on an id minted before + // decision 215. let occurrence = items .iter() - .flat_map(|item| item.modified_occurrences.iter()) - .find(|ov| ov.original_start == original_start); + .flat_map(|item| item.modified_occurrences.iter().map(move |ov| (item, ov))) + .find(|(item, ov)| crate::mapping::names_override(item, ov, original_start)) + .map(|(_, ov)| ov); match occurrence { Some(ov) => Ok(WriteTarget { kind: EventIdKind::Exception, diff --git a/crates/adapter-ews/src/mapping.rs b/crates/adapter-ews/src/mapping.rs index e51bb593..c348542d 100644 --- a/crates/adapter-ews/src/mapping.rs +++ b/crates/adapter-ews/src/mapping.rs @@ -2446,7 +2446,7 @@ pub fn override_event( return Ok(inherited_override_event(master_ev, master_item, ov)); }; let mut row = to_event(own.clone(), calendar_id)?; - row.id = encode_override_event_id(&master_ev.id, ov.original_start); + row.id = encode_override_event_id(&master_ev.id, override_slot(master_item, ov)); // An exception carries no rule of its own; the master keeps the series. row.recurrence = None; // The slot comes from the master's own list, which is what the expander @@ -2490,7 +2490,7 @@ fn inherited_override_event( ov: &ModifiedOccurrence, ) -> Event { let mut row = master_ev.clone(); - row.id = encode_override_event_id(&master_ev.id, ov.original_start); + row.id = encode_override_event_id(&master_ev.id, override_slot(master_item, ov)); row.recurrence = None; if master_item.is_all_day { row.start = all_day_local_anchor(ov.start); @@ -2515,6 +2515,33 @@ fn inherited_override_event( row } +/// The slot a single change names in its id: the instant the master's own +/// exception names for it (`to_event`). On an all-day series that is the +/// local midnight of the intended day, not the raw "some-zone midnight" EWS +/// sends: read by the day it is nearest to, as the views and the reminders +/// read an all-day slot (decision 95), the raw instant named the neighbouring +/// day wherever the mailbox's zone lies more than twelve hours from the +/// device's — the views hid that day, and its reminder fell silent once the +/// reminders honoured single changes too (decision 215). +pub(crate) fn override_slot(master_item: &ParsedItem, ov: &ModifiedOccurrence) -> DateTime { + if master_item.is_all_day { + all_day_local_anchor(ov.original_start) + } else { + ov.original_start + } +} + +/// Whether an override id's slot names this single change: as the id is minted +/// now ([`override_slot`]), or raw, as an id minted before decision 215 named +/// it and a row cached under it may still carry it. +pub(crate) fn names_override( + master_item: &ParsedItem, + ov: &ModifiedOccurrence, + slot: DateTime, +) -> bool { + override_slot(master_item, ov) == slot || ov.original_start == slot +} + /// EWS hands back a plain instant that is midnight of the intended day /// in SOME zone (the mailbox timezone, or UTC for boundaries we wrote /// ourselves) without saying which. Sampling 12 hours INTO the day lands @@ -7560,6 +7587,59 @@ mod tests { ); } + /// Decision 215: a single change of an all-day series names its slot as + /// the series' own exception does — the local midnight of its day — so the + /// views and the reminders read the same day for both. The raw instant is + /// midnight in the mailbox's zone (here New York), which a device more than + /// twelve hours away read as the neighbouring day. Zone-generic: asserts + /// against `all_day_local_anchor`, as the test above does. + #[test] + fn an_all_day_single_change_names_its_slot_as_the_series_does() { + let orig: DateTime = "2026-05-26T04:00:00Z".parse().unwrap(); + let mut item = ParsedItem { + item_id: "M".into(), + subject: "Daily all-day".into(), + start: Some("2026-05-20T04:00:00Z".parse().unwrap()), + end: Some("2026-05-21T04:00:00Z".parse().unwrap()), + is_all_day: true, + is_recurring: true, + item_type: Some("RecurringMaster".into()), + ..ParsedItem::default() + }; + item.recurrence = Some(EwsRecurrence { + pattern: EwsRecurrencePattern::Daily { interval: 1 }, + range: EwsRecurrenceRange::Numbered { occurrences: 30 }, + }); + let ov = ModifiedOccurrence { + item_id: "OCC".into(), + change_key: None, + start: "2026-05-27T04:00:00Z".parse().unwrap(), + end: "2026-05-28T04:00:00Z".parse().unwrap(), + original_start: orig, + cancelled: false, + own: None, + }; + item.modified_occurrences = vec![ov.clone()]; + let master = to_event(item.clone(), "cal").unwrap(); + let row = override_event(&master, &item, &ov, "cal").unwrap(); + let (series, slot) = cal_core::split_override_id(&row.id).unwrap().unwrap(); + assert_eq!(series, master.id); + assert_eq!(slot, all_day_local_anchor(orig)); + assert!(master.recurrence.unwrap().exceptions.contains(&slot)); + // Writing finds the change by the id's slot, and by the raw one an + // older id carries; never by another day's. + assert!(names_override(&item, &ov, slot)); + assert!(names_override(&item, &ov, orig)); + assert!(!names_override( + &item, + &ov, + all_day_local_anchor("2026-05-27T04:00:00Z".parse().unwrap()) + )); + // A timed series keeps its exact instant. + item.is_all_day = false; + assert_eq!(override_slot(&item, &ov), orig); + } + #[test] fn nominal_occurrence_index_maps_dates_to_ews_instance_index() { let dt = |s: &str| s.parse::>().unwrap(); diff --git a/crates/host-core/src/cache/mod.rs b/crates/host-core/src/cache/mod.rs index 2ebd4dc7..16a2e0a3 100644 --- a/crates/host-core/src/cache/mod.rs +++ b/crates/host-core/src/cache/mod.rs @@ -73,7 +73,12 @@ mod tests; /// instead of carving the occurrence out of its series (decision 79b). A /// listing stored under an earlier generation has no such flag and would /// keep carving out until the next re-bootstrap. -pub const CACHE_GENERATION: u32 = 6; +/// 7: an Exchange all-day single change names its slot by the local midnight of +/// its day, as its series' own exception does (decision 215). A row cached +/// under 6 carries the raw "some-zone midnight", which the views and the +/// reminders read as the neighbouring day where the mailbox's zone lies more +/// than twelve hours from the device's. +pub const CACHE_GENERATION: u32 = 7; /// `user_prefs` key holding the cache generation last applied on this device. pub const CACHE_GENERATION_KEY: &str = "cache.generation"; diff --git a/shared/contracts/eventOccurrences.json b/shared/contracts/eventOccurrences.json index fea84a11..bdcc5791 100644 --- a/shared/contracts/eventOccurrences.json +++ b/shared/contracts/eventOccurrences.json @@ -1,5 +1,5 @@ { - "what": "Event recurrence as a table: which occurrences a set of events (series, the overrides of single occurrences, plain events) yields inside a range. `expect` is what the calendar views and the widget get from shared/recurrence.ts expandAll (rrule.js). Where the reminder path in host-core (expand_occurrences on the rrule crate, one event at a time as event_triggers calls it) answers differently, the row carries that answer as `reminders` and says `surfacesDiffer`. Measured on the real code of both before the port; rows whose note names a decision were changed or added by hand after it.", + "what": "Event recurrence as a table: which occurrences a set of events (series, the overrides of single occurrences, plain events) yields inside a range. `expect` is what the calendar views and the widget get from shared/recurrence.ts expandAll (rrule.js). Where the reminder path in host-core (expand_occurrences on the rrule crate, each series with the slots of its single changes taken out, as event_triggers calls it) answers differently, the row carries that answer as `reminders` and says `surfacesDiffer`. Measured on the real code of both before the port; rows whose note names a decision were changed or added by hand after it.", "why": "The views and the reminders expand the same series with two libraries in two languages, and the survey of 2026-09-14 found them apart. One expander in the core answers both, and a frontend that is not JavaScript. Every row where the two differ is a decision for the port.", "writtenBy": [ "src/intl/eventOccurrences.measure.test.ts — a one-off, run once and deleted: expandAll on every case.", @@ -9,7 +9,7 @@ ], "readBy": [ "src/intl/eventOccurrences.contract.test.ts (the views: expandAll)", - "crates/host-core/src/reminders.rs, tests::event_occurrence_contract (the reminders: expand_occurrences, one event at a time)" + "crates/host-core/src/reminders.rs, tests::event_occurrence_contract (the reminders: expand_occurrences, with the slots of single changes folded in as event_triggers does)" ], "whereItLives": { "note": "In shared/contracts, beside the other tables both languages check themselves against, because host-core reads it and may embed only from here (KNOWN_REACHES in crates/host-plugins/tests/manifest_reach.rs). When the rule moves into cal-core the table moves beside taskOccurrences.json and host-core stops reading it." @@ -27,7 +27,7 @@ "shape": { "input": "{events: [{id, start, end, all_day?, recurrence: {rrule, exceptions, tzid?} | null, cancelled?}], range: {start, end}}; instants in RFC 3339, UTC. `all_day` reads as false when absent; an all-day series repeats on the DEVICE's calendar days (48a) and cancels by the day an exception falls on, so its rows only mean what they say when the reader is in `measuredWith.machineZone`", "expect": "[{event: index into events, start}], sorted by instant and then by input index", - "reminders": "present only where the reminder path answered differently: the same shape and order, each event expanded on its own (a cancelled event skipped, overrides not applied)", + "reminders": "present only where the reminder path answered differently: the same shape and order, each event expanded as event_triggers expands it: a cancelled event skipped, and since decision 214 every override (`{series}::rid::{slot}`, cancelled or moved) taking its slot out of its series first", "surfacesDiffer": "marks a row that records `reminders`", "wasTypeScript": "a row pinning a JavaScript artifact the port may decide against; it must then change the row on purpose, with a note", "instants": "compare as instants, never as text: expandAll writes milliseconds (.000Z)", diff --git a/web/src/content/docs/developers/adapters/ews.md b/web/src/content/docs/developers/adapters/ews.md index 66099a19..e013646c 100644 --- a/web/src/content/docs/developers/adapters/ews.md +++ b/web/src/content/docs/developers/adapters/ews.md @@ -47,6 +47,15 @@ The endpoint is discovered or user-supplied. from the master. An item that cannot be read, or that answers for another slot, leaves the row inheriting the series' content and says so in the log: an inherited value is wrong, a guessed one would be worse. +- **An all-day exception names its slot by its day.** EWS reports an + occurrence's `OriginalStart` as midnight in some zone, the mailbox's, without + saying which. The series' own exceptions are re-anchored to the local + midnight of that day (`all_day_local_anchor`), and the override id names the + same instant (`override_slot`, decision 215). With the raw instant, a device + more than twelve hours from the mailbox's zone read the neighbouring day: the + views hid it, and the reminders, which honour single changes since decision + 214, silenced it. Writing finds the exception by either spelling + (`names_override`), so an id minted before still resolves. - **Exceptions keep their rule field.** Editing one changed occurrence writes to the exception's own item, which the override id finds from the series head on every write. That update never sends `DeleteItemField From 6a6a16c7dab4a0cb470f2d32a7d24fe02ee41f58 Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Mon, 5 Oct 2026 21:20:52 +0200 Subject: [PATCH 3/7] Second check of #117: an all-day slot that depends on the device's zone (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 --- Cargo.lock | 1 + TODO.md | 11 +- crates/adapter-ews/Cargo.toml | 2 + crates/adapter-ews/src/api.rs | 116 +++++++++++++++++- crates/adapter-ews/src/lib.rs | 68 ++++++++-- crates/adapter-ews/src/mapping.rs | 7 +- crates/cal-core/src/event_anchor.rs | 56 ++++++++- crates/host-core/src/sound.rs | 29 ++++- .../content/docs/developers/adapters/ews.md | 7 +- 9 files changed, 275 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index caf30538..bba6a5a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -105,6 +105,7 @@ dependencies = [ "cal-core", "chrono", "futures", + "iana-time-zone", "mockito", "quick-xml 0.36.2", "reqwest 0.12.28", diff --git a/TODO.md b/TODO.md index 48592e80..38bc28ae 100644 --- a/TODO.md +++ b/TODO.md @@ -2481,8 +2481,15 @@ Siehe DESIGN §4.2. den Nachbartag. Der Adapter verankert den Platz jetzt wie die Ausnahme der Serie (`override_slot`, lokale Mitternacht des Tages), Schreiben findet beide Schreibweisen (`names_override`), Cache-Generation 7 lädt - einmal neu; eine Farbe an einer ganztägigen Exchange-Einzeländerung geht - dabei einmal verloren. ↻ im Test. + einmal neu. Die Folgen (216): Weil diese Plätze von der Zone des Geräts + abhängen, liest Exchange nach einem Zonenwechsel alles einmal neu (das + Token nennt die Zone); einen Tag einer ganztägigen Serie zu löschen + liest den Platz des Servers wie das Lesen (vorher scheiterte das über + sechs Stunden Abstand zum Postfach, auch bei normalen Vorkommen); eine + Farbe oder ein Meeting an einer Einzeländerung wandert bei einer neuen + Kennung zu ihr, nie auf die ganze Serie (`plan_repairs`, hilft auch + CalDAV und Google); ohne eigenen Ton klingelt eine Einzeländerung mit + dem der Serie. ↻ im Test. - ✅ **Google liest jede Schreibweise einer Löschung** (PR 6, 205): Andere Apps schreiben gelöschte Vorkommen als `EXDATE`-Zeilen in eine Google-Serie, meist als Wanduhr in der Zone (`EXDATE;TZID=…`), wie auch diff --git a/crates/adapter-ews/Cargo.toml b/crates/adapter-ews/Cargo.toml index 0ddff8b4..0f6466f3 100644 --- a/crates/adapter-ews/Cargo.toml +++ b/crates/adapter-ews/Cargo.toml @@ -13,6 +13,8 @@ async-trait.workspace = true cal-core.workspace = true chrono.workspace = true futures.workspace = true +# The device's zone names what an all-day slot was read on (events token). +iana-time-zone.workspace = true rrule.workspace = true quick-xml.workspace = true reqwest.workspace = true diff --git a/crates/adapter-ews/src/api.rs b/crates/adapter-ews/src/api.rs index 408f9830..421684a5 100644 --- a/crates/adapter-ews/src/api.rs +++ b/crates/adapter-ews/src/api.rs @@ -1083,11 +1083,28 @@ pub async fn delete_series_occurrence( })?; // 3. Verify the candidate (and ±1) against the server; delete the occurrence - // whose real Start matches `target`, else abort. + // whose real Start matches `target`, else abort. On an all-day series the + // server's slot is midnight in the mailbox's zone, and `target` the local + // midnight of that day — an occurrence the views expanded, or an override + // id (`override_slot`) — or, from an id minted before decision 215, the + // raw instant again. So the server's slot is also read as the local + // midnight of its day, as the read anchors it (decision 216); a device + // more than the tolerance from the mailbox's zone could not delete a day + // of an all-day series otherwise. `target` itself is never re-read: it is + // exact for this device, and anchoring it would move a midnight east of + // UTC+12 onto the day before. let mut best: Option<(u32, i64)> = None; for index in candidate_indices(candidate) { if let Some(s) = occurrence_start(client, master_id, change_key, index).await? { - let delta = (s - target).num_seconds().abs(); + let as_read = if master.is_all_day { + crate::mapping::all_day_local_anchor(s) + } else { + s + }; + let delta = (s - target) + .num_seconds() + .abs() + .min((as_read - target).num_seconds().abs()); if best.map(|(_, bd)| delta < bd).unwrap_or(true) { best = Some((index, delta)); } @@ -3522,6 +3539,101 @@ mod tests { /// where it now starts. Matched by its Start it was never found, the delete /// aborted, and a move that had already created the new event left the /// exception behind as a duplicate. + /// Decision 216: an all-day series' slots are the mailbox's midnights on + /// the server (here Honolulu's, 10:00 UTC) and the device's local midnights + /// in Aperio — an expanded occurrence, or an override id. The server's slot + /// is read as the read anchors it, so a device far from the mailbox's zone + /// still deletes the day it names, and only that one. + #[tokio::test] + async fn an_all_day_day_is_found_far_from_the_mailboxs_zone() { + let mut server = Server::new_async().await; + let _master = server + .mock("POST", "/") + .match_body(mockito::Matcher::AllOf(vec![ + mockito::Matcher::Regex("m:GetItem".into()), + mockito::Matcher::Regex(r#"ItemId Id="MASTER""#.into()), + ])) + .with_status(200) + .with_body( + r#" + + + + NoError + + + Weekly all-day + 2026-07-06T10:00:00Z + 2026-07-07T10:00:00Z + true + true + RecurringMaster + + + 1 + Monday + + 2026-07-06 + + + + +"#, + ) + .create_async() + .await; + let mut probes = Vec::new(); + for (idx, iso) in [ + (1, "2026-07-06T10:00:00Z"), + (2, "2026-07-13T10:00:00Z"), + (3, "2026-07-20T10:00:00Z"), + (4, "2026-07-27T10:00:00Z"), + ] { + probes.push( + server + .mock("POST", "/") + .match_body(mockito::Matcher::AllOf(vec![ + mockito::Matcher::Regex("m:GetItem".into()), + mockito::Matcher::Regex(format!(r#"InstanceIndex="{idx}""#)), + ])) + .with_status(200) + .with_body(occurrence_get_response(iso)) + .create_async() + .await, + ); + } + let del = server + .mock("POST", "/") + .match_body(mockito::Matcher::AllOf(vec![ + mockito::Matcher::Regex("DeleteType".into()), + mockito::Matcher::Regex(r#"InstanceIndex="3""#.into()), + ])) + .with_status(200) + .with_body( + r#" + + + + NoError + + +"#, + ) + .expect(1) + .create_async() + .await; + + // The day as this device names it: its local midnight of July 20. + let day = crate::mapping::all_day_local_anchor("2026-07-20T10:00:00Z".parse().unwrap()); + delete_series_occurrence(&client_for(&server), "MASTER", Some("CK"), day, false) + .await + .unwrap(); + del.assert_async().await; + } + #[tokio::test] async fn delete_series_occurrence_finds_an_exception_moved_far_by_its_slot() { let mut server = Server::new_async().await; diff --git a/crates/adapter-ews/src/lib.rs b/crates/adapter-ews/src/lib.rs index 9c6ad73c..3069c805 100644 --- a/crates/adapter-ews/src/lib.rs +++ b/crates/adapter-ews/src/lib.rs @@ -546,13 +546,27 @@ impl EwsAdapter { } /// The token the host keeps for an EWS events folder: - /// `zt-{translation}:{cookie}` — the `SyncFolderItems` cookie, prefixed - /// with the zone translation ([`windows_tz::translation_id`]) the emitted - /// events were made with. The host stores a token only together with the - /// change set it came with, so a token naming this build's translation - /// proves the host holds events translated by it. + /// `zt-{translation}~{device zone}:{cookie}` — the `SyncFolderItems` + /// cookie, prefixed with what the emitted events were made with + /// ([`Self::emitted_with`]). The host stores a token only together with + /// the change set it came with, so a token naming this build's translation + /// and this device's zone proves the host holds events made by them. fn events_token(cookie: Option<&str>) -> Option { - cookie.map(|cookie| format!("zt-{}:{cookie}", windows_tz::translation_id())) + cookie.map(|cookie| format!("zt-{}:{cookie}", Self::emitted_with())) + } + + /// What the emitted events depend on beyond Exchange's data: the zone + /// translation ([`windows_tz::translation_id`]), and the device's zone. An + /// all-day series' start, its exceptions and the slot in its single + /// changes' ids are the device's local midnights (`all_day_local_anchor`, + /// decision 215), so a device that moved to another zone reads the folder + /// again rather than keep ids no write finds any more (decision 216). + fn emitted_with() -> String { + format!( + "{}~{}", + windows_tz::translation_id(), + iana_time_zone::get_timezone().unwrap_or_default() + ) } /// A host token split into the zone translation it names, if any, and the @@ -611,8 +625,9 @@ impl EwsAdapter { let adapter_warm = prior.sync_state.is_some(); let mut force_full = since_token.is_none() || !adapter_warm; // The host's snapshot holds events translated with the zone - // translation its token names. A token from another translation — an - // older build's, or one without the prefix — gets every cached item + // translation, and read on the device zone, its token names. A token + // from another translation or zone — an older build's, one without the + // prefix, or one from before the device moved — gets every cached item // emitted again from what Exchange sent, so no view keeps a zone an // old table read, and no edit writes that zone back to Exchange under // a new id. Nothing is re-drained: the cookie inside the token still @@ -625,7 +640,7 @@ impl EwsAdapter { } None => (None, None), }; - let translation_current = token_translation == Some(windows_tz::translation_id().as_str()); + let translation_current = token_translation == Some(Self::emitted_with().as_str()); let seed = match cookie { Some(tok) if adapter_warm => SyncedFolderState { sync_state: Some(tok.to_string()), @@ -1997,6 +2012,17 @@ mod delta_read_tests { .expect(1) .create_async() .await; + let _fourth = server + .mock("POST", "/") + .match_body(Matcher::AllOf(vec![ + Matcher::Regex("SyncFolderItems".into()), + Matcher::Regex("COOKIE-6".into()), + ])) + .with_status(200) + .with_body(sync_page("COOKIE-7", "")) + .expect(1) + .create_async() + .await; let _enrich = server .mock("POST", "/") .match_body(Matcher::Regex("GetItem".into())) @@ -2073,6 +2099,30 @@ mod delta_read_tests { "another translation's token is a full resync" ); assert_eq!(titles(&third), ["Alpha v2", "Bravo"]); + + // Decision 216: the token names the device's zone too ... + let zone = iana_time_zone::get_timezone().expect("the test machine names its zone"); + assert!( + second + .new_token + .as_deref() + .is_some_and(|token| token.contains(&format!("~{zone}:"))), + "{:?}", + second.new_token + ); + // ... and one with this translation, but read on another device zone — + // the device moved, and its all-day slots are other local midnights + // now — emits everything again, so the ids follow. + let moved = format!("zt-{}~Not/This_Zone:COOKIE-6", windows_tz::translation_id()); + let fourth = adapter + .get_events_delta("FA|FCK", range(), Some(&moved)) + .await + .expect("fourth delta"); + assert!( + fourth.full_resync, + "a token read on another device zone is a full resync" + ); + assert_eq!(titles(&fourth), ["Alpha v2", "Bravo"]); } /// State filled by an older item parser lacks fields the read rule needs diff --git a/crates/adapter-ews/src/mapping.rs b/crates/adapter-ews/src/mapping.rs index c348542d..93185ce6 100644 --- a/crates/adapter-ews/src/mapping.rs +++ b/crates/adapter-ews/src/mapping.rs @@ -2532,8 +2532,11 @@ pub(crate) fn override_slot(master_item: &ParsedItem, ov: &ModifiedOccurrence) - } /// Whether an override id's slot names this single change: as the id is minted -/// now ([`override_slot`]), or raw, as an id minted before decision 215 named -/// it and a row cached under it may still carry it. +/// now ([`override_slot`]), or raw, as an id minted before decision 215 names +/// it. Exact on purpose: an all-day slot is the local midnight of the device +/// that read it, and a device that moves to another zone reads the folder +/// again ([`crate::EwsAdapter`]'s events token names the zone), so its ids +/// follow. A slot from another zone is refused, never read as a guessed day. pub(crate) fn names_override( master_item: &ParsedItem, ov: &ModifiedOccurrence, diff --git a/crates/cal-core/src/event_anchor.rs b/crates/cal-core/src/event_anchor.rs index b1a8c766..dd0cd100 100644 --- a/crates/cal-core/src/event_anchor.rs +++ b/crates/cal-core/src/event_anchor.rs @@ -218,13 +218,28 @@ pub fn plan_repairs( if wanted_start < lower || wanted_start > upper { continue; } - // Collapse to the series before asking whether the answer is unique: a - // master and a provider-sent override of one of its occurrences are - // two rows for ONE appointment. + // A row bound to ONE occurrence follows that occurrence, never the + // series (decision 216): its override was minted again under another + // id — a slot read in another zone, a series whose own id moved on — + // and the series is a different appointment, which taking the row + // would recolour whole. So only another override can be its + // occurrence now, by its own id. Any other row collapses to the series + // before asking whether the answer is unique: a master and a + // provider-sent override of one of its occurrences are two rows for + // ONE appointment. + let occurrence_bound = row.event_id.contains(OVERRIDE_ID_MARKER); let mut candidates: Vec<&str> = events .iter() .filter(|ev| normalize(&ev.title) == wanted_title && starts_the_same(ev, wanted_start)) - .map(|ev| series_master_id(&ev.id)) + .filter_map(|ev| { + if !occurrence_bound { + Some(series_master_id(&ev.id)) + } else if ev.id.contains(OVERRIDE_ID_MARKER) { + Some(ev.id.as_str()) + } else { + None + } + }) .collect(); candidates.sort_unstable(); candidates.dedup(); @@ -666,6 +681,39 @@ mod tests { .is_empty()); } + /// Decision 216: an occurrence's override minted again under another id — + /// its slot read in another zone, or its series' own id moved on — takes + /// the row along to the new override. It is never promoted to the series, + /// which would colour every other occurrence; and where no override is the + /// occurrence now, the row stays where it is. + #[test] + fn a_row_bound_to_an_occurrence_follows_its_reminted_override() { + let start = at(2); + let old = "M:ID|CK1::rid::2026-06-01T22:00:00+00:00"; + let reminted = "M:ID|CK1::rid::2026-06-01T23:00:00+00:00"; + let series = event("M:ID|CK1", "cal", "Standup", start); + assert_eq!( + plan_repairs( + &[row(old, "cal", "Standup", start)], + "cal", + &[series.clone(), event(reminted, "cal", "Standup", start)], + week_of(1), + ), + vec![Repair::Repoint { + event_id: old.into(), + to: reminted.into(), + }], + ); + // Only the series answers: the row is left alone, not promoted. + assert!(plan_repairs( + &[row(old, "cal", "Standup", start)], + "cal", + &[series], + week_of(1), + ) + .is_empty()); + } + #[test] fn a_row_outside_what_the_batch_covers_is_left_alone() { let start = at(1); diff --git a/crates/host-core/src/sound.rs b/crates/host-core/src/sound.rs index e322f544..15cb62e9 100644 --- a/crates/host-core/src/sound.rs +++ b/crates/host-core/src/sound.rs @@ -125,14 +125,24 @@ impl SoundPrefs { } /// The sound an item resolves to BEFORE any per-reminder override — - /// item ?? container ?? global ?? System. + /// item ?? its series ?? container ?? global ?? System. + /// + /// A single change of a series (`{series}::rid::{slot}`) rings with the + /// series' sound when it has none of its own (decision 216): the desktop + /// keys an event's sound by its series, and an occurrence's own key names + /// a slot that is minted again when the occurrence is read in another + /// zone, so it can stop matching while the series' still does. pub fn item_fallback( &self, item_id: &str, container_kind: ContainerKind, container_id: &str, ) -> SoundConfig { - if let Some(s) = self.by_item.get(item_id) { + if let Some(s) = self + .by_item + .get(item_id) + .or_else(|| self.by_item.get(cal_core::series_master_id(item_id))) + { return s.clone(); } let by_container = match container_kind { @@ -175,6 +185,21 @@ mod tests { assert_eq!(got.source, SoundSource::System); } + /// Decision 216: a single change without a sound of its own rings with its + /// series' — the key the desktop writes — and its own still wins. + #[test] + fn a_single_change_rings_with_its_series_sound() { + let mut prefs = SoundPrefs::default(); + prefs.insert_key("sound.calendar.cal-1", custom("cal")); + prefs.insert_key("sound.item.M:ID|CK", custom("series")); + let changed = "M:ID|CK::rid::2026-06-01T22:00:00+00:00"; + let got = prefs.resolve(None, changed, ContainerKind::Calendar, "cal-1"); + assert_eq!(got, custom("series")); + prefs.insert_key(&format!("sound.item.{changed}"), custom("own")); + let got = prefs.resolve(None, changed, ContainerKind::Calendar, "cal-1"); + assert_eq!(got, custom("own")); + } + #[test] fn reminder_override_beats_everything() { let mut prefs = SoundPrefs::default(); diff --git a/web/src/content/docs/developers/adapters/ews.md b/web/src/content/docs/developers/adapters/ews.md index e013646c..fc50c17b 100644 --- a/web/src/content/docs/developers/adapters/ews.md +++ b/web/src/content/docs/developers/adapters/ews.md @@ -55,7 +55,12 @@ The endpoint is discovered or user-supplied. more than twelve hours from the mailbox's zone read the neighbouring day: the views hid it, and the reminders, which honour single changes since decision 214, silenced it. Writing finds the exception by either spelling - (`names_override`), so an id minted before still resolves. + (`names_override`), so an id minted before still resolves. These slots are + this device's local midnights, so the events token names the device's zone + as well as the zone translation: a device that moved reads the folder again, + and its ids follow (decision 216). Deleting one day of an all-day series + reads the server's slot the same way before it compares, so a device far + from the mailbox's zone finds the day it names. - **Exceptions keep their rule field.** Editing one changed occurrence writes to the exception's own item, which the override id finds from the series head on every write. That update never sends `DeleteItemField From f36b11ab4149a679c8808f9278f5a3b9f7701a29 Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Mon, 5 Oct 2026 22:00:39 +0200 Subject: [PATCH 4/7] Third check of #117: Exchange reads an all-day day in the series' own zone (217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 1 + DESIGN.md | 1 + TODO.md | 20 ++- crates/adapter-ews/Cargo.toml | 2 + crates/adapter-ews/src/api.rs | 163 +++++++++++++++--- crates/adapter-ews/src/lib.rs | 2 +- crates/adapter-ews/src/mapping.rs | 146 +++++++++++++--- crates/cal-core/src/event_anchor.rs | 46 ++++- crates/host-core/src/sound.rs | 3 +- .../content/docs/developers/adapters/ews.md | 18 +- 10 files changed, 335 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bba6a5a6..183ab4f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,6 +104,7 @@ dependencies = [ "base64 0.22.1", "cal-core", "chrono", + "chrono-tz", "futures", "iana-time-zone", "mockito", diff --git a/DESIGN.md b/DESIGN.md index b0c9124c..f653ae07 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -2623,6 +2623,7 @@ Der Reminder-Scheduler lädt einmal pro Scan einen `SoundPrefs`-Snapshot aller ` ``` reminder.sound ?? prefs["sound.item.{itemId}"] + ?? prefs["sound.item.{seriesId}"] // Einzeländerung {seriesId}::rid::{slot} ohne eigenen Ton (216) ?? prefs["sound.{calendar|tasklist}.{containerId}"] ?? prefs["sound.global"] ?? System // SoundConfig::default() diff --git a/TODO.md b/TODO.md index 38bc28ae..c8c85949 100644 --- a/TODO.md +++ b/TODO.md @@ -2483,13 +2483,21 @@ Siehe DESIGN §4.2. findet beide Schreibweisen (`names_override`), Cache-Generation 7 lädt einmal neu. Die Folgen (216): Weil diese Plätze von der Zone des Geräts abhängen, liest Exchange nach einem Zonenwechsel alles einmal neu (das - Token nennt die Zone); einen Tag einer ganztägigen Serie zu löschen - liest den Platz des Servers wie das Lesen (vorher scheiterte das über - sechs Stunden Abstand zum Postfach, auch bei normalen Vorkommen); eine - Farbe oder ein Meeting an einer Einzeländerung wandert bei einer neuen - Kennung zu ihr, nie auf die ganze Serie (`plan_repairs`, hilft auch + Token nennt die Zone); eine Farbe oder ein Meeting an einer + Einzeländerung wandert bei einer neuen Kennung zu ihr, nie auf die ganze + Serie, und ein ganztägiger Termin wird dabei nach dem Tag gefunden, den + er nennt, auch aus einer anderen Zone (`plan_repairs`, hilft auch CalDAV und Google); ohne eigenen Ton klingelt eine Einzeländerung mit - dem der Serie. ↻ im Test. + dem der Serie. Und 217: Exchange liest die Zeitpunkte einer ganztägigen + Serie in ihrer eigenen Zone (Startzone wie die Zone der Serie, UTC für + eine ohne Zone, `all_day_zone`), nicht mehr mit der 12-Stunden-Regel, + die eine Mitternacht östlich von UTC+12 (Neuseelands Sommer) als den + Vortag las; die Regel bleibt nur, wo Exchange keine lesbare Zone nennt. + Einen Tag einer ganztägigen Serie zu löschen vergleicht den Platz des + Servers in dieser Zone; vorher scheiterte das über sechs Stunden Abstand + zum Postfach, und mit der Regel hätte es in Neuseelands Sommer den + Folgetag gelöscht. Ohne Zone werden die rohen Zeitpunkte verglichen und + im Zweifel abgebrochen. ↻ im Test. - ✅ **Google liest jede Schreibweise einer Löschung** (PR 6, 205): Andere Apps schreiben gelöschte Vorkommen als `EXDATE`-Zeilen in eine Google-Serie, meist als Wanduhr in der Zone (`EXDATE;TZID=…`), wie auch diff --git a/crates/adapter-ews/Cargo.toml b/crates/adapter-ews/Cargo.toml index 0f6466f3..1237de94 100644 --- a/crates/adapter-ews/Cargo.toml +++ b/crates/adapter-ews/Cargo.toml @@ -15,6 +15,8 @@ chrono.workspace = true futures.workspace = true # The device's zone names what an all-day slot was read on (events token). iana-time-zone.workspace = true +# An all-day item's instants are midnights in its own zone (decision 217). +chrono-tz.workspace = true rrule.workspace = true quick-xml.workspace = true reqwest.workspace = true diff --git a/crates/adapter-ews/src/api.rs b/crates/adapter-ews/src/api.rs index 421684a5..3763708c 100644 --- a/crates/adapter-ews/src/api.rs +++ b/crates/adapter-ews/src/api.rs @@ -1084,27 +1084,27 @@ pub async fn delete_series_occurrence( // 3. Verify the candidate (and ±1) against the server; delete the occurrence // whose real Start matches `target`, else abort. On an all-day series the - // server's slot is midnight in the mailbox's zone, and `target` the local + // server's slot is midnight in the series' zone, and `target` the local // midnight of that day — an occurrence the views expanded, or an override - // id (`override_slot`) — or, from an id minted before decision 215, the - // raw instant again. So the server's slot is also read as the local - // midnight of its day, as the read anchors it (decision 216); a device - // more than the tolerance from the mailbox's zone could not delete a day - // of an all-day series otherwise. `target` itself is never re-read: it is - // exact for this device, and anchoring it would move a midnight east of - // UTC+12 onto the day before. + // id (`override_slot`). Where Exchange names the series' zone, the + // server's slot is read in it (decision 217) and compared as the read + // anchors it: the day is exact, so a neighbour, a whole day away, never + // comes within the tolerance. Where it names none, the raw instants are + // compared as before, and a device far from the mailbox's zone aborts + // rather than trust a day the 12-hour sample may get wrong. + let day_zone = if master.is_all_day { + crate::mapping::all_day_zone(&master) + } else { + None + }; let mut best: Option<(u32, i64)> = None; for index in candidate_indices(candidate) { if let Some(s) = occurrence_start(client, master_id, change_key, index).await? { - let as_read = if master.is_all_day { - crate::mapping::all_day_local_anchor(s) - } else { - s + let slot = match day_zone { + Some(zone) => crate::mapping::all_day_anchor(s, Some(zone)), + None => s, }; - let delta = (s - target) - .num_seconds() - .abs() - .min((as_read - target).num_seconds().abs()); + let delta = (slot - target).num_seconds().abs(); if best.map(|(_, bd)| delta < bd).unwrap_or(true) { best = Some((index, delta)); } @@ -3534,16 +3534,11 @@ mod tests { assert!(matches!(err, EwsError::Protocol(_))); } - /// An exception moved three days away from its slot is still the - /// occurrence of that slot: it is matched by its `OriginalStart`, not by - /// where it now starts. Matched by its Start it was never found, the delete - /// aborted, and a move that had already created the new event left the - /// exception behind as a duplicate. - /// Decision 216: an all-day series' slots are the mailbox's midnights on - /// the server (here Honolulu's, 10:00 UTC) and the device's local midnights - /// in Aperio — an expanded occurrence, or an override id. The server's slot - /// is read as the read anchors it, so a device far from the mailbox's zone - /// still deletes the day it names, and only that one. + /// Decisions 216, 217: an all-day series' slots are the mailbox's midnights + /// on the server (here Honolulu's, 10:00 UTC) and the device's local + /// midnights in Aperio — an expanded occurrence, or an override id. The + /// server's slot is read in the series' zone, so a device far from the + /// mailbox's zone still deletes the day it names, and only that one. #[tokio::test] async fn an_all_day_day_is_found_far_from_the_mailboxs_zone() { let mut server = Server::new_async().await; @@ -3568,6 +3563,8 @@ mod tests { 2026-07-06T10:00:00Z 2026-07-07T10:00:00Z true + + true RecurringMaster @@ -3627,13 +3624,125 @@ mod tests { .await; // The day as this device names it: its local midnight of July 20. - let day = crate::mapping::all_day_local_anchor("2026-07-20T10:00:00Z".parse().unwrap()); + let day = crate::mapping::all_day_anchor( + "2026-07-20T10:00:00Z".parse().unwrap(), + Some(chrono_tz::Pacific::Honolulu), + ); delete_series_occurrence(&client_for(&server), "MASTER", Some("CK"), day, false) .await .unwrap(); del.assert_async().await; } + /// Decision 217: a daily all-day series in an Auckland mailbox, begun in + /// New Zealand's winter (+12) and deleted from in its summer (+13). Its + /// January slots are 11:00 UTC the day before. The 12-hour sample read + /// each as the day before, so the NEXT day's slot named the target and was + /// deleted. Read in the series' own zone, the day it names is exact. + #[tokio::test] + async fn an_all_day_day_in_auckland_s_summer_is_the_day_itself() { + use chrono::TimeZone; + let auckland = chrono_tz::Pacific::Auckland; + let midnight = |index: i64| -> DateTime { + let day = chrono::NaiveDate::from_ymd_opt(2026, 6, 1).unwrap() + + chrono::Duration::days(index - 1); + auckland + .from_local_datetime(&day.and_hms_opt(0, 0, 0).unwrap()) + .single() + .unwrap() + .with_timezone(&Utc) + }; + let mut server = Server::new_async().await; + let _master = server + .mock("POST", "/") + .match_body(mockito::Matcher::AllOf(vec![ + mockito::Matcher::Regex("m:GetItem".into()), + mockito::Matcher::Regex(r#"ItemId Id="MASTER""#.into()), + ])) + .with_status(200) + .with_body( + r#" + + + + NoError + + + Daily all-day + 2026-05-31T12:00:00Z + 2026-06-01T12:00:00Z + true + true + RecurringMaster + + + 1 + + 2026-06-01 + + + + + + +"#, + ) + .create_async() + .await; + let mut probes = Vec::new(); + for index in 225..=233 { + let iso = midnight(index).to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + probes.push( + server + .mock("POST", "/") + .match_body(mockito::Matcher::AllOf(vec![ + mockito::Matcher::Regex("m:GetItem".into()), + mockito::Matcher::Regex(format!(r#"InstanceIndex="{index}""#)), + ])) + .with_status(200) + .with_body(occurrence_get_response(&iso)) + .create_async() + .await, + ); + } + // 15 January 2027 is the 229th day. + let del = server + .mock("POST", "/") + .match_body(mockito::Matcher::AllOf(vec![ + mockito::Matcher::Regex("DeleteType".into()), + mockito::Matcher::Regex(r#"InstanceIndex="229""#.into()), + ])) + .with_status(200) + .with_body( + r#" + + + + NoError + + +"#, + ) + .expect(1) + .create_async() + .await; + + // The day as this device names it: its local midnight of 15 January. + let day = crate::mapping::all_day_anchor(midnight(229), Some(auckland)); + delete_series_occurrence(&client_for(&server), "MASTER", Some("CK"), day, false) + .await + .unwrap(); + del.assert_async().await; + } + + /// An exception moved three days away from its slot is still the + /// occurrence of that slot: it is matched by its `OriginalStart`, not by + /// where it now starts. Matched by its Start it was never found, the delete + /// aborted, and a move that had already created the new event left the + /// exception behind as a duplicate. #[tokio::test] async fn delete_series_occurrence_finds_an_exception_moved_far_by_its_slot() { let mut server = Server::new_async().await; diff --git a/crates/adapter-ews/src/lib.rs b/crates/adapter-ews/src/lib.rs index 3069c805..9ec2c8f4 100644 --- a/crates/adapter-ews/src/lib.rs +++ b/crates/adapter-ews/src/lib.rs @@ -558,7 +558,7 @@ impl EwsAdapter { /// What the emitted events depend on beyond Exchange's data: the zone /// translation ([`windows_tz::translation_id`]), and the device's zone. An /// all-day series' start, its exceptions and the slot in its single - /// changes' ids are the device's local midnights (`all_day_local_anchor`, + /// changes' ids are the device's local midnights (`all_day_anchor`, /// decision 215), so a device that moved to another zone reads the folder /// again rather than keep ids no write finds any more (decision 216). fn emitted_with() -> String { diff --git a/crates/adapter-ews/src/mapping.rs b/crates/adapter-ews/src/mapping.rs index 93185ce6..20202d86 100644 --- a/crates/adapter-ews/src/mapping.rs +++ b/crates/adapter-ews/src/mapping.rs @@ -1753,10 +1753,19 @@ pub fn to_event(item: ParsedItem, calendar_id: &str) -> EwsResult { let end = item .end .ok_or_else(|| EwsError::Protocol("CalendarItem missing End".into()))?; - // All-day boundaries re-anchor at LOCAL midnight of their local - // calendar day (the app-internal convention; see all_day_local_anchor). + // All-day boundaries re-anchor at LOCAL midnight of their calendar day, + // read in the item's own zone (the app-internal convention; see + // all_day_anchor). + let day_zone = if item.is_all_day { + all_day_zone(&item) + } else { + None + }; let (start, end) = if item.is_all_day { - (all_day_local_anchor(start), all_day_local_anchor(end)) + ( + all_day_anchor(start, day_zone), + all_day_anchor(end, day_zone), + ) } else { (start, end) }; @@ -1819,7 +1828,7 @@ pub fn to_event(item: ParsedItem, calendar_id: &str) -> EwsResult { // Anchor the exceptions the same way so they line up with the grid. let anchor = |dt: DateTime| { if item.is_all_day { - all_day_local_anchor(dt) + all_day_anchor(dt, day_zone) } else { dt } @@ -2454,8 +2463,9 @@ pub fn override_event( // boundaries always agree — the master's flag decided this before, and a // series can hold an occurrence that is not all-day. if row.all_day { - row.start = all_day_local_anchor(ov.start); - row.end = all_day_local_anchor(ov.end); + let zone = all_day_zone(master_item); + row.start = all_day_anchor(ov.start, zone); + row.end = all_day_anchor(ov.end, zone); } else { row.start = ov.start; row.end = ov.end; @@ -2493,8 +2503,9 @@ fn inherited_override_event( row.id = encode_override_event_id(&master_ev.id, override_slot(master_item, ov)); row.recurrence = None; if master_item.is_all_day { - row.start = all_day_local_anchor(ov.start); - row.end = all_day_local_anchor(ov.end); + let zone = all_day_zone(master_item); + row.start = all_day_anchor(ov.start, zone); + row.end = all_day_anchor(ov.end, zone); } else { row.start = ov.start; row.end = ov.end; @@ -2517,7 +2528,8 @@ fn inherited_override_event( /// The slot a single change names in its id: the instant the master's own /// exception names for it (`to_event`). On an all-day series that is the -/// local midnight of the intended day, not the raw "some-zone midnight" EWS +/// local midnight of the intended day, read in the series' zone +/// ([`all_day_anchor`]), not the raw midnight in the mailbox's zone EWS /// sends: read by the day it is nearest to, as the views and the reminders /// read an all-day slot (decision 95), the raw instant named the neighbouring /// day wherever the mailbox's zone lies more than twelve hours from the @@ -2525,7 +2537,7 @@ fn inherited_override_event( /// reminders honoured single changes too (decision 215). pub(crate) fn override_slot(master_item: &ParsedItem, ov: &ModifiedOccurrence) -> DateTime { if master_item.is_all_day { - all_day_local_anchor(ov.original_start) + all_day_anchor(ov.original_start, all_day_zone(master_item)) } else { ov.original_start } @@ -2545,14 +2557,36 @@ pub(crate) fn names_override( override_slot(master_item, ov) == slot || ov.original_start == slot } -/// EWS hands back a plain instant that is midnight of the intended day -/// in SOME zone (the mailbox timezone, or UTC for boundaries we wrote -/// ourselves) without saying which. Sampling 12 hours INTO the day lands -/// inside the intended day in UTC for any zone offset in (−12h, +12h], -/// so the sample's UTC date recovers the day without guessing the zone. -/// DST edge: fall forward when the local zone skips midnight. -pub(crate) fn all_day_local_anchor(when: DateTime) -> DateTime { - let day = (when + chrono::Duration::hours(12)).date_naive(); +/// The zone an all-day item's instants are midnights in, as Exchange names +/// it: its start zone, read the way its series' zone is read +/// ([`crate::windows_tz::read_series_zone`]), and UTC for one made without a +/// zone. `None` where Exchange names none Aperio can read. +pub(crate) fn all_day_zone(item: &ParsedItem) -> Option { + match crate::windows_tz::read_series_zone( + item.start_time_zone.as_deref(), + item.end_time_zone.as_deref(), + ) { + Some(crate::windows_tz::WindowsZoneRead::Zone(zone)) => zone.parse().ok(), + Some(crate::windows_tz::WindowsZoneRead::Utc) => Some(chrono_tz::UTC), + Some(crate::windows_tz::WindowsZoneRead::Unknown) | None => None, + } +} + +/// The local midnight of the day an all-day instant names. +/// +/// EWS hands back a plain instant that is midnight of the intended day in +/// the item's zone (the mailbox's, or UTC for boundaries we wrote ourselves). +/// Read in that zone ([`all_day_zone`]), the day is exact whatever its offset +/// (decision 217). Where Exchange names no zone Aperio can read, the day is +/// sampled 12 hours INTO it: the sample's UTC date is the intended day for +/// any zone offset in (−12h, +12h], and the day before beyond — which is how +/// a midnight of Auckland's summer (+13) was read. DST edge: fall forward +/// when the local zone skips midnight. +pub(crate) fn all_day_anchor(when: DateTime, zone: Option) -> DateTime { + let day = match zone { + Some(tz) => when.with_timezone(&tz).date_naive(), + None => (when + chrono::Duration::hours(12)).date_naive(), + }; let midnight = day.and_hms_opt(0, 0, 0).unwrap(); Local .from_local_datetime(&midnight) @@ -7549,7 +7583,7 @@ mod tests { // the frontend expander — which anchors on `start` and matches EXDATEs by // exact instant — won't suppress the vacated slot on a non-UTC device, // rendering it as a duplicate. Zone-generic: asserts the exceptions equal - // `all_day_local_anchor` of the raw instants (identity under UTC, shifted + // `all_day_anchor` of the raw instants (identity under UTC, shifted // under any other zone), matching whatever transform hit `start`. let del: DateTime = "2026-01-05T00:00:00Z".parse().unwrap(); let orig: DateTime = "2026-01-10T00:00:00Z".parse().unwrap(); @@ -7581,12 +7615,12 @@ mod tests { let ev = to_event(item, "cal").unwrap(); let rec = ev.recurrence.expect("master has recurrence"); assert_eq!(rec.exceptions.len(), 2); - assert_eq!(rec.exceptions[0], all_day_local_anchor(del)); - assert_eq!(rec.exceptions[1], all_day_local_anchor(orig)); + assert_eq!(rec.exceptions[0], all_day_anchor(del, None)); + assert_eq!(rec.exceptions[1], all_day_anchor(orig, None)); // The master start got the same transform, so grid + EXDATEs line up. assert_eq!( ev.start, - all_day_local_anchor("2026-01-01T00:00:00Z".parse().unwrap()) + all_day_anchor("2026-01-01T00:00:00Z".parse().unwrap(), None) ); } @@ -7595,7 +7629,7 @@ mod tests { /// views and the reminders read the same day for both. The raw instant is /// midnight in the mailbox's zone (here New York), which a device more than /// twelve hours away read as the neighbouring day. Zone-generic: asserts - /// against `all_day_local_anchor`, as the test above does. + /// against `all_day_anchor`, as the test above does. #[test] fn an_all_day_single_change_names_its_slot_as_the_series_does() { let orig: DateTime = "2026-05-26T04:00:00Z".parse().unwrap(); @@ -7627,7 +7661,7 @@ mod tests { let row = override_event(&master, &item, &ov, "cal").unwrap(); let (series, slot) = cal_core::split_override_id(&row.id).unwrap().unwrap(); assert_eq!(series, master.id); - assert_eq!(slot, all_day_local_anchor(orig)); + assert_eq!(slot, all_day_anchor(orig, None)); assert!(master.recurrence.unwrap().exceptions.contains(&slot)); // Writing finds the change by the id's slot, and by the raw one an // older id carries; never by another day's. @@ -7636,13 +7670,75 @@ mod tests { assert!(!names_override( &item, &ov, - all_day_local_anchor("2026-05-27T04:00:00Z".parse().unwrap()) + all_day_anchor("2026-05-27T04:00:00Z".parse().unwrap(), None) )); // A timed series keeps its exact instant. item.is_all_day = false; assert_eq!(override_slot(&item, &ov), orig); } + /// Decision 217: an all-day item's instants are midnights in its own zone, + /// and read in it the day is exact. An Auckland series begun in winter has + /// its January slots at 11:00 UTC the day before; the 12-hour sample read + /// them as that day before. Its start, its exceptions and the slot in its + /// single changes' ids now name the day itself. + #[test] + fn an_all_day_series_is_read_in_its_own_zone() { + let auckland = chrono_tz::Pacific::Auckland; + let slot: DateTime = "2027-01-14T11:00:00Z".parse().unwrap(); + let day = chrono::NaiveDate::from_ymd_opt(2027, 1, 15).unwrap(); + let local_midnight = Local + .from_local_datetime(&day.and_hms_opt(0, 0, 0).unwrap()) + .earliest() + .unwrap() + .with_timezone(&Utc); + let mut item = ParsedItem { + item_id: "M".into(), + subject: "Daily all-day".into(), + start: Some("2026-05-31T12:00:00Z".parse().unwrap()), + end: Some("2026-06-01T12:00:00Z".parse().unwrap()), + is_all_day: true, + is_recurring: true, + item_type: Some("RecurringMaster".into()), + start_time_zone: Some("New Zealand Standard Time".into()), + end_time_zone: Some("New Zealand Standard Time".into()), + ..ParsedItem::default() + }; + item.recurrence = Some(EwsRecurrence { + pattern: EwsRecurrencePattern::Daily { interval: 1 }, + range: EwsRecurrenceRange::NoEnd, + }); + let ov = ModifiedOccurrence { + item_id: "OCC".into(), + change_key: None, + start: slot, + end: "2027-01-15T11:00:00Z".parse().unwrap(), + original_start: slot, + cancelled: false, + own: None, + }; + item.modified_occurrences = vec![ov.clone()]; + assert_eq!(all_day_zone(&item), Some(auckland)); + assert_eq!(all_day_anchor(slot, Some(auckland)), local_midnight); + let master = to_event(item.clone(), "cal").unwrap(); + assert_eq!( + master.recurrence.as_ref().unwrap().exceptions, + vec![local_midnight] + ); + assert_eq!(override_slot(&item, &ov), local_midnight); + let row = override_event(&master, &item, &ov, "cal").unwrap(); + assert_eq!(row.start, local_midnight); + // A series made without a zone reads its UTC midnights in UTC. + item.start_time_zone = Some("Greenwich Standard Time".into()); + item.end_time_zone = Some("tzone://Microsoft/Utc".into()); + assert_eq!(all_day_zone(&item), Some(chrono_tz::UTC)); + // No zone Aperio can read: the day is sampled, as before. + item.start_time_zone = None; + item.end_time_zone = None; + assert_eq!(all_day_zone(&item), None); + assert_eq!(override_slot(&item, &ov), all_day_anchor(slot, None)); + } + #[test] fn nominal_occurrence_index_maps_dates_to_ews_instance_index() { let dt = |s: &str| s.parse::>().unwrap(); diff --git a/crates/cal-core/src/event_anchor.rs b/crates/cal-core/src/event_anchor.rs index dd0cd100..5f9c5cdd 100644 --- a/crates/cal-core/src/event_anchor.rs +++ b/crates/cal-core/src/event_anchor.rs @@ -283,7 +283,14 @@ pub fn plan_repairs( /// sees would need the device's timezone, which the core may never read. fn starts_the_same(ev: &Event, wanted: DateTime) -> bool { if ev.all_day { - ev.start.date_naive() == wanted.date_naive() + // An all-day start is a local midnight, and a device in another zone + // writes the same day as another instant: Berlin's 2 June is 22:00 UTC + // on 1 June, New York's 04:00 UTC on 2 June. Both name their day 12 + // hours into it, for any zone in (−12h, +12h] (decision 216); read by + // its UTC date, Berlin's named 1 June, and a row signed in Berlin + // missed its day in New York or found the day before. + let day = |at: DateTime| (at + chrono::Duration::hours(12)).date_naive(); + day(ev.start) == day(wanted) } else { ev.start == wanted } @@ -714,6 +721,43 @@ mod tests { .is_empty()); } + /// An all-day single change read in another zone: the row was signed in + /// Berlin (2 June is 22:00 UTC on 1 June), the device is in New York now + /// (2 June is 04:00 UTC on 2 June), and the day before is a single change + /// of the same title too. The row follows its own day. + #[test] + fn an_all_day_row_finds_its_day_in_another_zone() { + let berlin: DateTime = "2026-06-01T22:00:00Z".parse().unwrap(); + let new_york: DateTime = "2026-06-02T04:00:00Z".parse().unwrap(); + let day_before: DateTime = "2026-06-01T04:00:00Z".parse().unwrap(); + let old = "M:ID|CK::rid::2026-06-01T22:00:00+00:00"; + let ours = "M:ID|CK::rid::2026-06-02T04:00:00+00:00"; + let mut events = vec![ + event( + "M:ID|CK::rid::2026-06-01T04:00:00+00:00", + "cal", + "Homeoffice", + day_before, + ), + event(ours, "cal", "Homeoffice", new_york), + ]; + for ev in &mut events { + ev.all_day = true; + } + assert_eq!( + plan_repairs( + &[row(old, "cal", "Homeoffice", berlin)], + "cal", + &events, + (day_before, new_york), + ), + vec![Repair::Repoint { + event_id: old.into(), + to: ours.into(), + }], + ); + } + #[test] fn a_row_outside_what_the_batch_covers_is_left_alone() { let start = at(1); diff --git a/crates/host-core/src/sound.rs b/crates/host-core/src/sound.rs index 15cb62e9..0513b946 100644 --- a/crates/host-core/src/sound.rs +++ b/crates/host-core/src/sound.rs @@ -1,7 +1,7 @@ //! Notification-sound resolution (DESIGN.md §14.4). //! //! Aperio resolves the effective [`SoundConfig`] for a reminder -//! occurrence from a four-level hierarchy. All "override" levels live +//! occurrence from a hierarchy, most specific first. All "override" levels live //! in `user_prefs` (prefix `sound.`, already on the event-log sync //! whitelist), so the same mechanism works for local AND external //! calendars/items and survives a cache refresh: @@ -9,6 +9,7 @@ //! ```text //! reminder.sound (Reminder.sound, per alarm) //! ?? prefs["sound.item.{itemId}"] (per event / task override) +//! ?? prefs["sound.item.{seriesId}"] (a single change of a series, 216) //! ?? prefs["sound.{calendar|tasklist}.{containerId}"] (container) //! ?? prefs["sound.global"] (global default) //! ?? System (SoundConfig::default()) diff --git a/web/src/content/docs/developers/adapters/ews.md b/web/src/content/docs/developers/adapters/ews.md index fc50c17b..6e0e0193 100644 --- a/web/src/content/docs/developers/adapters/ews.md +++ b/web/src/content/docs/developers/adapters/ews.md @@ -48,10 +48,14 @@ The endpoint is discovered or user-supplied. slot, leaves the row inheriting the series' content and says so in the log: an inherited value is wrong, a guessed one would be worse. - **An all-day exception names its slot by its day.** EWS reports an - occurrence's `OriginalStart` as midnight in some zone, the mailbox's, without - saying which. The series' own exceptions are re-anchored to the local - midnight of that day (`all_day_local_anchor`), and the override id names the - same instant (`override_slot`, decision 215). With the raw instant, a device + all-day item's instants, an occurrence's `OriginalStart` among them, as + midnight in the item's own zone. The read takes the day in that zone — the + start zone, read as the series' zone is, UTC for a series made without one + (`all_day_zone`, decision 217) — and re-anchors the start, the series' + exceptions and the override id's slot to this device's local midnight of + it (`all_day_anchor`, `override_slot`, decision 215). Only where Exchange + names no zone the adapter can read is the day sampled twelve hours into + it, which reads a midnight east of UTC+12 as the day before. With the raw instant, a device more than twelve hours from the mailbox's zone read the neighbouring day: the views hid it, and the reminders, which honour single changes since decision 214, silenced it. Writing finds the exception by either spelling @@ -59,8 +63,10 @@ The endpoint is discovered or user-supplied. this device's local midnights, so the events token names the device's zone as well as the zone translation: a device that moved reads the folder again, and its ids follow (decision 216). Deleting one day of an all-day series - reads the server's slot the same way before it compares, so a device far - from the mailbox's zone finds the day it names. + reads the server's slot in the series' zone before it compares, so a device + far from the mailbox's zone finds the day it names, and a neighbour, a whole + day away, never comes within the tolerance. Without a zone it compares the + raw instants as before and aborts rather than trust a sampled day. - **Exceptions keep their rule field.** Editing one changed occurrence writes to the exception's own item, which the override id finds from the series head on every write. That update never sends `DeleteItemField From c05e3b5506bbfe427ab893e5eb2cc5ce88100d84 Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Mon, 5 Oct 2026 22:29:31 +0200 Subject: [PATCH 5/7] Fourth check of #117: read an all-day day twelve hours in, in the named 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 --- TODO.md | 41 +++-- crates/adapter-ews/src/api.rs | 153 +++++++++++++++++- crates/adapter-ews/src/mapping.rs | 53 +++++- crates/cal-core/src/event_anchor.rs | 94 +++++++++-- .../content/docs/developers/adapters/ews.md | 20 ++- 5 files changed, 314 insertions(+), 47 deletions(-) diff --git a/TODO.md b/TODO.md index c8c85949..68a2ef3e 100644 --- a/TODO.md +++ b/TODO.md @@ -2135,11 +2135,13 @@ Siehe DESIGN §4.2. Vortag fällt. Weder Zeitpunkt- noch Tagesvergleich trifft ihn: der gestrichene Tag kommt einmal zurück, und ein erneutes Löschen schreibt ihn richtig. Eine Wanderung wurde bewusst nicht gebaut (95). - - `plan_repairs` vergleicht ganztägige Zeilen weiter über den UTC-Tag - (`starts_the_same`, der Kern darf die Gerätezone nicht lesen). In einer + - ✅ `plan_repairs` verglich ganztägige Zeilen über den UTC-Tag; in einer Zone, deren lokale Mitternacht über die Umstellung den UTC-Tag wechselt - (etwa London), kann ein Anker deshalb den Nachbartag nennen. Betrifft nur - das Reparieren verwaister Farb-, Erinnerungs- und Gruppenzeilen. + (etwa London), konnte ein Anker den Nachbartag nennen. Seit 217 liest + `starts_the_same` den Tag 13:45 in den Zeitpunkt hinein (ohne Gerätezone): + eigener Tag für jede Zone in (−10:15, +13:45], auch über Zonen hinweg und + an jeder Umstellung; außerhalb (Niue, Pago Pago, Kiritimati, ohne + Sommerzeit) stimmen Zeilen und Termine eines Geräts weiter überein. **Live-Test Runde 3** (49a), gelaufen am 18.09.2026: - eine ganztägige Serie und ein ganztägiger Termin aus Outlook, geändert nach der Regel aus 47a und nach der heutigen; @@ -2485,19 +2487,24 @@ Siehe DESIGN §4.2. abhängen, liest Exchange nach einem Zonenwechsel alles einmal neu (das Token nennt die Zone); eine Farbe oder ein Meeting an einer Einzeländerung wandert bei einer neuen Kennung zu ihr, nie auf die ganze - Serie, und ein ganztägiger Termin wird dabei nach dem Tag gefunden, den - er nennt, auch aus einer anderen Zone (`plan_repairs`, hilft auch - CalDAV und Google); ohne eigenen Ton klingelt eine Einzeländerung mit - dem der Serie. Und 217: Exchange liest die Zeitpunkte einer ganztägigen - Serie in ihrer eigenen Zone (Startzone wie die Zone der Serie, UTC für - eine ohne Zone, `all_day_zone`), nicht mehr mit der 12-Stunden-Regel, - die eine Mitternacht östlich von UTC+12 (Neuseelands Sommer) als den - Vortag las; die Regel bleibt nur, wo Exchange keine lesbare Zone nennt. - Einen Tag einer ganztägigen Serie zu löschen vergleicht den Platz des - Servers in dieser Zone; vorher scheiterte das über sechs Stunden Abstand - zum Postfach, und mit der Regel hätte es in Neuseelands Sommer den - Folgetag gelöscht. Ohne Zone werden die rohen Zeitpunkte verglichen und - im Zweifel abgebrochen. ↻ im Test. + Serie (`plan_repairs`, hilft auch CalDAV und Google); ohne eigenen Ton + klingelt eine Einzeländerung mit dem der Serie. Und 217: Exchange liest + die Zeitpunkte einer ganztägigen Serie 12 Stunden in den Tag hinein in + der Zone, die Exchange nennt (Startzone wie die Zone der Serie, UTC für + eine ohne Zone, `all_day_zone`), nicht mehr in UTC, was eine Mitternacht + östlich von UTC+12 (Neuseelands Sommer) als den Vortag las; die 12 + Stunden fangen auch ein Etikett ab, das Exchange nach Aperios eigenem + Schreiben auf UTC umstellt, während die Zeitpunkte Mitternacht der alten + Zone bleiben (Live-Runde 3). Ohne lesbare Zone bleibt es beim Lesen in + UTC. Die Kopie einer Ausnahme, mit der ein Bearbeiten vergleicht, wird + in der Zone ihrer Serie gelesen wie ihre Zeile. Einen Tag einer + ganztägigen Serie zu löschen vergleicht den Platz des Servers so + gelesen; vorher scheiterte das über sechs Stunden Abstand zum Postfach, + und mit der UTC-Lesart hätte es in Neuseelands Sommer den Folgetag + gelöscht. Ohne Zone werden die rohen Zeitpunkte verglichen und im + Zweifel abgebrochen. `plan_repairs` liest den Tag eines ganztägigen + Termins 13:45 in den Zeitpunkt hinein und findet so eine Zeile auch aus + einer anderen Zone (siehe oben). ↻ im Test. - ✅ **Google liest jede Schreibweise einer Löschung** (PR 6, 205): Andere Apps schreiben gelöschte Vorkommen als `EXDATE`-Zeilen in eine Google-Serie, meist als Wanduhr in der Zone (`EXDATE;TZID=…`), wie auch diff --git a/crates/adapter-ews/src/api.rs b/crates/adapter-ews/src/api.rs index 3763708c..966754bc 100644 --- a/crates/adapter-ews/src/api.rs +++ b/crates/adapter-ews/src/api.rs @@ -1159,6 +1159,7 @@ async fn resolve_write_target( kind: decoded.kind, item_id: decoded.item_id.clone(), change_key: decoded.change_key.clone(), + series_zone: None, }); } let envelope = get_recurring_master(&decoded.item_id, decoded.change_key.as_deref()); @@ -1168,6 +1169,7 @@ async fn resolve_write_target( kind: EventIdKind::RecurringMaster, item_id: master.id, change_key: master.change_key, + series_zone: None, }) } @@ -1203,13 +1205,13 @@ async fn resolve_override_target( let occurrence = items .iter() .flat_map(|item| item.modified_occurrences.iter().map(move |ov| (item, ov))) - .find(|(item, ov)| crate::mapping::names_override(item, ov, original_start)) - .map(|(_, ov)| ov); + .find(|(item, ov)| crate::mapping::names_override(item, ov, original_start)); match occurrence { - Some(ov) => Ok(WriteTarget { + Some((master, ov)) => Ok(WriteTarget { kind: EventIdKind::Exception, item_id: ov.item_id.clone(), change_key: ov.change_key.clone(), + series_zone: Some((master.start_time_zone.clone(), master.end_time_zone.clone())), }), // Refused, not widened. The occurrence is gone from the series — someone // deleted it, or the series was rewritten — and the only other thing @@ -1252,9 +1254,19 @@ async fn read_before( }; let xml = client.post_soap(body).await?; let items = crate::mapping::parse_get_calendar_items_response(&xml)?; - let Some(item) = items.into_iter().find(|it| it.item_id == target.item_id) else { + let Some(mut item) = items.into_iter().find(|it| it.item_id == target.item_id) else { return Ok(None); }; + // An exception's row is read in its series' zone (`override_event`), and + // the exception shape asks for no zone of its own: read here without one, + // an all-day day the series' zone names otherwise would count as moved, + // and a title-only edit would write the slot back (decision 217). + if let Some((start_zone, end_zone)) = &target.series_zone { + if item.start_time_zone.is_none() && item.end_time_zone.is_none() { + item.start_time_zone = start_zone.clone(); + item.end_time_zone = end_zone.clone(); + } + } Ok(Some(crate::mapping::to_event(item, calendar_id)?)) } @@ -1265,6 +1277,9 @@ struct WriteTarget { kind: EventIdKind, item_id: String, change_key: Option, + /// For an exception, its series' StartTimeZone and EndTimeZone: the + /// zone its all-day day is read in, as its row is (`override_event`). + series_zone: Option<(Option, Option)>, } /// Rename a calendar folder via `UpdateFolder` + `folder:DisplayName`. @@ -2554,6 +2569,136 @@ mod tests { assert_eq!(updated.etag.as_deref(), Some("ECK-V2")); } + /// Decision 217: an all-day exception's row is read in its series' zone, + /// and so is the copy the update compares with — the exception shape asks + /// for no zone of its own. Read without one, New Zealand's summer named the + /// day before, the slot counted as moved, and renaming the occurrence wrote + /// its slot back. + #[tokio::test] + async fn an_all_day_exception_renamed_in_auckland_keeps_its_slot() { + use std::sync::{Arc, Mutex}; + let auckland = chrono_tz::Pacific::Auckland; + let mut server = Server::new_async().await; + let series = r#" + + + + NoError + + + true + + + + + 2027-01-14T11:00:00Z + 2027-01-15T11:00:00Z + 2027-01-14T11:00:00Z + + + + +"#; + let occurrence = r#" + + + + NoError + + + Old title + 2027-01-14T11:00:00Z + 2027-01-15T11:00:00Z + true + 2027-01-14T11:00:00Z + + + +"#; + let updated_body = r#" + + + + NoError + + + +"#; + let requests = Arc::new(Mutex::new(Vec::::new())); + let seen = Arc::clone(&requests); + let _any = server + .mock("POST", "/") + .with_status(200) + .with_body_from_request(move |request| { + let body = request.utf8_lossy_body().unwrap().into_owned(); + let answer = if body.contains("UpdateItem") { + updated_body + } else if body.contains(r#"Id="EXC-ID""#) { + occurrence + } else { + series + }; + seen.lock().unwrap().push(body); + answer.as_bytes().to_vec() + }) + .create_async() + .await; + + // The row as the read gives it: the local midnights of 15 and 16 January. + let raw: chrono::DateTime = "2027-01-14T11:00:00Z".parse().unwrap(); + let start = crate::mapping::all_day_anchor(raw, Some(auckland)); + let end = + crate::mapping::all_day_anchor("2027-01-15T11:00:00Z".parse().unwrap(), Some(auckland)); + let edit = Event { + keep_attendees: false, + keep_fields: Vec::new(), + clear_attendees: false, + organized_elsewhere: false, + id: crate::mapping::encode_override_event_id("M:MASTER-ID|MCK-V1", start), + calendar_id: "FOLDER-ID|FCK".into(), + title: "New title".into(), + description: None, + location: None, + start, + end, + all_day: true, + recurrence: None, + color_label: None, + color_hex: None, + reminders: Vec::new(), + sound: None, + attendees: Vec::new(), + send_invitations: false, + truncate_tail_overrides: false, + created_at: "2026-09-18T00:00:00Z".parse().unwrap(), + updated_at: "2026-09-18T00:00:00Z".parse().unwrap(), + etag: Some("ECK-V1".into()), + organizer: None, + attendee_responses: Vec::new(), + cancelled: false, + scheduling_silenced: false, + }; + update_event(&client_for(&server), &edit, None) + .await + .unwrap(); + let requests = requests.lock().unwrap(); + let update = requests + .iter() + .find(|body| body.contains("UpdateItem")) + .expect("an update"); + assert!(update.contains("item:Subject"), "{update}"); + assert!( + !update.contains("calendar:Start"), + "the slot stays: {update}" + ); + } + /// Answers an exception update with `update_code` and records every /// request: the series GetItem, the GetItem for the occurrence's own copy /// (decision 58a), the UpdateItem, and whatever follows. diff --git a/crates/adapter-ews/src/mapping.rs b/crates/adapter-ews/src/mapping.rs index 20202d86..5668fe87 100644 --- a/crates/adapter-ews/src/mapping.rs +++ b/crates/adapter-ews/src/mapping.rs @@ -2575,16 +2575,19 @@ pub(crate) fn all_day_zone(item: &ParsedItem) -> Option { /// The local midnight of the day an all-day instant names. /// /// EWS hands back a plain instant that is midnight of the intended day in -/// the item's zone (the mailbox's, or UTC for boundaries we wrote ourselves). -/// Read in that zone ([`all_day_zone`]), the day is exact whatever its offset -/// (decision 217). Where Exchange names no zone Aperio can read, the day is -/// sampled 12 hours INTO it: the sample's UTC date is the intended day for -/// any zone offset in (−12h, +12h], and the day before beyond — which is how -/// a midnight of Auckland's summer (+13) was read. DST edge: fall forward -/// when the local zone skips midnight. +/// the item's zone (the mailbox's, or UTC for boundaries we wrote ourselves) +/// — mostly: after Aperio's own write of an Outlook item, which sends UTC +/// midnights without a zone, Exchange rounds in the item's old zone and +/// labels it UTC (live round 3). So the day is sampled 12 hours INTO it, in +/// the zone Exchange names ([`all_day_zone`], decision 217): the right day +/// for a label up to twelve hours off, and for any offset the zone has — +/// Auckland's summer (+13) included, which the sample in UTC read as the day +/// before. Where Exchange names no zone Aperio can read, the sample is taken +/// in UTC: the intended day for any offset in (−12h, +12h]. DST edge: fall +/// forward when the local zone skips midnight. pub(crate) fn all_day_anchor(when: DateTime, zone: Option) -> DateTime { let day = match zone { - Some(tz) => when.with_timezone(&tz).date_naive(), + Some(tz) => (when.with_timezone(&tz) + chrono::Duration::hours(12)).date_naive(), None => (when + chrono::Duration::hours(12)).date_naive(), }; let midnight = day.and_hms_opt(0, 0, 0).unwrap(); @@ -7739,6 +7742,40 @@ mod tests { assert_eq!(override_slot(&item, &ov), all_day_anchor(slot, None)); } + /// Live round 3, T2: an Outlook all-day single in Berlin, re-dated by + /// Aperio's writer (UTC midnights, no zone), came back as 18 Oct 22:00 UTC + /// to 20 Oct 22:00 UTC with both zones `tzone://Microsoft/Utc` — Berlin's + /// midnights labelled UTC. Outlook shows Monday 19 and Tuesday 20. Read 12 + /// hours into the day in the zone Exchange names, so are they; read as the + /// UTC date, Sunday and Monday. + #[test] + fn a_relabelled_all_day_item_keeps_its_days() { + let midnight = |d: u32| { + Local + .from_local_datetime( + &chrono::NaiveDate::from_ymd_opt(2026, 10, d) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(), + ) + .earliest() + .unwrap() + .with_timezone(&Utc) + }; + let item = ParsedItem { + item_id: "T2".into(), + subject: "Outlook all-day".into(), + start: Some("2026-10-18T22:00:00Z".parse().unwrap()), + end: Some("2026-10-20T22:00:00Z".parse().unwrap()), + is_all_day: true, + start_time_zone: Some("tzone://Microsoft/Utc".into()), + end_time_zone: Some("tzone://Microsoft/Utc".into()), + ..ParsedItem::default() + }; + let ev = to_event(item, "cal").unwrap(); + assert_eq!((ev.start, ev.end), (midnight(19), midnight(21))); + } + #[test] fn nominal_occurrence_index_maps_dates_to_ews_instance_index() { let dt = |s: &str| s.parse::>().unwrap(); diff --git a/crates/cal-core/src/event_anchor.rs b/crates/cal-core/src/event_anchor.rs index 5f9c5cdd..68820b95 100644 --- a/crates/cal-core/src/event_anchor.rs +++ b/crates/cal-core/src/event_anchor.rs @@ -275,21 +275,27 @@ pub fn plan_repairs( /// half this was ported from carries the same limit, written down the same /// way. /// -/// **The day is the UTC day, which is not always the day the user sees.** For -/// a reader east of UTC, local midnight on the 10th is the 9th at 22:00Z, so -/// this compares "the 9th". That is correct here because it is used as a KEY, -/// not as a date: both sides of the comparison are derived from stored -/// instants the same way, so they agree. Deriving the calendar day the user -/// sees would need the device's timezone, which the core may never read. +/// **The day is read 13:45 into the stored instant, without a zone.** A local +/// midnight in any zone in (−10:15, +13:45] gets its own date that way, so a +/// row signed on one device and read on another, or after the device moved, +/// finds its day; outside the window a device's rows and events still agree, +/// one day off on both sides. Deriving the calendar day the user sees exactly +/// would need the device's timezone, which the core may never read. fn starts_the_same(ev: &Event, wanted: DateTime) -> bool { if ev.all_day { // An all-day start is a local midnight, and a device in another zone // writes the same day as another instant: Berlin's 2 June is 22:00 UTC - // on 1 June, New York's 04:00 UTC on 2 June. Both name their day 12 - // hours into it, for any zone in (−12h, +12h] (decision 216); read by - // its UTC date, Berlin's named 1 June, and a row signed in Berlin - // missed its day in New York or found the day before. - let day = |at: DateTime| (at + chrono::Duration::hours(12)).date_naive(); + // on 1 June, New York's 04:00 UTC on 2 June. Both name their day 13:45 + // into it, as does a midnight of any zone in (−10:15, +13:45] (decision + // 217) — New Zealand's and the Chatham Islands' both offsets, Hawaii + // and Adak, London's both, so no zone that keeps summer time sees its + // last winter day and first summer day under one key. Read by its UTC + // date, Berlin's named 1 June, and a row signed in Berlin missed its + // day in New York or found the day before; read 12 hours in, New + // Zealand's summer named the day before. Outside the window (Niue, + // Pago Pago, Kiritimati, none with summer time) a device's own rows + // still agree with its events, one day off on both sides. + let day = |at: DateTime| (at + chrono::Duration::minutes(13 * 60 + 45)).date_naive(); day(ev.start) == day(wanted) } else { ev.start == wanted @@ -758,6 +764,72 @@ mod tests { ); } + /// A row signed in New Zealand's summer (+13) and read in Berlin: 15 + /// January there is 11:00 UTC on the 14th, Berlin's 22:00 UTC on the 14th. + /// Read 12 hours in, the row named the 14th and moved to the day before. + #[test] + fn an_all_day_row_signed_in_new_zealand_s_summer_finds_its_day() { + let auckland: DateTime = "2027-01-14T11:00:00Z".parse().unwrap(); + let berlin_14th: DateTime = "2027-01-13T23:00:00Z".parse().unwrap(); + let berlin_15th: DateTime = "2027-01-14T23:00:00Z".parse().unwrap(); + let mut events = vec![ + event("day-14", "cal", "Homeoffice", berlin_14th), + event("day-15", "cal", "Homeoffice", berlin_15th), + ]; + for ev in &mut events { + ev.all_day = true; + } + assert_eq!( + plan_repairs( + &[row("old", "cal", "Homeoffice", auckland)], + "cal", + &events, + (berlin_14th, berlin_15th), + ), + vec![Repair::Repoint { + event_id: "old".into(), + to: "day-15".into(), + }], + ); + } + + /// New Zealand's summer time begins on Sunday 27 September 2026: Sunday's + /// midnight is 12:00 UTC on the 26th (+12), Monday's 11:00 UTC on the 27th + /// (+13). Read 12 hours in, both were the 27th, and a row could not tell + /// them apart; each keeps its own day. + #[test] + fn the_first_summer_day_is_not_the_last_winter_day() { + let sunday: DateTime = "2026-09-26T12:00:00Z".parse().unwrap(); + let monday: DateTime = "2026-09-27T11:00:00Z".parse().unwrap(); + let mut both = vec![ + event("sunday", "cal", "Urlaub", sunday), + event("monday", "cal", "Urlaub", monday), + ]; + for ev in &mut both { + ev.all_day = true; + } + assert_eq!( + plan_repairs( + &[row("old", "cal", "Urlaub", monday)], + "cal", + &both, + (sunday, monday), + ), + vec![Repair::Repoint { + event_id: "old".into(), + to: "monday".into(), + }], + ); + // Sunday alone is not Monday's. + assert!(plan_repairs( + &[row("old", "cal", "Urlaub", monday)], + "cal", + &both[..1], + (sunday, monday), + ) + .is_empty()); + } + #[test] fn a_row_outside_what_the_batch_covers_is_left_alone() { let start = at(1); diff --git a/web/src/content/docs/developers/adapters/ews.md b/web/src/content/docs/developers/adapters/ews.md index 6e0e0193..260fb1d0 100644 --- a/web/src/content/docs/developers/adapters/ews.md +++ b/web/src/content/docs/developers/adapters/ews.md @@ -49,13 +49,19 @@ The endpoint is discovered or user-supplied. an inherited value is wrong, a guessed one would be worse. - **An all-day exception names its slot by its day.** EWS reports an all-day item's instants, an occurrence's `OriginalStart` among them, as - midnight in the item's own zone. The read takes the day in that zone — the - start zone, read as the series' zone is, UTC for a series made without one - (`all_day_zone`, decision 217) — and re-anchors the start, the series' - exceptions and the override id's slot to this device's local midnight of - it (`all_day_anchor`, `override_slot`, decision 215). Only where Exchange - names no zone the adapter can read is the day sampled twelve hours into - it, which reads a midnight east of UTC+12 as the day before. With the raw instant, a device + midnight in the item's own zone — mostly: after Aperio's own write of an + Outlook item (UTC midnights, no zone) Exchange keeps the old zone's + midnights and labels them UTC (live round 3). The read samples the day + twelve hours into it, in the zone Exchange names — the start zone, read as + the series' zone is, UTC for a series made without one (`all_day_zone`, + decision 217) — which holds for any offset the zone has and for a label up + to twelve hours off. It re-anchors the start, the series' exceptions and + the override id's slot to this device's local midnight of that day + (`all_day_anchor`, `override_slot`, decision 215). Where Exchange names no + zone the adapter can read, the sample is taken in UTC, which reads a + midnight east of UTC+12 as the day before. An exception's own copy, which + an update compares with, is read in its series' zone like its row. With + the raw instant, a device more than twelve hours from the mailbox's zone read the neighbouring day: the views hid it, and the reminders, which honour single changes since decision 214, silenced it. Writing finds the exception by either spelling From 08a544a65cab4d226298848c507c63c5d136e353 Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Mon, 5 Oct 2026 23:03:25 +0200 Subject: [PATCH 6/7] Fifth check of #117: one window for every all-day day, and a skipped 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 --- TODO.md | 21 +++++--- crates/adapter-ews/src/mapping.rs | 50 +++++++++++++++---- crates/cal-core/src/event_anchor.rs | 2 +- .../content/docs/developers/adapters/ews.md | 17 ++++--- 4 files changed, 64 insertions(+), 26 deletions(-) diff --git a/TODO.md b/TODO.md index 68a2ef3e..c10b5235 100644 --- a/TODO.md +++ b/TODO.md @@ -2489,14 +2489,21 @@ Siehe DESIGN §4.2. Einzeländerung wandert bei einer neuen Kennung zu ihr, nie auf die ganze Serie (`plan_repairs`, hilft auch CalDAV und Google); ohne eigenen Ton klingelt eine Einzeländerung mit dem der Serie. Und 217: Exchange liest - die Zeitpunkte einer ganztägigen Serie 12 Stunden in den Tag hinein in - der Zone, die Exchange nennt (Startzone wie die Zone der Serie, UTC für - eine ohne Zone, `all_day_zone`), nicht mehr in UTC, was eine Mitternacht - östlich von UTC+12 (Neuseelands Sommer) als den Vortag las; die 12 - Stunden fangen auch ein Etikett ab, das Exchange nach Aperios eigenem + die Zeitpunkte einer ganztägigen Serie 13:45 in den Tag hinein in der + Zone, die Exchange nennt (Startzone wie die Zone der Serie, UTC für eine + ohne Zone, `all_day_zone`), statt 12 Stunden in UTC, was eine + Mitternacht östlich von UTC+12 (Neuseelands Sommer) als den Vortag las; + das fängt auch ein Etikett ab, das Exchange nach Aperios eigenem Schreiben auf UTC umstellt, während die Zeitpunkte Mitternacht der alten - Zone bleiben (Live-Runde 3). Ohne lesbare Zone bleibt es beim Lesen in - UTC. Die Kopie einer Ausnahme, mit der ein Bearbeiten vergleicht, wird + Zone bleiben (Live-Runde 3), für jede alte Zone in (−10:15, +13:45] + (Niue, Pago Pago, Kiritimati liegen außerhalb). Ohne lesbare Zone wird + in UTC gelesen, mit demselben Fenster. Fällt die Mitternacht des Geräts + an einer Umstellung aus, gilt die erste Stunde danach, wie in den + Ansichten. Offen: einen eigenen Ton für einen einzelnen Termin speichert + nur das Handy, unter der Kennung des Termins (`sound.item.{id}`); eine + neu geprägte Kennung (Exchange bei jeder ChangeKey-Änderung, seit 215/216 + ein ganztägiger Platz in anderer Zone) verliert ihn, die Reparatur kennt + Töne nicht, und der Desktop speichert Töne nur je Serie. Die Kopie einer Ausnahme, mit der ein Bearbeiten vergleicht, wird in der Zone ihrer Serie gelesen wie ihre Zeile. Einen Tag einer ganztägigen Serie zu löschen vergleicht den Platz des Servers so gelesen; vorher scheiterte das über sechs Stunden Abstand zum Postfach, diff --git a/crates/adapter-ews/src/mapping.rs b/crates/adapter-ews/src/mapping.rs index 5668fe87..39392f89 100644 --- a/crates/adapter-ews/src/mapping.rs +++ b/crates/adapter-ews/src/mapping.rs @@ -2578,22 +2578,30 @@ pub(crate) fn all_day_zone(item: &ParsedItem) -> Option { /// the item's zone (the mailbox's, or UTC for boundaries we wrote ourselves) /// — mostly: after Aperio's own write of an Outlook item, which sends UTC /// midnights without a zone, Exchange rounds in the item's old zone and -/// labels it UTC (live round 3). So the day is sampled 12 hours INTO it, in -/// the zone Exchange names ([`all_day_zone`], decision 217): the right day -/// for a label up to twelve hours off, and for any offset the zone has — -/// Auckland's summer (+13) included, which the sample in UTC read as the day -/// before. Where Exchange names no zone Aperio can read, the sample is taken -/// in UTC: the intended day for any offset in (−12h, +12h]. DST edge: fall -/// forward when the local zone skips midnight. +/// labels it UTC (live round 3). So the day is sampled 13:45 INTO it, in the +/// zone Exchange names ([`all_day_zone`], decision 217): exact for a midnight +/// that zone names, and the intended day for a label off by any offset in +/// (−10:15, +13:45] — New Zealand's summer, the Chatham Islands, Tonga and +/// Samoa among them, which twelve hours read as the day before; the same +/// window `cal_core`'s anchor repair reads days in. Where Exchange names no +/// zone Aperio can read, the sample is taken in UTC, with the same window. +/// DST edge: a device zone that skips midnight that day gets the first hour +/// after it, where the views place the day too. pub(crate) fn all_day_anchor(when: DateTime, zone: Option) -> DateTime { + let into_the_day = chrono::Duration::minutes(13 * 60 + 45); let day = match zone { - Some(tz) => (when.with_timezone(&tz) + chrono::Duration::hours(12)).date_naive(), - None => (when + chrono::Duration::hours(12)).date_naive(), + Some(tz) => (when.with_timezone(&tz) + into_the_day).date_naive(), + None => (when + into_the_day).date_naive(), }; let midnight = day.and_hms_opt(0, 0, 0).unwrap(); Local .from_local_datetime(&midnight) .earliest() + .or_else(|| { + Local + .from_local_datetime(&(midnight + chrono::Duration::hours(1))) + .earliest() + }) .map(|l| l.with_timezone(&Utc)) .unwrap_or(when) } @@ -7772,8 +7780,30 @@ mod tests { end_time_zone: Some("tzone://Microsoft/Utc".into()), ..ParsedItem::default() }; - let ev = to_event(item, "cal").unwrap(); + let ev = to_event(item.clone(), "cal").unwrap(); assert_eq!((ev.start, ev.end), (midnight(19), midnight(21))); + // The same from an Auckland mailbox in its summer (+13): dragged to + // Saturday 16 January, Exchange keeps Auckland's midnights, 11:00 UTC + // the day before, and labels them UTC. Twelve hours in read Friday. + let auckland_day = |d: u32| { + Local + .from_local_datetime( + &chrono::NaiveDate::from_ymd_opt(2027, 1, d) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(), + ) + .earliest() + .unwrap() + .with_timezone(&Utc) + }; + let auckland = ParsedItem { + start: Some("2027-01-15T11:00:00Z".parse().unwrap()), + end: Some("2027-01-17T11:00:00Z".parse().unwrap()), + ..item + }; + let ev = to_event(auckland, "cal").unwrap(); + assert_eq!((ev.start, ev.end), (auckland_day(16), auckland_day(18))); } #[test] diff --git a/crates/cal-core/src/event_anchor.rs b/crates/cal-core/src/event_anchor.rs index 68820b95..11e41ccf 100644 --- a/crates/cal-core/src/event_anchor.rs +++ b/crates/cal-core/src/event_anchor.rs @@ -765,7 +765,7 @@ mod tests { } /// A row signed in New Zealand's summer (+13) and read in Berlin: 15 - /// January there is 11:00 UTC on the 14th, Berlin's 22:00 UTC on the 14th. + /// January there is 11:00 UTC on the 14th, Berlin's 23:00 UTC on the 14th. /// Read 12 hours in, the row named the 14th and moved to the day before. #[test] fn an_all_day_row_signed_in_new_zealand_s_summer_finds_its_day() { diff --git a/web/src/content/docs/developers/adapters/ews.md b/web/src/content/docs/developers/adapters/ews.md index 260fb1d0..d2efb9cf 100644 --- a/web/src/content/docs/developers/adapters/ews.md +++ b/web/src/content/docs/developers/adapters/ews.md @@ -52,14 +52,15 @@ The endpoint is discovered or user-supplied. midnight in the item's own zone — mostly: after Aperio's own write of an Outlook item (UTC midnights, no zone) Exchange keeps the old zone's midnights and labels them UTC (live round 3). The read samples the day - twelve hours into it, in the zone Exchange names — the start zone, read as - the series' zone is, UTC for a series made without one (`all_day_zone`, - decision 217) — which holds for any offset the zone has and for a label up - to twelve hours off. It re-anchors the start, the series' exceptions and - the override id's slot to this device's local midnight of that day - (`all_day_anchor`, `override_slot`, decision 215). Where Exchange names no - zone the adapter can read, the sample is taken in UTC, which reads a - midnight east of UTC+12 as the day before. An exception's own copy, which + 13:45 into it, in the zone Exchange names — the start zone, read as the + series' zone is, UTC for a series made without one (`all_day_zone`, + decision 217) — which is exact for a midnight that zone names, and the + intended day for a label off by any offset in (−10:15, +13:45], New + Zealand's summer included. It re-anchors the start, the series' + exceptions and the override id's slot to this device's local midnight of + that day (`all_day_anchor`, `override_slot`, decision 215). Where + Exchange names no zone the adapter can read, the sample is taken in UTC + with the same window. An exception's own copy, which an update compares with, is read in its series' zone like its row. With the raw instant, a device more than twelve hours from the mailbox's zone read the neighbouring day: the From 2593a71f6bbe8a4a17479ce03c5d373804ff3459 Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Mon, 5 Oct 2026 23:17:36 +0200 Subject: [PATCH 7/7] Sixth check of #117: two comments still said twelve hours 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 --- crates/adapter-ews/src/api.rs | 2 +- crates/adapter-ews/src/mapping.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/adapter-ews/src/api.rs b/crates/adapter-ews/src/api.rs index 966754bc..09349246 100644 --- a/crates/adapter-ews/src/api.rs +++ b/crates/adapter-ews/src/api.rs @@ -1091,7 +1091,7 @@ pub async fn delete_series_occurrence( // anchors it: the day is exact, so a neighbour, a whole day away, never // comes within the tolerance. Where it names none, the raw instants are // compared as before, and a device far from the mailbox's zone aborts - // rather than trust a day the 12-hour sample may get wrong. + // rather than trust a day the sample 13:45 in may get wrong. let day_zone = if master.is_all_day { crate::mapping::all_day_zone(&master) } else { diff --git a/crates/adapter-ews/src/mapping.rs b/crates/adapter-ews/src/mapping.rs index 39392f89..24135476 100644 --- a/crates/adapter-ews/src/mapping.rs +++ b/crates/adapter-ews/src/mapping.rs @@ -7753,8 +7753,8 @@ mod tests { /// Live round 3, T2: an Outlook all-day single in Berlin, re-dated by /// Aperio's writer (UTC midnights, no zone), came back as 18 Oct 22:00 UTC /// to 20 Oct 22:00 UTC with both zones `tzone://Microsoft/Utc` — Berlin's - /// midnights labelled UTC. Outlook shows Monday 19 and Tuesday 20. Read 12 - /// hours into the day in the zone Exchange names, so are they; read as the + /// midnights labelled UTC. Outlook shows Monday 19 and Tuesday 20. Read + /// 13:45 into the day in the zone Exchange names, so are they; read as the /// UTC date, Sunday and Monday. #[test] fn a_relabelled_all_day_item_keeps_its_days() {