diff --git a/Cargo.lock b/Cargo.lock index caf30538..183ab4f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,7 +104,9 @@ dependencies = [ "base64 0.22.1", "cal-core", "chrono", + "chrono-tz", "futures", + "iana-time-zone", "mockito", "quick-xml 0.36.2", "reqwest 0.12.28", 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 eb7105fa..c10b5235 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 @@ -1945,12 +1948,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 @@ -2131,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; @@ -2458,6 +2464,54 @@ 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 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). 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. 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); 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. Und 217: Exchange liest + 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), 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, + 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/Cargo.toml b/crates/adapter-ews/Cargo.toml index 0ddff8b4..1237de94 100644 --- a/crates/adapter-ews/Cargo.toml +++ b/crates/adapter-ews/Cargo.toml @@ -13,6 +13,10 @@ 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 +# 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 5f643eec..09349246 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 series' zone, and `target` the local + // midnight of that day — an occurrence the views expanded, or an override + // 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 sample 13:45 in 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 delta = (s - target).num_seconds().abs(); + let slot = match day_zone { + Some(zone) => crate::mapping::all_day_anchor(s, Some(zone)), + None => s, + }; + let delta = (slot - target).num_seconds().abs(); if best.map(|(_, bd)| delta < bd).unwrap_or(true) { best = Some((index, delta)); } @@ -1142,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()); @@ -1151,6 +1169,7 @@ async fn resolve_write_target( kind: EventIdKind::RecurringMaster, item_id: master.id, change_key: master.change_key, + series_zone: None, }) } @@ -1180,15 +1199,19 @@ 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)); 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 @@ -1231,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)?)) } @@ -1244,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`. @@ -2533,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. @@ -3513,6 +3679,210 @@ mod tests { assert!(matches!(err, EwsError::Protocol(_))); } + /// 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; + 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_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 diff --git a/crates/adapter-ews/src/lib.rs b/crates/adapter-ews/src/lib.rs index 9c6ad73c..9ec2c8f4 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_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 e51bb593..24135476 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 } @@ -2446,7 +2455,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 @@ -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; @@ -2490,11 +2500,12 @@ 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); - 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; @@ -2515,18 +2526,82 @@ fn inherited_override_event( row } -/// 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 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, 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 +/// 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_anchor(ov.original_start, all_day_zone(master_item)) + } 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 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, + slot: DateTime, +) -> bool { + override_slot(master_item, ov) == slot || ov.original_start == slot +} + +/// 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) +/// — 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 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) + 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) } @@ -7519,7 +7594,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(); @@ -7551,13 +7626,184 @@ 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) + ); + } + + /// 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_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_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. + assert!(names_override(&item, &ov, slot)); + assert!(names_override(&item, &ov, orig)); + assert!(!names_override( + &item, + &ov, + 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)); + } + + /// 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 + /// 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() { + 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.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 b1a8c766..11e41ccf 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(); @@ -260,15 +275,28 @@ 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 { - 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 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 } @@ -666,6 +694,142 @@ 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()); + } + + /// 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(), + }], + ); + } + + /// 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 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() { + 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/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/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/crates/host-core/src/sound.rs b/crates/host-core/src/sound.rs index e322f544..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()) @@ -125,14 +126,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 +186,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/shared/contracts/eventOccurrences.json b/shared/contracts/eventOccurrences.json index eb3df135..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)", @@ -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', diff --git a/web/src/content/docs/developers/adapters/ews.md b/web/src/content/docs/developers/adapters/ews.md index 66099a19..d2efb9cf 100644 --- a/web/src/content/docs/developers/adapters/ews.md +++ b/web/src/content/docs/developers/adapters/ews.md @@ -47,6 +47,33 @@ 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 + all-day item's instants, an occurrence's `OriginalStart` among them, as + 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 + 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 + 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. 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 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