diff --git a/package-lock.json b/package-lock.json index 1df6f4b6..10d72f97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pm", - "version": "3.153.0-alpha", + "version": "3.153.1-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pm", - "version": "3.153.0-alpha", + "version": "3.153.1-alpha", "license": "AGPL-3.0-or-later", "dependencies": { "@fontsource/atkinson-hyperlegible": "^5.3.0", diff --git a/package.json b/package.json index def2699d..0ec1c742 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pm", "private": true, - "version": "3.153.0-alpha", + "version": "3.153.1-alpha", "license": "AGPL-3.0-or-later", "type": "module", "engines": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0c6174bf..ecf8c3cb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3072,7 +3072,7 @@ dependencies = [ [[package]] name = "pm" -version = "3.153.0-alpha" +version = "3.153.1-alpha" dependencies = [ "argon2", "base64 0.23.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ead2a9ea..20b14c8f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pm" -version = "3.153.0-alpha" +version = "3.153.1-alpha" description = "PM — a local-first personal manager (Archivist + Personal Assistant)" authors = ["Bobby Yu"] edition = "2021" diff --git a/src-tauri/src/calendar_recur/expand.rs b/src-tauri/src/calendar_recur/expand.rs index 4fff8455..4cc67988 100644 --- a/src-tauri/src/calendar_recur/expand.rs +++ b/src-tauri/src/calendar_recur/expand.rs @@ -287,6 +287,46 @@ pub fn last_before( Ok(last) } +/// The last occurrence a rule that ends (a COUNT or an UNTIL) makes, deleted or not; `None` when it +/// makes none. Only for a rule that ends: one that never does walks to its limit and is +/// [`RecurError::CannotCount`]. +pub fn last_occurrence( + facts: &RuleFacts, + start: &SeriesStart, +) -> Result, RecurError> { + let mut last = None; + walk(facts, start, |p| { + last = Some(p); + true + })?; + Ok(last) +} + +/// The rule's first occurrence that `kept` keeps (one not deleted, say), no later than `until`: the +/// one a series that stops repeating still shows (C11 critique 1 H1). `None` when there's none by +/// then. +pub fn first_kept( + facts: &RuleFacts, + start: &SeriesStart, + until: Point, + kept: impl Fn(&Point) -> bool, +) -> Result, RecurError> { + same_kind(start, &until)?; + let zone = start.zone(); + let mut first = None; + walk(facts, start, |p| { + if p.cmp_in(&until, zone) == Ordering::Greater { + return false; + } + if kept(&p) { + first = Some(p); + return false; + } + true + })?; + Ok(first) +} + /// The series' first occurrence on or after `start` under `spec`: "Weekly on Monday and Wednesday" /// chosen on a Tuesday starts on the Wednesday, at the time chosen. RFC 5545 counts a series' start as /// an occurrence whether or not its rule makes it, and the crate doesn't, so PM never writes a start @@ -753,6 +793,32 @@ mod tests { format!("{results:?} {moved:?}") } + /// C11 critique 1 H1: the first occurrence a series that stops repeating keeps is the first not + /// deleted, never past the one asked about. + #[test] + fn the_first_kept_occurrence_skips_deleted_ones_up_to_a_bound() { + let weekly = facts("RRULE:FREQ=WEEKLY;BYDAY=MO"); + let start = london(2026, 10, 5, 9); + let gone = [at("2026-10-05T08:00:00Z"), at("2026-10-12T08:00:00Z")]; + let kept = |p: &Point| !gone.contains(p); + assert_eq!( + first_kept(&weekly, &start, at("2026-10-26T09:00:00Z"), kept), + Ok(Some(at("2026-10-19T08:00:00Z"))) + ); + assert_eq!( + first_kept(&weekly, &start, at("2026-10-12T08:00:00Z"), kept), + Ok(None) + ); + assert_eq!( + first_kept(&weekly, &start, at("2026-10-26T09:00:00Z"), |_| true), + Ok(Some(at("2026-10-05T08:00:00Z"))) + ); + assert_eq!( + first_kept(&weekly, &start, Point::Day(day(2026, 10, 26)), kept), + Err(RecurError::Mismatch) + ); + } + /// The child half of the next test: in a process started with [`PROBE`] set, prints [`probe`] /// worked out in that process's zone. Without it, it checks nothing. #[test] diff --git a/src-tauri/src/calendar_recur/lines.rs b/src-tauri/src/calendar_recur/lines.rs index 7c70d7a5..a87e0e35 100644 --- a/src-tauri/src/calendar_recur/lines.rs +++ b/src-tauri/src/calendar_recur/lines.rs @@ -118,6 +118,12 @@ impl Rule { rule } + /// Whether it says where it ends (a COUNT or an UNTIL), read from its parts as written, so a rule + /// PM can only show as text says it too. + pub fn ends(&self) -> bool { + self.get("COUNT").is_some() || self.get("UNTIL").is_some() + } + /// The rule as an RRULE line, canonical: upper-case, parts in their order. pub fn line(&self) -> String { let parts: Vec = self.parts.iter().map(|(k, v)| format!("{k}={v}")).collect(); diff --git a/src-tauri/src/calendar_recur/spec.rs b/src-tauri/src/calendar_recur/spec.rs index 838247ba..fcc3afed 100644 --- a/src-tauri/src/calendar_recur/spec.rs +++ b/src-tauri/src/calendar_recur/spec.rs @@ -397,12 +397,12 @@ pub fn describe(spec: &RecurrenceSpec, start: &SeriesStart) -> String { /// described, or [`CUSTOM_RULE`] for anything outside the editor's set. EXDATEs and RDATEs don't /// change the words. pub fn describe_lines>(lines: &[S], start: &SeriesStart) -> String { - spec_of_lines(lines, start) - .map_or_else(|| CUSTOM_RULE.to_string(), |spec| describe(&spec, start)) + spec_of(lines, start).map_or_else(|| CUSTOM_RULE.to_string(), |spec| describe(&spec, start)) } -/// `recurrence` lines' one RRULE as a rule from the editor's set, or `None` for anything else. -fn spec_of_lines>(lines: &[S], start: &SeriesStart) -> Option { +/// `recurrence` lines' one RRULE as a rule from the editor's set, or `None` for anything else: what +/// the editor's Repeat row starts from, and what a change of rule is told from (C11). +pub fn spec_of>(lines: &[S], start: &SeriesStart) -> Option { let lines = lines::parse(lines); let mut rules = lines.iter().filter_map(|l| match &l.kind { Kind::Rule(rule) => Some(rule), @@ -423,7 +423,7 @@ fn spec_of_lines>(lines: &[S], start: &SeriesStart) -> Option>(lines: &[S], start: &SeriesStart) -> Option { - let spec = spec_of_lines(lines, start)?; + let spec = spec_of(lines, start)?; let n = spec.interval; let months = |text: String| match n { 1 => format!("{text} of each month"), diff --git a/src-tauri/src/calendar_write/dto.rs b/src-tauri/src/calendar_write/dto.rs index 4f461543..435c6984 100644 --- a/src-tauri/src/calendar_write/dto.rs +++ b/src-tauri/src/calendar_write/dto.rs @@ -101,6 +101,18 @@ pub struct EventPatchDraft { pub time: Option, pub show_as: Option, pub visibility: Option, + /// How it repeats from now on. `None` leaves the repeat as it is (C11). + pub recurrence: Option, +} + +/// A change to how an event repeats (C11): a rule from the editor's set, or none. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RepeatDraft { + /// Repeat by this rule from now on. + Rule { rule: RepeatRule }, + /// Stop repeating ("Does not repeat"). + Once, } /// A new event as the editor holds it (A22: defined with its first caller, the create command). No @@ -423,8 +435,9 @@ pub enum EditLoad { seen: Option, /// Its series, when it's an occurrence of one and PM could read Google's copy of it. The /// session keeps that copy, so "all events" and "this and following" never overwrite a - /// change someone made to the series after the editor opened (G9). - series: Option, + /// change someone made to the series after the editor opened (G9). Boxed, as its rule (C11) + /// makes it the outcome's largest part. + series: Option>, }, Gone, Reauth, @@ -440,6 +453,13 @@ pub struct SeriesInfo { pub first: bool, /// How the series repeats, as Google Calendar says it ("Weekly on Monday"). pub repeats: String, + /// The rule as one from the editor's set, or `None` for one PM can only show (`repeats` says + /// it), which can only be replaced (C11). + pub rule: Option, + /// The series' first occurrence's day, `YYYY-MM-DD`, in the zone it repeats in. + pub starts: String, + /// The zone it repeats in; `None` for an all-day one, which repeats by days. + pub zone: Option, } /// What the choices for a change to a repeating occurrence are asked for: an open editor's draft, or @@ -537,6 +557,10 @@ pub enum SeriesWarningDto { }, /// The new part is a new event in Google: links to the old one stay with the part before. NewIdentity, + /// It stops repeating: only the occurrence on `day` stays (C11). + BecomesOneEvent { day: String }, + /// This many occurrences changed on their own fall on days the new rule skips: their changes go. + ChangesLost { count: u32 }, } #[cfg(test)] @@ -834,10 +858,51 @@ mod tests { assert_eq!( serde_json::to_string(&SeriesInfo { first: false, - repeats: "Weekly on Monday".into() + repeats: "Weekly on Monday".into(), + rule: None, + starts: "2026-10-05".into(), + zone: Some("Europe/London".into()), }) .unwrap(), - r#"{"first":false,"repeats":"Weekly on Monday"}"# + r#"{"first":false,"repeats":"Weekly on Monday","rule":null,"starts":"2026-10-05","zone":"Europe/London"}"# + ); + for (warning, json) in [ + ( + SeriesWarningDto::BecomesOneEvent { + day: "2026-10-05".into(), + }, + r#"{"warning":"becomes_one_event","day":"2026-10-05"}"#, + ), + ( + SeriesWarningDto::ChangesLost { count: 2 }, + r#"{"warning":"changes_lost","count":2}"#, + ), + ] { + assert_eq!(serde_json::to_string(&warning).unwrap(), json); + } + } + + /// C11: a draft's repeat, as the editor will send it; left out or null, the repeat is untouched. + #[test] + fn a_draft_names_a_new_repeat_or_none() { + let rule: EventPatchDraft = serde_json::from_str( + r#"{"recurrence":{"kind":"rule","rule":{"pattern":{"kind":"daily"},"interval":2, + "ends":{"kind":"never"}}}}"#, + ) + .unwrap(); + assert!(matches!( + rule.recurrence, + Some(RepeatDraft::Rule { rule }) if rule.interval == 2 && rule.to_spec().is_ok() + )); + let once: EventPatchDraft = + serde_json::from_str(r#"{"recurrence":{"kind":"once"}}"#).unwrap(); + assert_eq!(once.recurrence, Some(RepeatDraft::Once)); + for json in [r#"{"summary":"Gym"}"#, r#"{"recurrence":null}"#] { + let untouched: EventPatchDraft = serde_json::from_str(json).unwrap(); + assert!(untouched.recurrence.is_none(), "{json}"); + } + assert!( + serde_json::from_str::(r#"{"recurrence":{"kind":"weekly"}}"#).is_err() ); } diff --git a/src-tauri/src/calendar_write/patch.rs b/src-tauri/src/calendar_write/patch.rs index 1d269bea..a9d1dcfc 100644 --- a/src-tauri/src/calendar_write/patch.rs +++ b/src-tauri/src/calendar_write/patch.rs @@ -483,6 +483,8 @@ mod tests { }), show_as: Some(ShowAs::Free), visibility: Some(Visibility::Private), + // A repeat is never a one-event body's: the series planners write it (C11). + recurrence: Some(crate::calendar_write::dto::RepeatDraft::Once), }; let body = build_patch(&fresh(), &draft).unwrap(); for key in body.keys() { diff --git a/src-tauri/src/calendar_write/series/diff.rs b/src-tauri/src/calendar_write/series/diff.rs index cdaf423b..7309f03e 100644 --- a/src-tauri/src/calendar_write/series/diff.rs +++ b/src-tauri/src/calendar_write/series/diff.rs @@ -42,6 +42,10 @@ pub enum Expect { Present { series: String }, /// Nothing of the series. Absent { series: String }, + /// The new event `id`, one that doesn't repeat, a split that stops repeating made (C11): it lands + /// from its insert's own answer, never a listing, since what Google lists for a one-off's + /// occurrences is unproven. [`diff_series`] reads no re-fetch for it. + Event { id: String }, } /// The rows a plan removes from the mirror, so a stale re-fetch can't bring them back (A14): a @@ -264,14 +268,15 @@ fn instant(p: Point) -> DateTime { /// Whether the re-fetch shows `expect`. A series that wasn't re-fetched shows nothing. fn shows(expect: &Expect, refetched: &[Refetch], zone: Tz) -> bool { - let series_of = |e: &Expect| match e { + let series = match expect { Expect::Updated { series, .. } | Expect::Cancelled { series, .. } | Expect::EndsBefore { series, .. } | Expect::Present { series } | Expect::Absent { series } => series.clone(), + // Landed from its insert's answer: no listing stands for it. + Expect::Event { .. } => return true, }; - let series = series_of(expect); let Some(r) = refetched.iter().find(|r| r.series == series) else { return false; }; @@ -302,6 +307,7 @@ fn shows(expect: &Expect, refetched: &[Refetch], zone: Tz) -> bool { } Expect::Present { .. } => live().next().is_some(), Expect::Absent { .. } => r.listing.complete && live().next().is_none(), + Expect::Event { .. } => true, } } @@ -582,6 +588,12 @@ mod tests { }]; let diff = diff_series(CAL, &[], &refetch, window(), &[], London, None); assert!(diff.upserts.is_empty() && diff.created_since(&[]).is_empty()); + + // C11: one event a split made lands from its insert's answer: no listing stands for it, so + // it never stales one. + let event = Expect::Event { id: "new".into() }; + let diff = diff_series(CAL, &[], &refetch, window(), &[event], London, None); + assert!(!diff.stale); } /// C8b review R2: an occurrence the run reported it couldn't cancel is still in Google. A diff --git a/src-tauri/src/calendar_write/series/journal.rs b/src-tauri/src/calendar_write/series/journal.rs index 8ecf1c54..67d9e549 100644 --- a/src-tauri/src/calendar_write/series/journal.rs +++ b/src-tauri/src/calendar_write/series/journal.rs @@ -47,6 +47,10 @@ pub struct Entry { pub cut: Vec, /// The change also moves or cancels occurrences changed on their own after the split. pub cleanup: bool, + /// The new part is one event that doesn't repeat ("Does not repeat", C11), for the words. A + /// record from before there was such a change has none: a new part that repeats. + #[serde(default)] + pub one_off: bool, } impl Entry { @@ -183,6 +187,7 @@ mod tests { before: vec!["RRULE:FREQ=WEEKLY;BYDAY=MO".into()], cut: vec!["RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20261018T225959Z".into()], cleanup: true, + one_off: false, } } @@ -307,6 +312,12 @@ mod tests { assert!(read(Some("not json")).is_empty()); assert!(read(None).is_empty()); assert_ne!(key("a", "bc"), key("ab", "c")); + // A record kept before a new part could be one event still reads, as a part that repeats. + let mut older: Value = serde_json::from_str(&stored).unwrap(); + for e in older.as_object_mut().unwrap().values_mut() { + e.as_object_mut().unwrap().remove("one_off"); + } + assert_eq!(read(Some(&older.to_string())), journal); } #[test] diff --git a/src-tauri/src/calendar_write/series/mod.rs b/src-tauri/src/calendar_write/series/mod.rs index 50c24908..15faa31e 100644 --- a/src-tauri/src/calendar_write/series/mod.rs +++ b/src-tauri/src/calendar_write/series/mod.rs @@ -8,6 +8,7 @@ //! //! - [`scope`]: which of the three choices a change can offer. //! - [`plan`]: the writes themselves, and the rules for reading their answers. +//! - [`rule`]: a series rebuilt from a new rule, or made one event that doesn't repeat (C11). //! - [`diff`]: a re-fetch of a series turned into mirror rows added and removed. //! - [`run`]: a plan sent step by step, its answers read by the rules in [`plan`] (C9a), with no I/O. //! - [`journal`]: what PM keeps of a "this and following" change while it runs, and how a record left @@ -27,18 +28,20 @@ pub mod diff; pub mod journal; pub mod plan; +pub mod rule; pub mod run; pub mod scope; use std::fmt; -use chrono::{DateTime, NaiveDate, NaiveTime, TimeDelta, Utc}; +use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, TimeDelta, Utc}; use chrono_tz::Tz; use serde_json::Value; -use crate::calendar_recur::expand::{crate_agrees, is_occurrence}; +use crate::calendar_recur::expand::{crate_agrees, is_occurrence, last_occurrence}; use crate::calendar_recur::lines::{self, Kind, TextOnly}; use crate::calendar_recur::remap::RetimeError; +use crate::calendar_recur::spec::SpecError; use crate::calendar_recur::{Point, RecurError, SeriesStart}; use super::dto::ReadOnlyReason; @@ -624,6 +627,26 @@ pub enum SeriesRefusal { SplitOverlap(NaiveDate), /// PM couldn't list the parts it split off this series. PartsUnread, + /// A repeat rule from the editor that can't be saved: a limit, a first day in a skipped hour, no + /// occurrence (C11). + Spec(SpecError), + /// A new rule can't apply to one occurrence. + RuleIsSeriesWide, + /// A later part of this series starts on this day, and the new rule would repeat on or past it + /// (C11 critique 1 H2). + RepeatsPastPart(NaiveDate), + /// A later part of this series whose start PM can't read. + LaterPartUnread, + /// The series is itself a part of one split earlier, and the new rule would repeat past this day, + /// where its own rule ends now ([`LaterPart::Through`]). + RepeatsPastItsEnd(NaiveDate), + /// Stopping it repeating would keep no occurrence PM can name. + NothingToKeep, + /// The occurrence that would stay once it stops repeating has something of its own PM can't keep. + KeptHasOwn(NaiveDate), + /// The occurrence deleted on this day would move with the days it repeats on, and PM can't read + /// the old rule to tell whether the day it moves to had an occurrence of its own (C11c review). + DeletedDayUnread(NaiveDate), } impl fmt::Display for SeriesRefusal { @@ -713,10 +736,58 @@ impl fmt::Display for SeriesRefusal { this repeating event, so it won't change more than this occurrence now. Try again \ in a moment." ), + SeriesRefusal::Spec(e) => e.fmt(f), + SeriesRefusal::RuleIsSeriesWide => write!( + f, + "Changing how it repeats can't apply to just this occurrence." + ), + SeriesRefusal::RepeatsPastPart(day) => write!( + f, + "PM can't make it repeat past {}, where a later part of it starts. Pick an end \ + date before then.", + day.format("%d-%m-%Y") + ), + SeriesRefusal::LaterPartUnread => write!( + f, + "PM can't tell where a later part of this repeating event starts, so it won't \ + change how it repeats." + ), + SeriesRefusal::RepeatsPastItsEnd(day) => write!( + f, + "An earlier “this and following” change made this repeating event from part of \ + another, and PM can't check the parts after it, so it won't make it repeat past {}. \ + Pick an end date on or before then.", + day.format("%d-%m-%Y") + ), + SeriesRefusal::NothingToKeep => write!( + f, + "Every occurrence of its repeat up to this one was deleted, so PM can't tell which \ + one should stay. Change it in Google Calendar." + ), + SeriesRefusal::KeptHasOwn(day) => write!( + f, + "The occurrence on {}, the one that would stay, has its own colour, reminders, \ + attachments, video call, label or who can see it, which PM can't keep when it \ + stops repeating.", + day.format("%d-%m-%Y") + ), + SeriesRefusal::DeletedDayUnread(day) => write!( + f, + "PM can't read how this event repeats well enough to tell where the occurrence \ + deleted on {} would move to. Change the days it repeats on in Google Calendar, or \ + keep this occurrence on its own day.", + day.format("%d-%m-%Y") + ), } } } +impl From for SeriesRefusal { + fn from(e: SpecError) -> Self { + SeriesRefusal::Spec(e) + } +} + impl From for SeriesRefusal { fn from(e: RecurError) -> Self { SeriesRefusal::Recur(e) @@ -772,28 +843,10 @@ pub fn parts_overlap( } let live = |item: &&Value| item.get("status").and_then(Value::as_str) != Some("cancelled"); let own = |item: &&Value| item.get("recurringEventId").and_then(Value::as_str) == Some(series); - // Each part's split point: its note, or (a part from before the note) its start. - let points: Vec = parts - .items - .iter() - .filter(live) - .filter(|p| { - p.get("recurringEventId").is_none() - && p.get("id").and_then(Value::as_str) != Some(series) - }) - .filter(|p| { - p.pointer("/extendedProperties/private/pmSplitOf") - .and_then(Value::as_str) - == Some(series) - }) - .filter_map(|p| { - p.pointer("/extendedProperties/private/pmSplitAt") - .and_then(Value::as_str) - .and_then(point_from_text) - .or_else(|| point_of(p.get("start"))) - }) - .collect(); - let Some(first) = points.iter().copied().min_by(|a, b| a.cmp_in(b, zone)) else { + let Some(first) = split_points(parts, master) + .into_iter() + .min_by(|a, b| a.cmp_in(b, zone)) + else { return Parts::Clear; }; if !window.complete { @@ -820,6 +873,165 @@ pub fn parts_overlap( } } +/// Each live part PM split off `master`, at its split point: its `pmSplitAt` note, or (a part from +/// before the note) its start. A part's own exceptions, which may carry the notes too, aren't parts. +fn split_points(parts: &Listing, master: &Master) -> Vec { + let series = master.id.as_str(); + parts + .items + .iter() + .filter(|p| p.get("status").and_then(Value::as_str) != Some("cancelled")) + .filter(|p| { + p.get("recurringEventId").is_none() + && p.get("id").and_then(Value::as_str) != Some(series) + }) + .filter(|p| { + p.pointer("/extendedProperties/private/pmSplitOf") + .and_then(Value::as_str) + == Some(series) + }) + .filter_map(|p| { + p.pointer("/extendedProperties/private/pmSplitAt") + .and_then(Value::as_str) + .and_then(point_from_text) + .or_else(|| point_of(p.get("start"))) + }) + .collect() +} + +/// Where the earliest later part of a series starts: what a new rule for it mustn't repeat on or past, +/// or the two would show the same days (C11 critique 1 H2). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum LaterPart { + /// None: the series may repeat as far as its rule says. + #[default] + None, + /// The earliest later part's split point. + At(Point), + /// The series is itself a part of one split earlier, whose other parts PM doesn't read: it may + /// repeat up to this day, its last occurrence's now, and no further (C11c review). Its rule ends + /// before any later part, as every split leaves it, unless it is made to repeat on. + Through(NaiveDate), + /// There's a later part PM can't place: its listing didn't run to its end, or Google's id for it + /// doesn't name its start. + Unread, +} + +/// The later parts of `master`: those PM split off it (`parts`, as [`parts_overlap`] reads them), and +/// those Google split off it, which the mirror's `series` ids name `_R` (a later stamp than +/// `master`'s own, when it is one of Google's parts itself; its original has no stamp). +/// +/// A series that is itself a part (Google's `_R` id, or PM's note: `noted_part`) may have later parts +/// of the series it came from, which neither names: PM's, or Google's split of the original after a +/// part of PM's. Every split leaves the part before it ending first, so such a series is held to where +/// its own rule ends now ([`LaterPart::Through`]), and one that repeats for ever has none after it. +pub fn later_part( + parts: &Listing, + master: &Master, + noted_part: bool, + series: &[String], +) -> LaterPart { + if !parts.complete { + return LaterPart::Unread; + } + let zone = master.start.zone(); + let through = if noted_part || google_part_of(&master.id).1.is_some() { + match own_end(master) { + Ok(end) => end, + Err(_) => return LaterPart::Unread, + } + } else { + None + }; + let mut points = split_points(parts, master); + let (base, own) = google_part_of(&master.id); + let own = match own { + Some(stamp) => match google_stamp(stamp) { + Some(p) => Some(p), + None => return LaterPart::Unread, + }, + None => None, + }; + for id in series.iter().filter(|id| **id != master.id) { + let (other_base, stamp) = google_part_of(id); + let Some(stamp) = stamp.filter(|_| other_base == base) else { + continue; + }; + let Some(p) = google_stamp(stamp) else { + return LaterPart::Unread; + }; + if own.is_none_or(|own| p.cmp_in(&own, zone).is_gt()) { + points.push(p); + } + } + let first = points.into_iter().min_by(|a, b| a.cmp_in(b, zone)); + match (first, through) { + (Some(p), Some(day)) if p.day_in(zone) > day => LaterPart::Through(day), + (Some(p), _) => LaterPart::At(p), + (None, Some(day)) => LaterPart::Through(day), + (None, None) => LaterPart::None, + } +} + +/// The day `master`'s last occurrence falls on now, its rule's or an extra date's, `None` for a rule +/// that never ends; an error when PM can't read the rule or walk it. +fn own_end(master: &Master) -> Result, RecurError> { + let parsed = lines::parse(&master.recurrence); + let facts = match lines::series_rule(&parsed) { + Ok((_, facts)) => facts, + // A rule PM can only show as text still says whether it ends: one that repeats for ever + // has none after it (C11c review). A line PM can't read at all may be where it ends. + Err(_) if never_ends(&parsed) => return Ok(None), + Err(why) => return Err(RecurError::TextOnly(why)), + }; + if facts.count.is_none() && facts.until.is_none() { + return Ok(None); + } + let zone = master.start.zone(); + // Its start counts even off its own rule (RFC 5545), and the rule's occurrences come after it. + let mut last = last_occurrence(&facts, &master.start)?.unwrap_or(master.first); + for line in &parsed { + if let Kind::RDate(dates) = &line.kind { + for value in &dates.values { + let p = dates + .point(value, &master.start) + .ok_or(RecurError::CannotCount)?; + if p.cmp_in(&last, zone).is_gt() { + last = p; + } + } + } + } + Ok(Some(last.day_in(zone))) +} + +/// Every line read, and one of its rules repeats for ever: no COUNT, no UNTIL. +fn never_ends(parsed: &[lines::Line]) -> bool { + parsed.iter().all(|l| l.kind != Kind::Unreadable) + && parsed + .iter() + .any(|l| matches!(&l.kind, Kind::Rule(rule) if !rule.ends())) +} + +/// A series id as Google's split names it: its base, and the stamp after `_R` when it has one. +fn google_part_of(id: &str) -> (&str, Option<&str>) { + match id.split_once("_R") { + Some((base, stamp)) => (base, Some(stamp)), + None => (id, None), + } +} + +/// The start a Google split's `_R` stamp names: `YYYYMMDDTHHMMSS` (UTC, a `Z` or not), or a date. +fn google_stamp(stamp: &str) -> Option { + let stamp = stamp.trim_end_matches('Z'); + if let Ok(at) = NaiveDateTime::parse_from_str(stamp, "%Y%m%dT%H%M%S") { + return Some(Point::At(at.and_utc())); + } + NaiveDate::parse_from_str(stamp, "%Y%m%d") + .ok() + .map(Point::Day) +} + /// Google-shaped series and occurrences for the series tests: a weekly 09:00–10:00 "Standup" in /// London from Monday 05-10-2026, as Google's API returns them (times in the calendar's zone, here /// also London). @@ -967,6 +1179,7 @@ pub(crate) mod fixtures { pub exceptions: Exceptions, pub instances: Option, pub sibling: bool, + pub later: LaterPart, pub guests_allowed: bool, } @@ -980,6 +1193,7 @@ pub(crate) mod fixtures { exceptions: complete(Vec::new()), instances: None, sibling: false, + later: LaterPart::None, guests_allowed: false, } } @@ -1009,6 +1223,7 @@ pub(crate) mod fixtures { exceptions: &self.exceptions, instances: self.instances.as_ref(), sibling: self.sibling, + later: self.later, guests_allowed: self.guests_allowed, } } @@ -1577,4 +1792,219 @@ mod tests { ); assert_eq!(read(&whole(vec![other]), &october, &none), Parts::Clear); } + + /// C11 critique 1 H2: where a new rule must stop, the earliest later part's start, whether PM + /// split it off (its note) or Google did (its `_R` id names it). + #[test] + fn a_later_part_is_where_a_new_rule_must_stop() { + let parts = |items: Vec| Listing { + items, + complete: true, + }; + let part = |id: &str, at: &str| { + json!({ + "id": id, + "status": "confirmed", + "start": { "dateTime": "2026-10-19T10:00:00+01:00" }, + "extendedProperties": { "private": { "pmSplitOf": "abc", "pmSplitAt": at } }, + }) + }; + let weekly_m = Master::read(&fresh(master(&["RRULE:FREQ=WEEKLY;BYDAY=MO"]))).unwrap(); + let none: Vec = Vec::new(); + assert_eq!( + later_part(&parts(Vec::new()), &weekly_m, false, &none), + LaterPart::None + ); + // The earlier of two of PM's, a cancelled one none. + let two = parts(vec![ + part("p2", "2026-11-02T09:00:00+00:00"), + part("p1", "2026-10-19T08:00:00+00:00"), + with( + part("p0", "2026-10-12T08:00:00+00:00"), + "status", + json!("cancelled"), + ), + ]); + assert_eq!( + later_part(&two, &weekly_m, false, &none), + LaterPart::At(point("2026-10-19T08:00:00Z")) + ); + // Google's own, from its id; another series' and the master's own aren't. + let series = |ids: &[&str]| ids.iter().map(|s| s.to_string()).collect::>(); + assert_eq!( + later_part( + &parts(Vec::new()), + &weekly_m, + false, + &series(&["abc", "xyz_R20261019T080000", "abc_R20261102T090000"]) + ), + LaterPart::At(point("2026-11-02T09:00:00Z")) + ); + assert_eq!( + later_part(&two, &weekly_m, false, &series(&["abc_R20261012T080000Z"])), + LaterPart::At(point("2026-10-12T08:00:00Z")) + ); + assert_eq!( + later_part( + &parts(Vec::new()), + &weekly_m, + false, + &series(&["abc_R20261102"]) + ), + LaterPart::At(Point::Day(NaiveDate::from_ymd_opt(2026, 11, 2).unwrap())) + ); + // A Google part itself: only a later stamp is a later part, and its original none. + let google_part = with( + master(&["RRULE:FREQ=WEEKLY;BYDAY=MO"]), + "id", + json!("abc_R20261019T080000"), + ); + let google_part = Master::read(&fresh(google_part)).unwrap(); + assert_eq!( + later_part( + &parts(Vec::new()), + &google_part, + false, + &series(&["abc", "abc_R20261005T080000", "abc_R20261019T080000"]) + ), + LaterPart::None + ); + assert_eq!( + later_part( + &parts(Vec::new()), + &google_part, + false, + &series(&["abc", "abc_R20261102T090000"]) + ), + LaterPart::At(point("2026-11-02T09:00:00Z")) + ); + // A stamp PM can't read, or parts not listed whole: it can't say. + assert_eq!( + later_part( + &parts(Vec::new()), + &weekly_m, + false, + &series(&["abc_Rsoon"]) + ), + LaterPart::Unread + ); + let short = Listing { + items: Vec::new(), + complete: false, + }; + assert_eq!( + later_part(&short, &weekly_m, false, &none), + LaterPart::Unread + ); + } + + /// C11c review: a series that is itself a part, by PM's note or Google's `_R` id, may have later + /// parts of the series it came from that neither names, so it's held to where its own rule ends + /// now: its last occurrence's day, by its UNTIL or its COUNT, or a later extra date. One that + /// repeats for ever has none after it, and an original is held only by its own parts. + #[test] + fn a_part_is_held_to_where_it_ends_now() { + let read = |recurrence: &[&str], id: &str| { + Master::read(&fresh(with(master(recurrence), "id", json!(id)))).unwrap() + }; + let parts = |items: Vec| Listing { + items, + complete: true, + }; + let none: Vec = Vec::new(); + let day = |d: u32| NaiveDate::from_ymd_opt(2026, 10, d).unwrap(); + // Cut before Monday 02-11, as the split that made a later part left it: Monday 26-10. + let until = ["RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20261102T085959Z"]; + for (m, noted) in [ + (read(&until, "p1"), true), + (read(&until, "abc_R20261005T080000"), false), + ] { + assert_eq!( + later_part(&parts(Vec::new()), &m, noted, &none), + LaterPart::Through(day(26)) + ); + } + // An original with the same rule is held only by its own parts. + assert_eq!( + later_part(&parts(Vec::new()), &read(&until, "abc"), false, &none), + LaterPart::None + ); + // Four times from 05-10, its last Monday 26-10; an extra date after it, Wednesday 28-10. + let counted = read(&["RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=4"], "p1"); + assert_eq!( + later_part(&parts(Vec::new()), &counted, true, &none), + LaterPart::Through(day(26)) + ); + let extra = read( + &[until[0], "RDATE;TZID=Europe/London:20261028T090000"], + "p1", + ); + assert_eq!( + later_part(&parts(Vec::new()), &extra, true, &none), + LaterPart::Through(day(28)) + ); + // A part that repeats for ever has none. + let forever = read(&["RRULE:FREQ=WEEKLY;BYDAY=MO"], "p1"); + assert_eq!( + later_part(&parts(Vec::new()), &forever, true, &none), + LaterPart::None + ); + // Its own later part counts where it's earlier than where it ends, never after. + let own = |at: &str| { + json!({ + "id": "p9", + "status": "confirmed", + "start": { "dateTime": at }, + "extendedProperties": { "private": { "pmSplitOf": "p1", "pmSplitAt": at } }, + }) + }; + let p1 = read(&until, "p1"); + assert_eq!( + later_part( + &parts(vec![own("2026-10-19T08:00:00+00:00")]), + &p1, + true, + &none + ), + LaterPart::At(point("2026-10-19T08:00:00Z")) + ); + assert_eq!( + later_part( + &parts(vec![own("2026-11-09T09:00:00+00:00")]), + &p1, + true, + &none + ), + LaterPart::Through(day(26)) + ); + // A rule PM can't walk can't say where it ends. + let custom = read(&["RRULE:FREQ=MONTHLY;BYDAY=TU;BYSETPOS=2;COUNT=3"], "p1"); + assert_eq!( + later_part(&parts(Vec::new()), &custom, true, &none), + LaterPart::Unread + ); + // But one that repeats for ever says so, walked or not: a part of either kind has none + // after it (C11c review); unless a line PM can't read at all may be where it ends. + let custom_forever = ["RRULE:FREQ=MONTHLY;BYDAY=TU;BYSETPOS=2"]; + for (m, noted) in [ + (read(&custom_forever, "p1"), true), + (read(&custom_forever, "abc_R20261005T080000"), false), + ] { + assert_eq!( + later_part(&parts(Vec::new()), &m, noted, &none), + LaterPart::None + ); + } + let unreadable = read( + &[ + custom_forever[0], + "EXDATE;TZID=Mars/Olympus:20261013T090000", + ], + "p1", + ); + assert_eq!( + later_part(&parts(Vec::new()), &unreadable, true, &none), + LaterPart::Unread + ); + } } diff --git a/src-tauri/src/calendar_write/series/plan.rs b/src-tauri/src/calendar_write/series/plan.rs index 2fa76605..f9669c41 100644 --- a/src-tauri/src/calendar_write/series/plan.rs +++ b/src-tauri/src/calendar_write/series/plan.rs @@ -59,7 +59,7 @@ use chrono_tz::Tz; use serde_json::{json, Map, Value}; use crate::calendar_recur::expand::{is_occurrence, last_before}; -use crate::calendar_recur::lines::{self, recurrence_verdict, same_recurrence, Applied}; +use crate::calendar_recur::lines::{self, same_recurrence, Applied}; use crate::calendar_recur::reanchor::DayMap; use crate::calendar_recur::remap::{exdate_lines, retime, RetimeError, Retimed}; use crate::calendar_recur::split::{ @@ -78,10 +78,11 @@ use super::super::plan::{ }; use super::super::time; use super::diff::{Expect, Removes, SeriesDiff}; +use super::rule::{plan_all_rule, plan_split_rule, rule_ask}; use super::scope::{is_first, zones_changed, Scope}; use super::{ attendee_set, classify_instance, excluded, google_before, guests_on, original_of, point_of, - Exceptions, FreshEvent, InstanceKind, Length, Listing, Master, SeriesRefusal, + Exceptions, FreshEvent, InstanceKind, LaterPart, Length, Listing, Master, SeriesRefusal, }; /// Everything a series plan is built from. C9 makes the GETs; nothing here fetches. @@ -99,6 +100,9 @@ pub struct SeriesInput<'a> { pub instances: Option<&'a Listing>, /// The mirror holds another part of the same series, split earlier (Google's own `_R…`). pub sibling: bool, + /// Where the earliest later part of the series starts, which a new rule mustn't repeat over (C11 + /// critique 1 H2). + pub later: LaterPart, /// Whether a series with guests may change: false until C12 brings invitations, whatever the /// occurrence shows. One whose guests were removed on its own passes the gate, and a split would /// invite the series' guests again (C8b review G5). @@ -167,11 +171,16 @@ pub struct SeriesPlan { /// The mirror rows the plan removes (A14), as planned: the run's own are /// [`SeriesPlan::removes_for`]. pub removes: Removes, - /// The series' start, for reading recurrence lines by what they mean. - start: SeriesStart, + /// The series' start, for reading recurrence lines by what they mean: what it held before the + /// plan, and what Google holds when a step didn't land. + pub(super) start: SeriesStart, + /// The start the series a step patches repeats from once it has landed: a rule rebuilt for every + /// occurrence starts on its own first day, maybe of another kind or in another zone, and what was + /// sent is read at it (C11; critique 2 M8). [`SeriesPlan::start`] for every other plan. + pub(super) rule_start: SeriesStart, /// Whether it was planned with guests allowed (C12): until then, a guest who appears before a /// step is sent again is a conflict ([`SeriesPlan::resend`]). - guests_allowed: bool, + pub(super) guests_allowed: bool, } /// One write. @@ -496,10 +505,12 @@ pub enum SeriesWarning { /// The days the series falls on move, as the occurrence at `from` moves to `to`: measured from /// where the occurrence first was, so retiming one that was already moved on its own moves the /// series' days too (AS2a). Raised for any move to another day, whatever the rule, a move of - /// whole weeks included. C9 words it from the rule and both dates: the new weekday, day of the + /// whole weeks included; but not with a new rule that names no day, whose start says it all + /// (C11). C9 words it from the rule and both dates: the new weekday, day of the /// month or date where the rule names one ("every Sunday → every Monday?"), and how far every /// occurrence moves wherever that is a week or more (a one-day weekly rule may move a week and a - /// day) or the rule names no day (daily). A split raises no `StartMoved`, so this is all it says. + /// day) or the rule names no day (daily). A split that keeps its rule raises no `StartMoved`, so + /// this is all it says; one with a new rule says where that starts too (C11). RepeatDayChanges { from: NaiveDate, to: NaiveDate }, /// Every occurrence gets this occurrence's length. LengthChanges, @@ -512,6 +523,12 @@ pub enum SeriesWarning { /// The new series is a new event in Google (a new iCal UID): links to the old one (a milestone, /// a reminder) stay with the part before the split. NewIdentity, + /// It stops repeating ("Does not repeat"): only the occurrence on `day` stays, as an event of its + /// own (C11). + BecomesOneEvent { day: NaiveDate }, + /// This many occurrences changed on their own fall on days the new rule skips, so their changes + /// go with them (C11). + ChangesLost { count: u32 }, } /// The fields a split's new series takes from the original, besides its times and rule. @@ -534,7 +551,7 @@ const TAIL_COPY: &[&str] = &[ /// Everything a split's new series takes from the original, its times and rule included: what may /// not have changed in Google before the split is planned, nor before its cut is sent again (R2, /// AS10). One list, so the two checks and the body can't drift (C8b review G1). -const TAIL_WATCH: &[&str] = &[ +pub(super) const TAIL_WATCH: &[&str] = &[ "recurrence", "start", "end", @@ -698,14 +715,16 @@ impl SeriesPlan { // The event itself is gone: whatever was sent can't be there. (Shows::Fields { .. }, None) => Applied::No, (Shows::Fields { sent, old }, Some(v)) => { - let holds = |fields: &Map| { + // What was sent is read at the start it repeats from once landed, and what was + // there at the start it had: never one start for both (C11 critique 2 M8). + let holds = |fields: &Map, start: &SeriesStart| { fields .iter() - .all(|(k, want)| self.same(k, Some(want), v.get(k.as_str()))) + .all(|(k, want)| self.same_at(k, Some(want), v.get(k.as_str()), start)) }; - if holds(sent) { + if holds(sent, &self.rule_start) { Applied::Yes - } else if !old.is_empty() && holds(old) { + } else if !old.is_empty() && holds(old, &self.start) { Applied::No } else { Applied::Unknown @@ -731,12 +750,15 @@ impl SeriesPlan { }; let start_landed = when_eq(Some(new_start), fresh.get("start")); let start_kept = when_eq(old.get("start"), fresh.get("start")); - let verdict = recurrence_verdict( - &lines_of(Some(sent_rule)), - &lines_of(old.get("recurrence")), - &lines_of(fresh.get("recurrence")), - &self.start, - ); + // The rule sent read at the start it repeats from once landed, the old one at its own. + let now = lines_of(fresh.get("recurrence")); + let verdict = if same_recurrence(&lines_of(Some(sent_rule)), &now, &self.rule_start) { + Applied::Yes + } else if same_recurrence(&lines_of(old.get("recurrence")), &now, &self.start) { + Applied::No + } else { + Applied::Unknown + }; (start_landed && verdict == Applied::No) || (start_kept && verdict == Applied::Yes) } @@ -860,10 +882,12 @@ impl SeriesPlan { "attachments", "eventLabelId", ]; + // Its rule read at its own start, which a new rule may put in another kind or zone (C11). + let start = body.get("start").and_then(start_of).unwrap_or(self.start); !is_gone(fresh) && TAIL_FIELDS .iter() - .all(|k| self.same(k, body.get(*k), fresh.get(*k))) + .all(|k| self.same_at(k, body.get(*k), fresh.get(*k), &start)) && NEVER_SENT .iter() .all(|k| fresh.get(*k).is_none_or(Value::is_null)) @@ -897,14 +921,26 @@ impl SeriesPlan { /// Whether two values of `key` say the same: start and end as instants in a zone, a rule by what it /// means, guests by who they are, Google's defaults filled in, anything else by value. Never - /// text-only, which reads any two lists as equal (DS9, G4). + /// text-only, which reads any two lists as equal (DS9, G4). A rule is read at the series' start. fn same(&self, key: &str, a: Option<&Value>, b: Option<&Value>) -> bool { + self.same_at(key, a, b, &self.start) + } + + /// [`SeriesPlan::same`], a rule read at `start`: both sides at one start, the one they repeat + /// from (C11 critique 2 M8). + fn same_at( + &self, + key: &str, + a: Option<&Value>, + b: Option<&Value>, + start: &SeriesStart, + ) -> bool { let or = |v: Option<&Value>, d: &str| v.and_then(Value::as_str).unwrap_or(d).to_string(); match key { "start" | "end" => when_eq(a, b), // A guest's reply isn't a change to the guest list (C8b review EXEC-6). "attendees" => attendee_set(a) == attendee_set(b), - "recurrence" => same_recurrence(&lines_of(a), &lines_of(b), &self.start), + "recurrence" => same_recurrence(&lines_of(a), &lines_of(b), start), "status" => or(a, "confirmed") == or(b, "confirmed"), "transparency" => or(a, "opaque") == or(b, "opaque"), "visibility" => or(a, "default") == or(b, "default"), @@ -970,7 +1006,7 @@ pub(crate) fn start_of(node: &Value) -> Option { }) } -/// A `recurrence` value's lines. +/// A `recurrence` value's lines: none for `null`, `[]` or a value left out alike (C11 critique 1 L10). fn lines_of(v: Option<&Value>) -> Vec { v.and_then(Value::as_array) .into_iter() @@ -981,7 +1017,7 @@ fn lines_of(v: Option<&Value>) -> Vec { } /// The values `fresh` holds for `keys`, absent ones as `null`. -fn snapshot(fresh: &Value, keys: &[&str]) -> Map { +pub(super) fn snapshot(fresh: &Value, keys: &[&str]) -> Map { keys.iter() .map(|k| { ( @@ -1015,7 +1051,7 @@ fn before_split( /// The series' own stored exceptions: a listing by iCal UID also holds the master, and any other /// series split off it (C8b review G6). -fn own_exceptions<'a>( +pub(super) fn own_exceptions<'a>( master: &Master, exceptions: &'a Exceptions, ) -> impl Iterator { @@ -1027,13 +1063,13 @@ fn own_exceptions<'a>( } /// A series cut just before one of its occurrences. -struct SplitCut { +pub(super) struct SplitCut { /// The original's new `recurrence`. - cut: Vec, + pub(super) cut: Vec, /// Its EXDATEs and RDATEs from the occurrence on, for the rest. - moved: Vec, + pub(super) moved: Vec, /// Google's count of what came before, when the rule has a COUNT. - before: Option, + pub(super) before: Option, } /// The original cut just before `at`, for "this and following": `None` when `at` is the series' @@ -1057,7 +1093,7 @@ fn cut_at(master: &Master, input: &SeriesInput, at: Point) -> Result( +pub(super) fn exceptions_from<'a>( master: &Master, exceptions: &'a Exceptions, at: Point, @@ -1082,7 +1118,7 @@ fn exceptions_from<'a>( /// occurrence (only those from it on are reached); `None` for the whole series. Only a complete /// listing can show there's none, so an incomplete one refuses too, a change of text included; the /// cross-check counts an occurrence with guests the listing lacks against it (C9a-2 review). -fn no_guests_from( +pub(super) fn no_guests_from( master: &Master, input: &SeriesInput, from: Option, @@ -1108,7 +1144,10 @@ fn no_guests_from( /// The gate reads the occurrence's description; a draft for the series writes the series' own, and /// would save a formatted one as plain text (C9 design review L7). -fn keeps_formatting(master_json: &Value, draft: &EventPatchDraft) -> Result<(), NotPlanned> { +pub(super) fn keeps_formatting( + master_json: &Value, + draft: &EventPatchDraft, +) -> Result<(), NotPlanned> { let formatted = master_json .get("description") .and_then(Value::as_str) @@ -1122,7 +1161,7 @@ fn keeps_formatting(master_json: &Value, draft: &EventPatchDraft) -> Result<(), /// A cleanup cancel of the original's own copy of the occurrence at `original`, which moved to the new /// series (or was cut off): done when Google already shows it cancelled or gone; re-sent on a 412 /// only while nothing it would throw away has changed (DS10). -fn cancel_exception( +pub(super) fn cancel_exception( calendar: &str, item: &Value, original: Point, @@ -1189,7 +1228,7 @@ fn plan_cut_delete( /// The PATCH that cuts the original. On a 412 it's sent again only if `watched` is as the plan saw /// it: its rule and times, and for a split every field the new series copied (AS10), or the new /// series would carry a value someone has just changed. -fn cut_step( +pub(super) fn cut_step( master: &Master, calendar: &str, master_json: &Value, @@ -1273,6 +1312,7 @@ pub fn plan_series_delete( series: master.id.clone(), }, start: master.start, + rule_start: master.start, guests_allowed: input.guests_allowed, }) }; @@ -1306,6 +1346,7 @@ pub fn plan_series_delete( event: text(instance, "id"), }, start: master.start, + rule_start: master.start, guests_allowed: input.guests_allowed, }), Scope::All => whole(warnings), @@ -1329,6 +1370,7 @@ pub fn plan_series_delete( left: Vec::new(), }, start: master.start, + rule_start: master.start, guests_allowed: input.guests_allowed, }) } @@ -1372,7 +1414,17 @@ fn new_time(draft: &EventPatchDraft, instance: &Value) -> Result /// Google's `start` and `end` for a series starting at `first` with occurrences `length` long, in /// the master's own zones. fn when_nodes(master: &Master, first: Point, length: Length) -> Result<(Value, Value), NotPlanned> { - let zone = master.start.zone(); + when_nodes_in(master.start.zone(), master.end_zone, first, length) +} + +/// Google's `start` and `end` for a series starting at `first` with occurrences `length` long, its +/// start in `zone` and its end in `end_zone`: a timed node always names its `timeZone` (G6). +pub(super) fn when_nodes_in( + zone: Tz, + end_zone: Tz, + first: Point, + length: Length, +) -> Result<(Value, Value), NotPlanned> { let timed = |t: DateTime, zone: Tz| { json!({ "dateTime": t.with_timezone(&zone).to_rfc3339_opts(SecondsFormat::Secs, false), @@ -1381,7 +1433,7 @@ fn when_nodes(master: &Master, first: Point, length: Length) -> Result<(Value, V }; let date = |d: NaiveDate| json!({ "date": d.format("%Y-%m-%d").to_string() }); match (first, length) { - (Point::At(t), Length::Timed(len)) => Ok((timed(t, zone), timed(t + len, master.end_zone))), + (Point::At(t), Length::Timed(len)) => Ok((timed(t, zone), timed(t + len, end_zone))), (Point::Day(d), Length::AllDay(days)) => { let end = d .checked_add_signed(chrono::TimeDelta::days(days)) @@ -1418,7 +1470,7 @@ fn with_cancelled( } /// The draft's touched text fields, as Google names them. -fn draft_fields(draft: &EventPatchDraft) -> Vec<(&'static str, Value)> { +pub(super) fn draft_fields(draft: &EventPatchDraft) -> Vec<(&'static str, Value)> { let mut out = Vec::new(); if let Some(v) = &draft.summary { out.push(("summary", Value::from(v.clone()))); @@ -1470,6 +1522,7 @@ impl SeriesPlan { expect: Vec::new(), removes: Removes::Nothing, start, + rule_start: start, guests_allowed: false, }) } @@ -1484,6 +1537,7 @@ fn probe(start: &SeriesStart) -> SeriesPlan { expect: Vec::new(), removes: Removes::Nothing, start: *start, + rule_start: *start, guests_allowed: false, } } @@ -1534,6 +1588,11 @@ fn plan_this( notify: Notify, ) -> Result { let instance = input.fresh.instance.json(); + // A new rule is the whole series' (scope.rs refuses the row; this is the planner's own guard). The + // rule the series already has is no change of rule, as the rows read it (C11 critique 1 L12). + if rule_ask(draft, master)?.is_some() { + return Err(SeriesRefusal::RuleIsSeriesWide.into()); + } let body = build_patch(instance, draft)?; if body.is_empty() { return Err(NotPlanned::NoChange); @@ -1572,6 +1631,7 @@ fn plan_this( expect: Vec::new(), removes: Removes::Nothing, start: master.start, + rule_start: master.start, guests_allowed: input.guests_allowed, }) } @@ -1583,8 +1643,12 @@ fn plan_all( draft: &EventPatchDraft, notify: Notify, ) -> Result { + // A new rule, or none: the series is rebuilt from it (C11). + if let Some(ask) = rule_ask(draft, master)? { + return plan_all_rule(input, master, original, draft, ask, notify); + } let (instance, master_json) = (input.fresh.instance.json(), input.fresh.master.json()); - let opened = input.opened.master.ok_or(SeriesRefusal::NoBase)?; + input.opened.master.ok_or(SeriesRefusal::NoBase)?; no_guests_from(master, input, None)?; keeps_formatting(master_json, draft)?; let text_only = EventPatchDraft { @@ -1680,6 +1744,24 @@ fn plan_all( SeriesRefusal::OnlyThisDiffers.into() }); } + all_plan(input, master, body, warnings, undo, notify, master.start) +} + +/// The one PATCH of the series' master that "all events" sends, `body` being what it changes: what a +/// 412 rechecks and what was changed in Google since the editor opened (R2), the warning for the +/// occurrences changed on their own, after `warnings`, and `undo`. `rule_start`: the start it repeats +/// from once it lands ([`SeriesPlan::rule_start`]). +pub(super) fn all_plan( + input: &SeriesInput, + master: &Master, + body: Map, + mut warnings: Vec, + undo: Option, + notify: Notify, + rule_start: SeriesStart, +) -> Result { + let master_json = input.fresh.master.json(); + let opened = input.opened.master.ok_or(SeriesRefusal::NoBase)?; // A new end is measured from the series' start, so it watches the start and rule too. let mut watched: Vec = body.keys().cloned().collect(); if ["start", "end", "recurrence"] @@ -1739,12 +1821,16 @@ fn plan_all( expect: Vec::new(), removes: Removes::Nothing, start: master.start, + rule_start, guests_allowed: input.guests_allowed, }) } /// The original starts of the series' cancelled occurrences, from a complete listing. -fn cancelled_points(master: &Master, exceptions: &Exceptions) -> Result, NotPlanned> { +pub(super) fn cancelled_points( + master: &Master, + exceptions: &Exceptions, +) -> Result, NotPlanned> { if !exceptions.complete { return Err(SeriesRefusal::Incomplete.into()); } @@ -1755,7 +1841,7 @@ fn cancelled_points(master: &Master, exceptions: &Exceptions) -> Result Result { + // A new rule, or none: the rest is rebuilt from it (C11). + if let Some(ask) = rule_ask(draft, master)? { + return plan_split_rule(input, master, at, cut, draft, tail_id, ask, notify); + } + let SplitCut { cut, moved, before } = cut; let (instance, master_json) = (input.fresh.instance.json(), input.fresh.master.json()); let opened = input.opened.master.ok_or(SeriesRefusal::NoBase)?; keeps_formatting(master_json, draft)?; @@ -1798,31 +1889,7 @@ fn plan_split( }; let rest = tail_recurrence(&master.recurrence, remaining, &moved)?; let zone = master.start.zone(); - // The rest repeats at the series' own wall time from the split's day. On a day the clocks skip - // that time, the occurrence sits an hour off it, and the rest can't start there: it would repeat - // an hour late, and every EXDATE after it would name nothing (C8b fix review). - let rest_start = match (master.start, at) { - (SeriesStart::Timed { local, .. }, Point::At(_)) => { - // The occurrence's own day: where the series' time, read as RFC 5545 reads a skipped - // hour, lands on `at`. A time in an evening's skipped hour lands on the next date. - let time = local.time(); - let today = at.day_in(zone); - let day = [today, today.pred_opt().unwrap_or(today)] - .into_iter() - .find(|d| rfc_instant(zone, d.and_time(time)).map(Point::At) == Some(at)) - .unwrap_or(today); - let start = SeriesStart::Timed { - local: day.and_time(time), - zone, - }; - if start.point() != Some(at) { - return Err(SeriesRefusal::SplitInGap(day).into()); - } - start - } - (SeriesStart::AllDay(_), Point::Day(d)) => SeriesStart::AllDay(d), - _ => return Err(RetimeError::KindChange.into()), - }; + let rest_start = rest_start(master, at)?; let exceptions = exceptions_from(master, input.exceptions, at)?; no_guests_from(master, input, Some(at))?; let cancelled: Vec = exceptions @@ -1888,122 +1955,16 @@ fn plan_split( }); } - // The new series: the original's own fields (from Google's copy, never the mirror), the draft's - // over them, its times in the original's zones, and a private note of where it came from. - let mut body: Map = TAIL_COPY - .iter() - .filter_map(|k| master_json.get(*k).map(|v| ((*k).to_string(), v.clone()))) - .collect(); - if let Some(guests) = master_json.get("attendees").and_then(Value::as_array) { - let guests: Vec = guests - .iter() - .filter_map(|g| { - let mut kept = Map::new(); - for k in ["email", "optional", "displayName"] { - if let Some(v) = g.get(k) { - kept.insert(k.to_string(), v.clone()); - } - } - kept.contains_key("email").then_some(Value::Object(kept)) - }) - .collect(); - body.insert("attendees".into(), Value::from(guests)); - } - let mut properties = master_json - .get("extendedProperties") - .cloned() - .filter(Value::is_object) - .unwrap_or_else(|| json!({})); - if let Some(obj) = properties.as_object_mut() { - let private = obj.entry("private").or_insert_with(|| json!({})); - if let Some(private) = private.as_object_mut() { - private.insert("pmSplitOf".into(), Value::from(master.id.clone())); - // And the occurrence it was split at, which an uncut original still holds whatever day - // the new part was moved to: what the overlap backstop measures from (review round 6). - private.insert("pmSplitAt".into(), Value::from(super::point_text(at))); - } - } - body.insert("extendedProperties".into(), properties); - for (k, v) in &touched { - body.insert((*k).to_string(), v.clone()); - } - let (start, end) = when_nodes(master, first, length)?; - body.insert("start".into(), start); - body.insert("end".into(), end); - body.insert("recurrence".into(), Value::from(tail_lines)); - // And a note of this plan, so its insert can't be read off another plan's tail (Shows::Tail). - let plan = plan_digest(&body); - if let Some(private) = body - .get_mut("extendedProperties") - .and_then(|p| p.get_mut("private")) - .and_then(Value::as_object_mut) - { - private.insert("pmSplitPlan".into(), Value::from(plan.clone())); - } - warnings.push(SeriesWarning::NotCarried { - attachments: master_json - .get("attachments") - .and_then(Value::as_array) - .is_some_and(|a| !a.is_empty()), - conference: master_json.get("conferenceData").is_some() - || master_json.get("hangoutLink").is_some(), - label: master_json.get("eventLabelId").is_some(), - }); - warnings.retain(|w| { - !matches!( - w, - SeriesWarning::NotCarried { - attachments: false, - conference: false, - label: false - } - ) - }); + let nodes = when_nodes(master, first, length)?; + let body = tail_body(master, master_json, at, &touched, nodes, Some(tail_lines)); + warnings.extend(not_carried(master_json)); warnings.push(SeriesWarning::NewIdentity); if input.sibling { warnings.push(SeriesWarning::SplitSibling); } - - // Nothing the plan sends may have changed in Google since the editor opened (R2). - let watched: Vec = TAIL_WATCH.iter().map(|k| (*k).to_string()).collect(); - let changed = changed_since_opened(&master.start, opened.json(), master_json, &watched); - if !changed.is_empty() { - return Err(NotPlanned::Conflict(changed)); - } - let instance_keys: Vec = touched.iter().map(|(k, _)| (*k).to_string()).collect(); - let changed = changed_since_opened( - &master.start, - input.opened.instance.json(), - instance, - &instance_keys, - ); - if !changed.is_empty() { - return Err(NotPlanned::Conflict(changed)); - } - + split_unchanged_since_opened(master, input, opened, &touched)?; let mut steps = vec![ - Step { - op: Op::Insert { - calendar: input.calendar.to_string(), - id: tail_id.to_string(), - body, - }, - notify, - stage: Stage::Commit, - requires: None, - conflict: Recheck::Never, - shows: Shows::Tail { - of: master.id.clone(), - plan: plan.clone(), - }, - gone_is_done: false, - undo: Some(Undo::DeleteInserted { - calendar: input.calendar.to_string(), - event: tail_id.to_string(), - split_of: master.id.clone(), - plan, - }), - }, + insert_step(input, master, tail_id, body, notify), cut_step(master, input.calendar, master_json, cut, TAIL_WATCH, notify), ]; @@ -2105,15 +2066,202 @@ fn plan_split( left: Vec::new(), }, start: master.start, + rule_start: master.start, guests_allowed: input.guests_allowed, }) } +/// Where the rest of a series split at `at` repeats from: the series' own wall time on that +/// occurrence's day. On a day the clocks skip that time, the occurrence sits an hour off it, and the +/// rest can't start there: it would repeat an hour late, and every EXDATE after it would name nothing +/// (C8b fix review). +pub(super) fn rest_start(master: &Master, at: Point) -> Result { + let zone = master.start.zone(); + match (master.start, at) { + (SeriesStart::Timed { local, .. }, Point::At(_)) => { + // The occurrence's own day: where the series' time, read as RFC 5545 reads a skipped + // hour, lands on `at`. A time in an evening's skipped hour lands on the next date. + let time = local.time(); + let today = at.day_in(zone); + let day = [today, today.pred_opt().unwrap_or(today)] + .into_iter() + .find(|d| rfc_instant(zone, d.and_time(time)).map(Point::At) == Some(at)) + .unwrap_or(today); + let start = SeriesStart::Timed { + local: day.and_time(time), + zone, + }; + if start.point() != Some(at) { + return Err(SeriesRefusal::SplitInGap(day).into()); + } + Ok(start) + } + (SeriesStart::AllDay(_), Point::Day(d)) => Ok(SeriesStart::AllDay(d)), + _ => Err(RetimeError::KindChange.into()), + } +} + +/// A split's new series, or the one event a split that stops repeating makes (C11): the original's +/// own fields (from Google's copy, never the mirror), its guests, `fields` over them, `start` and +/// `end`, its `recurrence` if it repeats, and private notes of where it came from and of this plan, +/// so its insert can't be read off another plan's (`Shows::Tail`). +pub(super) fn tail_body( + master: &Master, + master_json: &Value, + at: Point, + fields: &[(&str, Value)], + (start, end): (Value, Value), + recurrence: Option>, +) -> Map { + let mut body: Map = TAIL_COPY + .iter() + .filter_map(|k| master_json.get(*k).map(|v| ((*k).to_string(), v.clone()))) + .collect(); + if let Some(guests) = master_json.get("attendees").and_then(Value::as_array) { + let guests: Vec = guests + .iter() + .filter_map(|g| { + let mut kept = Map::new(); + for k in ["email", "optional", "displayName"] { + if let Some(v) = g.get(k) { + kept.insert(k.to_string(), v.clone()); + } + } + kept.contains_key("email").then_some(Value::Object(kept)) + }) + .collect(); + body.insert("attendees".into(), Value::from(guests)); + } + let mut properties = master_json + .get("extendedProperties") + .cloned() + .filter(Value::is_object) + .unwrap_or_else(|| json!({})); + if let Some(obj) = properties.as_object_mut() { + let private = obj.entry("private").or_insert_with(|| json!({})); + if let Some(private) = private.as_object_mut() { + private.insert("pmSplitOf".into(), Value::from(master.id.clone())); + // And the occurrence it was split at, which an uncut original still holds whatever day + // the new part was moved to: what the overlap backstop measures from (review round 6). + private.insert("pmSplitAt".into(), Value::from(super::point_text(at))); + } + } + body.insert("extendedProperties".into(), properties); + for (k, v) in fields { + body.insert((*k).to_string(), v.clone()); + } + body.insert("start".into(), start); + body.insert("end".into(), end); + if let Some(lines) = recurrence { + body.insert("recurrence".into(), Value::from(lines)); + } + let plan = plan_digest(&body); + if let Some(private) = body + .get_mut("extendedProperties") + .and_then(|p| p.get_mut("private")) + .and_then(Value::as_object_mut) + { + private.insert("pmSplitPlan".into(), Value::from(plan)); + } + body +} + +/// The new part doesn't take the original's attachments, video call or label: the warning, when it +/// names one. +pub(super) fn not_carried(master_json: &Value) -> Option { + let warning = SeriesWarning::NotCarried { + attachments: master_json + .get("attachments") + .and_then(Value::as_array) + .is_some_and(|a| !a.is_empty()), + conference: master_json.get("conferenceData").is_some() + || master_json.get("hangoutLink").is_some(), + label: master_json.get("eventLabelId").is_some(), + }; + let none = SeriesWarning::NotCarried { + attachments: false, + conference: false, + label: false, + }; + (warning != none).then_some(warning) +} + +/// Nothing a split sends may have changed in Google since the editor opened (R2): every field the new +/// part copies, on the series `opened` as the editor kept it, and the draft's `touched` fields on the +/// occurrence. +pub(super) fn split_unchanged_since_opened( + master: &Master, + input: &SeriesInput, + opened: &FreshEvent, + touched: &[(&str, Value)], +) -> Result<(), NotPlanned> { + let watched: Vec = TAIL_WATCH.iter().map(|k| (*k).to_string()).collect(); + let changed = changed_since_opened( + &master.start, + opened.json(), + input.fresh.master.json(), + &watched, + ); + if !changed.is_empty() { + return Err(NotPlanned::Conflict(changed)); + } + let instance_keys: Vec = touched.iter().map(|(k, _)| (*k).to_string()).collect(); + let changed = changed_since_opened( + &master.start, + input.opened.instance.json(), + input.fresh.instance.json(), + &instance_keys, + ); + if !changed.is_empty() { + return Err(NotPlanned::Conflict(changed)); + } + Ok(()) +} + +/// The insert of a split's new part under `tail_id`, PM's own id: proved by its notes, undone by +/// deleting it while it's still PM's. +pub(super) fn insert_step( + input: &SeriesInput, + master: &Master, + tail_id: &str, + body: Map, + notify: Notify, +) -> Step { + let plan = body + .get("extendedProperties") + .and_then(|p| p.pointer("/private/pmSplitPlan")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + Step { + op: Op::Insert { + calendar: input.calendar.to_string(), + id: tail_id.to_string(), + body, + }, + notify, + stage: Stage::Commit, + requires: None, + conflict: Recheck::Never, + shows: Shows::Tail { + of: master.id.clone(), + plan: plan.clone(), + }, + gone_is_done: false, + undo: Some(Undo::DeleteInserted { + calendar: input.calendar.to_string(), + event: tail_id.to_string(), + split_of: master.id.clone(), + plan, + }), + } +} + /// The last occurrence the original keeps before `at`, a split's occurrence: its rule's latest that /// isn't deleted (an EXDATE, or among `cancelled`), else its start, which RFC 5545 and Google count /// even off its own rule. The rest can't start on that day or before (C8b reviews ADV-9). An extra /// date (RDATE) is an occurrence of its own, which the rest's rule can't double, so it doesn't count. -fn last_kept( +pub(super) fn last_kept( master: &Master, facts: &lines::RuleFacts, at: Point, diff --git a/src-tauri/src/calendar_write/series/rule.rs b/src-tauri/src/calendar_write/series/rule.rs new file mode 100644 index 00000000..184525e5 --- /dev/null +++ b/src-tauri/src/calendar_write/series/rule.rs @@ -0,0 +1,3606 @@ +// SPDX-FileCopyrightText: 2026 Bobby Yu +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! A repeating event rebuilt from a rule in the editor's set (#884, C11): a new rule for every +//! occurrence or from one on, and "Does not repeat". Where it starts is the rule's first day on or +//! after the day asked ([`sync_start`]), its times are written with their zone, and an occurrence that +//! was deleted stays deleted wherever the new rule still has its day. C11d routes a move between +//! all-day and timed, or into another zone, through the same rebuild. +//! +//! Beside the C9 guards every series plan keeps (R2, guests, formatting, the overlap refusal), two of +//! the C11 critiques' own: +//! - "Does not repeat" for every occurrence keeps the first one still there, with its own changes, +//! never a deleted one (critique 1 H1); +//! - no rebuilt rule repeats on or past a later part of the series, which would show those days +//! twice (critique 1 H2). +//! +//! Like the rest of the core, nothing here can reach Google, and nothing from a Google reply goes into +//! a log or panic message. + +use chrono::{Datelike, NaiveDate, TimeDelta}; +use chrono_tz::Tz; +use serde_json::{Map, Value}; + +use crate::calendar_recur::expand::{first_kept, is_occurrence, occurrences_from, sync_start}; +use crate::calendar_recur::lines::{self, Kind, RuleFacts, TextOnly}; +use crate::calendar_recur::remap::{exdate_lines, RetimeError}; +use crate::calendar_recur::spec::{ + day_phrase, rule_from_spec, spec_of, Ends, Pattern, RecurrenceSpec, SpecError, YearlyOn, +}; +use crate::calendar_recur::split::remaining_count; +use crate::calendar_recur::{rfc_instant, Point, RecurError, SeriesStart}; + +use super::super::dto::{EventPatchDraft, Notify, RepeatDraft, TimeDraft}; +use super::super::patch::{build_patch, drop_other_kind, is_gone, when_eq}; +use super::super::plan::is_plain_segment; +use super::super::time; +use super::diff::{Expect, Removes}; +use super::plan::{ + all_plan, cancel_exception, cancelled_points, cut_step, draft_fields, exceptions_from, + insert_step, keeps_formatting, last_kept, no_guests_from, not_carried, own_exceptions, + rest_start, snapshot, split_unchanged_since_opened, start_of, tail_body, + unchanged_since_opened, when_nodes_in, NotPlanned, Op, Recheck, SeriesInput, SeriesPlan, + SeriesWarning, Shows, SplitCut, Stage, Step, Undo, TAIL_WATCH, +}; +use super::scope::{RuleChange, Scope}; +use super::{ + classify_instance, excluded, original_of, point_of, InstanceKind, LaterPart, Length, Master, + SeriesRefusal, +}; + +/// What `draft` asks of `master`'s rule, as the choice reads it ([`super::scope::Change`]): `None` +/// when it leaves the rule alone or asks for the very rule the series has. A rule PM can't read is a +/// new one, which its planner refuses in words. +pub fn rule_change(draft: &EventPatchDraft, master: &Master) -> Option { + match &draft.recurrence { + None => None, + Some(RepeatDraft::Once) => Some(RuleChange::Stop), + Some(RepeatDraft::Rule { rule }) => match rule.to_spec() { + Ok(spec) if same_rule(&spec, master) => None, + _ => Some(RuleChange::New), + }, + } +} + +/// What a planner is asked to make of the series' rule. +pub(super) enum RuleAsk { + New(RecurrenceSpec), + /// "Does not repeat". + Stop, +} + +/// The rule change `draft` asks for, read as [`rule_change`] reads it, so the rows and the plans +/// agree on what is one (C11 critique 1 L12): `None` for none, a rule PM can't read refused. +pub(super) fn rule_ask( + draft: &EventPatchDraft, + master: &Master, +) -> Result, NotPlanned> { + Ok(match &draft.recurrence { + None => None, + Some(RepeatDraft::Once) => Some(RuleAsk::Stop), + Some(RepeatDraft::Rule { rule }) => { + let spec = rule.to_spec().map_err(SeriesRefusal::Spec)?; + (!same_rule(&spec, master)).then_some(RuleAsk::New(spec)) + } + }) +} + +/// Whether `spec` is the series' own rule, read as the editor's Repeat row reads it. +fn same_rule(spec: &RecurrenceSpec, master: &Master) -> bool { + spec_of(&master.recurrence, &master.start).as_ref() == Some(spec) +} + +/// What a series is asked to become. +pub(super) struct RebuildAsk<'a> { + pub spec: &'a RecurrenceSpec, + /// The day it should start on (the rule may move it later), at its wall time, kind and zone. + pub start: SeriesStart, + pub length: Length, + /// The zone its end is written in (a flight can end in another). + pub end_zone: Tz, + /// The old series' deleted occurrences (its EXDATEs and cancelled occurrences), as points in it. + pub deleted: &'a [Point], + /// The old series' start, where `deleted` and `extra` were read. + pub old_start: &'a SeriesStart, + /// The old series' rule, read; `None` when PM can only show it as text. + pub old_rule: Option, + /// How many days the change moves every occurrence (a day move with the new rule, C11 critique + /// 2 M4), counted in `days`: a deleted day the new rule doesn't make is looked for that much + /// later ([`Landing`]). + pub shift: i64, + /// How an old occurrence's day reads in the series rebuilt ([`Days`]). + pub days: Days, + /// Where the occurrence opened is in the old series. + pub opened_at: Point, + /// The day the draft puts the occurrence opened on, in `start`'s terms: where the new rule makes + /// it, that slot is the opened occurrence's alone (C11c review), unless it is an extra date kept, + /// which stays where it is. + pub opened: NaiveDate, + /// The old series' RDATE lines, as Google wrote them. + pub extra: &'a [String], +} + +/// A series rebuilt. +pub(super) struct Rebuilt { + /// Where it starts: the rule's first day, the asked wall time. + pub start: SeriesStart, + pub first: Point, + /// Google's `start` and `end`; a timed node always names its `timeZone` (G6). + pub nodes: (Value, Value), + /// The RRULE, then any EXDATE line, then any RDATE line kept. + pub lines: Vec, + /// The new rule, read. + pub facts: RuleFacts, + /// The extra dates (RDATEs) kept, where each still is an occurrence. + pub extra: Vec, + /// The start isn't on the day asked. + pub moved: bool, + /// How an old occurrence's day reads in it ([`Days`]). + pub days: Days, + /// Where the draft puts the occurrence opened, when the new rule makes that slot: nothing else + /// lands there ([`Landing`]). + pub opened: Option, +} + +/// The series `ask` describes, refused in words where it can't be written: a rule outside the +/// limits, a first day whose time the clocks skip, an extra date it can't keep. +pub(super) fn rebuild(ask: &RebuildAsk) -> Result { + let synced = sync_start(ask.spec, &ask.start).map_err(SeriesRefusal::Spec)?; + let first = synced + .point() + .ok_or(SeriesRefusal::Spec(SpecError::Gap(synced.date())))?; + let rule = rule_from_spec(ask.spec, &synced).map_err(SeriesRefusal::Spec)?; + let parsed = lines::parse(std::slice::from_ref(&rule)); + let (_, facts) = lines::series_rule(&parsed).map_err(RecurError::TextOnly)?; + // An extra date names a moment of the old series: it stays only where that is still one of + // this series' times, as a move refuses it today. + let same_times = ask.shift == 0 && same_wall_time(ask.old_start, &synced); + if !ask.extra.is_empty() && !same_times { + return Err(RetimeError::HasExtraDates.into()); + } + // So each one kept is still an occurrence where it was. + let mut extra_points = Vec::new(); + for line in lines::parse(ask.extra) { + if let Kind::RDate(dates) = &line.kind { + for value in &dates.values { + extra_points.push( + dates + .point(value, ask.old_start) + .ok_or(RecurError::CannotCount)?, + ); + } + } + } + // An extra date opened and kept stays where it is, as its own RDATE: it takes no slot of the new + // rule's, so an occurrence deleted there stays deleted (C11c review). + let opened = match on_its_day(&synced, ask.opened) { + Some(q) if !extra_points.contains(&ask.opened_at) && is_occurrence(&facts, &synced, q)? => { + Some(q) + } + _ => None, + }; + // A deleted day stays deleted wherever the new rule still makes it (from its start on), and a + // deleted extra date wherever it's kept (RM1). An EXDATE neither makes is never written: Google + // refuses one that "doesn't point to a valid instance". + let landing = Landing { + facts: &facts, + start: &synced, + old: ask.old_rule.as_ref().map(|f| (f, ask.old_start)), + shift: ask.shift, + days: ask.days, + opened, + }; + let mut deleted = Vec::new(); + for p in ask.deleted { + if extra_points.contains(p) { + deleted.push(*p); + continue; + } + if let Some(q) = landing.at(*p)? { + deleted.push(q); + } + } + let mut out = vec![rule]; + out.extend(exdate_lines(&deleted).into_iter().map(|l| l.raw)); + out.extend(ask.extra.iter().cloned()); + let nodes = when_nodes_in(synced.zone(), ask.end_zone, first, ask.length)?; + Ok(Rebuilt { + moved: synced.date() != ask.start.date(), + start: synced, + first, + nodes, + lines: out, + facts, + extra: extra_points, + days: ask.days, + opened, + }) +} + +/// How the old series' days read in a series rebuilt from it (C11c review): an occurrence's wall day +/// in the old series' own zone, moved on as far as the occurrence opened moves between the two zones' +/// walls. Both rules repeat by wall day in their own zone, so each occurrence keeps its place in the +/// week whatever either zone's clocks do, and a move into another zone that keeps every moment moves +/// no day. Across a change between timed and all-day, the wall day is the day. +#[derive(Clone, Copy)] +pub(super) struct Days { + /// The old series' zone. + zone: Tz, + /// How many days on the new series' wall is from the old's: 0 unless both are timed. + offset: i64, +} + +impl Days { + /// The days of a series moved from `old` to `new`, measured at `opened`, where the occurrence + /// opened is in the old series. + fn new(old: &SeriesStart, new: &SeriesStart, opened: Point) -> Days { + let offset = match (old, new) { + (SeriesStart::Timed { zone: was, .. }, SeriesStart::Timed { zone: is, .. }) => { + (opened.day_in(*is) - opened.day_in(*was)).num_days() + } + _ => 0, + }; + Days { + zone: old.zone(), + offset, + } + } + + /// The day the old occurrence at `p` is on, in the new series' days. + fn of(self, p: Point) -> Option { + moved_day(p.day_in(self.zone), self.offset) + } + + /// The old series' own day for `day`, one of the new series'. + fn back(self, day: NaiveDate) -> Option { + moved_day(day, -self.offset) + } +} + +/// The day a yearly rule "on its own date" should start on: the first day on or after `from` with +/// `like`'s month and day (29 February: the next leap year's). So "Annually on 13 October", picked on +/// the 13-10 occurrence of a series that began on 05-10, repeats on 13 October as its words said. +pub(super) fn yearly_anchor(from: NaiveDate, like: NaiveDate) -> NaiveDate { + // A leap year comes within eight years, a century's skipped one included. + (from.year()..=from.year() + 8) + .filter_map(|y| NaiveDate::from_ymd_opt(y, like.month(), like.day())) + .find(|d| *d >= from) + .unwrap_or(like) +} + +/// `day` moved `shift` days. +fn moved_day(day: NaiveDate, shift: i64) -> Option { + day.checked_add_signed(TimeDelta::try_days(shift)?) +} + +/// `start` moved to `day`: its wall time, kind and zone kept. +fn on_day(start: &SeriesStart, day: NaiveDate) -> SeriesStart { + match *start { + SeriesStart::Timed { local, zone } => SeriesStart::Timed { + local: day.and_time(local.time()), + zone, + }, + SeriesStart::AllDay(_) => SeriesStart::AllDay(day), + } +} + +/// Where the series starting at `start` puts an occurrence on `day`, should its rule make one: its +/// wall time read as RFC 5545 reads a skipped hour, or the day. +fn on_its_day(start: &SeriesStart, day: NaiveDate) -> Option { + match *start { + SeriesStart::Timed { local, zone } => { + rfc_instant(zone, day.and_time(local.time())).map(Point::At) + } + SeriesStart::AllDay(_) => Some(Point::Day(day)), + } +} + +/// Where the old series' occurrences, deleted or changed on their own, land in a series rebuilt under +/// a new rule (C11c review). +struct Landing<'a> { + /// The rebuilt series' rule and start. + facts: &'a RuleFacts, + start: &'a SeriesStart, + /// The old series' rule, read, and its start; `None` when PM can only show the rule as text. + old: Option<(&'a RuleFacts, &'a SeriesStart)>, + /// How many days the change moves every occurrence, in `days`. + shift: i64, + /// How an occurrence's day reads in the new series ([`Days`]). + days: Days, + /// Where the draft puts the occurrence opened: never another's. + opened: Option, +} + +impl Landing<'_> { + /// Where the old occurrence at `p` is: on its own day where the new rule still makes it, so it + /// stays as the user left it there; else, when the change moves the day, that many days on, but + /// only where the old series had no occurrence of its own, since which of the two it is would + /// then be a guess; else nowhere. Never where the draft puts the occurrence opened, which the user + /// put there. An old rule PM can't read can't say whether the day moved to had one of its own: + /// refused in words where the new rule makes it, rather than guessed, naming the day deleted as + /// the series has it, in its own zone. + fn at(&self, p: Point) -> Result, NotPlanned> { + let day = self.days.of(p).ok_or(SeriesRefusal::Unreadable)?; + let landed = match self.made(day)? { + Some(q) => Some(q), + None => match moved_day(day, self.shift) { + None => None, + Some(moved) => match self.old { + Some((facts, start)) if self.had(facts, start, moved)? => None, + Some(_) => self.made(moved)?, + None => match self.made(moved)? { + Some(_) => { + let own = p.day_in(self.days.zone); + return Err(SeriesRefusal::DeletedDayUnread(own).into()); + } + None => None, + }, + }, + }, + }; + Ok(landed.filter(|q| Some(*q) != self.opened)) + } + + /// Whether the old series, from `start` under `facts`, had an occurrence of its own on `day`, one + /// of the new series' days: read on its own day in its own zone ([`Days::back`]). + fn had( + &self, + facts: &RuleFacts, + start: &SeriesStart, + day: NaiveDate, + ) -> Result { + match self.days.back(day) { + Some(own) => has_day(facts, start, own), + None => Ok(true), + } + } + + /// The rebuilt series' occurrence on `day`, if its rule makes one. + fn made(&self, day: NaiveDate) -> Result, RecurError> { + match on_its_day(self.start, day) { + Some(q) if is_occurrence(self.facts, self.start, q)? => Ok(Some(q)), + _ => Ok(None), + } + } +} + +/// Whether the series from `start` under `facts` has an occurrence on `day`, read in its own zone. A +/// day PM can't place counts as one, so nothing lands there on a guess. +fn has_day(facts: &RuleFacts, start: &SeriesStart, day: NaiveDate) -> Result { + let zone = start.zone(); + let from = match start { + SeriesStart::AllDay(_) => Point::Day(day), + SeriesStart::Timed { .. } => { + match rfc_instant(zone, day.and_time(chrono::NaiveTime::MIN)) { + Some(t) => Point::At(t), + None => return Ok(true), + } + } + }; + Ok(occurrences_from(facts, start, from, 1)? + .first() + .is_some_and(|q| q.day_in(zone) == day)) +} + +/// Where one event that doesn't repeat starts, at `start`: a time the clocks skip that day is refused +/// as a one-off's save refuses it, never nudged (R6). +fn one_event_at(start: &SeriesStart) -> Result { + start.point().ok_or_else(|| match *start { + SeriesStart::Timed { local, zone } => SeriesRefusal::Time(time::TimeError::Gap { + zone: zone.name().to_string(), + wall: local.format("%d-%m-%Y %H:%M").to_string(), + }) + .into(), + SeriesStart::AllDay(_) => SeriesRefusal::Unreadable.into(), + }) +} + +/// Two starts of the same kind, zone and wall time. +fn same_wall_time(a: &SeriesStart, b: &SeriesStart) -> bool { + match (a, b) { + (SeriesStart::AllDay(_), SeriesStart::AllDay(_)) => true, + (SeriesStart::Timed { local: x, zone: y }, SeriesStart::Timed { local: v, zone: w }) => { + x.time() == v.time() && y == w + } + _ => false, + } +} + +/// A rebuilt rule mustn't repeat on or past a later part of the series (C11 critique 1 H2): those +/// days would show twice, and the overlap backstop would then refuse every change past this one. +/// Measured in the rebuilt series' own terms: from the later part's instant for a timed one, from its +/// day for an all-day one, so an equal day across kinds counts (critique 1 L3). A series that is +/// itself a part, held to where its rule ends now, mustn't repeat on any day after it (C11c review). +fn clear_of_later(later: LaterPart, rebuilt: &Rebuilt, old_zone: Tz) -> Result<(), NotPlanned> { + let (at, refusal) = match later { + LaterPart::None => return Ok(()), + LaterPart::Unread => return Err(SeriesRefusal::LaterPartUnread.into()), + LaterPart::At(at) => (at, SeriesRefusal::RepeatsPastPart(at.day_in(old_zone))), + LaterPart::Through(last) => { + let after = last.succ_opt().ok_or(SeriesRefusal::LaterPartUnread)?; + (Point::Day(after), SeriesRefusal::RepeatsPastItsEnd(last)) + } + }; + let day = at.day_in(old_zone); + let from = match (rebuilt.start, at) { + (SeriesStart::AllDay(_), _) => Point::Day(day), + (SeriesStart::Timed { .. }, Point::At(_)) => at, + (SeriesStart::Timed { zone, .. }, Point::Day(d)) => { + match rfc_instant(zone, d.and_time(chrono::NaiveTime::MIN)) { + Some(t) => Point::At(t), + None => return Err(SeriesRefusal::LaterPartUnread.into()), + } + } + }; + if occurrences_from(&rebuilt.facts, &rebuilt.start, from, 1)?.is_empty() { + Ok(()) + } else { + Err(refusal.into()) + } +} + +/// Where the draft puts the occurrence, when it changes its time: its start as typed (its day, wall +/// time, kind and zone), its length, and the zone of its end. Read as a move reads it, but another +/// zone is no refusal here: a new rule rebuilds the series in it (C11 §9.4.3). +struct Asked { + start: SeriesStart, + length: Length, + end_zone: Tz, +} + +fn asked_time(draft: &EventPatchDraft, instance: &Value) -> Result, NotPlanned> { + let Some(draft_time) = &draft.time else { + return Ok(None); + }; + let when = time::resolve_against(draft_time, instance.get("start"), instance.get("end"))?; + if when_eq(instance.get("start"), Some(&when.start)) + && when_eq(instance.get("end"), Some(&when.end)) + { + return Ok(None); + } + let length = match (point_of(Some(&when.start)), point_of(Some(&when.end))) { + (Some(Point::At(s)), Some(Point::At(e))) => Length::Timed(e - s), + (Some(Point::Day(s)), Some(Point::Day(e))) => Length::AllDay((e - s).num_days()), + _ => return Err(SeriesRefusal::Unreadable.into()), + }; + let end_zone = match draft_time { + TimeDraft::Timed { end_zone, .. } => time::parse_zone(end_zone)?, + TimeDraft::AllDay { .. } => Tz::UTC, + }; + Ok(Some(Asked { + start: time::series_start(draft_time)?, + length, + end_zone, + })) +} + +/// Google's `start` and `end` for `first`, `length` long, its start in `zone` and its end in +/// `end_zone`; `null` for the other kind's keys when that isn't `master`'s kind, since a PATCH merges +/// the nodes and would keep both. +fn nodes_for_master( + master: &Master, + zone: Tz, + end_zone: Tz, + first: Point, + length: Length, +) -> Result<(Value, Value), NotPlanned> { + let (mut start, mut end) = when_nodes_in(zone, end_zone, first, length)?; + let master_all_day = matches!(master.start, SeriesStart::AllDay(_)); + if matches!(first, Point::Day(_)) != master_all_day { + drop_other_kind(&mut start); + drop_other_kind(&mut end); + } + Ok((start, end)) +} + +/// An occurrence's own `start` and `end`, written as PM writes every node: in its own zone (else the +/// series'), with its length. +fn own_nodes(item: &Value, master: &Master) -> Result<(Point, Tz, Tz, Length), NotPlanned> { + let (start, end) = (point_of(item.get("start")), point_of(item.get("end"))); + let zone_of = |key: &str| { + item.get(key) + .and_then(|n| n.get("timeZone")) + .and_then(Value::as_str) + .and_then(|z| z.parse::().ok()) + }; + let zone = zone_of("start").unwrap_or(master.start.zone()); + let end_zone = zone_of("end").unwrap_or(zone); + match (start, end) { + (Some(Point::At(s)), Some(Point::At(e))) => { + Ok((Point::At(s), zone, end_zone, Length::Timed(e - s))) + } + (Some(Point::Day(s)), Some(Point::Day(e))) => Ok(( + Point::Day(s), + Tz::UTC, + Tz::UTC, + Length::AllDay((e - s).num_days()), + )), + _ => Err(SeriesRefusal::Unreadable.into()), + } +} + +/// The series' deleted occurrences: its EXDATEs and its cancelled ones, from a complete listing. +fn deleted_points(master: &Master, input: &SeriesInput) -> Result, NotPlanned> { + let mut deleted = excluded(&master.recurrence, &master.start); + deleted.extend(cancelled_points(master, input.exceptions)?); + Ok(deleted) +} + +/// A rule is replaced whole, so every line of it must be one PM can read: an EXDATE it can't would be +/// dropped, and the occurrence it deleted would come back. +fn every_line_readable(master: &Master) -> Result<(), NotPlanned> { + if lines::parse(&master.recurrence) + .iter() + .any(|l| l.kind == Kind::Unreadable) + { + return Err(RecurError::TextOnly(TextOnly::Unreadable).into()); + } + Ok(()) +} + +/// The occurrence's own copy as an exception of the series: changed on its own, not cancelled. +fn changed_copy<'a>(master: &Master, input: &SeriesInput<'a>, at: Point) -> Option<&'a Value> { + own_exceptions(master, input.exceptions) + .find(|item| original_of(item) == Some(at) && !is_gone(item)) +} + +/// The first occurrence still there: the series' first, or the rule's first after it that isn't +/// deleted, up to `original` (the one the user opened, which is there) (C11 critique 1 H1). +fn first_live(master: &Master, original: Point, deleted: &[Point]) -> Result { + let kept = |p: &Point| !deleted.contains(p); + if kept(&master.first) { + return Ok(master.first); + } + let parsed = lines::parse(&master.recurrence); + let (_, facts) = lines::series_rule(&parsed).map_err(RecurError::TextOnly)?; + first_kept(&facts, &master.start, original, kept)? + .ok_or_else(|| SeriesRefusal::NothingToKeep.into()) +} + +/// "All events" with a new rule, or with none (C11 §9.4): the master rebuilt from it, one PATCH. +pub(super) fn plan_all_rule( + input: &SeriesInput, + master: &Master, + original: Point, + draft: &EventPatchDraft, + ask: RuleAsk, + notify: Notify, +) -> Result { + let (instance, master_json) = (input.fresh.instance.json(), input.fresh.master.json()); + input.opened.master.ok_or(SeriesRefusal::NoBase)?; + no_guests_from(master, input, None)?; + keeps_formatting(master_json, draft)?; + every_line_readable(master)?; + // The draft's time is read against the occurrence as Google holds it now, and a new rule moves + // the times whatever the draft says: it must still be where the user saw it (R2). + unchanged_since_opened(master, input, &["start", "end"])?; + let text = EventPatchDraft { + time: None, + recurrence: None, + ..draft.clone() + }; + let mut body = build_patch(master_json, &text)?; + let zone = master.start.zone(); + let asked = asked_time(draft, instance)?; + let (wall, length, end_zone) = match &asked { + Some(a) => (a.start, a.length, a.end_zone), + None => (master.start, master.length, master.end_zone), + }; + // A move of this occurrence to another day moves every occurrence as far, as a move of the + // series does (C11 critique 2 M4), measured from where it first was, in the days of the series + // it moves into ([`Days`], C11c review). + let out_of_range = || NotPlanned::from(SeriesRefusal::Unreadable); + let days = Days::new(&master.start, &wall, original); + let from = days.of(original).ok_or_else(out_of_range)?; + let shift = asked + .as_ref() + .map_or(0, |a| (a.start.date() - from).num_days()); + let first_day = days.of(master.first).ok_or_else(out_of_range)?; + let deleted = deleted_points(master, input)?; + let mut warnings = Vec::new(); + let undo = Some(Undo::RestoreMaster { + calendar: input.calendar.to_string(), + event: master.id.clone(), + body: snapshot(master_json, &["start", "end", "recurrence"]), + }); + match ask { + RuleAsk::New(spec) => { + let asked_day = moved_day(from, shift).ok_or_else(out_of_range)?; + let shifted = moved_day(first_day, shift).ok_or_else(out_of_range)?; + let day = match spec.pattern { + // From the first day, or from where the move puts it when that's earlier. + Pattern::Yearly(YearlyOn::Date) => yearly_anchor(first_day.min(shifted), asked_day), + _ => shifted, + }; + let extra: Vec = lines::parse(&master.recurrence) + .into_iter() + .filter(|l| matches!(l.kind, Kind::RDate(_))) + .map(|l| l.raw) + .collect(); + let rebuilt = rebuild(&RebuildAsk { + spec: &spec, + start: on_day(&wall, day), + length, + end_zone, + deleted: &deleted, + old_start: &master.start, + old_rule: lines::series_rule(&lines::parse(&master.recurrence)) + .ok() + .map(|(_, facts)| facts), + shift, + days, + opened_at: original, + opened: asked_day, + extra: &extra, + })?; + clear_of_later(input.later, &rebuilt, zone)?; + let (start, end) = nodes_for_master( + master, + rebuilt.start.zone(), + end_zone, + rebuilt.first, + length, + )?; + put_times(&mut body, master_json, start, end); + let same = rebuilt.start == master.start + && lines::same_recurrence(&rebuilt.lines, &master.recurrence, &master.start); + if !same { + body.insert("recurrence".into(), Value::from(rebuilt.lines.clone())); + } + // A new rule that names no day (daily) has none to move: where it starts says it all. + if shift != 0 && day_phrase(&rebuilt.lines, &rebuilt.start).is_some() { + warnings.push(SeriesWarning::RepeatDayChanges { + from, + to: asked_day, + }); + } + if rebuilt.start.date() != first_day { + warnings.push(SeriesWarning::StartMoved { + from: first_day, + to: rebuilt.start.date(), + }); + } + if length != master.length { + warnings.push(SeriesWarning::LengthChanges); + } + if body.is_empty() { + return Err(NotPlanned::NoChange); + } + all_plan(input, master, body, warnings, undo, notify, rebuilt.start) + } + RuleAsk::Stop => { + // The first occurrence still there stays (Q2), never a deleted one, with its own changes + // under the draft's (C11 critique 1 H1). + let kept = first_live(master, original, &deleted)?; + let mut own_time = None; + if let Some(item) = changed_copy(master, input, kept) { + if let InstanceKind::Modified { carry, other } = + classify_instance(master_json, master, item) + { + // The series' PATCH can carry its text and times, never the rest. + if !other.is_empty() + || carry.iter().any(|f| matches!(*f, "colorId" | "reminders")) + { + return Err(SeriesRefusal::KeptHasOwn(kept.day_in(zone)).into()); + } + let draft_keys: Vec<&str> = + draft_fields(draft).iter().map(|(k, _)| *k).collect(); + for field in carry { + if field == "time" { + own_time = Some(item); + } else if !draft_keys.contains(&field) { + let mine = item.get(field).cloned().unwrap_or(match field { + "summary" | "description" | "location" => Value::from(""), + _ => Value::Null, + }); + body.insert(field.to_string(), mine); + } + } + } + } + stop_body(StopAsk { + input, + master, + body, + kept, + own_time, + asked: asked.as_ref(), + shift, + days, + warnings, + undo, + notify, + }) + } + } +} + +/// `start` and `end` into `body` where they differ from the series' own. +fn put_times(body: &mut Map, master_json: &Value, start: Value, end: Value) { + if !when_eq(master_json.get("start"), Some(&start)) { + body.insert("start".into(), start); + } + if !when_eq(master_json.get("end"), Some(&end)) { + body.insert("end".into(), end); + } +} + +/// What the "Does not repeat" PATCH of every occurrence is made from. +struct StopAsk<'a> { + input: &'a SeriesInput<'a>, + master: &'a Master, + /// Its text so far: the draft's, and the kept occurrence's own under it. + body: Map, + /// The occurrence that stays, where it sits in the series. + kept: Point, + /// Its own copy, when it has a time of its own. + own_time: Option<&'a Value>, + asked: Option<&'a Asked>, + shift: i64, + /// The days `shift` is counted in ([`Days`]). + days: Days, + warnings: Vec, + undo: Option, + notify: Notify, +} + +/// The master as one event, at the kept occurrence: at the draft's time, moved as far as the draft +/// moved this occurrence; else at its own time; else where the series puts it. No rule is sent +/// (`null`; probe P-R2 says whether Google wants `[]`, and the reply is read either way). +fn stop_body(ask: StopAsk) -> Result { + let StopAsk { + input, + master, + mut body, + kept, + own_time, + asked, + shift, + days, + mut warnings, + undo, + notify, + } = ask; + let master_json = input.fresh.master.json(); + let zone = master.start.zone(); + // The kept occurrence's day, read as the draft's: in another zone, the same place in the week. + let kept_day = days.of(kept).ok_or(SeriesRefusal::Unreadable)?; + let (first, start_zone, end_zone, length) = match (asked, own_time) { + (Some(a), _) => { + let day = moved_day(kept_day, shift).ok_or(SeriesRefusal::Unreadable)?; + let start = on_day(&a.start, day); + (one_event_at(&start)?, start.zone(), a.end_zone, a.length) + } + (None, Some(item)) => own_nodes(item, master)?, + (None, None) => (kept, zone, master.end_zone, master.length), + }; + let (start, end) = nodes_for_master(master, start_zone, end_zone, first, length)?; + let rule_start = start_of(&start).unwrap_or(master.start); + put_times(&mut body, master_json, start, end); + body.insert("recurrence".into(), Value::Null); + warnings.push(SeriesWarning::BecomesOneEvent { + day: rule_start.date(), + }); + // The draft moved this occurrence to another day, so the one kept moves as far: no other line + // says it leaves its own day (`shift` is the draft's, 0 without one). + if shift != 0 { + warnings.push(SeriesWarning::StartMoved { + from: kept_day, + to: rule_start.date(), + }); + } + let mut plan = all_plan(input, master, body, warnings, undo, notify, rule_start)?; + // Every other occurrence goes, as its own words say; but what Google does with the ones changed + // on their own is P-R2's to show, and their landing keeps any it still shows, so the hedge stays + // for them. Never for the one kept: this PATCH carries its changes. + let others = own_exceptions(master, input.exceptions) + .filter(|item| original_of(item) != Some(kept)) + .filter(|item| { + matches!( + classify_instance(master_json, master, item), + InstanceKind::Modified { .. } + ) + }) + .count(); + plan.warnings + .retain(|w| !matches!(w, SeriesWarning::ExceptionsMayReset { .. })); + if others > 0 { + plan.warnings.push(SeriesWarning::ExceptionsMayReset { + count: u32::try_from(others).unwrap_or(u32::MAX), + complete: input.exceptions.complete, + }); + } + Ok(plan) +} + +/// "This and following" with a new rule, or with none (C11 §9.5): a new part rebuilt from it from +/// this occurrence on, or one event in its place, inserted first, then the original cut, then each +/// occurrence changed on its own carried where the new rule still has its day, and cancelled on the +/// original. +#[allow(clippy::too_many_arguments)] +pub(super) fn plan_split_rule( + input: &SeriesInput, + master: &Master, + at: Point, + SplitCut { cut, moved, before }: SplitCut, + draft: &EventPatchDraft, + tail_id: &str, + ask: RuleAsk, + notify: Notify, +) -> Result { + let (instance, master_json) = (input.fresh.instance.json(), input.fresh.master.json()); + let opened = input.opened.master.ok_or(SeriesRefusal::NoBase)?; + keeps_formatting(master_json, draft)?; + // An id PM minted for this plan, never the series' own (C8b review ADV-5). + if !is_plain_segment(tail_id) || tail_id == master.id { + return Err(SeriesRefusal::Unreadable.into()); + } + let parsed = lines::parse(&master.recurrence); + let (_, facts) = lines::series_rule(&parsed).map_err(RecurError::TextOnly)?; + let zone = master.start.zone(); + let rest = rest_start(master, at)?; + let exceptions = exceptions_from(master, input.exceptions, at)?; + no_guests_from(master, input, Some(at))?; + if draft.time.is_some() { + // Read against the occurrence as Google holds it now: it must still be where the user saw it + // (R2; C8b reviews ADV-1). + unchanged_since_opened(master, input, &["start", "end"])?; + } + let asked = asked_time(draft, instance)?; + // The new part starts where the draft put this occurrence, else where the series has it. + let (start, length, end_zone) = match &asked { + Some(a) => (a.start, a.length, a.end_zone), + None => (rest, master.length, master.end_zone), + }; + let at_day = at.day_in(zone); + // In the days of the part the rest moves into, as for every occurrence ([`Days`], C11c review). + let days = Days::new(&master.start, &start, at); + let shift = (start.date() - days.of(at).ok_or(SeriesRefusal::Unreadable)?).num_days(); + // Deleted from here on: its cancelled occurrences, and the EXDATEs the cut hands the rest; its + // extra dates are the rest's too. + let mut deleted: Vec = exceptions + .iter() + .filter(|(item, _)| classify_instance(master_json, master, item) == InstanceKind::Cancelled) + .map(|(_, p)| *p) + .collect(); + let mut extra = Vec::new(); + for line in &moved { + match &line.kind { + Kind::ExDate(dates) => { + for value in &dates.values { + deleted.push( + dates + .point(value, &master.start) + .ok_or(RecurError::CannotCount)?, + ); + } + } + Kind::RDate(_) => extra.push(line.raw.clone()), + _ => {} + } + } + let touched = draft_fields(draft); + let draft_keys: Vec<&str> = touched.iter().map(|(k, _)| *k).collect(); + let mut warnings = Vec::new(); + let mut lost: u32 = 0; + // The new part's body and kind, and where each occurrence changed on its own lands in it. + let (body, all_day, landing) = match ask { + RuleAsk::New(spec) => { + // An "after n times" still as the series has it is the series' own count, rebased on what + // is left of it; only a count the user set is written as set (C11 critique 1 M4, Q3). + let seed = spec_of(&master.recurrence, &master.start).map(|s| s.ends); + let spec = match (spec.ends, seed, facts.count, before) { + (Ends::After(n), Some(Ends::After(m)), Some(count), Some(k)) if n == m => { + let left = u16::try_from(remaining_count(count, k)?) + .map_err(|_| RecurError::CannotCount)?; + RecurrenceSpec { + ends: Ends::After(left), + ..spec + } + } + _ => spec, + }; + let rebuilt = rebuild(&RebuildAsk { + spec: &spec, + start, + length, + end_zone, + deleted: &deleted, + old_start: &master.start, + old_rule: Some(facts.clone()), + shift, + days, + opened_at: at, + opened: start.date(), + extra: &extra, + })?; + // The rest can't start where the part before it still reaches (A29): both days read in + // the series' own zone, whatever zone the rest is moved into, so an equal day counts (an + // all-day series has no zone of its own: the rest's own date). + let head = last_kept( + master, + &facts, + at, + &cancelled_points(master, input.exceptions)?, + )?; + let first_day = match master.start { + SeriesStart::Timed { .. } => rebuilt.first.day_in(zone), + SeriesStart::AllDay(_) => rebuilt.start.date(), + }; + if head.is_some_and(|h| first_day <= h.day_in(zone)) { + return Err(RetimeError::Overlaps(rebuilt.start.date()).into()); + } + clear_of_later(input.later, &rebuilt, zone)?; + if rebuilt.moved { + warnings.push(SeriesWarning::StartMoved { + from: at_day, + to: rebuilt.start.date(), + }); + } + let all_day = matches!(rebuilt.start, SeriesStart::AllDay(_)); + let body = tail_body( + master, + master_json, + at, + &touched, + rebuilt.nodes.clone(), + Some(rebuilt.lines.clone()), + ); + (body, all_day, Some(rebuilt)) + } + RuleAsk::Stop => { + // One event in place of the rest: this occurrence, its own changes travelling with it + // under the draft's (C11 §9.5.7). + let own = exceptions.iter().find(|(_, p)| *p == at).map(|(i, _)| *i); + let mut fields: Vec<(&str, Value)> = Vec::new(); + let mut own_time = None; + if let Some(item) = own { + match classify_instance(master_json, master, item) { + InstanceKind::Modified { other, .. } if !other.is_empty() => { + return Err(SeriesRefusal::CannotCarry(at_day).into()); + } + InstanceKind::Modified { carry, .. } => { + for field in carry { + if field == "time" { + own_time = Some(item); + } else if !draft_keys.contains(&field) { + let value = item.get(field).cloned().unwrap_or(match field { + "summary" | "description" | "location" => Value::from(""), + _ => Value::Null, + }); + fields.push((field, value)); + } + } + } + InstanceKind::Plain | InstanceKind::Cancelled => {} + } + } + fields.extend(touched.iter().cloned()); + let (first, start_zone, end_zone, length) = match (&asked, own_time) { + (Some(a), _) => ( + one_event_at(&a.start)?, + a.start.zone(), + a.end_zone, + a.length, + ), + (None, Some(item)) => own_nodes(item, master)?, + (None, None) => (at, zone, master.end_zone, master.length), + }; + let nodes = when_nodes_in(start_zone, end_zone, first, length)?; + let one_day = first.day_in(start_zone); + warnings.push(SeriesWarning::BecomesOneEvent { day: one_day }); + let all_day = matches!(first, Point::Day(_)); + ( + tail_body(master, master_json, at, &fields, nodes, None), + all_day, + None, + ) + } + }; + let stops = landing.is_none(); + split_unchanged_since_opened(master, input, opened, &touched)?; + let mut steps = vec![ + insert_step(input, master, tail_id, body, notify), + cut_step(master, input.calendar, master_json, cut, TAIL_WATCH, notify), + ]; + // Each stored exception from here on: carried onto the new part's same occurrence where the new + // rule still has its day (Q3), then cancelled on the original, only once the carry is done (DS3). + // Changes on a day the new rule skips go, and are counted; a part that doesn't repeat carries + // none, and every later change goes. + for (item, p) in &exceptions { + let kind = classify_instance(master_json, master, item); + if kind == InstanceKind::Cancelled { + continue; + } + let split_itself = *p == at; + let requires = match &landing { + // One event in place of the rest: every later occurrence goes, changed or not, as its + // own words say, so none is counted apart. + None => None, + Some(rebuilt) => { + let carry = match kind { + InstanceKind::Modified { other, .. } if !other.is_empty() => { + return Err(SeriesRefusal::CannotCarry(p.day_in(zone)).into()); + } + InstanceKind::Modified { carry, .. } => carry, + InstanceKind::Plain | InstanceKind::Cancelled => Vec::new(), + }; + let moved_time = split_itself && asked.is_some(); + let carried = + carried_fields(item, &carry, split_itself, &draft_keys, moved_time, all_day); + // An extra date kept is still an occurrence where it was (RM1); the occurrence split + // at goes where the draft put it; any later one where the new rule still has its own + // day, else where the day move takes it, never onto the one split at ([`Landing`]). + let target = if rebuilt.extra.contains(p) { + Some(*p) + } else if split_itself { + rebuilt.opened + } else { + Landing { + facts: &rebuilt.facts, + start: &rebuilt.start, + old: Some((&facts, &master.start)), + shift, + days: rebuilt.days, + opened: rebuilt.opened, + } + .at(*p)? + }; + match (carried.is_empty(), target) { + (true, _) => None, + (false, None) => { + lost = lost.saturating_add(1); + None + } + (false, Some(target)) => { + steps.push(Step { + op: Op::Carry { + calendar: input.calendar.to_string(), + series: tail_id.to_string(), + original: target, + body: carried.clone(), + }, + notify, + stage: Stage::Cleanup, + requires: None, + conflict: Recheck::Never, + shows: Shows::Fields { + sent: carried, + old: Map::new(), + }, + gone_is_done: false, + undo: None, + }); + Some(steps.len() - 1) + } + } + } + }; + steps.push(cancel_exception( + input.calendar, + item, + *p, + notify, + requires, + )?); + } + if lost > 0 { + warnings.push(SeriesWarning::ChangesLost { count: lost }); + } + if !stops { + if length != master.length { + warnings.push(SeriesWarning::LengthChanges); + } + warnings.extend(not_carried(master_json)); + warnings.push(SeriesWarning::NewIdentity); + } + if input.sibling { + warnings.push(SeriesWarning::SplitSibling); + } + let mut expect = vec![Expect::EndsBefore { + series: master.id.clone(), + at, + left: Vec::new(), + }]; + expect.push(if stops { + Expect::Event { + id: tail_id.to_string(), + } + } else { + Expect::Present { + series: tail_id.to_string(), + } + }); + Ok(SeriesPlan { + scope: Scope::Following, + steps, + warnings, + expect, + removes: Removes::From { + series: master.id.clone(), + at, + left: Vec::new(), + }, + start: master.start, + rule_start: master.start, + guests_allowed: input.guests_allowed, + }) +} + +/// What an occurrence changed on its own carries onto the new part's same occurrence: its `carry` +/// fields, but on the occurrence split at the draft's own fields (and its time, when the draft moved +/// it: `moved_time`). Its time between timed and all-day goes with the other kind's keys as `null`, +/// as Google merges nodes (C8b review G4; C11 critique 1 L9: carried across kinds as before). +fn carried_fields( + item: &Value, + carry: &[&'static str], + split_itself: bool, + draft_keys: &[&str], + moved_time: bool, + all_day: bool, +) -> Map { + let mut carried = Map::new(); + for field in carry { + if split_itself && draft_keys.contains(field) { + continue; + } + match *field { + "time" => { + if moved_time { + continue; + } + for k in ["start", "end"] { + let mut node = item.get(k).cloned().unwrap_or(Value::Null); + if node.get("date").is_some() != all_day { + drop_other_kind(&mut node); + } + carried.insert(k.to_string(), node); + } + } + _ => { + let value = item.get(*field).cloned().unwrap_or(match *field { + "summary" | "description" | "location" => Value::from(""), + _ => Value::Null, + }); + carried.insert((*field).to_string(), value); + } + } + } + carried +} + +#[cfg(test)] +mod tests { + use super::super::fixtures::*; + use super::super::{Exceptions, Listing}; + use super::*; + use crate::calendar_recur::lines::Applied; + use crate::calendar_recur::wire::{DayCode, NthCode, RepeatEnds, RepeatPattern, RepeatRule}; + use chrono::NaiveDateTime; + use chrono_tz::Europe::London; + use serde_json::json; + + fn day(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() + } + + fn wall(y: i32, m: u32, d: u32, h: u32, min: u32) -> NaiveDateTime { + day(y, m, d).and_hms_opt(h, min, 0).unwrap() + } + + fn london(y: i32, m: u32, d: u32, h: u32) -> SeriesStart { + SeriesStart::Timed { + local: wall(y, m, d, h, 0), + zone: London, + } + } + + fn rule(pattern: RepeatPattern, interval: u16, ends: RepeatEnds) -> RepeatRule { + RepeatRule { + pattern, + interval, + ends, + } + } + + fn weekly_on(days: &[DayCode]) -> RepeatRule { + rule( + RepeatPattern::Weekly { + days: days.to_vec(), + }, + 1, + RepeatEnds::Never, + ) + } + + fn daily() -> RepeatRule { + rule(RepeatPattern::Daily, 1, RepeatEnds::Never) + } + + /// A draft asking for `rule` from now on. + fn repeat(rule: RepeatRule) -> EventPatchDraft { + EventPatchDraft { + recurrence: Some(RepeatDraft::Rule { rule }), + ..Default::default() + } + } + + /// "Does not repeat". + fn stop() -> EventPatchDraft { + EventPatchDraft { + recurrence: Some(RepeatDraft::Once), + ..Default::default() + } + } + + fn body(plan: &SeriesPlan, step: usize) -> &Map { + match &plan.steps[step].op { + Op::Insert { body, .. } | Op::Patch { body, .. } | Op::Carry { body, .. } => body, + _ => panic!("step {step} has no body"), + } + } + + fn timed_node(at: &str) -> Value { + json!({ "dateTime": at, "timeZone": "Europe/London" }) + } + + fn spec_of_rule(rule: &RepeatRule) -> RecurrenceSpec { + rule.to_spec().unwrap() + } + + /// The old series every rebuild here replaces: weekly on Monday from 05-10 09:00 in London. + fn old() -> SeriesStart { + london(2026, 10, 5, 9) + } + + /// `ask` for `spec` from `start` an hour long, the old series starting at `old` (weekly on + /// Monday), with `deleted`, no extra dates and no shift, the occurrence opened its first, put on + /// the new first day. + fn ask<'a>( + spec: &'a RecurrenceSpec, + start: SeriesStart, + deleted: &'a [Point], + old: &'a SeriesStart, + ) -> RebuildAsk<'a> { + let mondays = lines::parse(&["RRULE:FREQ=WEEKLY;BYDAY=MO"]); + let first = old.point().unwrap(); + RebuildAsk { + spec, + start, + length: Length::Timed(TimeDelta::hours(1)), + end_zone: London, + deleted, + old_start: old, + old_rule: lines::series_rule(&mondays).ok().map(|(_, facts)| facts), + shift: 0, + days: Days::new(old, &start, first), + opened_at: first, + opened: start.date(), + extra: &[], + } + } + + // --- rebuilding a series from a rule --- + + /// A deleted Monday stays deleted under a daily rule, which still makes it. + #[test] + fn rebuild_keeps_deleted_days_the_new_rule_still_has() { + let (spec, old) = (spec_of_rule(&daily()), old()); + let deleted = [point("2026-10-19T08:00:00Z")]; + let rebuilt = rebuild(&ask(&spec, london(2026, 10, 5, 9), &deleted, &old)).unwrap(); + assert_eq!( + rebuilt.lines, + ["RRULE:FREQ=DAILY", "EXDATE:20261019T080000Z"] + ); + assert!(!rebuilt.moved); + } + + /// An EXDATE the new rule doesn't make is never written: Google refuses one that names nothing. + #[test] + fn rebuild_drops_exdates_off_the_new_rule() { + let (spec, old) = (spec_of_rule(&weekly_on(&[DayCode::Tue])), old()); + let deleted = [point("2026-10-19T08:00:00Z")]; + let rebuilt = rebuild(&ask(&spec, london(2026, 10, 5, 9), &deleted, &old)).unwrap(); + assert_eq!(rebuilt.lines, ["RRULE:FREQ=WEEKLY;BYDAY=TU"]); + assert_eq!(rebuilt.start, london(2026, 10, 6, 9)); + assert!(rebuilt.moved); + } + + /// At a new time, a deleted day stays deleted at that time. + #[test] + fn rebuild_moves_exdates_to_the_new_time() { + let (spec, old) = (spec_of_rule(&daily()), old()); + let deleted = [point("2026-10-19T08:00:00Z"), point("2026-11-02T09:00:00Z")]; + let rebuilt = rebuild(&ask(&spec, london(2026, 10, 5, 10), &deleted, &old)).unwrap(); + assert_eq!( + rebuilt.lines, + [ + "RRULE:FREQ=DAILY", + "EXDATE:20261019T090000Z,20261102T100000Z" + ] + ); + // C11c review: a day the change moves every occurrence by leaves a deleted day the new rule + // still makes where it was: the user deleted that Monday, not the Tuesday after it. + let shifted = RebuildAsk { + shift: 1, + ..ask(&spec, london(2026, 10, 6, 9), &deleted, &old) + }; + assert_eq!( + rebuild(&shifted).unwrap().lines, + [ + "RRULE:FREQ=DAILY", + "EXDATE:20261019T080000Z,20261102T090000Z" + ] + ); + // A rule without the day moves the deletion with the day: Mondays made Tuesdays. + let tuesdays = spec_of_rule(&weekly_on(&[DayCode::Tue])); + let moved_on = RebuildAsk { + shift: 1, + ..ask(&tuesdays, london(2026, 10, 6, 9), &deleted, &old) + }; + assert_eq!( + rebuild(&moved_on).unwrap().lines, + [ + "RRULE:FREQ=WEEKLY;BYDAY=TU", + "EXDATE:20261020T080000Z,20261103T090000Z" + ] + ); + // Never onto a day the old series had an occurrence of its own: which one was deleted would be + // a guess. Mondays moved two days on, made Wednesdays, from a series with Wednesdays too. + let both = lines::parse(&["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"]); + let wednesdays = spec_of_rule(&weekly_on(&[DayCode::Wed])); + let guessed = RebuildAsk { + shift: 2, + old_rule: lines::series_rule(&both).ok().map(|(_, facts)| facts), + ..ask(&wednesdays, london(2026, 10, 7, 9), &deleted, &old) + }; + assert_eq!( + rebuild(&guessed).unwrap().lines, + ["RRULE:FREQ=WEEKLY;BYDAY=WE"] + ); + // When PM can't read the old rule, it can't tell whether the day moved to had one of its own: + // refused in words where the new rule makes that day (C11c review), never dropped. + let unread = RebuildAsk { + shift: 1, + old_rule: None, + ..ask(&tuesdays, london(2026, 10, 6, 9), &deleted, &old) + }; + assert!(matches!( + rebuild(&unread), + Err(NotPlanned::Refused(SeriesRefusal::DeletedDayUnread(d))) if d == day(2026, 10, 19) + )); + // Where it doesn't make the day moved to either, the deletion has nowhere to go. + let thursdays = spec_of_rule(&weekly_on(&[DayCode::Thu])); + let nowhere = RebuildAsk { + shift: 1, + old_rule: None, + ..ask(&thursdays, london(2026, 10, 8, 9), &deleted, &old) + }; + assert_eq!( + rebuild(&nowhere).unwrap().lines, + ["RRULE:FREQ=WEEKLY;BYDAY=TH"] + ); + } + + /// An extra date (RDATE) is kept as Google wrote it while the series' times stay; moved, it's + /// refused as a move refuses it. + #[test] + fn rebuild_keeps_extra_dates_only_when_times_stay() { + let (spec, old) = ( + spec_of_rule(&weekly_on(&[DayCode::Mon, DayCode::Wed])), + old(), + ); + let extra = ["RDATE;TZID=Europe/London:20261023T090000".to_string()]; + let kept = rebuild(&RebuildAsk { + extra: &extra, + ..ask(&spec, london(2026, 10, 5, 9), &[], &old) + }) + .unwrap(); + assert_eq!( + kept.lines, + [ + "RRULE:FREQ=WEEKLY;BYDAY=MO,WE", + "RDATE;TZID=Europe/London:20261023T090000" + ] + ); + for moved in [ + RebuildAsk { + extra: &extra, + ..ask(&spec, london(2026, 10, 5, 10), &[], &old) + }, + RebuildAsk { + extra: &extra, + shift: 1, + ..ask(&spec, london(2026, 10, 5, 9), &[], &old) + }, + RebuildAsk { + extra: &extra, + length: Length::AllDay(1), + ..ask(&spec, SeriesStart::AllDay(day(2026, 10, 5)), &[], &old) + }, + ] { + assert!(matches!( + rebuild(&moved), + Err(NotPlanned::Refused(SeriesRefusal::Retime( + RetimeError::HasExtraDates + ))) + )); + } + } + + /// RM1: a deleted extra date kept as Google wrote it is still an occurrence where it was, so it + /// stays deleted there, whatever the new rule makes; and only there, never at the rule's time. + #[test] + fn rebuild_keeps_a_deleted_extra_date_deleted() { + let old = old(); + // Friday 23-10 at the series' time, Friday 30-10 (London on GMT again) at 14:00. + let extra = ["RDATE;TZID=Europe/London:20261023T090000,20261030T140000".to_string()]; + let deleted = [point("2026-10-23T08:00:00Z"), point("2026-10-30T14:00:00Z")]; + for (rule, first) in [ + ( + weekly_on(&[DayCode::Mon, DayCode::Wed]), + "RRULE:FREQ=WEEKLY;BYDAY=MO,WE", + ), + (daily(), "RRULE:FREQ=DAILY"), + ] { + let spec = spec_of_rule(&rule); + let rebuilt = rebuild(&RebuildAsk { + extra: &extra, + ..ask(&spec, london(2026, 10, 5, 9), &deleted, &old) + }) + .unwrap(); + assert_eq!( + rebuilt.lines, + [ + first, + "EXDATE:20261023T080000Z,20261030T140000Z", + "RDATE;TZID=Europe/London:20261023T090000,20261030T140000" + ] + ); + } + } + + /// A first day whose time the clocks skip is refused, never moved to another hour. + #[test] + fn rebuild_refuses_a_gap_start() { + // London skips 01:00-02:00 on Sunday 29-03-2026; "Weekly on Sunday" asked on the Saturday + // starts that Sunday. + let (spec, old) = (spec_of_rule(&weekly_on(&[DayCode::Sun])), old()); + let start = SeriesStart::Timed { + local: wall(2026, 3, 28, 1, 30), + zone: London, + }; + assert!(matches!( + rebuild(&ask(&spec, start, &[], &old)), + Err(NotPlanned::Refused(SeriesRefusal::Spec(SpecError::Gap(d)))) if d == day(2026, 3, 29) + )); + } + + /// A timed start names its zone on both nodes (G6), its end in its own. + #[test] + fn rebuild_names_the_zone_on_a_timed_start() { + let (spec, old) = (spec_of_rule(&daily()), old()); + let rebuilt = rebuild(&RebuildAsk { + end_zone: chrono_tz::America::New_York, + length: Length::Timed(TimeDelta::hours(8)), + ..ask(&spec, london(2026, 10, 5, 9), &[], &old) + }) + .unwrap(); + assert_eq!(rebuilt.nodes.0, timed_node("2026-10-05T09:00:00+01:00")); + assert_eq!( + rebuilt.nodes.1, + json!({ "dateTime": "2026-10-05T12:00:00-04:00", "timeZone": "America/New_York" }) + ); + } + + /// An all-day one is written as dates, its UNTIL a date. + #[test] + fn rebuild_writes_dates_for_an_all_day_one() { + let spec = spec_of_rule(&rule( + RepeatPattern::Daily, + 1, + RepeatEnds::OnDate { + date: "2026-12-31".into(), + }, + )); + let (deleted, old) = ([point("2026-10-19T08:00:00Z")], old()); + let rebuilt = rebuild(&RebuildAsk { + length: Length::AllDay(2), + end_zone: Tz::UTC, + ..ask(&spec, SeriesStart::AllDay(day(2026, 10, 5)), &deleted, &old) + }) + .unwrap(); + assert_eq!( + rebuilt.nodes, + ( + json!({ "date": "2026-10-05" }), + json!({ "date": "2026-10-07" }) + ) + ); + assert_eq!( + rebuilt.lines, + [ + "RRULE:FREQ=DAILY;UNTIL=20261231", + "EXDATE;VALUE=DATE:20261019" + ] + ); + } + + /// "Annually on its own date" starts on the first such date on or after the series' start. + #[test] + fn yearly_on_a_date_anchors_at_the_occurrences_date() { + assert_eq!( + yearly_anchor(day(2026, 10, 5), day(2026, 10, 13)), + day(2026, 10, 13) + ); + assert_eq!( + yearly_anchor(day(2026, 10, 13), day(2026, 10, 5)), + day(2027, 10, 5) + ); + assert_eq!( + yearly_anchor(day(2025, 1, 1), day(2024, 2, 29)), + day(2028, 2, 29) + ); + assert_eq!( + yearly_anchor(day(2096, 3, 1), day(2096, 2, 29)), + day(2104, 2, 29) + ); + // Through a plan: picked on a later occurrence, it repeats on that date. + let case = Case::new( + master(&["RRULE:FREQ=WEEKLY;BYDAY=MO"]), + plain("2026-10-12T09:00:00+01:00"), + ); + let yearly = rule(RepeatPattern::YearlyDate, 1, RepeatEnds::Never); + let plan = case.edit(Scope::All, repeat(yearly.clone())).unwrap(); + assert_eq!( + body(&plan, 0)["start"], + timed_node("2026-10-12T09:00:00+01:00") + ); + assert_eq!(body(&plan, 0)["recurrence"], json!(["RRULE:FREQ=YEARLY"])); + // The first occurrence moved two days earlier, to Saturday 03-10: it repeats from there, + // never from a year on. + let first = Case::new(weekly(), plain("2026-10-05T09:00:00+01:00")); + let earlier = EventPatchDraft { + recurrence: Some(RepeatDraft::Rule { rule: yearly }), + ..at("09:00", "2026-10-03") + }; + let plan = first.edit(Scope::All, earlier).unwrap(); + assert_eq!( + body(&plan, 0)["start"], + timed_node("2026-10-03T09:00:00+01:00") + ); + assert_eq!(body(&plan, 0)["recurrence"], json!(["RRULE:FREQ=YEARLY"])); + assert!(plan.warnings.contains(&SeriesWarning::StartMoved { + from: day(2026, 10, 5), + to: day(2026, 10, 3) + })); + } + + const PROBE: &str = "PM_RULE_ZONE_PROBE"; + const PROBE_LINE: &str = "rule-probe: "; + + /// Rebuilds whose every value is built with an explicit zone. + fn probe() -> String { + let deleted = [point("2026-10-19T08:00:00Z"), point("2026-11-02T09:00:00Z")]; + let weekdays = spec_of_rule(&weekly_on(&[DayCode::Mon, DayCode::Wed])); + let monthly = spec_of_rule(&rule( + RepeatPattern::MonthlyDay { day: 31 }, + 1, + RepeatEnds::After { count: 5 }, + )); + let (all_day, old) = (spec_of_rule(&daily()), old()); + let out: Vec = [ + rebuild(&ask(&weekdays, london(2026, 10, 13, 23), &deleted, &old)), + rebuild(&ask(&monthly, london(2026, 10, 5, 0), &deleted, &old)), + rebuild(&RebuildAsk { + length: Length::AllDay(1), + end_zone: Tz::UTC, + ..ask( + &all_day, + SeriesStart::AllDay(day(2026, 10, 5)), + &deleted, + &old, + ) + }), + ] + .into_iter() + .map(|r| match r { + Ok(r) => format!("{:?} {:?} {:?}", r.start, r.nodes, r.lines), + Err(_) => "refused".into(), + }) + .collect(); + out.join(" | ") + } + + /// The child half of the next test. + #[test] + fn rule_zone_probe() { + if std::env::var_os(PROBE).is_some() { + println!("{PROBE_LINE}{}", probe()); + } + } + + /// Trap (b): a rebuild run in a process set to Auckland or Los Angeles is the one run here. + #[test] + fn rebuild_is_the_same_under_another_computer_zone() { + let here = probe(); + let exe = std::env::current_exe().unwrap(); + for zone in ["Pacific/Auckland", "America/Los_Angeles"] { + let out = std::process::Command::new(&exe) + .args([ + "calendar_write::series::rule::tests::rule_zone_probe", + "--exact", + "--nocapture", + "--test-threads=1", + ]) + .env(PROBE, "1") + .env("TZ", zone) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + let line = stdout + .lines() + .find_map(|l| l.split_once(PROBE_LINE).map(|(_, probe)| probe)) + .unwrap_or_else(|| panic!("no probe output with TZ={zone}")); + assert_eq!(line, here, "TZ={zone}"); + } + } + + // --- "All events" with a new rule --- + + /// §9.10: weekly Monday from 05-10 made "Weekly on Tuesday" starts on Tuesday 06-10, and says so; + /// one PATCH of the start, the end and the rule, undone only when half of it lands. + #[test] + fn all_with_a_new_rule_patches_start_end_and_rule_and_says_the_start_moved() { + let case = Case::new(weekly(), monday_19()); + let plan = case + .edit(Scope::All, repeat(weekly_on(&[DayCode::Tue]))) + .unwrap(); + assert_eq!(plan.scope, Scope::All); + assert_eq!(plan.steps.len(), 1); + let sent = body(&plan, 0); + assert_eq!(sent["start"], timed_node("2026-10-06T09:00:00+01:00")); + assert_eq!(sent["end"], timed_node("2026-10-06T10:00:00+01:00")); + assert_eq!(sent["recurrence"], json!(["RRULE:FREQ=WEEKLY;BYDAY=TU"])); + assert_eq!(sent.len(), 3); + assert_eq!( + plan.warnings, + [SeriesWarning::StartMoved { + from: day(2026, 10, 5), + to: day(2026, 10, 6) + }] + ); + assert!(matches!( + &plan.steps[0].undo, + Some(Undo::RestoreMaster { body, .. }) + if body["recurrence"] == json!(["RRULE:FREQ=WEEKLY;BYDAY=MO"]) + )); + assert_eq!(plan.rule_start, london(2026, 10, 6, 9)); + // The same rule asked again is no change of rule at all. + assert_eq!( + case.edit(Scope::All, repeat(weekly_on(&[DayCode::Mon]))), + Err(NotPlanned::NoChange) + ); + } + + /// A rule PM can only show is replaced whole by one from the editor's set. + #[test] + fn all_with_a_new_rule_replaces_a_custom_rule() { + let custom = master(&["RRULE:FREQ=MONTHLY;BYDAY=TU;BYSETPOS=2"]); + let case = Case::new(custom, plain("2026-10-13T09:00:00+01:00")); + let plan = case.edit(Scope::All, repeat(daily())).unwrap(); + let sent = body(&plan, 0); + assert_eq!(sent["recurrence"], json!(["RRULE:FREQ=DAILY"])); + // Already on the daily rule's first day: the start stays. + assert!(sent.get("start").is_none()); + // A line PM can't read could be an EXDATE, whose occurrence would come back. + let unreadable = master(&[ + "RRULE:FREQ=WEEKLY;BYDAY=MO", + "EXDATE;TZID=Mars/Olympus:20261019T090000", + ]); + assert_eq!( + Case::new(unreadable, monday_19()).edit(Scope::All, repeat(daily())), + Err(NotPlanned::Refused(SeriesRefusal::Recur( + RecurError::TextOnly(TextOnly::Unreadable) + ))) + ); + } + + /// C11c review: the second Tuesday from 08-09, November's deleted, moved a day on and made "the + /// second Wednesday". Spelt BYSETPOS, a rule PM can only show as text, PM can't tell whether + /// Wednesday 11-11 had an occurrence of its own, so it refuses in words rather than bring + /// November's back; spelt 2TU, the deletion moves to 11-11. + #[test] + fn a_moved_day_under_a_custom_rule_refuses_rather_than_bring_a_deletion_back() { + let second_tuesday = |rule: &str| { + master_with( + &[rule, "EXDATE;TZID=Europe/London:20261110T090000"], + "2026-09-08T09:00:00+01:00", + "2026-09-08T10:00:00+01:00", + ) + }; + let second_wednesday = EventPatchDraft { + recurrence: Some(RepeatDraft::Rule { + rule: rule( + RepeatPattern::MonthlyWeekday { + nth: NthCode::Second, + weekday: DayCode::Wed, + }, + 1, + RepeatEnds::Never, + ), + }), + ..at("09:00", "2026-10-14") + }; + let october = plain("2026-10-13T09:00:00+01:00"); + let custom = Case::new( + second_tuesday("RRULE:FREQ=MONTHLY;BYDAY=TU;BYSETPOS=2"), + october.clone(), + ); + assert_eq!( + custom.edit(Scope::All, second_wednesday.clone()), + Err(NotPlanned::Refused(SeriesRefusal::DeletedDayUnread(day( + 2026, 11, 10 + )))) + ); + let read = Case::new(second_tuesday("RRULE:FREQ=MONTHLY;BYDAY=2TU"), october); + let plan = read.edit(Scope::All, second_wednesday).unwrap(); + assert_eq!( + body(&plan, 0)["recurrence"], + json!(["RRULE:FREQ=MONTHLY;BYDAY=2WE", "EXDATE:20261111T090000Z"]) + ); + // The refusal names the day deleted as the series has it, in its own zone (C11c review round + // 3). At 23:30 in London, the 13-10 occurrence is 14-10 07:30 in Tokyo, moved a day on there + // and made "Weekly on Thursday": November's, deleted on Tuesday 10-11, is Wednesday 11-11 in + // Tokyo. + let late = master_with( + &[ + "RRULE:FREQ=MONTHLY;BYDAY=TU;BYSETPOS=2", + "EXDATE;TZID=Europe/London:20261110T233000", + ], + "2026-09-08T23:30:00+01:00", + "2026-09-09T00:30:00+01:00", + ); + let thursdays = EventPatchDraft { + time: Some(in_tokyo("2026-10-15", "07:30", "2026-10-15", "08:30")), + recurrence: Some(RepeatDraft::Rule { + rule: weekly_on(&[DayCode::Thu]), + }), + ..Default::default() + }; + assert_eq!( + Case::new(late, plain("2026-10-13T23:30:00+01:00")).edit(Scope::All, thursdays), + Err(NotPlanned::Refused(SeriesRefusal::DeletedDayUnread(day( + 2026, 11, 10 + )))) + ); + } + + /// C11 critique 2 M4: a new rule with this occurrence moved to another day moves every + /// occurrence as far, as Google allows, and asks about the days it repeats on. + #[test] + fn all_with_a_new_rule_and_a_moved_day_moves_the_series_by_as_much() { + let case = Case::new(weekly(), monday_19()); + let to_tuesday = EventPatchDraft { + recurrence: Some(RepeatDraft::Rule { + rule: weekly_on(&[DayCode::Tue]), + }), + ..at("10:00", "2026-10-20") + }; + let plan = case.edit(Scope::All, to_tuesday.clone()).unwrap(); + let sent = body(&plan, 0); + assert_eq!(sent["start"], timed_node("2026-10-06T10:00:00+01:00")); + assert_eq!(sent["recurrence"], json!(["RRULE:FREQ=WEEKLY;BYDAY=TU"])); + assert_eq!( + plan.warnings, + [ + SeriesWarning::RepeatDayChanges { + from: day(2026, 10, 19), + to: day(2026, 10, 20) + }, + SeriesWarning::StartMoved { + from: day(2026, 10, 5), + to: day(2026, 10, 6) + } + ] + ); + // A daily rule names no day: the series' first moves by as much, but a deleted Monday it still + // makes stays deleted, never the Tuesday after it (C11c review). + let with_exdate = master(&[ + "RRULE:FREQ=WEEKLY;BYDAY=MO", + "EXDATE;TZID=Europe/London:20261026T090000", + ]); + let every_day = EventPatchDraft { + recurrence: Some(RepeatDraft::Rule { rule: daily() }), + ..at("09:00", "2026-10-20") + }; + let plan = Case::new(with_exdate, monday_19()) + .edit(Scope::All, every_day) + .unwrap(); + let sent = body(&plan, 0); + assert_eq!(sent["start"], timed_node("2026-10-06T09:00:00+01:00")); + assert_eq!( + sent["recurrence"], + json!(["RRULE:FREQ=DAILY", "EXDATE:20261026T090000Z"]) + ); + // No question of which days it repeats on, as if Mondays became Tuesdays: where it starts + // says it all. + assert_eq!( + plan.warnings, + [SeriesWarning::StartMoved { + from: day(2026, 10, 5), + to: day(2026, 10, 6) + }] + ); + // On the first occurrence it's the same plan, the move its own. + let first = Case::new(weekly(), plain("2026-10-05T09:00:00+01:00")); + let from_first = EventPatchDraft { + recurrence: Some(RepeatDraft::Rule { + rule: weekly_on(&[DayCode::Tue]), + }), + ..at("10:00", "2026-10-06") + }; + let plan = first.edit(Scope::All, from_first).unwrap(); + assert_eq!( + body(&plan, 0)["start"], + timed_node("2026-10-06T10:00:00+01:00") + ); + } + + /// A cancelled occurrence and an EXDATE stay deleted wherever the new rule still has the day. + #[test] + fn all_with_a_new_rule_keeps_deleted_occurrences_deleted() { + let with_exdate = master(&[ + "RRULE:FREQ=WEEKLY;BYDAY=MO", + "EXDATE;TZID=Europe/London:20261102T090000", + ]); + let case = + Case::new(with_exdate, monday_19()).exceptions(vec![cancelled("2026-10-26T09:00:00Z")]); + let plan = case.edit(Scope::All, repeat(daily())).unwrap(); + assert_eq!( + body(&plan, 0)["recurrence"], + json!([ + "RRULE:FREQ=DAILY", + "EXDATE:20261026T090000Z,20261102T090000Z" + ]) + ); + assert!(plan.warnings.is_empty()); + } + + /// RM1: an extra date deleted, by an EXDATE or cancelled, stays deleted wherever it's kept, for + /// every occurrence and from one on, though the new rule doesn't make its day. + #[test] + fn a_new_rule_keeps_a_deleted_extra_date_deleted() { + let both = |extra: &str| { + let exdate = extra.replacen("RDATE", "EXDATE", 1); + [ + Case::new( + master(&["RRULE:FREQ=WEEKLY;BYDAY=MO", extra, exdate.as_str()]), + monday_19(), + ), + Case::new(master(&["RRULE:FREQ=WEEKLY;BYDAY=MO", extra]), monday_19()), + ] + }; + // Every occurrence: Friday 23-10, made Mondays and Wednesdays. + let friday = "RDATE;TZID=Europe/London:20261023T090000"; + let [exdated, cancelled_one] = both(friday); + let cancelled_one = cancelled_one.exceptions(vec![cancelled("2026-10-23T09:00:00+01:00")]); + for case in [exdated, cancelled_one] { + let plan = case + .edit(Scope::All, repeat(weekly_on(&[DayCode::Mon, DayCode::Wed]))) + .unwrap(); + assert_eq!( + body(&plan, 0)["recurrence"], + json!([ + "RRULE:FREQ=WEEKLY;BYDAY=MO,WE", + "EXDATE:20261023T080000Z", + friday + ]) + ); + } + // From Monday 19-10 on: Wednesday 28-10 (London on GMT again), made Mondays and Fridays. + let wednesday = "RDATE;TZID=Europe/London:20261028T090000"; + let [exdated, cancelled_one] = both(wednesday); + let cancelled_one = cancelled_one.exceptions(vec![cancelled("2026-10-28T09:00:00Z")]); + for case in [exdated, cancelled_one] { + let plan = case + .edit( + Scope::Following, + repeat(weekly_on(&[DayCode::Mon, DayCode::Fri])), + ) + .unwrap(); + assert_eq!( + body(&plan, 0)["recurrence"], + json!([ + "RRULE:FREQ=WEEKLY;BYDAY=MO,FR", + "EXDATE:20261028T090000Z", + wednesday + ]) + ); + } + } + + /// C11c review round 3: an extra date opened stays where it is, as its own RDATE, so it takes no + /// slot of the new rule's. Monday 19-10's 09:00, moved to 14:00 as some imports write a move (an + /// EXDATE and an RDATE), opened at 14:00 and made "Mondays and Wednesdays": 09:00 stays deleted. + #[test] + fn an_extra_date_opened_takes_no_slot_of_the_new_rule() { + let two_pm = "RDATE;TZID=Europe/London:20261019T140000"; + let moved_on_import = master(&[ + "RRULE:FREQ=WEEKLY;BYDAY=MO", + "EXDATE;TZID=Europe/London:20261019T090000", + two_pm, + ]); + let plan = Case::new(moved_on_import, plain("2026-10-19T14:00:00+01:00")) + .edit(Scope::All, repeat(weekly_on(&[DayCode::Mon, DayCode::Wed]))) + .unwrap(); + assert_eq!( + body(&plan, 0)["recurrence"], + json!([ + "RRULE:FREQ=WEEKLY;BYDAY=MO,WE", + "EXDATE:20261019T080000Z", + two_pm + ]) + ); + } + + /// A deleted occurrence PM can't see could come back: an incomplete listing refuses. + #[test] + fn all_with_a_new_rule_needs_every_changed_occurrence() { + let mut case = Case::new(weekly(), monday_19()); + case.exceptions = Exceptions { + items: Vec::new(), + complete: false, + }; + for draft in [repeat(daily()), stop()] { + assert_eq!( + case.edit(Scope::All, draft), + Err(NotPlanned::Refused(SeriesRefusal::Incomplete)) + ); + } + } + + /// Q2: "Does not repeat" for every occurrence keeps the first as one event and sends no rule; + /// what Google answers is read by meaning, `null`, `[]` and none alike. + #[test] + fn all_does_not_repeat_sends_no_rule_and_keeps_the_first() { + let case = Case::new(weekly(), monday_19()); + let plan = case.edit(Scope::All, stop()).unwrap(); + assert_eq!( + body(&plan, 0), + json!({ "recurrence": null }).as_object().unwrap() + ); + assert_eq!( + plan.warnings, + [SeriesWarning::BecomesOneEvent { + day: day(2026, 10, 5) + }] + ); + assert!(matches!( + &plan.steps[0].undo, + Some(Undo::RestoreMaster { .. }) + )); + // A stop reply without a rule is applied, one still holding the old rule ignored. + let mut one_off = weekly(); + one_off.as_object_mut().unwrap().remove("recurrence"); + assert_eq!(plan.verdict(0, Some(&one_off)), Applied::Yes); + assert_eq!( + plan.verdict(0, Some(&with(weekly(), "recurrence", json!([])))), + Applied::Yes + ); + assert_eq!(plan.verdict(0, Some(&weekly())), Applied::No); + } + + /// C11 critique 1 H1: a deleted first occurrence never comes back; the first one still there is + /// kept, whether deleted by an EXDATE or cancelled. + #[test] + fn all_does_not_repeat_skips_a_deleted_first() { + let exdated = master(&[ + "RRULE:FREQ=WEEKLY;BYDAY=MO", + "EXDATE;TZID=Europe/London:20261005T090000", + ]); + let cancelled_first = Case::new(weekly(), monday_19()) + .exceptions(vec![cancelled("2026-10-05T09:00:00+01:00")]); + for case in [Case::new(exdated, monday_19()), cancelled_first] { + let plan = case.edit(Scope::All, stop()).unwrap(); + let sent = body(&plan, 0); + assert_eq!(sent["start"], timed_node("2026-10-12T09:00:00+01:00")); + assert_eq!(sent["end"], timed_node("2026-10-12T10:00:00+01:00")); + assert_eq!(sent["recurrence"], Value::Null); + assert_eq!( + plan.warnings, + [SeriesWarning::BecomesOneEvent { + day: day(2026, 10, 12) + }] + ); + } + // An extra date opened after every one of the rule's was deleted: none to keep. + let only_extra = master(&[ + "RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=1", + "EXDATE;TZID=Europe/London:20261005T090000", + "RDATE;TZID=Europe/London:20261007T090000", + ]); + let extra = plain("2026-10-07T09:00:00+01:00"); + assert_eq!( + Case::new(only_extra, extra).edit(Scope::All, stop()), + Err(NotPlanned::Refused(SeriesRefusal::NothingToKeep)) + ); + // A line PM can't read may be the EXDATE that deleted the first: it could come back as the + // one event, so a stop refuses it as a new rule does. + let unreadable = master(&[ + "RRULE:FREQ=WEEKLY;BYDAY=MO", + "EXDATE;TZID=Mars/Olympus:20261005T090000", + ]); + assert_eq!( + Case::new(unreadable, monday_19()).edit(Scope::All, stop()), + Err(NotPlanned::Refused(SeriesRefusal::Recur( + RecurError::TextOnly(TextOnly::Unreadable) + ))) + ); + // Its words point where it can be done: "This event" can't change how it repeats. + assert_eq!( + SeriesRefusal::NothingToKeep.to_string(), + "Every occurrence of its repeat up to this one was deleted, so PM can't tell which one \ + should stay. Change it in Google Calendar." + ); + } + + /// Every other occurrence goes, but Google may keep those changed on their own, so the hedge + /// counts them; never the one kept, whose own changes go with it. + #[test] + fn all_does_not_repeat_hedges_on_the_others_changed_on_their_own() { + let kick_off = with( + plain("2026-10-05T09:00:00+01:00"), + "summary", + json!("Kick-off"), + ); + let planning = with(plain("2026-10-26T09:00:00Z"), "summary", json!("Planning")); + let replied = plain("2026-11-02T09:00:00Z"); + let case = + Case::new(weekly(), monday_19()).exceptions(vec![kick_off.clone(), planning, replied]); + let plan = case.edit(Scope::All, stop()).unwrap(); + assert_eq!(body(&plan, 0)["summary"], "Kick-off"); + assert_eq!( + plan.warnings, + [ + SeriesWarning::BecomesOneEvent { + day: day(2026, 10, 5) + }, + SeriesWarning::ExceptionsMayReset { + count: 1, + complete: true + } + ] + ); + // Only the one kept was changed: nothing to hedge on. + let plan = Case::new(weekly(), monday_19()) + .exceptions(vec![kick_off]) + .edit(Scope::All, stop()) + .unwrap(); + assert_eq!( + plan.warnings, + [SeriesWarning::BecomesOneEvent { + day: day(2026, 10, 5) + }] + ); + } + + /// With this occurrence moved a day on, the one kept moves as far, and the dialog says it leaves + /// its own day; a new time on the same day moves nothing. + #[test] + fn all_does_not_repeat_with_a_moved_day_says_the_first_moves() { + let case = Case::new(weekly(), monday_19()); + let a_day_on = EventPatchDraft { + recurrence: Some(RepeatDraft::Once), + ..at("09:00", "2026-10-20") + }; + let plan = case.edit(Scope::All, a_day_on).unwrap(); + assert_eq!( + body(&plan, 0)["start"], + timed_node("2026-10-06T09:00:00+01:00") + ); + assert_eq!( + plan.warnings, + [ + SeriesWarning::BecomesOneEvent { + day: day(2026, 10, 6) + }, + SeriesWarning::StartMoved { + from: day(2026, 10, 5), + to: day(2026, 10, 6) + } + ] + ); + let an_hour_on = EventPatchDraft { + recurrence: Some(RepeatDraft::Once), + ..at("10:00", "2026-10-19") + }; + assert_eq!( + case.edit(Scope::All, an_hour_on).unwrap().warnings, + [SeriesWarning::BecomesOneEvent { + day: day(2026, 10, 5) + }] + ); + } + + /// C11 critique 1 H1: the kept occurrence's own time and title stay, under the draft's. + #[test] + fn all_does_not_repeat_keeps_the_firsts_own_changes() { + let kick_off = with( + moved( + "2026-10-05T09:00:00+01:00", + "2026-10-06T11:00:00+01:00", + "2026-10-06T12:00:00+01:00", + ), + "summary", + json!("Kick-off"), + ); + let case = Case::new(weekly(), monday_19()).exceptions(vec![kick_off.clone()]); + let plan = case.edit(Scope::All, stop()).unwrap(); + let sent = body(&plan, 0); + assert_eq!(sent["start"], timed_node("2026-10-06T11:00:00+01:00")); + assert_eq!(sent["end"], timed_node("2026-10-06T12:00:00+01:00")); + assert_eq!(sent["summary"], "Kick-off"); + assert_eq!( + plan.warnings, + [SeriesWarning::BecomesOneEvent { + day: day(2026, 10, 6) + }] + ); + // The draft's own title wins. + let retro = EventPatchDraft { + recurrence: Some(RepeatDraft::Once), + ..title("Retro") + }; + assert_eq!( + body(&case.edit(Scope::All, retro).unwrap(), 0)["summary"], + "Retro" + ); + // A colour, reminders or a video call of its own, which the series' PATCH can't keep, refuse + // in words. + for (key, value) in [ + ("colorId", json!("5")), + ( + "reminders", + json!({ "useDefault": false, "overrides": [{ "method": "popup", "minutes": 5 }] }), + ), + ("conferenceData", json!({ "conferenceId": "abc-defg-hij" })), + ] { + let own = with(plain("2026-10-05T09:00:00+01:00"), key, value); + assert_eq!( + Case::new(weekly(), monday_19()) + .exceptions(vec![own]) + .edit(Scope::All, stop()), + Err(NotPlanned::Refused(SeriesRefusal::KeptHasOwn(day( + 2026, 10, 5 + )))), + "{key}" + ); + } + } + + /// C11 critique 1 H2: a rebuilt rule never repeats on or past where a later part starts; one + /// that ends before it is fine, and so is a stop. + #[test] + fn a_new_rule_never_repeats_past_a_later_part() { + let ending = |date: &str| { + repeat(rule( + RepeatPattern::Daily, + 1, + RepeatEnds::OnDate { date: date.into() }, + )) + }; + let past = Err(NotPlanned::Refused(SeriesRefusal::RepeatsPastPart(day( + 2026, 11, 2, + )))); + for later in [ + LaterPart::At(point("2026-11-02T09:00:00Z")), + // A later part known by its day: that day counts. + LaterPart::At(Point::Day(day(2026, 11, 2))), + ] { + let mut case = Case::new(weekly(), monday_19()); + case.later = later; + assert_eq!(case.edit(Scope::All, repeat(daily())), past); + assert_eq!(case.edit(Scope::Following, repeat(daily())), past); + assert_eq!(case.edit(Scope::All, ending("2026-11-02")), past); + assert!(case.edit(Scope::All, ending("2026-11-01")).is_ok()); + assert!(case.edit(Scope::Following, ending("2026-11-01")).is_ok()); + assert!(case.edit(Scope::All, stop()).is_ok()); + } + let mut case = Case::new(weekly(), monday_19()); + case.later = LaterPart::Unread; + assert_eq!( + case.edit(Scope::All, repeat(daily())), + Err(NotPlanned::Refused(SeriesRefusal::LaterPartUnread)) + ); + } + + /// C11c review: a series that is itself a part, held to its last occurrence's day now (Monday + /// 26-10), never repeats on a day after it, timed or all-day, whatever later part there may be. + #[test] + fn a_part_never_repeats_past_where_it_ends_now() { + let ending = |date: &str| { + rule( + RepeatPattern::Daily, + 1, + RepeatEnds::OnDate { date: date.into() }, + ) + }; + let all_day = |rule: RepeatRule| EventPatchDraft { + time: Some(TimeDraft::AllDay { + first_day: "2026-10-19".into(), + last_day: "2026-10-19".into(), + }), + recurrence: Some(RepeatDraft::Rule { rule }), + ..Default::default() + }; + let mut case = Case::new(weekly(), monday_19()); + case.later = LaterPart::Through(day(2026, 10, 26)); + let past = Err(NotPlanned::Refused(SeriesRefusal::RepeatsPastItsEnd(day( + 2026, 10, 26, + )))); + for scope in [Scope::All, Scope::Following] { + assert_eq!(case.edit(scope, repeat(daily())), past); + assert_eq!(case.edit(scope, repeat(ending("2026-10-27"))), past); + assert!(case.edit(scope, repeat(ending("2026-10-26"))).is_ok()); + assert_eq!(case.edit(scope, all_day(ending("2026-10-27"))), past); + assert!(case.edit(scope, all_day(ending("2026-10-26"))).is_ok()); + assert!(case.edit(scope, stop()).is_ok()); + } + } + + /// C11 critique 2 M8: what was sent is read at the start it repeats from once landed. Every + /// occurrence made all-day, daily until 31-12: Google writing the UNTIL as midnight UTC is the + /// same day there, which the old timed start would read as another moment. + #[test] + fn a_new_rule_reply_is_read_at_the_new_start() { + let case = Case::new(weekly(), monday_19()); + let all_day = EventPatchDraft { + time: Some(TimeDraft::AllDay { + first_day: "2026-10-19".into(), + last_day: "2026-10-19".into(), + }), + recurrence: Some(RepeatDraft::Rule { + rule: rule( + RepeatPattern::Daily, + 1, + RepeatEnds::OnDate { + date: "2026-12-31".into(), + }, + ), + }), + ..Default::default() + }; + let plan = case.edit(Scope::All, all_day).unwrap(); + let sent = body(&plan, 0); + assert_eq!( + sent["start"], + json!({ "date": "2026-10-05", "dateTime": null, "timeZone": null }) + ); + assert_eq!( + sent["recurrence"], + json!(["RRULE:FREQ=DAILY;UNTIL=20261231"]) + ); + assert!(plan.warnings.contains(&SeriesWarning::LengthChanges)); + assert_eq!(plan.rule_start, SeriesStart::AllDay(day(2026, 10, 5))); + let reply = with( + with( + with(weekly(), "start", json!({ "date": "2026-10-05" })), + "end", + json!({ "date": "2026-10-06" }), + ), + "recurrence", + json!(["RRULE:UNTIL=20261231T000000Z;FREQ=DAILY"]), + ); + assert_eq!(plan.verdict(0, Some(&reply)), Applied::Yes); + // The rule landed without its start: half a change, read the same way. + let half = with( + weekly(), + "recurrence", + json!(["RRULE:UNTIL=20261231T000000Z;FREQ=DAILY"]), + ); + assert!(plan.half_applied(0, &half)); + // Weekly on Tuesday, written back in another order. + let tuesday = case + .edit(Scope::All, repeat(weekly_on(&[DayCode::Tue]))) + .unwrap(); + let landed = with( + with( + with(weekly(), "start", timed_node("2026-10-06T08:00:00Z")), + "end", + timed_node("2026-10-06T09:00:00Z"), + ), + "recurrence", + json!(["RRULE:BYDAY=TU;FREQ=WEEKLY"]), + ); + assert_eq!(tuesday.verdict(0, Some(&landed)), Applied::Yes); + } + + /// DS8: a new rule that lands without its start (or the start without the rule) is half a + /// change, and the master is put back. + #[test] + fn half_applied_reads_a_new_rule_with_the_old_start() { + let plan = Case::new(weekly(), monday_19()) + .edit(Scope::All, repeat(weekly_on(&[DayCode::Tue]))) + .unwrap(); + let tuesdays = json!(["RRULE:FREQ=WEEKLY;BYDAY=TU"]); + assert!(plan.half_applied(0, &with(weekly(), "recurrence", tuesdays.clone()))); + let new_start = with( + with(weekly(), "start", timed_node("2026-10-06T09:00:00+01:00")), + "end", + timed_node("2026-10-06T10:00:00+01:00"), + ); + assert!(plan.half_applied(0, &new_start)); + assert!(!plan.half_applied(0, &with(new_start, "recurrence", tuesdays))); + assert!(!plan.half_applied(0, &weekly())); + } + + // --- "This and following" with a new rule --- + + /// §9.10: a Tuesday occurrence made "Weekly on Monday and Wednesday" starts the new part on the + /// Wednesday, and says so. + #[test] + fn following_with_a_new_rule_starts_the_new_part_on_its_first_day() { + let tuesdays = master_with( + &["RRULE:FREQ=WEEKLY;BYDAY=TU"], + "2026-10-06T09:00:00+01:00", + "2026-10-06T10:00:00+01:00", + ); + let case = Case::new(tuesdays, plain("2026-10-20T09:00:00+01:00")); + let plan = case + .edit( + Scope::Following, + repeat(weekly_on(&[DayCode::Wed, DayCode::Mon])), + ) + .unwrap(); + assert_eq!(plan.scope, Scope::Following); + let inserted = body(&plan, 0); + assert_eq!(inserted["start"], timed_node("2026-10-21T09:00:00+01:00")); + assert_eq!( + inserted["recurrence"], + json!(["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"]) + ); + assert_eq!( + inserted["extendedProperties"]["private"]["pmSplitOf"], + "abc" + ); + assert_eq!( + body(&plan, 1)["recurrence"], + json!(["RRULE:FREQ=WEEKLY;BYDAY=TU;UNTIL=20261020T075959Z"]) + ); + assert_eq!( + plan.warnings, + [ + SeriesWarning::StartMoved { + from: day(2026, 10, 20), + to: day(2026, 10, 21) + }, + SeriesWarning::NewIdentity + ] + ); + assert!(plan.expect.contains(&Expect::Present { + series: TAIL.into() + })); + assert!(matches!(plan.steps[0].shows, Shows::Tail { .. })); + } + + /// A new part made all-day is read against its own start: Google writing its rule's UNTIL as + /// midnight UTC is still the part PM made, never a change made to it since. + #[test] + fn a_new_part_of_another_kind_is_read_at_its_own_start() { + let all_day = EventPatchDraft { + time: Some(TimeDraft::AllDay { + first_day: "2026-10-19".into(), + last_day: "2026-10-19".into(), + }), + recurrence: Some(RepeatDraft::Rule { + rule: rule( + RepeatPattern::Daily, + 1, + RepeatEnds::OnDate { + date: "2026-12-31".into(), + }, + ), + }), + ..Default::default() + }; + let plan = Case::new(weekly(), monday_19()) + .edit(Scope::Following, all_day) + .unwrap(); + let mut copy = Value::Object(body(&plan, 0).clone()); + assert_eq!(copy["start"], json!({ "date": "2026-10-19" })); + assert_eq!( + copy["recurrence"], + json!(["RRULE:FREQ=DAILY;UNTIL=20261231"]) + ); + assert!(plan.tail_as_inserted(0, ©)); + copy["recurrence"] = json!(["RRULE:FREQ=DAILY;UNTIL=20261231T000000Z"]); + assert!(plan.tail_as_inserted(0, ©)); + copy["recurrence"] = json!(["RRULE:FREQ=DAILY;UNTIL=20261230"]); + assert!(!plan.tail_as_inserted(0, ©)); + } + + /// What a new part can't carry refuses, as a split does; a stop takes every later occurrence + /// away, so only this one's own must travel. + #[test] + fn a_new_rule_or_a_stop_refuses_what_it_cant_carry() { + let attachment = json!([{ "fileUrl": "https://drive.google.com/x" }]); + let later = with( + plain("2026-10-26T09:00:00Z"), + "attachments", + attachment.clone(), + ); + let case = Case::new(weekly(), monday_19()).exceptions(vec![later]); + assert_eq!( + case.edit(Scope::Following, repeat(daily())), + Err(NotPlanned::Refused(SeriesRefusal::CannotCarry(day( + 2026, 10, 26 + )))) + ); + assert!(case.edit(Scope::Following, stop()).is_ok()); + let this_one = with(monday_19(), "attachments", attachment); + let case = Case::new(weekly(), this_one.clone()).exceptions(vec![this_one]); + assert_eq!( + case.edit(Scope::Following, stop()), + Err(NotPlanned::Refused(SeriesRefusal::CannotCarry(day( + 2026, 10, 19 + )))) + ); + } + + /// One event at a time the clocks skip is refused as a one-off's save refuses it: every + /// occurrence a week later puts the first, 22-03 01:30, on 29-03, when London skips 01:30. + #[test] + fn a_stop_at_a_time_the_clocks_skip_is_refused() { + let sundays = master_with( + &["RRULE:FREQ=WEEKLY;BYDAY=SU"], + "2026-03-22T01:30:00+00:00", + "2026-03-22T02:30:00+00:00", + ); + let case = Case::new(sundays, plain("2026-04-05T01:30:00+01:00")); + let a_week_on = EventPatchDraft { + recurrence: Some(RepeatDraft::Once), + ..at("01:30", "2026-04-12") + }; + assert!(matches!( + case.edit(Scope::All, a_week_on), + Err(NotPlanned::Refused(SeriesRefusal::Time(time::TimeError::Gap { wall, .. }))) + if wall == "29-03-2026 01:30" + )); + } + + /// Q3: a change made on its own to an occurrence the new rule still has is carried onto it; one + /// on a day it skips goes, and is counted. Both are cancelled on the original. + #[test] + fn following_with_a_new_rule_carries_only_changes_on_its_days() { + let planning = with(plain("2026-10-26T09:00:00Z"), "summary", json!("Planning")); + let off_site = with(plain("2026-11-02T09:00:00Z"), "summary", json!("Off-site")); + let case = Case::new(weekly(), monday_19()).exceptions(vec![planning, off_site]); + let fortnightly = rule( + RepeatPattern::Weekly { + days: vec![DayCode::Mon], + }, + 2, + RepeatEnds::Never, + ); + let plan = case.edit(Scope::Following, repeat(fortnightly)).unwrap(); + // Insert, cut; 26-10 cancelled with nothing carried; 02-11 carried, then cancelled. + assert_eq!(plan.steps.len(), 5); + assert!(matches!( + &plan.steps[2].op, + Op::Cancel { original, .. } if *original == point("2026-10-26T09:00:00Z") + )); + assert_eq!(plan.steps[2].requires, None); + assert!(matches!( + &plan.steps[3].op, + Op::Carry { original, series, body, .. } + if *original == point("2026-11-02T09:00:00Z") + && series == TAIL + && body["summary"] == "Off-site" + )); + assert!(matches!( + &plan.steps[4].op, + Op::Cancel { original, .. } if *original == point("2026-11-02T09:00:00Z") + )); + assert_eq!(plan.steps[4].requires, Some(3)); + assert_eq!( + plan.warnings, + [ + SeriesWarning::ChangesLost { count: 1 }, + SeriesWarning::NewIdentity + ] + ); + } + + /// C11c review: moved to Wednesday and made "Mondays and Wednesdays", the new part keeps a + /// Monday deleted and a Monday changed where they were, since it still has Mondays; never on the + /// Wednesdays two days on, which nobody deleted or changed. + #[test] + fn following_with_a_moved_day_keeps_deletions_and_changes_on_days_it_keeps() { + let planning = with(plain("2026-11-02T09:00:00Z"), "summary", json!("Planning")); + let case = Case::new(weekly(), monday_19()) + .exceptions(vec![cancelled("2026-10-26T09:00:00Z"), planning]); + let to_wednesday = EventPatchDraft { + recurrence: Some(RepeatDraft::Rule { + rule: weekly_on(&[DayCode::Mon, DayCode::Wed]), + }), + ..at("09:00", "2026-10-21") + }; + let plan = case.edit(Scope::Following, to_wednesday).unwrap(); + let inserted = body(&plan, 0); + assert_eq!(inserted["start"], timed_node("2026-10-21T09:00:00+01:00")); + assert_eq!( + inserted["recurrence"], + json!(["RRULE:FREQ=WEEKLY;BYDAY=MO,WE", "EXDATE:20261026T090000Z"]) + ); + assert!(matches!( + &plan.steps[2].op, + Op::Carry { original, body, .. } + if *original == point("2026-11-02T09:00:00Z") && body["summary"] == "Planning" + )); + assert_eq!(plan.warnings, [SeriesWarning::NewIdentity]); + } + + /// A change on a day the new rule doesn't keep moves with the day, but never onto a day the old + /// series had an occurrence of its own: which is which would be a guess, so it goes, counted. + #[test] + fn following_with_a_moved_day_carries_by_the_move_only_onto_new_days() { + let planning = with(plain("2026-11-02T09:00:00Z"), "summary", json!("Planning")); + let case = Case::new(weekly(), monday_19()).exceptions(vec![planning.clone()]); + let wednesdays = EventPatchDraft { + recurrence: Some(RepeatDraft::Rule { + rule: weekly_on(&[DayCode::Wed]), + }), + ..at("09:00", "2026-10-21") + }; + let plan = case.edit(Scope::Following, wednesdays).unwrap(); + assert!(matches!( + &plan.steps[2].op, + Op::Carry { original, .. } if *original == point("2026-11-04T09:00:00Z") + )); + // Mondays and Wednesdays made Wednesdays and Fridays, two days on: Monday 26-10 would land + // on Wednesday 28-10, which has its own change already. + let both = master(&["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"]); + let monday = with(plain("2026-10-26T09:00:00Z"), "summary", json!("Planning")); + let wednesday = with(plain("2026-10-28T09:00:00Z"), "summary", json!("Review")); + let case = Case::new(both, monday_19()).exceptions(vec![monday, wednesday]); + let later_on = EventPatchDraft { + recurrence: Some(RepeatDraft::Rule { + rule: weekly_on(&[DayCode::Wed, DayCode::Fri]), + }), + ..at("09:00", "2026-10-21") + }; + let plan = case.edit(Scope::Following, later_on).unwrap(); + let carried: Vec<(Point, &Value)> = plan + .steps + .iter() + .filter_map(|s| match &s.op { + Op::Carry { original, body, .. } => Some((*original, &body["summary"])), + _ => None, + }) + .collect(); + assert_eq!(carried, [(point("2026-10-28T09:00:00Z"), &json!("Review"))]); + assert!(plan + .warnings + .contains(&SeriesWarning::ChangesLost { count: 1 })); + } + + /// The occurrence split at goes where the draft put it, a day earlier, though the new rule still + /// has its own day. + #[test] + fn following_carries_this_ones_changes_where_the_draft_put_it() { + let retro = with(monday_19(), "summary", json!("Retro")); + let case = Case::new(weekly(), retro.clone()).exceptions(vec![retro]); + let a_day_back = EventPatchDraft { + recurrence: Some(RepeatDraft::Rule { rule: daily() }), + ..at("09:00", "2026-10-18") + }; + let plan = case.edit(Scope::Following, a_day_back).unwrap(); + assert!(matches!( + &plan.steps[2].op, + Op::Carry { original, body, .. } + if *original == point("2026-10-18T08:00:00Z") && body["summary"] == "Retro" + )); + } + + /// Every carry a plan makes: where it goes, and the title it carries. + fn carries(plan: &SeriesPlan) -> Vec<(Point, Value)> { + plan.steps + .iter() + .filter_map(|s| match &s.op { + Op::Carry { original, body, .. } => Some((*original, body["summary"].clone())), + _ => None, + }) + .collect() + } + + /// C11c review: Mondays and Tuesdays, Monday 19-10 opened and moved onto Tuesday 20-10, made + /// "Weekly on Tuesday". That slot is the occurrence opened's: Tuesday 20-10 deleted, by an EXDATE + /// or cancelled, writes no EXDATE there, for every occurrence or from this one on; changed on its + /// own, its changes go, counted, rather than land on the one the user just moved there. + #[test] + fn the_occurrence_opened_keeps_the_slot_the_draft_gives_it() { + let mon_tue = |more: &[&str]| { + let mut lines = vec!["RRULE:FREQ=WEEKLY;BYDAY=MO,TU"]; + lines.extend_from_slice(more); + master(&lines) + }; + let to_tuesday = EventPatchDraft { + recurrence: Some(RepeatDraft::Rule { + rule: weekly_on(&[DayCode::Tue]), + }), + ..at("09:00", "2026-10-20") + }; + let exdated = Case::new( + mon_tue(&["EXDATE;TZID=Europe/London:20261020T090000"]), + monday_19(), + ); + let cancelled_one = Case::new(mon_tue(&[]), monday_19()) + .exceptions(vec![cancelled("2026-10-20T09:00:00+01:00")]); + for case in [exdated, cancelled_one] { + let plan = case.edit(Scope::Following, to_tuesday.clone()).unwrap(); + let inserted = body(&plan, 0); + assert_eq!(inserted["start"], timed_node("2026-10-20T09:00:00+01:00")); + assert_eq!( + inserted["recurrence"], + json!(["RRULE:FREQ=WEEKLY;BYDAY=TU"]) + ); + let plan = case.edit(Scope::All, to_tuesday.clone()).unwrap(); + assert_eq!( + body(&plan, 0)["recurrence"], + json!(["RRULE:FREQ=WEEKLY;BYDAY=TU"]) + ); + } + // Tuesday 20-10 retitled and moved to 11:00 on its own; Monday 19-10, opened, retitled too. + let review = with( + moved( + "2026-10-20T09:00:00+01:00", + "2026-10-20T11:00:00+01:00", + "2026-10-20T12:00:00+01:00", + ), + "summary", + json!("Review"), + ); + let retro = with(monday_19(), "summary", json!("Retro")); + let case = Case::new(mon_tue(&[]), retro.clone()).exceptions(vec![retro, review]); + let plan = case.edit(Scope::Following, to_tuesday).unwrap(); + assert_eq!( + carries(&plan), + [(point("2026-10-20T08:00:00Z"), json!("Retro"))] + ); + assert!(plan + .warnings + .contains(&SeriesWarning::ChangesLost { count: 1 })); + assert!(plan.steps.iter().any(|s| matches!( + &s.op, + Op::Cancel { original, .. } if *original == point("2026-10-20T08:00:00Z") + ))); + } + + /// Mondays and Tuesdays at 23:30 in London, Monday 19-10 opened and moved into Tokyo at the same + /// moment, Tuesday 20-10 07:30, made "Tuesdays and Wednesdays", as the days read there. + fn into_tokyo() -> (Value, EventPatchDraft) { + let late = master_with( + &["RRULE:FREQ=WEEKLY;BYDAY=MO,TU"], + "2026-10-05T23:30:00+01:00", + "2026-10-06T00:30:00+01:00", + ); + let draft = EventPatchDraft { + time: Some(in_tokyo("2026-10-20", "07:30", "2026-10-20", "08:30")), + recurrence: Some(RepeatDraft::Rule { + rule: weekly_on(&[DayCode::Tue, DayCode::Wed]), + }), + ..Default::default() + }; + (late, draft) + } + + /// A time in Tokyo, from `start` on `start_date` to `end` on `end_date`. + fn in_tokyo(start_date: &str, start: &str, end_date: &str, end: &str) -> TimeDraft { + TimeDraft::Timed { + start_date: start_date.into(), + start_time: start.into(), + start_zone: "Asia/Tokyo".into(), + end_date: end_date.into(), + end_time: end.into(), + end_zone: "Asia/Tokyo".into(), + } + } + + fn tokyo_node(at: &str) -> Value { + json!({ "dateTime": at, "timeZone": "Asia/Tokyo" }) + } + + /// C11c review: a move into another zone that keeps every moment moves no day. Tuesday 20-10 + /// 23:30 in London, deleted, is Wednesday 21-10 07:30 in Tokyo: it stays deleted there, never on + /// the Tuesday before it, which is the occurrence opened; and nothing asks about the days. + #[test] + fn all_into_another_zone_keeps_each_deletion_at_its_moment() { + let (late, draft) = into_tokyo(); + let case = Case::new(late, plain("2026-10-19T23:30:00+01:00")) + .exceptions(vec![cancelled("2026-10-20T23:30:00+01:00")]); + let plan = case.edit(Scope::All, draft).unwrap(); + let sent = body(&plan, 0); + assert_eq!(sent["start"], tokyo_node("2026-10-06T07:30:00+09:00")); + assert_eq!(sent["end"], tokyo_node("2026-10-06T08:30:00+09:00")); + assert_eq!( + sent["recurrence"], + json!(["RRULE:FREQ=WEEKLY;BYDAY=TU,WE", "EXDATE:20261020T223000Z"]) + ); + assert!(!plan.warnings.iter().any(|w| matches!( + w, + SeriesWarning::RepeatDayChanges { .. } | SeriesWarning::StartMoved { .. } + ))); + // A day move there is read in Tokyo's days too. Moved on to Wednesday 21-10 and made + // "Wednesdays and Thursdays": Monday 12-10 deleted, Tuesday 13-10 in Tokyo, which the new rule + // skips, would move to Wednesday 14-10 there; but that's Tuesday 13-10 23:30 in London, an + // occurrence of its own, so nothing is deleted on it. + let (late, _) = into_tokyo(); + let wednesday = EventPatchDraft { + time: Some(in_tokyo("2026-10-21", "07:30", "2026-10-21", "08:30")), + recurrence: Some(RepeatDraft::Rule { + rule: weekly_on(&[DayCode::Wed, DayCode::Thu]), + }), + ..Default::default() + }; + let case = Case::new(late, plain("2026-10-19T23:30:00+01:00")) + .exceptions(vec![cancelled("2026-10-12T23:30:00+01:00")]); + let plan = case.edit(Scope::All, wednesday).unwrap(); + assert_eq!( + body(&plan, 0)["recurrence"], + json!(["RRULE:FREQ=WEEKLY;BYDAY=WE,TH"]) + ); + } + + /// The same from this occurrence on: the deletion stays at its moment, and a change made on its + /// own (Tuesday 27-10, London on GMT again) goes onto the new part's occurrence on its own day + /// there, Wednesday 28-10, never onto the one before it. + #[test] + fn following_into_another_zone_keeps_each_occurrence_on_its_own_day() { + let (late, draft) = into_tokyo(); + let review = with(plain("2026-10-27T23:30:00Z"), "summary", json!("Review")); + let case = Case::new(late, plain("2026-10-19T23:30:00+01:00")) + .exceptions(vec![cancelled("2026-10-20T23:30:00+01:00"), review]); + let plan = case.edit(Scope::Following, draft).unwrap(); + let inserted = body(&plan, 0); + assert_eq!(inserted["start"], tokyo_node("2026-10-20T07:30:00+09:00")); + assert_eq!( + inserted["recurrence"], + json!(["RRULE:FREQ=WEEKLY;BYDAY=TU,WE", "EXDATE:20261020T223000Z"]) + ); + assert_eq!( + carries(&plan), + [(point("2026-10-27T22:30:00Z"), json!("Review"))] + ); + assert!(!plan + .warnings + .iter() + .any(|w| matches!(w, SeriesWarning::ChangesLost { .. }))); + } + + /// C11c review round 3: from this occurrence on, the day move is counted in the new part's days + /// too, so a move into Tokyo at the same moment moves no day. Made "Tuesdays and Thursdays", + /// Tuesday 20-10 23:30 in London is Wednesday 21-10 there, which the new rule skips and the series + /// had its own on: changed on its own, its change goes, counted; deleted, nothing is deleted. + /// Never onto Thursday 22-10, which nobody changed or deleted. + #[test] + fn following_into_another_zone_moves_no_day() { + let (late, draft) = into_tokyo(); + let draft = EventPatchDraft { + recurrence: Some(RepeatDraft::Rule { + rule: weekly_on(&[DayCode::Tue, DayCode::Thu]), + }), + ..draft + }; + let opened = plain("2026-10-19T23:30:00+01:00"); + let review = with( + plain("2026-10-20T23:30:00+01:00"), + "summary", + json!("Review"), + ); + let plan = Case::new(late.clone(), opened.clone()) + .exceptions(vec![review]) + .edit(Scope::Following, draft.clone()) + .unwrap(); + assert!(carries(&plan).is_empty()); + assert!(plan + .warnings + .contains(&SeriesWarning::ChangesLost { count: 1 })); + let plan = Case::new(late, opened) + .exceptions(vec![cancelled("2026-10-20T23:30:00+01:00")]) + .edit(Scope::Following, draft) + .unwrap(); + assert_eq!( + body(&plan, 0)["recurrence"], + json!(["RRULE:FREQ=WEEKLY;BYDAY=TU,TH"]) + ); + } + + /// Mondays and Tuesdays at 15:30 in London from 05-10, with `more` lines; London's clocks go back + /// on 25-10. + fn afternoons(more: &[&str]) -> Value { + let mut lines = vec!["RRULE:FREQ=WEEKLY;BYDAY=MO,TU"]; + lines.extend_from_slice(more); + master_with( + &lines, + "2026-10-05T15:30:00+01:00", + "2026-10-05T16:30:00+01:00", + ) + } + + /// C11c review round 3: both rules repeat by wall day in their own zone, so a move into another + /// zone keeps each occurrence's place in the week whatever either zone's clocks do. Monday 19-10 + /// opened and moved into Tokyo at the same moment, 23:30 there: once London's clocks go back, + /// Monday 02-11 15:30 is Tuesday 03-11 00:30 in Tokyo, but it's still the Monday, now Monday 23:30 + /// there. Deleted, it stays deleted on that Monday, never on the live Tuesday; changed on its own, + /// its change goes onto that Monday, and the Tuesday's onto the Tuesday. + #[test] + fn a_zone_move_across_a_clock_change_keeps_each_occurrence_in_its_place() { + let draft = EventPatchDraft { + time: Some(in_tokyo("2026-10-19", "23:30", "2026-10-20", "00:30")), + recurrence: Some(RepeatDraft::Rule { + rule: rule( + RepeatPattern::Weekly { + days: vec![DayCode::Mon, DayCode::Tue], + }, + 1, + RepeatEnds::OnDate { + date: "2026-12-31".into(), + }, + ), + }), + ..Default::default() + }; + let opened = plain("2026-10-19T15:30:00+01:00"); + let exdated = Case::new( + afternoons(&["EXDATE;TZID=Europe/London:20261102T153000"]), + opened.clone(), + ); + let cancelled_one = Case::new(afternoons(&[]), opened.clone()) + .exceptions(vec![cancelled("2026-11-02T15:30:00Z")]); + for case in [exdated, cancelled_one] { + let plan = case.edit(Scope::All, draft.clone()).unwrap(); + let sent = body(&plan, 0); + assert_eq!(sent["start"], tokyo_node("2026-10-05T23:30:00+09:00")); + assert_eq!( + sent["recurrence"], + json!([ + "RRULE:FREQ=WEEKLY;BYDAY=MO,TU;UNTIL=20261231T145959Z", + "EXDATE:20261102T143000Z" + ]) + ); + assert!(plan.warnings.is_empty()); + } + let retro = with(plain("2026-11-02T15:30:00Z"), "summary", json!("Retro")); + let review = with(plain("2026-11-03T15:30:00Z"), "summary", json!("Review")); + let plan = Case::new(afternoons(&[]), opened) + .exceptions(vec![retro, review]) + .edit(Scope::Following, draft) + .unwrap(); + assert_eq!( + carries(&plan), + [ + (point("2026-11-02T14:30:00Z"), json!("Retro")), + (point("2026-11-03T14:30:00Z"), json!("Review")) + ] + ); + assert!(!plan + .warnings + .iter() + .any(|w| matches!(w, SeriesWarning::ChangesLost { .. }))); + // And the other way: Monday 02-11 opened, in winter time, is Tuesday 03-11 00:30 in Tokyo, made + // "Tuesdays and Wednesdays" there. Tuesday 13-10 15:30, in summer time, is 23:30 that Tuesday + // in Tokyo, but it's the Tuesday: deleted, it stays deleted on the Wednesday 00:30 after it. + let winter = EventPatchDraft { + time: Some(in_tokyo("2026-11-03", "00:30", "2026-11-03", "01:30")), + recurrence: Some(RepeatDraft::Rule { + rule: weekly_on(&[DayCode::Tue, DayCode::Wed]), + }), + ..Default::default() + }; + let plan = Case::new( + afternoons(&["EXDATE;TZID=Europe/London:20261013T153000"]), + plain("2026-11-02T15:30:00Z"), + ) + .edit(Scope::All, winter) + .unwrap(); + let sent = body(&plan, 0); + assert_eq!(sent["start"], tokyo_node("2026-10-06T00:30:00+09:00")); + assert_eq!( + sent["recurrence"], + json!(["RRULE:FREQ=WEEKLY;BYDAY=TU,WE", "EXDATE:20261013T153000Z"]) + ); + } + + /// C11c review round 3: whether the old series had an occurrence of its own on the day a deletion + /// moves to is read on that day in its own zone. Mondays and Wednesdays at 15:30 in London, Monday + /// 19-10 moved to Wednesday 21-10 23:30 in Tokyo and made "Weekly on Wednesday": Monday 02-11, + /// deleted, would move to Wednesday 04-11, which had its own (Thursday 00:30 in Tokyo, once + /// London's clocks go back), so nothing is deleted on it. + #[test] + fn a_zone_move_looks_for_the_old_days_in_their_own_zone() { + let mon_wed = master_with( + &[ + "RRULE:FREQ=WEEKLY;BYDAY=MO,WE", + "EXDATE;TZID=Europe/London:20261102T153000", + ], + "2026-10-05T15:30:00+01:00", + "2026-10-05T16:30:00+01:00", + ); + let wednesdays = EventPatchDraft { + time: Some(in_tokyo("2026-10-21", "23:30", "2026-10-22", "00:30")), + recurrence: Some(RepeatDraft::Rule { + rule: weekly_on(&[DayCode::Wed]), + }), + ..Default::default() + }; + let plan = Case::new(mon_wed, plain("2026-10-19T15:30:00+01:00")) + .edit(Scope::All, wednesdays) + .unwrap(); + assert_eq!( + body(&plan, 0)["recurrence"], + json!(["RRULE:FREQ=WEEKLY;BYDAY=WE"]) + ); + } + + /// C11c review round 3: "Does not repeat" for every occurrence moved into Tokyo keeps the first + /// at its own moment, in the draft's days. Monday 05-10 23:30 in London is Tuesday 06-10 07:30 + /// there, and nothing says it moves; moved a day on there, it moves from that Tuesday. + #[test] + fn all_does_not_repeat_into_another_zone_keeps_the_first_at_its_moment() { + let once = |time: TimeDraft| EventPatchDraft { + time: Some(time), + recurrence: Some(RepeatDraft::Once), + ..Default::default() + }; + let (late, _) = into_tokyo(); + let case = Case::new(late, plain("2026-10-19T23:30:00+01:00")); + let plan = case + .edit( + Scope::All, + once(in_tokyo("2026-10-20", "07:30", "2026-10-20", "08:30")), + ) + .unwrap(); + assert_eq!( + body(&plan, 0)["start"], + tokyo_node("2026-10-06T07:30:00+09:00") + ); + assert_eq!( + plan.warnings, + [SeriesWarning::BecomesOneEvent { + day: day(2026, 10, 6) + }] + ); + let plan = case + .edit( + Scope::All, + once(in_tokyo("2026-10-21", "07:30", "2026-10-21", "08:30")), + ) + .unwrap(); + assert_eq!( + body(&plan, 0)["start"], + tokyo_node("2026-10-07T07:30:00+09:00") + ); + assert_eq!( + plan.warnings, + [ + SeriesWarning::BecomesOneEvent { + day: day(2026, 10, 7) + }, + SeriesWarning::StartMoved { + from: day(2026, 10, 6), + to: day(2026, 10, 7) + } + ] + ); + // Across the clock change: weekly on Monday at 15:30 in London, Monday 02-11 opened, in winter + // time, and moved into Tokyo at the same moment, Tuesday 03-11 00:30. The first, Monday 05-10 + // 15:30 in summer time, is 23:30 that Monday in Tokyo, but it's the Monday the series now puts + // on Tuesday 00:30: one event on Tuesday 06-10, never Monday 05-10 00:30, a day early. A new + // rule starts there too. + let afternoon = master_with( + &["RRULE:FREQ=WEEKLY;BYDAY=MO"], + "2026-10-05T15:30:00+01:00", + "2026-10-05T16:30:00+01:00", + ); + let case = Case::new(afternoon, plain("2026-11-02T15:30:00Z")); + let winter = in_tokyo("2026-11-03", "00:30", "2026-11-03", "01:30"); + let plan = case.edit(Scope::All, once(winter.clone())).unwrap(); + assert_eq!( + body(&plan, 0)["start"], + tokyo_node("2026-10-06T00:30:00+09:00") + ); + assert_eq!( + plan.warnings, + [SeriesWarning::BecomesOneEvent { + day: day(2026, 10, 6) + }] + ); + let every_day = EventPatchDraft { + time: Some(winter), + recurrence: Some(RepeatDraft::Rule { rule: daily() }), + ..Default::default() + }; + let plan = case.edit(Scope::All, every_day).unwrap(); + assert_eq!( + body(&plan, 0)["start"], + tokyo_node("2026-10-06T00:30:00+09:00") + ); + assert!(plan.warnings.is_empty()); + } + + /// RM1: an extra date kept is still an occurrence where it was, so its own changes go onto it + /// there, though the new rule doesn't make its day. + #[test] + fn following_with_a_new_rule_carries_an_extra_dates_own_changes() { + let wednesday = "RDATE;TZID=Europe/London:20261028T090000"; + let review = with(plain("2026-10-28T09:00:00Z"), "summary", json!("Review")); + let case = Case::new( + master(&["RRULE:FREQ=WEEKLY;BYDAY=MO", wednesday]), + monday_19(), + ) + .exceptions(vec![review]); + let plan = case + .edit( + Scope::Following, + repeat(weekly_on(&[DayCode::Mon, DayCode::Fri])), + ) + .unwrap(); + assert_eq!( + body(&plan, 0)["recurrence"], + json!(["RRULE:FREQ=WEEKLY;BYDAY=MO,FR", wednesday]) + ); + assert!(matches!( + &plan.steps[2].op, + Op::Carry { original, body, .. } + if *original == point("2026-10-28T09:00:00Z") && body["summary"] == "Review" + )); + assert_eq!(plan.warnings, [SeriesWarning::NewIdentity]); + } + + /// Monday 19-10 moved on its own to 11:00. + fn monday_19_at_eleven() -> Value { + moved( + "2026-10-19T09:00:00+01:00", + "2026-10-19T11:00:00+01:00", + "2026-10-19T12:00:00+01:00", + ) + } + + /// The occurrence split at keeps its own time on the new part's same occurrence, but never over + /// a time the draft gives it. + #[test] + fn following_with_a_new_rule_carries_this_ones_time_unless_the_draft_moves_it() { + let eleven = monday_19_at_eleven(); + let case = Case::new(weekly(), eleven.clone()).exceptions(vec![eleven]); + let plan = case + .edit( + Scope::Following, + repeat(weekly_on(&[DayCode::Mon, DayCode::Wed])), + ) + .unwrap(); + assert!(matches!( + &plan.steps[2].op, + Op::Carry { original, .. } if *original == point("2026-10-19T08:00:00Z") + )); + assert_eq!( + body(&plan, 2)["start"], + timed_node("2026-10-19T11:00:00+01:00") + ); + // Moved to 14:00 in the draft: the new part starts then, and nothing puts 11:00 back. + let two_pm = EventPatchDraft { + recurrence: Some(RepeatDraft::Rule { + rule: weekly_on(&[DayCode::Mon, DayCode::Wed]), + }), + ..at("14:00", "2026-10-19") + }; + let plan = case.edit(Scope::Following, two_pm).unwrap(); + assert_eq!( + body(&plan, 0)["start"], + timed_node("2026-10-19T14:00:00+01:00") + ); + assert!(!plan.steps.iter().any(|s| matches!(s.op, Op::Carry { .. }))); + } + + /// A later occurrence moved on its own keeps its time on the new part; onto a new part made + /// all-day, with the other kind's key `null`, as Google merges nodes (C11 critique 1 L9). + #[test] + fn following_with_a_new_rule_carries_a_later_ones_own_time() { + let eleven = moved( + "2026-10-26T09:00:00Z", + "2026-10-26T11:00:00Z", + "2026-10-26T12:00:00Z", + ); + let case = Case::new(weekly(), monday_19()).exceptions(vec![eleven]); + let plan = case.edit(Scope::Following, repeat(daily())).unwrap(); + assert!(matches!( + &plan.steps[2].op, + Op::Carry { original, .. } if *original == point("2026-10-26T09:00:00Z") + )); + let node = |at: &str| json!({ "dateTime": at, "timeZone": "Europe/London" }); + assert_eq!(body(&plan, 2)["start"], node("2026-10-26T11:00:00Z")); + assert_eq!(body(&plan, 2)["end"], node("2026-10-26T12:00:00Z")); + let all_day = EventPatchDraft { + time: Some(TimeDraft::AllDay { + first_day: "2026-10-19".into(), + last_day: "2026-10-19".into(), + }), + recurrence: Some(RepeatDraft::Rule { rule: daily() }), + ..Default::default() + }; + let plan = case.edit(Scope::Following, all_day).unwrap(); + assert!(matches!( + &plan.steps[2].op, + Op::Carry { original, .. } if *original == Point::Day(day(2026, 10, 26)) + )); + let crossed = + |at: &str| json!({ "dateTime": at, "timeZone": "Europe/London", "date": null }); + assert_eq!(body(&plan, 2)["start"], crossed("2026-10-26T11:00:00Z")); + assert_eq!(body(&plan, 2)["end"], crossed("2026-10-26T12:00:00Z")); + } + + /// C11 critique 1 L3: an all-day rule is measured by days, so it can't repeat on the day a timed + /// later part starts, nor past it. + #[test] + fn an_all_day_rule_never_repeats_into_a_timed_later_part() { + let all_day = |ends: RepeatEnds| EventPatchDraft { + time: Some(TimeDraft::AllDay { + first_day: "2026-10-19".into(), + last_day: "2026-10-19".into(), + }), + recurrence: Some(RepeatDraft::Rule { + rule: rule(RepeatPattern::Daily, 1, ends), + }), + ..Default::default() + }; + let until = |date: &str| RepeatEnds::OnDate { date: date.into() }; + let mut case = Case::new(weekly(), monday_19()); + case.later = LaterPart::At(point("2026-11-02T09:00:00Z")); + let past = Err(NotPlanned::Refused(SeriesRefusal::RepeatsPastPart(day( + 2026, 11, 2, + )))); + for scope in [Scope::All, Scope::Following] { + assert_eq!(case.edit(scope, all_day(RepeatEnds::Never)), past); + assert_eq!(case.edit(scope, all_day(until("2026-11-02"))), past); + assert!(case.edit(scope, all_day(until("2026-11-01"))).is_ok()); + } + } + + /// C11c review round 4: a timed series made all-day is read by its own wall days, never by the + /// all-day start's zone. Weekly on Monday at 00:30 in London, Monday 12-10 deleted (Sunday 11-10 + /// 23:30 UTC), Monday 19-10 opened and made all-day on 19-10, "Weekly on Sunday and Monday": the + /// user moved no day, so nothing warns that the repeat day changes, and the deletion stays on + /// Monday 12-10, never on Sunday 11-10, which the new rule makes. From this one on, Monday 26-10, + /// changed on its own, goes onto the new part's Monday 26-10. + #[test] + fn a_timed_series_made_all_day_keeps_its_own_days() { + let half_past_midnight = || { + master_with( + &["RRULE:FREQ=WEEKLY;BYDAY=MO"], + "2026-10-05T00:30:00+01:00", + "2026-10-05T01:30:00+01:00", + ) + }; + let all_day = EventPatchDraft { + time: Some(TimeDraft::AllDay { + first_day: "2026-10-19".into(), + last_day: "2026-10-19".into(), + }), + recurrence: Some(RepeatDraft::Rule { + rule: weekly_on(&[DayCode::Sun, DayCode::Mon]), + }), + ..Default::default() + }; + let no_day_move = |plan: &SeriesPlan| { + !plan + .warnings + .iter() + .any(|w| matches!(w, SeriesWarning::RepeatDayChanges { .. })) + }; + let case = Case::new(half_past_midnight(), plain("2026-10-19T00:30:00+01:00")) + .exceptions(vec![cancelled("2026-10-12T00:30:00+01:00")]); + let plan = case.edit(Scope::All, all_day.clone()).unwrap(); + let sent = body(&plan, 0); + assert_eq!( + sent["start"], + json!({ "date": "2026-10-05", "dateTime": null, "timeZone": null }) + ); + assert_eq!( + sent["recurrence"], + json!([ + "RRULE:FREQ=WEEKLY;BYDAY=MO,SU", + "EXDATE;VALUE=DATE:20261012" + ]) + ); + assert!(no_day_move(&plan)); + let review = with(plain("2026-10-26T00:30:00Z"), "summary", json!("Review")); + let case = Case::new(half_past_midnight(), plain("2026-10-19T00:30:00+01:00")) + .exceptions(vec![review]); + let plan = case.edit(Scope::Following, all_day).unwrap(); + assert_eq!(body(&plan, 0)["start"], json!({ "date": "2026-10-19" })); + assert_eq!( + carries(&plan), + [(Point::Day(day(2026, 10, 26)), json!("Review"))] + ); + assert!(no_day_move(&plan)); + } + + /// Q3: "after n times" the user set is n occurrences of the new part, whatever the old count. + #[test] + fn following_with_a_new_rule_writes_after_n_as_set() { + let plan = counted_case() + .edit( + Scope::Following, + repeat(rule( + RepeatPattern::Weekly { + days: vec![DayCode::Tue], + }, + 1, + RepeatEnds::After { count: 5 }, + )), + ) + .unwrap(); + assert_eq!( + body(&plan, 0)["recurrence"], + json!(["RRULE:FREQ=WEEKLY;BYDAY=TU;COUNT=5"]) + ); + } + + /// C11 critique 1 M4: the series' own count, left as it was, is what's left of it: 10 in all, + /// two before Monday 19-10. + #[test] + fn following_with_an_untouched_count_rebases_it() { + let plan = counted_case() + .edit( + Scope::Following, + repeat(rule( + RepeatPattern::Weekly { + days: vec![DayCode::Tue], + }, + 1, + RepeatEnds::After { count: 10 }, + )), + ) + .unwrap(); + assert_eq!( + body(&plan, 0)["recurrence"], + json!(["RRULE:FREQ=WEEKLY;BYDAY=TU;COUNT=8"]) + ); + } + + /// Weekly on Monday, 10 times, from Monday 19-10, with Google's count of what came before. + fn counted_case() -> Case { + let mut case = Case::new( + master(&["RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=10"]), + monday_19(), + ); + case.instances = Some(Listing { + items: vec![ + plain("2026-10-05T09:00:00+01:00"), + plain("2026-10-12T09:00:00+01:00"), + monday_19(), + ], + complete: true, + }); + case + } + + /// A29: the new part can't start where the part before it still reaches. + #[test] + fn following_with_a_new_rule_that_starts_before_the_last_kept_is_refused() { + let back_a_week = EventPatchDraft { + recurrence: Some(RepeatDraft::Rule { + rule: weekly_on(&[DayCode::Mon, DayCode::Wed]), + }), + ..at("09:00", "2026-10-12") + }; + assert_eq!( + Case::new(weekly(), monday_19()).edit(Scope::Following, back_a_week), + Err(NotPlanned::Refused(SeriesRefusal::Retime( + RetimeError::Overlaps(day(2026, 10, 12)) + ))) + ); + } + + /// A29 in the series' own zone, whatever zone the new part moves into (C11c review): from a daily + /// 23:00 in London, Wednesday 14-10 moved to 06:00 in Tokyo is Tuesday 13-10 22:00 in London, the + /// day the part before still reaches; 20:00 in New York on the 13th is the 14th in London, past it. + #[test] + fn following_into_another_zone_measures_the_part_before_in_its_own() { + let nights = master_with( + &["RRULE:FREQ=DAILY"], + "2026-10-05T23:00:00+01:00", + "2026-10-06T00:00:00+01:00", + ); + let case = Case::new(nights, plain("2026-10-14T23:00:00+01:00")); + let moved_to = |date: &str, start: &str, end: &str, zone: &str| EventPatchDraft { + time: Some(TimeDraft::Timed { + start_date: date.into(), + start_time: start.into(), + start_zone: zone.into(), + end_date: date.into(), + end_time: end.into(), + end_zone: zone.into(), + }), + recurrence: Some(RepeatDraft::Rule { + rule: weekly_on(&[ + DayCode::Mon, + DayCode::Tue, + DayCode::Wed, + DayCode::Thu, + DayCode::Fri, + ]), + }), + ..Default::default() + }; + assert_eq!( + case.edit( + Scope::Following, + moved_to("2026-10-14", "06:00", "07:00", "Asia/Tokyo") + ), + Err(NotPlanned::Refused(SeriesRefusal::Retime( + RetimeError::Overlaps(day(2026, 10, 14)) + ))) + ); + let plan = case + .edit( + Scope::Following, + moved_to("2026-10-13", "20:00", "21:00", "America/New_York"), + ) + .unwrap(); + assert_eq!( + body(&plan, 0)["start"], + json!({ "dateTime": "2026-10-13T20:00:00-04:00", "timeZone": "America/New_York" }) + ); + // An all-day series has no zone of its own: made timed, the rest starts on its own date, + // Wednesday 14-10 at 00:30 in London (Tuesday in UTC), the day after the part before ends. + let days = with( + with( + master(&["RRULE:FREQ=DAILY"]), + "start", + json!({ "date": "2026-10-05" }), + ), + "end", + json!({ "date": "2026-10-06" }), + ); + let wednesday = json!({ + "kind": "calendar#event", + "id": "abc_20261014", + "etag": "\"d1\"", + "status": "confirmed", + "summary": "Standup", + "description": "Daily notes", + "recurringEventId": "abc", + "originalStartTime": { "date": "2026-10-14" }, + "start": { "date": "2026-10-14" }, + "end": { "date": "2026-10-15" }, + "organizer": { "email": "me@example.com", "self": true }, + "reminders": { "useDefault": true }, + }); + assert!(Case::new(days, wednesday) + .edit( + Scope::Following, + moved_to("2026-10-14", "00:30", "01:30", "Europe/London") + ) + .is_ok()); + } + + /// §9.5.7: "Does not repeat" from this occurrence on makes one event of it, its own title with + /// it, and cuts the original; every later occurrence goes, changed or not, as the one warning + /// says, and nothing is carried. + #[test] + fn following_does_not_repeat_inserts_one_event_and_cuts() { + let retro = with(monday_19(), "summary", json!("Retro")); + let planning = with(plain("2026-10-26T09:00:00Z"), "summary", json!("Planning")); + let replied = plain("2026-11-02T09:00:00Z"); + let case = Case::new(weekly(), retro.clone()).exceptions(vec![retro, planning, replied]); + let plan = case.edit(Scope::Following, stop()).unwrap(); + let inserted = body(&plan, 0); + assert!(inserted.get("recurrence").is_none()); + assert_eq!(inserted["summary"], "Retro"); + assert_eq!(inserted["start"], timed_node("2026-10-19T09:00:00+01:00")); + assert_eq!( + inserted["extendedProperties"]["private"]["pmSplitOf"], + "abc" + ); + assert!(matches!(plan.steps[1].op, Op::Patch { .. })); + assert_eq!(plan.steps.len(), 5); + for step in &plan.steps[2..] { + assert!(matches!(step.op, Op::Cancel { .. }) && step.requires.is_none()); + } + assert_eq!( + plan.warnings, + [SeriesWarning::BecomesOneEvent { + day: day(2026, 10, 19) + }] + ); + assert!(plan.expect.contains(&Expect::Event { id: TAIL.into() })); + assert!(!plan + .expect + .iter() + .any(|e| matches!(e, Expect::Present { .. }))); + // The draft's title wins over the occurrence's own. + let renamed = EventPatchDraft { + recurrence: Some(RepeatDraft::Once), + ..title("Wrap-up") + }; + assert_eq!( + body(&case.edit(Scope::Following, renamed).unwrap(), 0)["summary"], + "Wrap-up" + ); + } + + /// "Does not repeat" from an occurrence moved on its own to 11:00 keeps it at 11:00. + #[test] + fn following_does_not_repeat_keeps_this_ones_own_time() { + let eleven = monday_19_at_eleven(); + let case = Case::new(weekly(), eleven.clone()).exceptions(vec![eleven]); + let plan = case.edit(Scope::Following, stop()).unwrap(); + let inserted = body(&plan, 0); + assert_eq!(inserted["start"], timed_node("2026-10-19T11:00:00+01:00")); + assert_eq!(inserted["end"], timed_node("2026-10-19T12:00:00+01:00")); + assert!(inserted.get("time").is_none()); + assert_eq!( + plan.warnings, + [SeriesWarning::BecomesOneEvent { + day: day(2026, 10, 19) + }] + ); + } + + // --- the guards every series plan keeps, on the rule paths (C11c review) --- + + /// R2: this occurrence moved in Google since the editor opened is a conflict. For every + /// occurrence even with no time in the draft, since a new rule, or none, sets the times anyway; + /// from this one on, when the draft gives it a time. + #[test] + fn a_rule_change_refuses_this_occurrence_moved_in_google_since() { + let mut case = Case::new(weekly(), monday_19()); + case.opened_instance = fresh(moved( + "2026-10-19T09:00:00+01:00", + "2026-10-19T10:00:00+01:00", + "2026-10-19T11:00:00+01:00", + )); + let conflict = Err(NotPlanned::Conflict(vec!["start".into(), "end".into()])); + assert_eq!(case.edit(Scope::All, repeat(daily())), conflict); + assert_eq!(case.edit(Scope::All, stop()), conflict); + for recurrence in [RepeatDraft::Rule { rule: daily() }, RepeatDraft::Once] { + let timed = EventPatchDraft { + recurrence: Some(recurrence), + ..at("09:00", "2026-10-19") + }; + assert_eq!(case.edit(Scope::Following, timed), conflict); + } + } + + /// R2 from this occurrence on: the series changed in Google since the editor opened, its title or + /// how it repeats, is a conflict. + #[test] + fn a_rule_change_from_here_refuses_a_series_changed_in_google_since() { + for (key, was) in [ + ("summary", json!("Daily standup")), + ("recurrence", json!(["RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=20"])), + ] { + let mut case = Case::new(weekly(), monday_19()); + case.opened_master = fresh(with(weekly(), key, was)); + for draft in [repeat(daily()), stop()] { + assert_eq!( + case.edit(Scope::Following, draft), + Err(NotPlanned::Conflict(vec![key.to_string()])), + "{key}" + ); + } + } + } + + /// Until C12, a change that would reach an occurrence with guests of its own is refused, for every + /// occurrence and from one on, a new rule or none. + #[test] + fn a_rule_change_refuses_to_reach_an_occurrence_with_guests() { + let guests = with( + plain("2026-10-26T09:00:00Z"), + "attendees", + json!([{ "email": "ana@example.com" }]), + ); + let case = Case::new(weekly(), monday_19()).exceptions(vec![guests]); + let refused = Err(NotPlanned::Refused(SeriesRefusal::GuestsElsewhere(day( + 2026, 10, 26, + )))); + for scope in [Scope::All, Scope::Following] { + for draft in [repeat(daily()), stop()] { + assert_eq!(case.edit(scope, draft), refused); + } + } + } + + /// A formatted description is never saved over as plain text, whatever the rule. + #[test] + fn a_rule_change_keeps_a_formatted_description() { + let case = Case::new( + with(weekly(), "description", json!("Agenda")), + monday_19(), + ); + for scope in [Scope::All, Scope::Following] { + for recurrence in [RepeatDraft::Rule { rule: daily() }, RepeatDraft::Once] { + let notes = EventPatchDraft { + description: Some("Notes".into()), + recurrence: Some(recurrence), + ..Default::default() + }; + assert_eq!( + case.edit(scope, notes), + Err(NotPlanned::Refused(SeriesRefusal::FormattedDescription)) + ); + } + } + } + + /// The new part from here goes under an id PM minted for it, never the series' own (C8b review + /// ADV-5). + #[test] + fn a_rule_change_from_here_needs_an_id_of_its_own() { + let case = Case::new(weekly(), monday_19()); + for id in ["abc", ".."] { + for draft in [repeat(daily()), stop()] { + assert_eq!( + super::super::plan::plan_series_edit( + Scope::Following, + &case.input(), + &draft, + id, + Notify::None + ), + Err(NotPlanned::Refused(SeriesRefusal::Unreadable)), + "{id}" + ); + } + } + } + + /// §9.6: a rule, or none, is never one occurrence's; the series' own rule is no rule change. + #[test] + fn this_event_with_a_rule_is_refused_not_no_change() { + let case = Case::new(weekly(), monday_19()); + for draft in [repeat(weekly_on(&[DayCode::Tue])), stop()] { + assert_eq!( + case.edit(Scope::This, draft), + Err(NotPlanned::Refused(SeriesRefusal::RuleIsSeriesWide)) + ); + } + let same_rule = EventPatchDraft { + recurrence: Some(RepeatDraft::Rule { + rule: weekly_on(&[DayCode::Mon]), + }), + ..title("Retro") + }; + assert!(case.edit(Scope::This, same_rule).is_ok()); + // A rule outside the limits is no one occurrence's either, and every occurrence's refuses it + // in its own words. + let bad = repeat(rule(RepeatPattern::Daily, 0, RepeatEnds::Never)); + assert_eq!( + case.edit(Scope::This, bad.clone()), + Err(NotPlanned::Refused(SeriesRefusal::RuleIsSeriesWide)) + ); + assert_eq!( + case.edit(Scope::All, bad), + Err(NotPlanned::Refused(SeriesRefusal::Spec( + SpecError::Interval + ))) + ); + } + + /// The choice reads a rule as the planners do. + #[test] + fn a_rule_change_is_read_as_the_planners_read_it() { + let m = Master::read(&fresh(weekly())).unwrap(); + assert_eq!(rule_change(&title("Retro"), &m), None); + assert_eq!(rule_change(&repeat(weekly_on(&[DayCode::Mon])), &m), None); + assert_eq!( + rule_change(&repeat(weekly_on(&[DayCode::Tue])), &m), + Some(RuleChange::New) + ); + assert_eq!(rule_change(&stop(), &m), Some(RuleChange::Stop)); + let bad = repeat(rule(RepeatPattern::Daily, 0, RepeatEnds::Never)); + assert_eq!(rule_change(&bad, &m), Some(RuleChange::New)); + } +} diff --git a/src-tauri/src/calendar_write/series/scope.rs b/src-tauri/src/calendar_write/series/scope.rs index 728fb5eb..3a92cf4c 100644 --- a/src-tauri/src/calendar_write/series/scope.rs +++ b/src-tauri/src/calendar_write/series/scope.rs @@ -17,6 +17,7 @@ use crate::calendar_recur::{Point, RecurError}; use super::super::dto::{EventPatchDraft, SeriesScope, TimeDraft}; use super::super::patch::build_patch; use super::super::time::{self, TimeError}; +use super::rule::rule_change; use super::{original_of, point_of, FreshEvent, Length, Master, SeriesRefusal}; /// The three choices. @@ -44,9 +45,20 @@ pub struct Change { pub fields: bool, pub visibility: bool, pub time: Option, + /// A new rule, or none at all (C11). + pub rule: Option, pub delete: bool, } +/// A change to how the series repeats, as far as the choice cares (C11). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuleChange { + /// A rule other than the series' own: a rule PM can't read is one too, and its planner says why. + New, + /// "Does not repeat". + Stop, +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct TimeChange { /// Measured from where the occurrence came from, not where it is now (T7). @@ -79,6 +91,7 @@ impl Change { .iter() .any(|k| body.contains_key(*k)), visibility: body.contains_key("visibility"), + rule: rule_change(draft, master), ..Change::default() }; if let (Some(draft_time), true) = (&draft.time, body.contains_key("start")) { @@ -90,7 +103,8 @@ impl Change { } /// Whether a timed draft puts the occurrence's start or end in a zone other than its own (an end -/// with no zone of its own is in the start's): a whole series can't follow it until C11. +/// with no zone of its own is in the start's): a whole series follows it only when a new rule +/// rebuilds the series (C11c), until C11d rebuilds it from its own rule too. pub(super) fn zones_changed(draft: &TimeDraft, instance: &Value) -> bool { let TimeDraft::Timed { start_zone, @@ -151,9 +165,12 @@ pub enum ScopeReason { VisibilityIsSeriesWide, /// PM shows this series' rule as text only, so it can't move or cut it. TextOnly(TextOnly), - /// Between timed and all-day, or into another zone, for more than one occurrence (until C11). + /// Between timed and all-day, or into another zone, for more than one occurrence, with the + /// series' own rule (until C11d; a new rule rebuilds the series in any kind and zone). KindChange, ZoneChange, + /// A new rule, or none, can't apply to one occurrence (C11). + RuleIsSeriesWide, } /// Shown on the row as is, in the planners' own words for the same refusals. @@ -164,6 +181,7 @@ impl fmt::Display for ScopeReason { ScopeReason::TextOnly(why) => RecurError::TextOnly(why.clone()).fmt(f), ScopeReason::KindChange => RetimeError::KindChange.fmt(f), ScopeReason::ZoneChange => SeriesRefusal::ZoneChange.fmt(f), + ScopeReason::RuleIsSeriesWide => SeriesRefusal::RuleIsSeriesWide.fmt(f), } } } @@ -193,7 +211,14 @@ pub fn scope_options(change: &Change, first: bool, text_only: Option<&TextOnly>) if change.visibility { this = Row::Refused(ScopeReason::VisibilityIsSeriesWide); } - if let Some(t) = change.time { + // How the series repeats is the series' own: never one occurrence's (the plan's "This event is + // absent when the rule changes", shown refused with its reason instead, C11 §0.4). + if change.rule.is_some() { + this = Row::Refused(ScopeReason::RuleIsSeriesWide); + } + // A new rule replaces the old one whole, in any kind and zone, so the series' own rule (or a + // kind or zone it can't follow yet) doesn't stand in its way (C11 §9.2). + if let (Some(t), None) = (change.time, change.rule) { let series_wide = if t.kind_changed { Some(ScopeReason::KindChange) } else if t.zone_changed { @@ -435,5 +460,131 @@ mod tests { assert!(ScopeReason::ZoneChange .to_string() .contains("another time zone")); + assert_eq!( + ScopeReason::RuleIsSeriesWide.to_string(), + "Changing how it repeats can't apply to just this occurrence." + ); + assert_eq!( + ScopeReason::RuleIsSeriesWide.to_string(), + SeriesRefusal::RuleIsSeriesWide.to_string() + ); + } + + fn repeat(days: &[crate::calendar_recur::wire::DayCode]) -> EventPatchDraft { + use crate::calendar_recur::wire::{RepeatEnds, RepeatPattern, RepeatRule}; + EventPatchDraft { + recurrence: Some(crate::calendar_write::dto::RepeatDraft::Rule { + rule: RepeatRule { + pattern: RepeatPattern::Weekly { + days: days.to_vec(), + }, + interval: 1, + ends: RepeatEnds::Never, + }, + }), + ..Default::default() + } + } + + /// The test moved here from C9 (c9-design:108): a change to how it repeats never offers "This + /// event", shown refused with its reason (C11 §0.4.5); a stop is one too. + #[test] + fn a_rule_change_refuses_this_event() { + use crate::calendar_recur::wire::DayCode; + let new = change(repeat(&[DayCode::Tue])); + assert_eq!(new.rule, Some(RuleChange::New)); + let o = scope_options(&new, false, None); + assert_eq!(o.this, Row::Refused(ScopeReason::RuleIsSeriesWide)); + assert_eq!((o.following, o.all), (Row::Offered, Row::Offered)); + let stop = change(EventPatchDraft { + recurrence: Some(crate::calendar_write::dto::RepeatDraft::Once), + ..Default::default() + }); + assert_eq!(stop.rule, Some(RuleChange::Stop)); + assert_eq!( + scope_options(&stop, false, None).this, + Row::Refused(ScopeReason::RuleIsSeriesWide) + ); + // From the first occurrence, "this and following" is still every occurrence. + assert_eq!(scope_options(&new, true, None).following, Row::AsAll); + } + + /// A rule PM can only show is replaced whole on every occurrence, but can't be cut, so "this and + /// following" stays refused; a new time with the new rule doesn't stand in the way either. + #[test] + fn a_new_rule_on_a_custom_rule_offers_all_but_not_following() { + let custom = TextOnly::Part("BYSETPOS".into()); + for time in [None, Some(TimeChange::default())] { + let new = Change { + rule: Some(RuleChange::New), + time, + ..Default::default() + }; + let o = scope_options(&new, false, Some(&custom)); + assert_eq!(o.all, Row::Offered); + assert_eq!( + o.following, + Row::Refused(ScopeReason::TextOnly(custom.clone())) + ); + } + } + + /// C11c: a change between timed and all-day, or into another zone, goes to every occurrence only + /// with a new rule, which rebuilds the series in it. + #[test] + fn a_new_rule_with_a_kind_change_is_offered() { + for t in [ + TimeChange { + kind_changed: true, + ..Default::default() + }, + TimeChange { + zone_changed: true, + ..Default::default() + }, + ] { + let o = scope_options( + &Change { + time: Some(t), + rule: Some(RuleChange::New), + ..Default::default() + }, + false, + None, + ); + assert_eq!((o.following, o.all), (Row::Offered, Row::Offered)); + } + } + + /// Until C11d, the series' own rule doesn't follow it there. + #[test] + fn a_kind_change_alone_is_still_refused() { + let o = scope_options( + &Change { + time: Some(TimeChange { + kind_changed: true, + ..Default::default() + }), + ..Default::default() + }, + false, + None, + ); + assert_eq!(o.this, Row::Offered); + assert_eq!(o.all, Row::Refused(ScopeReason::KindChange)); + assert_eq!(o.following, Row::Refused(ScopeReason::KindChange)); + } + + /// The rule the series already has, sent back, is no change of rule: "This event" stays offered. + #[test] + fn the_same_rule_is_no_rule_change() { + use crate::calendar_recur::wire::DayCode; + let same = change(EventPatchDraft { + summary: Some("Retro".into()), + ..repeat(&[DayCode::Mon]) + }); + assert_eq!(same.rule, None); + assert!(same.fields); + assert_eq!(scope_options(&same, false, None).this, Row::Offered); } } diff --git a/src-tauri/src/commands/calendar_edit.rs b/src-tauri/src/commands/calendar_edit.rs index 47de3e75..28a1efc8 100644 --- a/src-tauri/src/commands/calendar_edit.rs +++ b/src-tauri/src/commands/calendar_edit.rs @@ -40,7 +40,8 @@ use crate::calendar::{self, CalendarEvent, CalendarFacts}; use crate::calendar_editing::{self, EditingStatus}; use crate::calendar_recur::expand; use crate::calendar_recur::lines::{self, Applied}; -use crate::calendar_recur::spec::{day_phrase, describe_lines}; +use crate::calendar_recur::spec::{day_phrase, describe_lines, spec_of}; +use crate::calendar_recur::wire::RepeatRule; use crate::calendar_recur::{Point, SeriesStart}; use crate::calendar_write::classify::{Method, Verdict}; use crate::calendar_write::dto::{ @@ -64,11 +65,11 @@ use crate::calendar_write::series::run::{ Why, }; use crate::calendar_write::series::scope::{ - is_first, scope_options, Change, Row, Scope, ScopeReason, + is_first, scope_options, Change, Row, RuleChange, Scope, ScopeReason, }; use crate::calendar_write::series::{ - original_of, parts_overlap, text_only, Exceptions, FreshEvent, Listing, Master, Parts, - SeriesRefusal, + later_part, original_of, parts_overlap, text_only, Exceptions, FreshEvent, LaterPart, Listing, + Master, Parts, SeriesRefusal, }; use crate::calendar_write::{plan, time}; use crate::error::{Error, Result}; @@ -471,7 +472,9 @@ fn refused_field(perms: &FieldPermissions, draft: &EventPatchDraft) -> Option( device_zone: Option, ) -> Result { let (fresh, master) = open_series(wire, &target, fresh).await; - let series = master.as_ref().and_then(|m| series_info(&fresh, m)); + let series = master + .as_ref() + .and_then(|m| series_info(&fresh, m)) + .map(Box::new); let permissions = gate::edit_rights(&facts_from_fresh(&fresh, &target.facts, editing)); // Only for a start or end Google sent without a zone: the calendar's zone, else this computer's. // Never a guessed UTC (rule R6). @@ -652,12 +658,20 @@ async fn open_master(wire: &W, target: &Target, fresh: &Value) -> Optio series_copy(get(wire, &target.remote_calendar, id).await, id) } -/// Where the occurrence `fresh` sits in its series `master`, or `None` for a series PM can't read. +/// Where the occurrence `fresh` sits in its series `master`, and how that repeats: in words, as a rule +/// from the editor's set when it is one (the Repeat row's seed, C11), from which day and in which zone. +/// `None` for a series PM can't read. fn series_info(fresh: &Value, master: &Value) -> Option { let read = Master::read(&FreshEvent::from_google(master.clone())).ok()?; Some(SeriesInfo { first: is_first(&read, &FreshEvent::from_google(fresh.clone())), repeats: describe_lines(&read.recurrence, &read.start), + rule: spec_of(&read.recurrence, &read.start).map(|spec| RepeatRule::from_spec(&spec)), + starts: read.start.date().format("%Y-%m-%d").to_string(), + zone: match read.start { + SeriesStart::Timed { zone, .. } => Some(zone.name().to_string()), + SeriesStart::AllDay(_) => None, + }, }) } @@ -734,6 +748,10 @@ pub async fn update_calendar_event( .await) } +/// A one-off event can't be made to repeat from the editor yet (C11d). +const ONE_OFF_REPEAT: &str = + "PM can't change whether this event repeats yet, so nothing was saved."; + /// [`update_calendar_event`] for one event, past its shell: Google through `wire`, the mirror and /// the claims through `host`. async fn save_event( @@ -772,6 +790,13 @@ async fn save_event( if fresh.get("recurringEventId").is_some() { return WriteOutcome::NeedsChoice; } + // A one-off given a repeat is C11d's: until then it's refused, never saved without it + // (C11 critique 2 L6). Only a GET has gone out. + if draft.recurrence.is_some() { + return WriteOutcome::Failed { + message: ONE_OFF_REPEAT.into(), + }; + } let body = match patch::build_patch(&fresh, draft) { Ok(body) => body, Err(e) => { @@ -2178,6 +2203,8 @@ struct Series { sibling: bool, /// Whether a part PM split off it still overlaps it (scope-M4). parts: Parts, + /// Where its earliest later part starts, which a new rule mustn't repeat over (C11 critique 1 H2). + later: LaterPart, /// For the dev report: whether the listing ran to its end before the cross-check, and the /// occurrences it was checked against. #[cfg(debug_assertions)] @@ -2293,6 +2320,14 @@ async fn gather( &master, &window, ); + // PM's note says this series is itself a part of one split earlier. + let noted_part = master_fresh + .json() + .pointer("/extendedProperties/private/pmSplitOf") + .is_some(); + // The same parts, and Google's own `_R` ones the mirror holds, as where a new rule must stop; a + // part itself, where its own rule ends now. + let later = later_part(&parts, &master, noted_part, &mirror.series_ids); let parts = parts_overlap(&parts, &master, &window, &exceptions); // Google's count of what comes before a split, for a rule that ends after so many (A7): every // occurrence, unwindowed, only the fields counting reads. @@ -2327,10 +2362,7 @@ async fn gather( .series_ids .iter() .any(|id| *id != master.id && base(id) == base(&master.id)) - || master_fresh - .json() - .pointer("/extendedProperties/private/pmSplitOf") - .is_some(); + || noted_part; Ok(Some(Series { master_fresh, master, @@ -2338,6 +2370,7 @@ async fn gather( counted, sibling, parts, + later, #[cfg(debug_assertions)] listing_complete, #[cfg(debug_assertions)] @@ -2471,6 +2504,7 @@ impl Planning { exceptions: &self.series.exceptions, instances: self.series.counted.as_ref(), sibling: self.series.sibling, + later: self.series.later, guests_allowed: GUESTS_ALLOWED, } } @@ -2702,6 +2736,10 @@ fn warning_dto(warning: &SeriesWarning, plan: &SeriesPlan, series: &Series) -> S label, }, SeriesWarning::NewIdentity => SeriesWarningDto::NewIdentity, + SeriesWarning::BecomesOneEvent { day } => { + SeriesWarningDto::BecomesOneEvent { day: date(day) } + } + SeriesWarning::ChangesLost { count } => SeriesWarningDto::ChangesLost { count }, } } @@ -2736,6 +2774,11 @@ const PUT_BACK: &str = "PM put this repeating event's times and repeat back as t shows it after the next refresh. Open the event again to change it."; const RETIME_UNSURE: &str = "PM lost touch with Google while moving the occurrences, so they may \ have moved, or moved only partly. Check them in Google Calendar."; +const RULE_UNSURE: &str = "PM lost touch with Google while changing how this event repeats, so it \ + may still repeat as before, or have changed only partly. Check it in \ + Google Calendar."; +const STOP_UNSURE: &str = "PM lost touch with Google while making this an event that doesn't \ + repeat, so it may still repeat. Check it in Google Calendar."; /// Whether a scoped write may leave out the dialog's choice: dev builds only, for the devtools /// probes. The one release gate for it, which every shell's [`SeriesWrite`] takes (a settle, which @@ -3259,9 +3302,15 @@ async fn land_series( .steps .get(step) .is_some_and(|s| matches!(s.undo, Some(Undo::RestoreMaster { .. }))); + // What may be half done is what was asked: a new rule, or none, moves nothing as such. + let message = match planning.change.rule { + None => RETIME_UNSURE, + Some(RuleChange::New) => RULE_UNSURE, + Some(RuleChange::Stop) => STOP_UNSURE, + }; return if moving { WriteOutcome::Failed { - message: RETIME_UNSURE.into(), + message: message.into(), } } else { WriteOutcome::Unconfirmed @@ -3310,6 +3359,13 @@ async fn land_series( } }; } + // "Does not repeat" for every occurrence: Google's answer is the one event it now is (C11 §9.7). + if plan.scope == Scope::All + && matches!(&plan.steps[0].op, + Op::Patch { body, .. } if body.get("recurrence").is_some_and(Value::is_null)) + { + return land_one_event(ctx, mirror, planning, reply, date, snapshot).await; + } let left_behind: Vec = plan .left_behind(&skipped) .iter() @@ -3431,6 +3487,97 @@ async fn land_series( } } +/// "Does not repeat" for every occurrence, landed (C11 §9.7; critiques 1 M2, 2 M1): Google's `copy` +/// (its answer) is the one event the series now is, recorded as created so a list that lags can't +/// take it off the screen, unless it's outside the months PM mirrors. No instances listing: the old +/// series' rows go, but those Google still holds live under its iCal UID (what it does with +/// occurrences changed on their own is probe P-R2's to show); a listing that didn't run to its end +/// removes none, and the next sync settles them. Floored at the answer's own time. +async fn land_one_event( + ctx: &SeriesWrite<'_, W, H>, + mirror: &MirrorFacts, + planning: &Planning, + copy: Option, + date: Option>, + snapshot: Vec, +) -> WriteOutcome { + let (host, target) = (ctx.host, ctx.target); + let cal = target.calendar_id.as_str(); + let master = planning.series.master.id.as_str(); + let zone = planning.series.master.start.zone(); + let pending = || { + drop_hash(host, cal); + host.landed(cal); + series_saved(vec![WriteWarning::MirrorRefreshPending], Vec::new()) + }; + let Some(copy) = copy else { + return pending(); + }; + let Some(row) = calendar::parse_event(cal, ©) else { + return pending(); + }; + let still: Option> = match copy.get("iCalUID").and_then(Value::as_str) { + Some(ical) => { + let remote = target.remote_calendar.as_str(); + let listed = list_pages( + ctx.wire, + |page: Option<&str>| plan::list_by_ical(remote, ical, page), + EXCEPTION_PAGES, + ) + .await; + listed.complete.then(|| { + listed + .items + .iter() + .filter(|item| !patch::is_gone(item)) + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .map(|id| format!("{cal}:{id}")) + .collect() + }) + } + None => None, + }; + let floor = diff::floor_of( + [updated_of(Some(©)), date_floor(date)] + .iter() + .flatten() + .map(String::as_str), + ); + let outside = in_mirror(&row, mirror.instants()) == Some(false); + let landed = host.with_conn(|conn| { + let before = rows_union(&snapshot, calendar::series_rows(conn, cal, &[master])?); + let gone: Vec = match &still { + Some(still) => Removes::Series { + series: master.to_string(), + } + .rows(cal, &before, zone, floor.as_deref()) + .into_iter() + .filter(|g| !still.contains(&g.row_id) && g.row_id != row.id) + .collect(), + None => Vec::new(), + }; + let (upserts, created) = if outside { + (Vec::new(), Vec::new()) + } else { + (vec![row.clone()], vec![row.id.clone()]) + }; + record(conn, host.edit(), cal, upserts, &created, gone) + }); + if landed.is_err() { + return said_outside(pending(), outside); + } + host.landed(cal); + let mut warnings = Vec::new(); + if outside { + warnings.push(WriteWarning::OutsideMirror); + } + if still.is_none() { + drop_hash(host, cal); + warnings.push(WriteWarning::MirrorRefreshPending); + } + series_saved(warnings, Vec::new()) +} + /// `Saved`, with `LeftOnOldSeries` when occurrences were left behind. fn series_saved(mut warnings: Vec, left_behind: Vec) -> WriteOutcome { if !left_behind.is_empty() { @@ -4365,16 +4512,27 @@ fn land_series_gone( } /// The new part PM made is gone from Google: its rows, if a sync wrote any meanwhile, go too, no -/// older than the copies of it PM saw (C9a-3b critiques L2, L3). +/// older than the copies of it PM saw (C9a-3b critiques L2, L3). A new part that doesn't repeat is a +/// row of its own (C11). fn land_tail_removed(host: &impl Host, cal: &str, facts: &SplitFacts, seen: &[Option<&Value>]) { let moments: Vec = seen.iter().filter_map(|copy| updated_of(*copy)).collect(); let floor = diff::floor_of(moments.iter().map(String::as_str)); let _ = host.with_conn(|conn| { - let rows = calendar::series_rows(conn, cal, &[facts.tail.as_str()])?; - let gone = Removes::Series { + let mut rows = calendar::series_rows(conn, cal, &[facts.tail.as_str()])?; + rows.extend(calendar::event_by_id( + conn, + &format!("{cal}:{}", facts.tail), + )?); + let mut gone = Removes::Series { series: facts.tail.clone(), } .rows(cal, &rows, facts.zone, floor.as_deref()); + gone.extend( + Removes::Occurrence { + event: facts.tail.clone(), + } + .rows(cal, &rows, facts.zone, floor.as_deref()), + ); record(conn, host.edit(), cal, Vec::new(), &[], gone) }); host.landed(cal); @@ -4538,6 +4696,24 @@ async fn land_split_done( let Some(window) = mirror.instants() else { return stale_saved(host, cal, removal, left_behind); }; + // A split that stops repeating made one event in place of a new part: it lands from its insert's + // own answer, never a listing, since what Google lists for a one-off's occurrences is unproven + // (C11 §9.7). One outside the months PM mirrors isn't landed (C10 §6.2). + let one_event = plan + .expect + .iter() + .any(|e| matches!(e, Expect::Event { .. })); + let event_row = if one_event { + match inserted_copy(ran, facts.insert).and_then(|copy| calendar::parse_event(cal, copy)) { + Some(row) => Some(row), + None => return stale_saved(host, cal, removal, left_behind), + } + } else { + None + }; + let outside = event_row + .as_ref() + .is_some_and(|row| in_mirror(row, Some(window)) == Some(false)); let (mut original, mut tail) = (Listing::default(), Listing::default()); let mut stale = true; for pause in [None, Some(REFETCH_AGAIN[0]), Some(REFETCH_AGAIN[1])] { @@ -4546,20 +4722,13 @@ async fn land_split_done( } // Neither series is meant to be gone, so a 404 is never an empty listing (review M7). original = refetch(ctx.wire, remote, &facts.master, &mirror.window, false).await; - tail = refetch(ctx.wire, remote, &facts.tail, &mirror.window, false).await; + if !one_event { + tail = refetch(ctx.wire, remote, &facts.tail, &mirror.window, false).await; + } let probe = diff::diff_series( cal, &snapshot, - &[ - Refetch { - series: &facts.master, - listing: &original, - }, - Refetch { - series: &facts.tail, - listing: &tail, - }, - ], + &split_refetched(facts, one_event, &original, &tail), window, &expect, facts.zone, @@ -4567,13 +4736,13 @@ async fn land_split_done( ); // Every row of the new part is new: half of it would land the original's removals with // only some of their replacements (review M8). - stale = probe.stale || !original.complete || !tail.complete; + stale = probe.stale || !original.complete || (!one_event && !tail.complete); if !stale { break; } } if stale { - return stale_saved(host, cal, removal, left_behind); + return said_outside(stale_saved(host, cal, removal, left_behind), outside); } let landed = host.with_conn(|conn| { let now = calendar::series_rows(conn, cal, &[facts.master.as_str(), facts.tail.as_str()])?; @@ -4581,16 +4750,7 @@ async fn land_split_done( let diff = diff::diff_series( cal, &before, - &[ - Refetch { - series: &facts.master, - listing: &original, - }, - Refetch { - series: &facts.tail, - listing: &tail, - }, - ], + &split_refetched(facts, one_event, &original, &tail), window, &expect, facts.zone, @@ -4601,18 +4761,22 @@ async fn land_split_done( .rows(cal, &before, facts.zone, floor.as_deref()); // The snapshot holds none of the new part's rows, so every one of them is created: a list // that lags behind can't take them off the screen. - let created = diff.created_since(&snapshot); + let mut created = diff.created_since(&snapshot); let mut deletes = diff.deletes; for row in gone { if !deletes.iter().any(|d| d.row_id == row.row_id) { deletes.push(row); } } - let upserts = diff.upserts; + let mut upserts = diff.upserts; + if let Some(row) = event_row.clone().filter(|_| !outside) { + created.push(row.id.clone()); + upserts.push(row); + } deletes.retain(|d| !upserts.iter().any(|u| u.id == d.row_id)); record(conn, host.edit(), cal, upserts, &created, deletes) }); - match landed { + let saved = match landed { Ok(()) => { host.landed(cal); series_saved(Vec::new(), left_behind) @@ -4621,7 +4785,40 @@ async fn land_split_done( drop_hash(host, cal); series_saved(vec![WriteWarning::MirrorRefreshPending], left_behind) } + }; + said_outside(saved, outside) +} + +/// `outcome`, saying first that the one event a save made is `outside` the months PM mirrors: known +/// before the mirror is written, it stands whatever the writing came to (C11c review). +fn said_outside(mut outcome: WriteOutcome, outside: bool) -> WriteOutcome { + if let (true, WriteOutcome::Saved { warnings, .. }) = (outside, &mut outcome) { + let rest = std::mem::take(warnings); + warnings.push(WriteWarning::OutsideMirror); + warnings.extend(rest); + } + outcome +} + +/// The series a landed split re-fetched: the original, and the new part unless it's one event +/// (`one_event`), which lands from its insert's answer instead. +fn split_refetched<'a>( + facts: &'a SplitFacts, + one_event: bool, + original: &'a Listing, + tail: &'a Listing, +) -> Vec> { + let mut out = vec![Refetch { + series: facts.master.as_str(), + listing: original, + }]; + if !one_event { + out.push(Refetch { + series: facts.tail.as_str(), + listing: tail, + }); } + out } /// The new part PM made, as Google holds it now. @@ -5072,12 +5269,21 @@ fn journal_entry( facts: &SplitFacts, start: &Value, ) -> Option { - let Shows::Tail { plan: digest, .. } = &plan.steps.get(facts.insert)?.shows else { + let insert = plan.steps.get(facts.insert)?; + let Shows::Tail { plan: digest, .. } = &insert.shows else { return None; }; let Shows::Fields { sent, old } = &plan.steps.get(facts.insert + 1)?.shows else { return None; }; + // "Does not repeat" from here inserts one event: no rule goes with it. + let one_off = match &insert.op { + Op::Insert { body, .. } => body + .get("recurrence") + .and_then(Value::as_array) + .is_none_or(Vec::is_empty), + _ => false, + }; let lines = |v: Option<&Value>| -> Option> { v?.as_array()? .iter() @@ -5096,6 +5302,7 @@ fn journal_entry( before: lines(old.get("recurrence"))?, cut: lines(sent.get("recurrence"))?, cleanup: plan.steps.len() > facts.insert + 2, + one_off, }) } @@ -5155,6 +5362,18 @@ impl Orphaned { fn words(&self) -> String { let day = day_words(&self.entry.from); match self.orphan { + // "Does not repeat" from here: the event PM made in its place has no "All events" to + // delete, and the occurrences after it were to go, not move. + Orphan::Twice if self.entry.one_off => format!( + "PM closed while it was making this repeating event stop repeating from {day}: it \ + still repeats from then, and the event PM made in its place is there too. In \ + Google Calendar, delete that event, the one that doesn't repeat, then try again." + ), + Orphan::CutLanded if self.entry.one_off => format!( + "PM closed before it finished making this repeating event stop repeating from \ + {day}: occurrences you changed on their own from then may still show. If they \ + do, delete them in Google Calendar." + ), Orphan::Twice => format!( "PM closed while it was changing this repeating event, so it shows twice from {day}. \ In Google Calendar, delete the newer copy (Delete › All events on it), then try \ @@ -6748,6 +6967,17 @@ mod tests { ..Default::default() }; assert_eq!(refused_field(&locked, &busy), None); + // How it repeats moves its times: a locked time locks it too (C11). + let stop = EventPatchDraft { + recurrence: Some(crate::calendar_write::dto::RepeatDraft::Once), + ..Default::default() + }; + assert_eq!(refused_field(&locked, &stop), Some(ReadOnlyReason::Locked)); + let open = FieldPermissions { + time: true, + ..locked.clone() + }; + assert_eq!(refused_field(&open, &stop), None); } #[test] @@ -7310,12 +7540,10 @@ mod tests { Some(series.clone()) ); script.finished(); - assert_eq!( - series_info(&instance, &series), - Some(SeriesInfo { - first: false, - repeats: "Weekly on Monday".into() - }) + assert!( + series_info(&instance, &series).is_some_and(|info| !info.first + && info.repeats == "Weekly on Monday" + && info.rule.is_some()) ); let first = plain("2026-10-05T09:00:00+01:00"); assert!(series_info(&first, &series).unwrap().first); @@ -7344,6 +7572,71 @@ mod tests { } } + /// C11 §11: the editor learns the series' rule as one of its own, the day it starts and the zone + /// it repeats in, so the Repeat row starts from it. + #[test] + fn the_editor_gets_the_rule_its_first_day_and_zone() { + use crate::calendar_recur::wire::{DayCode, RepeatEnds, RepeatPattern}; + let series = master(&["RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=10"]); + let info = series_info(&monday_19(), &series).unwrap(); + let rule = RepeatRule { + pattern: RepeatPattern::Weekly { + days: vec![DayCode::Mon], + }, + interval: 1, + ends: RepeatEnds::After { count: 10 }, + }; + assert!(info.rule == Some(rule), "the series' own rule"); + assert_eq!(info.starts, "2026-10-05"); + assert_eq!(info.zone.as_deref(), Some("Europe/London")); + assert_eq!(info.repeats, "Weekly on Monday, 10 times"); + // Its start in its own zone, whatever offset Google wrote it with: 08:00 on the 6th in Tokyo + // is still the 5th in New York and in UTC. + let late = with( + master(&["RRULE:FREQ=DAILY"]), + "start", + json!({ "dateTime": "2026-10-05T19:00:00-04:00", "timeZone": "Asia/Tokyo" }), + ); + let late = with( + late, + "end", + json!({ "dateTime": "2026-10-05T20:00:00-04:00", "timeZone": "Asia/Tokyo" }), + ); + let info = series_info(&monday_19(), &late).unwrap(); + assert_eq!(info.starts, "2026-10-06"); + assert_eq!(info.zone.as_deref(), Some("Asia/Tokyo")); + } + + /// A rule PM can only show gives its words and no rule: it can only be replaced. + #[test] + fn a_custom_rule_gives_its_words_and_no_rule() { + let series = master(&["RRULE:FREQ=MONTHLY;BYDAY=TU;BYSETPOS=2"]); + let info = series_info(&monday_19(), &series).unwrap(); + assert!(info.rule.is_none()); + assert_eq!(info.repeats, crate::calendar_recur::spec::CUSTOM_RULE); + } + + /// An all-day series repeats by days, in no zone. + #[test] + fn an_all_day_series_names_no_zone() { + let series = json!({ + "id": "abc", "etag": "\"m1\"", "recurrence": ["RRULE:FREQ=YEARLY"], + "start": { "date": "2026-03-01" }, "end": { "date": "2026-03-02" }, + "organizer": { "self": true }, + }); + let occurrence = json!({ + "id": "abc_20270301", "recurringEventId": "abc", + "originalStartTime": { "date": "2027-03-01" }, + "start": { "date": "2027-03-01" }, "end": { "date": "2027-03-02" }, + }); + let info = series_info(&occurrence, &series).unwrap(); + assert!(info.zone.is_none()); + assert_eq!(info.starts, "2026-03-01"); + assert!(info + .rule + .is_some_and(|r| r.pattern == crate::calendar_recur::wire::RepeatPattern::YearlyDate)); + } + /// DS12: every row a change offers is planned, with its warnings and token; a row the change /// can't take says why in words. #[tokio::test] @@ -9337,6 +9630,37 @@ mod tests { } } + /// C11c review: a new rule, or "Does not repeat", whose answers prove nothing says what may be + /// half done, never that occurrences moved. + #[tokio::test] + async fn an_unknown_rule_change_says_what_may_be_half_done() { + let look = || Step::get("/events/abc", "", weekly()); + for (draft, message) in [ + (on_tuesdays(), RULE_UNSURE), + (stop_repeating(), STOP_UNSURE), + ] { + let host = TestHost::holding(&october()); + let mut steps = october_reads(); + steps.extend([ + series_patch(Scripted::Lost { retried: false }), + look(), + series_patch(Scripted::Lost { retried: false }), + look(), + ]); + let script = Script::new(steps); + let (outcome, _) = + save(&host, &script, &monday_19(), &weekly(), draft, Scope::All).await; + script.asked_all(); + assert_eq!( + outcome, + WriteOutcome::Failed { + message: message.into() + } + ); + assert!(host.nothing_recorded() && host.hash_dropped()); + } + } + /// §2.3: "this and following" without a new series cuts the series, then cancels the stored /// occurrences after the cut. One changed in Google since is left, said, and its row stays. #[tokio::test] @@ -9756,57 +10080,634 @@ mod tests { assert_eq!(outcome, WriteOutcome::NeedsChoice); } - /// Review scope-M2: a release build sends only the plan the user chose, by its token; a save - /// or a held delete without one is asked again, with nothing but reads sent. + // --- changing how it repeats (C11c) --- + + fn every_day() -> RepeatRule { + use crate::calendar_recur::wire::{RepeatEnds, RepeatPattern}; + RepeatRule { + pattern: RepeatPattern::Daily, + interval: 1, + ends: RepeatEnds::Never, + } + } + + /// A draft that makes it repeat by `rule`. + fn repeating(rule: RepeatRule) -> EventPatchDraft { + EventPatchDraft { + recurrence: Some(crate::calendar_write::dto::RepeatDraft::Rule { rule }), + ..Default::default() + } + } + + fn on_tuesdays() -> EventPatchDraft { + use crate::calendar_recur::wire::{DayCode, RepeatEnds, RepeatPattern}; + repeating(RepeatRule { + pattern: RepeatPattern::Weekly { + days: vec![DayCode::Tue], + }, + interval: 1, + ends: RepeatEnds::Never, + }) + } + + /// "Does not repeat". + fn stop_repeating() -> EventPatchDraft { + EventPatchDraft { + recurrence: Some(crate::calendar_write::dto::RepeatDraft::Once), + ..Default::default() + } + } + + /// C11 §9.2: a new rule refuses "This event" in words and offers the rest, each with what it + /// does: "this and following" starts again on Tuesday 20-10, every occurrence from Tuesday 06-10. #[tokio::test] - async fn a_save_names_its_choice_in_release_builds() { - assert!(!choice_matches(None, "t", false)); - assert!(choice_matches(None, "t", true)); - assert!(!choice_matches(Some("u"), "t", true)); - assert!(choice_matches(Some("t"), "t", false)); - let host = TestHost::holding(&october()); + async fn choices_for_a_rule_change_refuse_this_event_and_offer_the_rest() { let (series, instance) = (weekly(), monday_19()); - let target = target(&instance); let script = Script::new(october_reads()); - let ctx = SeriesWrite { - missing_ok: false, - ..writer(&script, &host, &target) + let opened = Some((&instance, Some(&series))); + let (this, following, all) = + ask(&script, &instance, edit(on_tuesdays()), opened, &["abc"]).await; + assert!( + this == ScopeRow::Refused { + message: "Changing how it repeats can't apply to just this occurrence.".into() + }, + "\"This event\" is refused in words" + ); + assert!( + warnings(&following) + == [ + SeriesWarningDto::StartMoved { + from: "2026-10-19".into(), + to: "2026-10-20".into() + }, + SeriesWarningDto::NewIdentity + ], + "the new part starts on Tuesday" + ); + assert!( + warnings(&all) + == [SeriesWarningDto::StartMoved { + from: "2026-10-05".into(), + to: "2026-10-06".into() + }], + "every occurrence moves to Tuesdays" + ); + // "Does not repeat": the first occurrence stays, and from this one on one event does. + let script = Script::new(october_reads()); + let (this, following, all) = + ask(&script, &instance, edit(stop_repeating()), opened, &["abc"]).await; + assert!(matches!(this, ScopeRow::Refused { .. })); + assert!( + warnings(&all) + == [SeriesWarningDto::BecomesOneEvent { + day: "2026-10-05".into() + }], + "only the first stays" + ); + assert!( + warnings(&following) + == [SeriesWarningDto::BecomesOneEvent { + day: "2026-10-19".into() + }], + "this one becomes the one event" + ); + } + + /// C11 critique 1 H2, through the reads: a part Google split off from 02-11 (its id says so) + /// stops a rule that would repeat past it. + #[tokio::test] + async fn a_new_rule_stops_short_of_a_later_part_google_made() { + let (series, instance) = (weekly(), monday_19()); + let script = Script::new(october_reads()); + let opened = Some((&instance, Some(&series))); + let (_, following, all) = ask( + &script, + &instance, + edit(repeating(every_day())), + opened, + &["abc", "abc_R20261102T090000"], + ) + .await; + let past = ScopeRow::Refused { + message: "PM can't make it repeat past 02-11-2026, where a later part of it starts. \ + Pick an end date before then." + .into(), }; - let (session, open) = open_in(&host, &instance, &series); - assert_eq!( - save_series(&ctx, &session, open, title("Retro"), Scope::All, None).await, - WriteOutcome::NeedsChoice + assert!(following == past && all == past, "both refused in words"); + } + + /// C11c review, through the reads: a series that PM's note says is itself a part of another, + /// cut before Monday 02-11 when a later part was made from there, can't see that part among its + /// own; it isn't made to repeat past Monday 26-10, where it ends now. + #[tokio::test] + async fn a_part_is_not_made_to_repeat_past_where_it_ends() { + let part = with( + master(&["RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20261102T085959Z"]), + "extendedProperties", + json!({ "private": { "pmSplitOf": "root", "pmSplitAt": "2026-10-05T08:00:00+00:00" } }), ); - script.finished(); + let instance = monday_19(); + let opened = Some((&instance, Some(&part))); + let script = Script::new(reads(&instance, &part, vec![part.clone()], october())); + let (_, following, all) = ask( + &script, + &instance, + edit(repeating(every_day())), + opened, + &["abc"], + ) + .await; + let past = ScopeRow::Refused { + message: "An earlier “this and following” change made this repeating event from part \ + of another, and PM can't check the parts after it, so it won't make it \ + repeat past 26-10-2026. Pick an end date on or before then." + .into(), + }; + assert!(following == past && all == past, "both refused in words"); + // Ending by then, it's offered. + let by_then = repeating(RepeatRule { + ends: crate::calendar_recur::wire::RepeatEnds::OnDate { + date: "2026-10-26".into(), + }, + ..every_day() + }); + let script = Script::new(reads(&instance, &part, vec![part.clone()], october())); + let (_, following, all) = ask(&script, &instance, edit(by_then), opened, &["abc"]).await; + assert!( + matches!(following, ScopeRow::Offered { .. }) + && matches!(all, ScopeRow::Offered { .. }), + "both offered" + ); + } + + /// Made daily with this occurrence moved a day on, every occurrence says where it now starts, + /// and nothing asks about Mondays becoming Tuesdays: a daily rule names no day. + #[tokio::test] + async fn a_daily_rule_with_a_moved_day_asks_nothing_of_its_days() { + let (series, instance) = (weekly(), monday_19()); let script = Script::new(october_reads()); - let ctx = SeriesWrite { - missing_ok: false, - ..writer(&script, &host, &target) + let daily_from_tuesday = EventPatchDraft { + recurrence: Some(crate::calendar_write::dto::RepeatDraft::Rule { rule: every_day() }), + ..at("09:00", "2026-10-20") }; - let seen = seen_by_user(&fresh_event(instance.clone())); - let planned = plan_held_delete(&ctx, &target.row_id, &seen, Scope::All, None).await; - assert_eq!(planned.err(), Some(WriteOutcome::NeedsChoice)); - script.finished(); + let (_, _, all) = ask( + &script, + &instance, + edit(daily_from_tuesday), + Some((&instance, Some(&series))), + &["abc"], + ) + .await; + assert!( + warnings(&all) + == [SeriesWarningDto::StartMoved { + from: "2026-10-05".into(), + to: "2026-10-06".into() + }], + "where it starts, and no question" + ); } - /// Review L13: the series claimed (the one the editor opened) must be the one Google now holds - /// the occurrence under, or two writes could hold different claims for one series. + /// The choice's token binds the rule: chosen for one, saved for another (whose plan is + /// otherwise the same), it's asked again, with nothing but reads sent. #[tokio::test] - async fn a_series_that_isnt_the_one_claimed_is_refused() { + async fn the_choice_token_binds_the_rule() { + use crate::calendar_recur::wire::{RepeatEnds, RepeatPattern}; let host = TestHost::holding(&october()); let (series, instance) = (weekly(), monday_19()); - let elsewhere = with(instance.clone(), "recurringEventId", json!("xyz")); - let other = with( - with(weekly(), "id", json!("xyz")), - "iCalUID", - json!("xyz@google.com"), - ); - let reads_now = || { - Script::new(vec![ - instance_read(&elsewhere), - Step::get("/events/xyz", "", other.clone()), - Step::get( - "/events", + let (session, open) = open_in(&host, &instance, &series); + let request = ChoiceRequest::Edit { + session: session.clone(), + draft: repeating(every_day()), + }; + let script = Script::new(october_reads()); + let (_, _, all) = ask(&script, &instance, request, Some(open.opened()), &["abc"]).await; + let every_other_day = repeating(RepeatRule { + pattern: RepeatPattern::Daily, + interval: 2, + ends: RepeatEnds::Never, + }); + let script = Script::new(october_reads()); + let target = target(&instance); + let outcome = save_series( + &writer(&script, &host, &target), + &session, + open, + every_other_day, + Scope::All, + Some(token(&all)), + ) + .await; + script.finished(); + assert!(matches!(outcome, WriteOutcome::NeedsChoice), "asked again"); + } + + /// C11 §9.7, critiques 1 M2 and 2 M1: "Does not repeat" for every occurrence lands Google's answer + /// as the one event, recorded as created, and removes the old rows but those Google still holds + /// live under the iCal UID; no instances listing goes out. + #[tokio::test] + async fn does_not_repeat_on_all_lands_one_event_and_removes_the_rest() { + let host = TestHost::holding(&october()); + let (series, instance) = (weekly(), monday_19()); + let mut one_event = written(&series, &[], "\"m2\""); + one_event.as_object_mut().unwrap().remove("recurrence"); + let planning = with(plain("2026-10-26T09:00:00Z"), "summary", json!("Planning")); + let mut steps = october_reads(); + steps.push(series_patch(Scripted::Json(one_event.clone()))); + steps.push(ical_listing( + "iCalUID=abc%40google.com showDeleted=true", + Scripted::Json(page( + vec![one_event, planning, cancelled("2026-10-12T09:00:00+01:00")], + None, + )), + )); + let script = Script::new(steps); + let before = Instant::now(); + let (outcome, session) = save( + &host, + &script, + &instance, + &series, + stop_repeating(), + Scope::All, + ) + .await; + script.asked_all(); + assert!(outcome == saved(), "saved, nothing pending"); + assert_eq!( + first_write(&script).body, + Some(json!({ "recurrence": null })) + ); + // The one event is a row of its own; the old ones go but the one Google still shows. + assert_eq!(host.row("abc").start, "2026-10-05T08:00:00Z"); + assert_eq!(host.ids(), ["cal:abc_20261026T090000Z"]); + assert_eq!(host.told(), ["cal"]); + assert!(open_session(&host.edit, &session).unwrap().is_none()); + // A whole sync that lags behind it, begun before the save or after, keeps the one event and + // hides the rows that went. + for started in [before, Instant::now()] { + let shown = ids_of(host.synced(&october(), started)); + assert!(shown.contains(&"cal:abc".to_string())); + assert!(!shown.contains(&"cal:abc_20261019T080000Z".to_string())); + assert!(!shown.contains(&"cal:abc_20261012T080000Z".to_string())); + } + } + + /// The one event outside the months PM mirrors isn't landed, and the old rows still go; a + /// listing that didn't run to its end removes none and leaves the rest to the next sync. + #[tokio::test] + async fn does_not_repeat_on_all_outside_the_mirror_or_unlisted() { + let (series, instance) = (weekly(), monday_19()); + let mut long_ago = written(&series, &[], "\"m2\""); + long_ago.as_object_mut().unwrap().remove("recurrence"); + long_ago["start"] = + json!({ "dateTime": "2026-08-03T09:00:00+01:00", "timeZone": "Europe/London" }); + long_ago["end"] = + json!({ "dateTime": "2026-08-03T10:00:00+01:00", "timeZone": "Europe/London" }); + let host = TestHost::holding(&october()); + let mut steps = october_reads(); + steps.push(series_patch(Scripted::Json(long_ago.clone()))); + steps.push(ical_listing( + "iCalUID=abc%40google.com", + Scripted::Json(page(vec![long_ago.clone()], None)), + )); + let script = Script::new(steps); + let (outcome, _) = save( + &host, + &script, + &instance, + &series, + stop_repeating(), + Scope::All, + ) + .await; + script.asked_all(); + assert!( + matches!(&outcome, WriteOutcome::Saved { warnings, .. } + if *warnings == [WriteWarning::OutsideMirror]), + "saved, outside the mirror" + ); + assert!(host.ids().is_empty()); + assert!(host + .with_conn(|conn| calendar::event_by_id(conn, "cal:abc")) + .unwrap() + .is_none()); + // The listing cut short: nothing removed, the calendar to catch up. + let host = TestHost::holding(&october()); + let mut one_event = written(&series, &[], "\"m2\""); + one_event.as_object_mut().unwrap().remove("recurrence"); + let mut steps = october_reads(); + steps.push(series_patch(Scripted::Json(one_event.clone()))); + steps.push(ical_listing( + "iCalUID=abc%40google.com", + Scripted::Json(page(vec![one_event], Some("next"))), + )); + steps.push(ical_listing("pageToken=next", Scripted::Status(500))); + let script = Script::new(steps); + let (outcome, _) = save( + &host, + &script, + &instance, + &series, + stop_repeating(), + Scope::All, + ) + .await; + script.asked_all(); + assert!(outcome == pending(), "saved, the calendar to catch up"); + assert_eq!(host.ids().len(), 3); + assert_eq!(host.row("abc").start, "2026-10-05T08:00:00Z"); + // Outside the mirror and a mirror that can't be written: said to be outside all the same, + // beside the calendar catching up (C11c review). + let host = TestHost::holding(&october()); + let refusing = Arc::clone(&host); + let mut steps = october_reads(); + steps.push(series_patch(Scripted::Json(long_ago.clone()))); + steps.push( + ical_listing( + "iCalUID=abc%40google.com", + Scripted::Json(page(vec![long_ago], None)), + ) + .then(move || mirror_refuses_removals(&refusing)), + ); + let script = Script::new(steps); + let (outcome, _) = save( + &host, + &script, + &instance, + &series, + stop_repeating(), + Scope::All, + ) + .await; + script.asked_all(); + assert!( + matches!(&outcome, WriteOutcome::Saved { warnings, .. } + if *warnings == [WriteWarning::OutsideMirror, WriteWarning::MirrorRefreshPending]), + "outside, the calendar to catch up" + ); + assert_eq!(host.ids().len(), 3); + } + + /// C11 §9.7: "Does not repeat" from this occurrence on lands the one event from the insert's own + /// answer, never an instances listing of it, as created, beside the cut original. + #[tokio::test] + async fn does_not_repeat_on_following_lands_the_new_event_from_its_reply() { + let host = TestHost::holding(&october()); + let plan = Case::new(weekly(), monday_19()) + .edit(Scope::Following, stop_repeating()) + .unwrap(); + let before = Instant::now(); + let mut steps = october_reads(); + let at_insert = Arc::clone(&host); + steps.extend([ + insert_step(Scripted::Json(tail_copy(&plan, "\"t1\"", SAVED_AT))).then(move || { + // Its record says the new part is one event, for the words after a restart. + let journal = at_insert.journal(); + let entry = journal.get(&journal::key(CALENDAR, "abc")); + assert!(entry.is_some_and(|e| e.one_off), "recorded as one event"); + }), + series_patch(Scripted::Json(cut_copy(&plan))), + refetch_read(Scripted::Json(page(vec![october()[0].clone()], None))), + ]); + let script = Script::new(steps); + let (outcome, session) = save( + &host, + &script, + &monday_19(), + &weekly(), + stop_repeating(), + Scope::Following, + ) + .await; + script.asked_all(); + assert!(outcome == saved(), "saved"); + let inserted = first_write(&script).body.unwrap(); + assert!(inserted.get("recurrence").is_none()); + let (original, tail) = split_rows(&host); + assert_eq!(original, ["cal:abc_20261012T080000Z"]); + assert!(tail.is_empty(), "one event is no series' rows"); + assert_eq!(host.row(TAIL).start, "2026-10-19T08:00:00Z"); + assert!(open_session(&host.edit, &session).unwrap().is_none()); + assert_eq!(held_count(&host), 0); + for started in [before, Instant::now()] { + let lagging = ids_of(host.synced(&october(), started)); + assert!(lagging.contains(&format!("cal:{TAIL}"))); + assert!(!lagging.contains(&"cal:abc_20261026T090000Z".to_string())); + } + } + + /// The one event put past the months PM mirrors isn't landed, and says so; the original is cut + /// as ever. + #[tokio::test] + async fn does_not_repeat_on_following_outside_the_mirror_says_so() { + let host = TestHost::holding(&october()); + let far = EventPatchDraft { + recurrence: Some(crate::calendar_write::dto::RepeatDraft::Once), + ..at("09:00", "2027-12-06") + }; + let plan = Case::new(weekly(), monday_19()) + .edit(Scope::Following, far.clone()) + .unwrap(); + let mut steps = october_reads(); + steps.extend([ + insert_step(Scripted::Json(tail_copy(&plan, "\"t1\"", SAVED_AT))), + series_patch(Scripted::Json(cut_copy(&plan))), + refetch_read(Scripted::Json(page(vec![october()[0].clone()], None))), + ]); + let script = Script::new(steps); + let (outcome, _) = save( + &host, + &script, + &monday_19(), + &weekly(), + far, + Scope::Following, + ) + .await; + script.asked_all(); + assert!( + matches!(&outcome, WriteOutcome::Saved { warnings, .. } + if *warnings == [WriteWarning::OutsideMirror]), + "saved, outside the mirror" + ); + assert!(host + .with_conn(|conn| calendar::event_by_id(conn, &format!("cal:{TAIL}"))) + .unwrap() + .is_none()); + assert_eq!(split_rows(&host).0, ["cal:abc_20261012T080000Z"]); + } + + /// The mirror refuses every removal from now on, as a full disk would. + fn mirror_refuses_removals(host: &TestHost) { + host.with_conn(|conn| { + conn.execute_batch( + "CREATE TRIGGER no_removal BEFORE DELETE ON calendar_events \ + BEGIN SELECT RAISE(ABORT, 'full'); END;", + )?; + Ok(()) + }) + .unwrap(); + } + + /// C11c review: the one event put past the months PM mirrors is said to be outside whatever the + /// landing came to, a re-fetch that never shows the cut or a mirror that can't be written, beside + /// the calendar catching up. + #[tokio::test] + async fn does_not_repeat_on_following_outside_the_mirror_says_so_when_pending() { + let far = EventPatchDraft { + recurrence: Some(crate::calendar_write::dto::RepeatDraft::Once), + ..at("09:00", "2027-12-06") + }; + let plan = Case::new(weekly(), monday_19()) + .edit(Scope::Following, far.clone()) + .unwrap(); + let outside_pending = |outcome: &WriteOutcome| { + matches!(outcome, WriteOutcome::Saved { warnings, .. } + if *warnings == [WriteWarning::OutsideMirror, WriteWarning::MirrorRefreshPending]) + }; + // The re-fetch never shows the cut. + let host = TestHost::holding(&october()); + let mut steps = october_reads(); + steps.extend([ + insert_step(Scripted::Json(tail_copy(&plan, "\"t1\"", SAVED_AT))), + series_patch(Scripted::Json(cut_copy(&plan))), + ]); + for _ in 0..3 { + steps.push(refetch_read(Scripted::Status(404))); + } + let script = Script::new(steps); + let (outcome, _) = save( + &host, + &script, + &monday_19(), + &weekly(), + far.clone(), + Scope::Following, + ) + .await; + script.asked_all(); + assert!(outside_pending(&outcome), "never shown: outside, pending"); + // The mirror can't be written. + let host = TestHost::holding(&october()); + let refusing = Arc::clone(&host); + let mut steps = october_reads(); + steps.extend([ + insert_step(Scripted::Json(tail_copy(&plan, "\"t1\"", SAVED_AT))), + series_patch(Scripted::Json(cut_copy(&plan))), + refetch_read(Scripted::Json(page(vec![october()[0].clone()], None))) + .then(move || mirror_refuses_removals(&refusing)), + ]); + let script = Script::new(steps); + let (outcome, _) = save( + &host, + &script, + &monday_19(), + &weekly(), + far, + Scope::Following, + ) + .await; + script.asked_all(); + assert!(outside_pending(&outcome), "not written: outside, pending"); + assert!(host.hash_dropped()); + } + + /// C11 §9.7: a new rule for every occurrence gives each a new id, so the whole series is + /// listed again and the old rows can't linger. + #[tokio::test] + async fn a_new_rule_on_all_relists_the_whole_series() { + let host = TestHost::holding(&october()); + let plan = Case::new(weekly(), monday_19()) + .edit(Scope::All, on_tuesdays()) + .unwrap(); + let Op::Patch { body, .. } = &plan.steps[0].op else { + panic!("not a patch"); + }; + let fields: Vec<(&str, Value)> = + body.iter().map(|(k, v)| (k.as_str(), v.clone())).collect(); + let tuesdays = written(&weekly(), &fields, "\"m2\""); + let occurrences: Vec = [ + "2026-10-13T09:00:00+01:00", + "2026-10-20T09:00:00+01:00", + "2026-10-27T09:00:00Z", + ] + .into_iter() + .enumerate() + .map(|(i, start)| written(&plain(start), &[], &format!("\"n{i}\""))) + .collect(); + let mut steps = october_reads(); + steps.push(series_patch(Scripted::Json(tuesdays))); + steps.push(refetch_read(Scripted::Json(page( + occurrences.clone(), + None, + )))); + let script = Script::new(steps); + let (outcome, _) = save( + &host, + &script, + &monday_19(), + &weekly(), + on_tuesdays(), + Scope::All, + ) + .await; + script.asked_all(); + assert!(outcome == saved(), "saved"); + assert_eq!(host.ids(), ids_of(rows_of(&occurrences))); + } + + /// Review scope-M2: a release build sends only the plan the user chose, by its token; a save + /// or a held delete without one is asked again, with nothing but reads sent. + #[tokio::test] + async fn a_save_names_its_choice_in_release_builds() { + assert!(!choice_matches(None, "t", false)); + assert!(choice_matches(None, "t", true)); + assert!(!choice_matches(Some("u"), "t", true)); + assert!(choice_matches(Some("t"), "t", false)); + let host = TestHost::holding(&october()); + let (series, instance) = (weekly(), monday_19()); + let target = target(&instance); + let script = Script::new(october_reads()); + let ctx = SeriesWrite { + missing_ok: false, + ..writer(&script, &host, &target) + }; + let (session, open) = open_in(&host, &instance, &series); + assert_eq!( + save_series(&ctx, &session, open, title("Retro"), Scope::All, None).await, + WriteOutcome::NeedsChoice + ); + script.finished(); + let script = Script::new(october_reads()); + let ctx = SeriesWrite { + missing_ok: false, + ..writer(&script, &host, &target) + }; + let seen = seen_by_user(&fresh_event(instance.clone())); + let planned = plan_held_delete(&ctx, &target.row_id, &seen, Scope::All, None).await; + assert_eq!(planned.err(), Some(WriteOutcome::NeedsChoice)); + script.finished(); + } + + /// Review L13: the series claimed (the one the editor opened) must be the one Google now holds + /// the occurrence under, or two writes could hold different claims for one series. + #[tokio::test] + async fn a_series_that_isnt_the_one_claimed_is_refused() { + let host = TestHost::holding(&october()); + let (series, instance) = (weekly(), monday_19()); + let elsewhere = with(instance.clone(), "recurringEventId", json!("xyz")); + let other = with( + with(weekly(), "id", json!("xyz")), + "iCalUID", + json!("xyz@google.com"), + ); + let reads_now = || { + Script::new(vec![ + instance_read(&elsewhere), + Step::get("/events/xyz", "", other.clone()), + Step::get( + "/events", "iCalUID=xyz%40google.com", page(vec![other.clone()], None), ), @@ -10261,6 +11162,53 @@ mod tests { assert_eq!(held_count(&host), 0); } + /// C11: the one event "Does not repeat" from this occurrence on made is a row of its own; a + /// sync that wrote it meanwhile doesn't leave it behind when the change is undone. + #[tokio::test] + async fn a_removed_one_event_takes_the_row_a_sync_wrote() { + let host = TestHost::holding(&october()); + let plan = Case::new(weekly(), monday_19()) + .edit(Scope::Following, stop_repeating()) + .unwrap(); + let one_event = tail_copy(&plan, "\"t1\"", SAVED_AT); + let syncing = Arc::clone(&host); + let synced = one_event.clone(); + let mut steps = october_reads(); + steps.extend([ + insert_step(Scripted::Json(one_event)), + series_patch(Scripted::Json(written(&weekly(), &[], "\"m2\""))).then(move || { + let mut rows = rows_of(&october()); + rows.extend(rows_of(std::slice::from_ref(&synced))); + syncing + .with_conn(|conn| calendar::replace_events(conn, "cal", &rows, true)) + .unwrap(); + }), + Step::write(Method::Delete, &format!("/events/{TAIL}"), "\"t1\"", { + Scripted::Status(204) + }), + ]); + let script = Script::new(steps); + let (outcome, _) = save( + &host, + &script, + &monday_19(), + &weekly(), + stop_repeating(), + Scope::Following, + ) + .await; + script.asked_all(); + assert!( + matches!(outcome, WriteOutcome::IgnoredByGoogle { split: true }), + "the cut was ignored, the one event removed" + ); + assert!(host + .with_conn(|conn| calendar::event_by_id(conn, &format!("cal:{TAIL}"))) + .unwrap() + .is_none()); + assert_eq!(host.ids(), ids_of(rows_of(&october()))); + } + /// C9a-3b critique M9: the new series is claimed once planned, so a write to it meanwhile (or /// this split, when one already holds it) is Busy, with only reads sent. #[tokio::test] @@ -11458,6 +12406,7 @@ mod tests { before: lines(&old["recurrence"]), cut: lines(&sent["recurrence"]), cleanup, + one_off: false, } } @@ -11565,6 +12514,31 @@ mod tests { assert!(host.journal().is_empty()); } + /// C11c review: a "Does not repeat" from one occurrence on, left from before a restart, is worded + /// as that: the event PM made has no "All events" to delete, and the occurrences changed on their + /// own after it were to go, not move. + #[test] + fn a_stop_left_from_before_a_restart_says_what_to_delete() { + let entry = Entry { + one_off: true, + ..orphan_entry(&split_plan(Vec::new()), true) + }; + let words = |orphan: Orphan| { + Orphaned { + entry: entry.clone(), + orphan, + } + .words() + }; + let twice = words(Orphan::Twice); + assert!(twice.contains("stop repeating from 19-10-2026")); + assert!(twice.contains("delete that event, the one that doesn't repeat")); + assert!(!twice.contains("All events")); + let cut = words(Orphan::CutLanded); + assert!(cut.contains("may still show") && cut.contains("delete them in Google Calendar")); + assert!(!cut.contains("new part")); + } + /// C1-H1: after a restart, a new part that's there while the original provably still holds /// its rule as before shows twice: every change it reaches is refused with what to do, the /// occurrences before the split stay open to "this event", and nothing is deleted. @@ -11580,6 +12554,7 @@ mod tests { } .words(); assert!(twice.contains("shows twice from 19-10-2026")); + assert!(twice.contains("Delete › All events")); let ask_at = |instance: Value| { let (host, tail) = (Arc::clone(&host), tail.clone()); async move { @@ -13446,6 +14421,40 @@ mod tests { assert_eq!(row.summary, "Dentist"); } + /// C11 critique 2 L6: until C11d makes a one-off repeat, a draft that asks it to is refused, + /// never saved without its repeat; only a GET goes out. + #[tokio::test] + async fn a_one_off_refuses_a_repeat_until_it_can_make_one() { + let event = dentist(); + let target = target(&event); + let host = TestHost::holding(std::slice::from_ref(&event)); + let session = keep_session(&host.edit, target.clone(), event.clone(), None).unwrap(); + let open = open_session(&host.edit, &session).unwrap().unwrap(); + for recurrence in [ + crate::calendar_write::dto::RepeatDraft::Once, + crate::calendar_write::dto::RepeatDraft::Rule { rule: every_day() }, + ] { + let script = Script::new(vec![Step::get("/events/solo", "", event.clone())]); + let draft = EventPatchDraft { + recurrence: Some(recurrence), + ..title("Retro") + }; + let outcome = + save_event(&script, &*host, &session, &open, &draft, EditingStatus::On).await; + script.finished(); + assert!( + matches!(&outcome, WriteOutcome::Failed { message } if message == ONE_OFF_REPEAT), + "refused in words" + ); + assert_eq!(script.sent().len(), 1); + } + let row = host + .with_conn(|conn| calendar::event_by_id(conn, "cal:solo")) + .unwrap() + .unwrap(); + assert_eq!(row.summary, "Dentist"); + } + /// A read-only reason outranks the choice: asking which occurrences of an event PM can't /// change would lead nowhere. #[tokio::test] @@ -13548,6 +14557,7 @@ mod tests { use crate::calendar_recur::lines::TextOnly; use crate::calendar_recur::reanchor::ReanchorError; use crate::calendar_recur::remap::RetimeError; + use crate::calendar_recur::spec::SpecError; use crate::calendar_recur::RecurError; use crate::calendar_write::time::TimeError; use chrono::{NaiveDate, Weekday}; @@ -13631,7 +14641,32 @@ mod tests { SeriesRefusal::ZoneChange, SeriesRefusal::SplitOverlap(day), SeriesRefusal::PartsUnread, + SeriesRefusal::RuleIsSeriesWide, + SeriesRefusal::RepeatsPastPart(day), + SeriesRefusal::LaterPartUnread, + SeriesRefusal::RepeatsPastItsEnd(day), + SeriesRefusal::NothingToKeep, + SeriesRefusal::KeptHasOwn(day), + SeriesRefusal::DeletedDayUnread(day), ]) + // Every repeat the editor can't save, as a row repeats it (C11 §9.8). + .chain( + [ + SpecError::Interval, + SpecError::NoWeekdays, + SpecError::DayOfMonth, + SpecError::Month, + SpecError::Count, + SpecError::TooMany, + SpecError::EndsBeforeStart, + SpecError::NoOccurrence, + SpecError::Gap(day), + SpecError::EndGap(day), + SpecError::BadDate, + ] + .into_iter() + .map(SeriesRefusal::Spec), + ) .collect(); let reasons = [ ReadOnlyReason::NotGoogle, @@ -13647,7 +14682,11 @@ mod tests { ]; let mut lines: Vec = refusals.iter().map(ToString::to_string).collect(); lines.extend(reasons.iter().map(|r| read_only_message(*r).to_string())); - lines.extend([PUT_BACK.to_string(), RETIME_UNSURE.to_string()]); + lines.extend( + [PUT_BACK, RETIME_UNSURE, RULE_UNSURE, STOP_UNSURE] + .iter() + .map(|l| l.to_string()), + ); for line in &lines { let lower = line.to_lowercase(); for word in ["series", "split", "instance", "exception", "master"] { @@ -13663,6 +14702,14 @@ mod tests { assert!(SeriesRefusal::CannotCarry(day) .to_string() .contains("19-10-2026")); + for dated in [ + SeriesRefusal::RepeatsPastPart(day), + SeriesRefusal::RepeatsPastItsEnd(day), + SeriesRefusal::KeptHasOwn(day), + SeriesRefusal::DeletedDayUnread(day), + ] { + assert!(dated.to_string().contains("19-10-2026")); + } // A row refused for a reason says that reason, never just "can't change this event". let reason_lines: Vec<&str> = reasons.iter().map(|r| read_only_message(*r)).collect(); assert!(reason_lines diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 120968ac..35b70d71 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "PM", - "version": "3.153.0-alpha", + "version": "3.153.1-alpha", "identifier": "org.itsatlas.pm", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/calendar/edit/DeleteEventDialog.test.tsx b/src/components/calendar/edit/DeleteEventDialog.test.tsx index b2442db0..c766b836 100644 --- a/src/components/calendar/edit/DeleteEventDialog.test.tsx +++ b/src/components/calendar/edit/DeleteEventDialog.test.tsx @@ -118,7 +118,11 @@ describe("DeleteEventDialog", () => { fireEvent.click(radio(/^This and following events/)); expect(screen.getByText(/can't restore these from its Trash/)).toBeTruthy(); fireEvent.click(del); - expect(onConfirm).toHaveBeenCalledWith(occurrence, { scope: "following", token: "f" }); + expect(onConfirm).toHaveBeenCalledWith(occurrence, { + scope: "following", + token: "f", + asAll: false, + }); // The held Undo still applies. expect(screen.getByText(/You can undo it for a few seconds/)).toBeTruthy(); }); diff --git a/src/components/calendar/edit/EventEditor.test.tsx b/src/components/calendar/edit/EventEditor.test.tsx index 1f69e77d..a0d04aa7 100644 --- a/src/components/calendar/edit/EventEditor.test.tsx +++ b/src/components/calendar/edit/EventEditor.test.tsx @@ -203,6 +203,8 @@ describe("EventEditor", () => { { outcome: "saved", warnings: [] }, null, "2026-10-12", + false, + false, ); expect(choicesForEdit).not.toHaveBeenCalled(); }); @@ -783,7 +785,7 @@ describe("EventEditor, a repeating event", () => { expect(saveEdit).toHaveBeenCalledWith( "s1", { summary: "Gym" }, - { scope: "all", token: "t-all" }, + { scope: "all", token: "t-all", asAll: false }, "Gym", ); // The very object the choices were asked for: the token binds it. @@ -794,13 +796,50 @@ describe("EventEditor, a repeating event", () => { { outcome: "saved", warnings: [] }, "all", "2026-10-12", + false, + false, + ); + }); + + // C11c review round 2: "This and following" from the first occurrence goes as that row, with its + // token, but saves every occurrence, so the notice is told to say so. + it("tells the notice a 'this and following' pick from the first occurrence saved every one", async () => { + openForEdit.mockResolvedValue(ready(event())); + choicesForEdit.mockResolvedValue( + choices({ following: { row: "as_all", warnings: [], token: "t-first" } }), + ); + saveEdit.mockResolvedValue({ outcome: "saved", warnings: [] }); + const { onClose, dialog } = await retitle(); + const d = inDialog(dialog); + fireEvent.click(await d.findByRole("radio", { name: /^This and following events/ })); + fireEvent.click(d.getByRole("button", { name: "Save" })); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + const picked = saveEdit.mock.calls[0][2]; + expect([picked?.scope, picked?.token]).toEqual(["following", "t-first"]); + expect(noteSaved).toHaveBeenCalledWith( + "Gym", + { outcome: "saved", warnings: [] }, + "following", + "2026-10-12", + false, + true, ); }); it("asks when the editor knows its repeating event, whatever the row says", async () => { openForEdit.mockResolvedValue({ ...ready(event()), - series: { first: false, repeats: "Weekly on Monday" }, + series: { + first: false, + repeats: "Weekly on Monday", + rule: { + pattern: { kind: "weekly", days: ["mon"] }, + interval: 1, + ends: { kind: "never" }, + }, + starts: "2026-10-05", + zone: "Europe/London", + }, } as EditLoad); choicesForEdit.mockResolvedValue(choices()); await open(); @@ -901,7 +940,7 @@ describe("EventEditor, a repeating event", () => { expect(saveEdit).toHaveBeenCalledTimes(1); expect(saveEdit.mock.calls[0][1]).toBe(choicesForEdit.mock.calls[1][1]); expect(saveEdit.mock.calls[0][1]).toEqual({ summary: "Gym (moved)" }); - expect(saveEdit.mock.calls[0][2]).toEqual({ scope: "this", token: "t-this" }); + expect(saveEdit.mock.calls[0][2]).toEqual({ scope: "this", token: "t-this", asAll: false }); }); it("opens the choices when a save without one is answered that it repeats (R9)", async () => { diff --git a/src/components/calendar/edit/EventEditor.tsx b/src/components/calendar/edit/EventEditor.tsx index 3fe38ceb..e4e224f8 100644 --- a/src/components/calendar/edit/EventEditor.tsx +++ b/src/components/calendar/edit/EventEditor.tsx @@ -499,7 +499,14 @@ export function EventEditor({ row, calendar, account, milestone, onDelete, onClo if (outcome.outcome !== "needs_choice") heldNotice.current = null; switch (outcome.outcome) { case "saved": - noteSaved(what.title, outcome, picked?.scope ?? null, what.from); + noteSaved( + what.title, + outcome, + picked?.scope ?? null, + what.from, + what.changes.recurrence?.kind === "once", + picked?.asAll ?? false, + ); stayOpen = false; onClose(); return; diff --git a/src/components/calendar/edit/SaveChoicesDialog.test.tsx b/src/components/calendar/edit/SaveChoicesDialog.test.tsx index f751540b..3fdcfc9a 100644 --- a/src/components/calendar/edit/SaveChoicesDialog.test.tsx +++ b/src/components/calendar/edit/SaveChoicesDialog.test.tsx @@ -106,7 +106,7 @@ describe("SaveChoicesDialog", () => { fireEvent.click(save); fireEvent.click(save); expect(onChoose).toHaveBeenCalledTimes(1); - expect(onChoose).toHaveBeenCalledWith({ scope: "following", token: "f" }); + expect(onChoose).toHaveBeenCalledWith({ scope: "following", token: "f", asAll: false }); }); it("shows rows that can't be chosen disabled, with their reasons", () => { @@ -138,17 +138,24 @@ describe("SaveChoicesDialog", () => { fireEvent.click(radio(/^All events/)); expect(button("Save").disabled).toBe(false); fireEvent.click(button("Save")); - expect(onChoose).toHaveBeenCalledWith({ scope: "all", token: "a" }); + expect(onChoose).toHaveBeenCalledWith({ scope: "all", token: "a", asAll: false }); }); it("says what 'this and following' means from the first occurrence", () => { - open({ view: view(ready(offered("t"), { row: "as_all", warnings: [], token: "x" })) }); + const { onChoose } = open({ + view: view(ready(offered("t"), { row: "as_all", warnings: [], token: "x" })), + }); expect(radio(/^This and following events/)).toBeTruthy(); expect( screen.getByText( "This is the first occurrence, so this changes every occurrence, like All events.", ), ).toBeTruthy(); + // Picked, it goes as the row it is, with its token, and says it saves every occurrence, so the + // notice after it can (C11c review round 2). + fireEvent.click(radio(/^This and following events/)); + fireEvent.click(button("Save")); + expect(onChoose).toHaveBeenCalledWith({ scope: "following", token: "x", asAll: true }); }); it("says the picked row's question in the live region, naming both days", () => { diff --git a/src/components/calendar/edit/SaveChoicesDialog.tsx b/src/components/calendar/edit/SaveChoicesDialog.tsx index 3d5e6580..9c68f013 100644 --- a/src/components/calendar/edit/SaveChoicesDialog.tsx +++ b/src/components/calendar/edit/SaveChoicesDialog.tsx @@ -102,7 +102,7 @@ export function SaveChoicesDialog({ // Once: a second click before the parent unmounts the dialog sends nothing. if (!canSend || sent.current || !selected?.token) return; sent.current = true; - onChoose({ scope: selected.scope, token: selected.token }); + onChoose({ scope: selected.scope, token: selected.token, asAll: selected.asAll }); } const lead = diff --git a/src/components/calendar/edit/useEventWrites.test.ts b/src/components/calendar/edit/useEventWrites.test.ts index 945fa6cd..1edf428a 100644 --- a/src/components/calendar/edit/useEventWrites.test.ts +++ b/src/components/calendar/edit/useEventWrites.test.ts @@ -1029,6 +1029,20 @@ describe("a repeating event", () => { // A one-off's words are as they were. act(() => mod.noteSaved("Dentist", { outcome: "saved", warnings: [] })); expect(hook.result.current.notices[1].text).toBe("Saved “Dentist”."); + // "This and following" from the first occurrence saved every one, and says so (C11c review). + act(() => + mod.noteSaved( + "Gym", + { outcome: "saved", warnings: [] }, + "following", + "2026-10-05", + true, + true, + ), + ); + expect(hook.result.current.notices[2].text).toBe( + "Saved “Gym” as one event that doesn't repeat, on 05-10. To change it back, edit it again.", + ); }); }); diff --git a/src/components/calendar/edit/useEventWrites.ts b/src/components/calendar/edit/useEventWrites.ts index 22bbbc17..0e815923 100644 --- a/src/components/calendar/edit/useEventWrites.ts +++ b/src/components/calendar/edit/useEventWrites.ts @@ -540,15 +540,18 @@ export function noteSeriesNotice(text: string) { /** A save that landed, said once the editor has closed. `scope`: a repeating event's occurrences * saved, which have no Undo, so the notice says how to change them back; `from` (YYYY-MM-DD) is - * where a "this and following" save starts. */ + * where a "this and following" save starts; `stops`: the save made it "Does not repeat"; `asAll`: + * the "this and following" row picked saved every occurrence (`Picked.asAll`). */ export function noteSaved( summary: string, outcome: WriteOutcome, scope: SeriesScope | null = null, from?: string, + stops = false, + asAll = false, ) { if (outcome.outcome !== "saved") return; - put({ id: nextNotice++, ...savedNotice(summary, outcome, scope, from) }); + put({ id: nextNotice++, ...savedNotice(summary, outcome, scope, from, stops, asAll) }); offerMoves(outcome); } diff --git a/src/lib/calendarEdit/editReasons.test.ts b/src/lib/calendarEdit/editReasons.test.ts index 3e3ae8ef..4e596d06 100644 --- a/src/lib/calendarEdit/editReasons.test.ts +++ b/src/lib/calendarEdit/editReasons.test.ts @@ -332,6 +332,59 @@ describe("a repeating event's save, said once the editor has closed", () => { text: "Saved “Gym” from 21-10 on, as a repeating event of its own. Some occurrences you changed on their own may still be on the earlier part, or hidden by Google (02-11); check them in Google Calendar.", }); }); + + // C11c review: "Does not repeat" from one occurrence on makes one event, never a repeating one. + it("words a 'this and following' save that stops it repeating as one event", () => { + expect(savedNotice("Gym", saved([]), "following", "2026-10-21", true)).toEqual({ + tone: "ok", + text: "Saved “Gym” from 21-10 on, as one event that doesn't repeat. There's no Undo: editing it again changes that event, but won't join it back to the earlier ones.", + }); + }); + + // C11c review: a save that leaves its one event outside the months PM syncs says so, warns, and + // never says to edit it again here, where it no longer shows. + it("says a save left outside the months PM syncs won't show, and nothing of editing it again", () => { + expect(savedNotice("Standup", saved(["outside_mirror"]), "all")).toEqual({ + tone: "warn", + text: "Saved “Standup” for every occurrence. It starts outside the months PM syncs, so PM's calendar won't show it.", + }); + }); + + // C11c review round 2: the old occurrences still show until the next refresh, so the calendar + // catching up is still said. The pair "Does not repeat" for every occurrence answers when Google's + // list of what's left stops short, and from one occurrence on when PM's calendar couldn't be + // written. + it("says the calendar catches up when the old occurrences still show beside an event outside", () => { + const both = saved(["outside_mirror", "mirror_refresh_pending"]); + expect(savedNotice("Standup", both, "all", "2026-10-19", true)).toEqual({ + tone: "warn", + text: "Saved “Standup” for every occurrence. It starts outside the months PM syncs, so PM's calendar won't show it. PM's calendar will catch up after the next refresh.", + }); + expect(savedNotice("Standup", both, "following", "2026-12-06", true)).toEqual({ + tone: "warn", + text: "Saved “Standup” from 06-12 on, as one event that doesn't repeat. It starts outside the months PM syncs, so PM's calendar won't show it. PM's calendar will catch up after the next refresh.", + }); + }); + + // C11c review round 2: "This and following" on the first occurrence saves every occurrence, so its + // notice is All's: no part made, no earlier one, and a stop names the one event left. + it("words a 'this and following' save from the first occurrence as every occurrence", () => { + expect(savedNotice("Gym", saved([]), "following", "2026-10-05", false, true)).toEqual({ + tone: "ok", + text: "Saved “Gym” for every occurrence. To change it back, edit it again.", + }); + expect(savedNotice("Gym", saved([]), "following", "2026-10-06", true, true)).toEqual({ + tone: "ok", + text: "Saved “Gym” as one event that doesn't repeat, on 06-10. To change it back, edit it again.", + }); + // Only that row: All's own stop keeps its words, and so does any other row picked. + expect(savedNotice("Gym", saved([]), "all", "2026-10-19", true, false).text).toBe( + "Saved “Gym” for every occurrence. To change it back, edit it again.", + ); + expect(savedNotice("Gym", saved([]), "this", "2026-10-19", false, true).text).toBe( + "Saved “Gym” for this occurrence. To change it back, edit it again.", + ); + }); }); describe("settling a change PM couldn't finish", () => { @@ -415,6 +468,21 @@ describe("settling a change PM couldn't finish", () => { }); }); + // C11c review round 3: a Finish words what landed as a save does. Outside the months PM syncs, + // PM's calendar won't show it, so it catches up rather than shows it, and that's a warning. + it("words a Finish that lands outside the months PM syncs as a save says it", () => { + const both = saved(["outside_mirror", "mirror_refresh_pending"]); + expect(settleText(both, "Standup")).toEqual({ + tone: "warn", + text: "Finished the change to “Standup”. It starts outside the months PM syncs, so PM's calendar won't show it. PM's calendar will catch up after the next refresh.", + }); + expect(settleText(saved(["outside_mirror"]), "Standup").tone).toBe("warn"); + expect(settleText(saved(["mirror_refresh_pending"]), "Standup")).toEqual({ + tone: "ok", + text: "Finished the change to “Standup”. PM's calendar will show it after the next refresh.", + }); + }); + it("says an earlier change blocks this one, hinting at deleting only when Remove is offered", () => { const cases: [SplitAction[], RegExp][] = [ [["finish", "remove"], /Finish it or remove the new part first\.$/], diff --git a/src/lib/calendarEdit/editReasons.ts b/src/lib/calendarEdit/editReasons.ts index 8f19ba93..42881a17 100644 --- a/src/lib/calendarEdit/editReasons.ts +++ b/src/lib/calendarEdit/editReasons.ts @@ -146,7 +146,7 @@ export function writeWarningText( ? `Some occurrences you changed on their own may still be on the earlier part, or hidden by Google${days}; check them in Google Calendar.` : `Some occurrences changed on their own may still show${days}; check them in Google Calendar.`; } - // Only a new event comes back with it. + // A new event comes back with it, and a repeating event saved as one that doesn't repeat. case "outside_mirror": return "It starts outside the months PM syncs, so PM's calendar won't show it."; // A notice per milestone names each one (`milestoneStayedText`); this is the short form. @@ -372,13 +372,10 @@ export function settleText(result: WriteOutcome, title: string): { tone: Tone; t const t = quote(title); switch (result.outcome) { case "saved": { - // A milestone that stayed has a notice of its own, naming it. - const warnings = result.warnings - .filter((w) => w !== "milestone_not_offered") - .map((w) => writeWarningText(w, "save", result.left_behind)); + const said = savedWarnings(result); return { - tone: result.warnings.includes("left_on_old_series") ? "warn" : "ok", - text: [`Finished the change to ${t}.`, ...warnings].join(" "), + tone: said.warn ? "warn" : "ok", + text: [`Finished the change to ${t}.`, ...said.words].join(" "), }; } case "split_removed": @@ -421,19 +418,27 @@ export function settleText(result: WriteOutcome, title: string): { tone: Tone; t /** A save that landed, said once the editor has closed. `scope` names the occurrences of a * repeating event it was for: there's no Undo after one (DECISIONS 17-10 #4), so the notice says * how to change it back, and for "this and following", which makes a repeating event of its own - * from `from` (YYYY-MM-DD), that editing again won't join it back (C9b-1 critique 2 H2). */ + * from `from` (YYYY-MM-DD), that editing again won't join it back (C9b-1 critique 2 H2). `stops`: + * the save made it "Does not repeat", so from `from` on it's one event, not a repeating one. + * `asAll`: the "this and following" row was the first occurrence's, which saves every occurrence + * (`RowView.asAll`), so it's worded as All, a stop naming the day of the one event left, `from` + * (C11c review). One that left the event outside the months PM syncs says so, and never to edit it + * again here (C11c review). */ export function savedNotice( title: string, outcome: Extract, scope: SeriesScope | null, from?: string, + stops = false, + asAll = false, ): { tone: Tone; text: string } { const t = quote(title); const pending = outcome.warnings.includes("mirror_refresh_pending"); - const left = outcome.warnings.includes("left_on_old_series"); + const day = from ? formatDateOnly(from) : null; + const saved: SeriesScope | null = asAll && scope === "following" ? "all" : scope; let first: string; let back: string | null; - switch (scope) { + switch (saved) { case null: first = pending ? `Saved ${t} to Google.` : `Saved ${t}.`; back = null; @@ -442,24 +447,53 @@ export function savedNotice( first = `Saved ${t} for this occurrence.`; back = "To change it back, edit it again."; break; - case "following": - first = `Saved ${t} from ${from ? formatDateOnly(from) : "this occurrence"} on, as a repeating event of its own.`; - back = - "There's no Undo: editing it again changes that part, but won't join it back to the earlier one."; + case "following": { + const since = day ?? "this occurrence"; + first = stops + ? `Saved ${t} from ${since} on, as one event that doesn't repeat.` + : `Saved ${t} from ${since} on, as a repeating event of its own.`; + back = stops + ? "There's no Undo: editing it again changes that event, but won't join it back to the earlier ones." + : "There's no Undo: editing it again changes that part, but won't join it back to the earlier one."; break; + } case "all": - first = `Saved ${t} for every occurrence.`; + // From the first occurrence, the one event left is the one opened, where the draft put it. + first = + stops && asAll + ? `Saved ${t} as one event that doesn't repeat${day ? `, on ${day}` : ""}.` + : `Saved ${t} for every occurrence.`; back = "To change it back, edit it again."; break; default: - return unreachable(scope); + return unreachable(saved); } - const parts = [first]; - if (pending) parts.push(writeWarningText("mirror_refresh_pending", "save")); - // Occurrences left on the earlier part come first: changing it back isn't the whole story. - if (left) parts.push(writeWarningText("left_on_old_series", "save", outcome.left_behind)); - else if (back) parts.push(back); - return { tone: left ? "warn" : "ok", text: parts.join(" ") }; + const said = savedWarnings(outcome); + const parts = [first, ...said.words]; + // With occurrences left on the earlier part, changing it back isn't the whole story; an event + // PM's calendar won't show can't be edited again here. + if (back && !said.warn) parts.push(back); + return { tone: said.warn ? "warn" : "ok", text: parts.join(" ") }; +} + +/** A saved outcome's warnings in words, in one order for every notice that says a save landed + * (`savedNotice`, and `settleText` for a Finish): outside the months PM syncs; then PM's calendar + * catching up, which beside an event outside is in a delete's words, since the saved event won't + * show after a refresh but what it replaced still does until then (C11c review); then occurrences + * left on the earlier part. A milestone that stayed has a notice of its own, naming it. `warn`: the + * user should look, and nothing says to edit it again here. */ +function savedWarnings(outcome: Extract): { + words: string[]; + warn: boolean; +} { + const outside = outcome.warnings.includes("outside_mirror"); + const pending = outcome.warnings.includes("mirror_refresh_pending"); + const left = outcome.warnings.includes("left_on_old_series"); + const words: string[] = []; + if (outside) words.push(writeWarningText("outside_mirror", "save")); + if (pending) words.push(writeWarningText("mirror_refresh_pending", outside ? "delete" : "save")); + if (left) words.push(writeWarningText("left_on_old_series", "save", outcome.left_behind)); + return { words, warn: outside || left }; } // --- creating an event (C10) --- diff --git a/src/lib/calendarEdit/saveChoices.test.ts b/src/lib/calendarEdit/saveChoices.test.ts index 2f894aff..e7875e08 100644 --- a/src/lib/calendarEdit/saveChoices.test.ts +++ b/src/lib/calendarEdit/saveChoices.test.ts @@ -222,7 +222,9 @@ describe("a row Google changed since the editor opened", () => { describe("the warnings", () => { const every: SeriesWarning[] = [ dayMove("every Sunday", "every Monday", 1), + { warning: "becomes_one_event", day: "2026-10-05" }, { warning: "start_moved", from: "2026-10-05", to: "2026-10-06" }, + { warning: "changes_lost", count: 2 }, { warning: "length_changes" }, { warning: "exceptions_may_reset", count: 1, complete: true }, { warning: "not_carried", attachments: true, conference: true, label: true }, @@ -240,7 +242,7 @@ describe("the warnings", () => { } } } - expect(seriesWarningText(every[1], "save", "all", "Gym")).toBe( + expect(seriesWarningText(every[2], "save", "all", "Gym")).toBe( "The first occurrence of “Gym” moves from 05-10 to 06-10.", ); expect(seriesWarningText({ warning: "length_changes" }, "save", "following", "Gym")).toBe( @@ -307,6 +309,7 @@ describe("the warnings", () => { label: "This and following events", detail: "", selectable: true, + asAll: false, token: "f", warnings: [...every].reverse(), }; @@ -325,6 +328,77 @@ describe("the warnings", () => { ).toEqual([seriesWarningText({ warning: "new_identity" }, "save", "following", "Gym")]); }); + it("words a new repeat and 'Does not repeat' for each row (C11 §9.9)", () => { + const say = (w: SeriesWarning, scope: "following" | "all") => + seriesWarningText(w, "save", scope, "Gym"); + const stops: SeriesWarning = { warning: "becomes_one_event", day: "2026-10-05" }; + expect(say(stops, "all")).toBe( + "Only the occurrence on 05-10 stays, as an event that doesn't repeat. Every other occurrence of “Gym” goes.", + ); + // Never "a new repeating event" for one that doesn't repeat (C11 critique 2 M2). + expect(say(stops, "following")).toBe( + "From this occurrence on, “Gym” becomes a separate event that doesn't repeat: this occurrence stays, and every one after it goes.", + ); + expect(say({ warning: "changes_lost", count: 1 }, "following")).toBe( + "One occurrence of “Gym” you changed on its own falls on a day the new repeat skips, so that change goes.", + ); + expect(say({ warning: "changes_lost", count: 3 }, "following")).toBe( + "3 occurrences of “Gym” you changed on their own fall on days the new repeat skips, so those changes go.", + ); + const moved: SeriesWarning = { warning: "start_moved", from: "2026-10-20", to: "2026-10-21" }; + expect(say(moved, "following")).toBe( + "From this occurrence on, the new part of “Gym” starts on 21-10, the first day the new repeat allows.", + ); + expect(say(moved, "all")).toBe("The first occurrence of “Gym” moves from 20-10 to 21-10."); + // The question first, then the stop, where it starts and what's lost, before the rest. + const row: RowView = { + scope: "following", + label: "This and following events", + detail: "", + selectable: true, + asAll: false, + token: "f", + warnings: [ + { warning: "new_identity" }, + { warning: "changes_lost", count: 1 }, + moved, + { warning: "length_changes" }, + stops, + dayMove("every Sunday", "every Monday", 1), + ], + }; + const fromHere = (w: SeriesWarning) => say(w, "following"); + expect(warningLines(row, "save", "Gym")).toEqual([ + fromHere(dayMove("every Sunday", "every Monday", 1)), + fromHere(stops), + fromHere(moved), + fromHere({ warning: "changes_lost", count: 1 }), + fromHere({ warning: "length_changes" }), + fromHere({ warning: "new_identity" }), + ]); + }); + + it("words a 'this and following' row that changes every occurrence as All", () => { + // From the first occurrence: no new part, so never "the new part starts" or "from this one on". + const warnings: SeriesWarning[] = [ + { warning: "start_moved", from: "2026-10-05", to: "2026-10-06" }, + { warning: "becomes_one_event", day: "2026-10-06" }, + ]; + const [, asAll] = rowsOf(ready(offered("t"), { row: "as_all", warnings, token: "x" }), "save"); + expect(asAll).toMatchObject({ scope: "following", asAll: true, token: "x" }); + expect(warningLines(asAll, "save", "Gym")).toEqual([ + "Only the occurrence on 06-10 stays, as an event that doesn't repeat. Every other occurrence of “Gym” goes.", + "The first occurrence of “Gym” moves from 05-10 to 06-10.", + ]); + // The same warnings on a row that makes a new part keep that row's words. + const [, split] = rowsOf(ready(offered("t"), offered("f", warnings)), "save"); + expect(split.asAll).toBe(false); + expect(warningLines(split, "save", "Gym")).toEqual([ + "From this occurrence on, “Gym” becomes a separate event that doesn't repeat: this occurrence stays, and every one after it goes.", + "From this occurrence on, the new part of “Gym” starts on 06-10, the first day the new repeat allows.", + ]); + }); + it("says a re-ask once per kind", () => { expect(againText("save")).toBe( "This repeating event changed since you chose which occurrences to change. Choose again; nothing was saved.", diff --git a/src/lib/calendarEdit/saveChoices.ts b/src/lib/calendarEdit/saveChoices.ts index 88fa2da6..d9aa2d74 100644 --- a/src/lib/calendarEdit/saveChoices.ts +++ b/src/lib/calendarEdit/saveChoices.ts @@ -23,6 +23,10 @@ export type ReadyChoices = Extract; export interface Picked { scope: SeriesScope; token: string; + /** The row was "this and following" from the first occurrence (`RowView.asAll`): what saves is + * every occurrence's plan, so the notice after it speaks as All. Never sent: the write names + * `scope` and `token` only. */ + asAll?: boolean; } function unreachable(value: never): never { @@ -45,6 +49,9 @@ export interface RowView { detail: string; /** Offered, or "this and following" from the first occurrence (every occurrence). */ selectable: boolean; + /** "This and following" from the first occurrence: its warnings are every occurrence's plan's, + * and are worded as All's. */ + asAll: boolean; /** The row's token when selectable. */ token: string | null; warnings: SeriesWarning[]; @@ -82,7 +89,7 @@ function offeredDetail(scope: SeriesScope, asAll: boolean, kind: ChoiceKind, day } function rowView(scope: SeriesScope, row: ScopeRow, kind: ChoiceKind, day?: string): RowView { - const base = { scope, label: LABEL[scope] }; + const base = { scope, label: LABEL[scope], asAll: false }; switch (row.row) { case "offered": case "as_all": @@ -90,6 +97,7 @@ function rowView(scope: SeriesScope, row: ScopeRow, kind: ChoiceKind, day?: stri ...base, detail: offeredDetail(scope, row.row === "as_all", kind, day), selectable: true, + asAll: row.row === "as_all", token: row.token, warnings: row.warnings, }; @@ -183,7 +191,9 @@ export function againText(kind: ChoiceKind): string { const WARNING_ORDER: readonly SeriesWarning["warning"][] = [ "repeat_day_changes", + "becomes_one_event", "start_moved", + "changes_lost", "length_changes", "exceptions_may_reset", "not_carried", @@ -192,13 +202,15 @@ const WARNING_ORDER: readonly SeriesWarning["warning"][] = [ ]; /** The selected row's lines, the question first; equal kinds keep the backend's order. A warning - * with nothing to say (a "not carried" naming nothing) has no line. */ + * with nothing to say (a "not carried" naming nothing) has no line. Each is worded by the plan the + * row was made from: a "this and following" row that changes every occurrence speaks as All. */ export function warningLines(row: RowView, kind: ChoiceKind, title: string): string[] { const rank = (w: SeriesWarning) => WARNING_ORDER.indexOf(w.warning); + const scope: SeriesScope = row.asAll ? "all" : row.scope; return [...row.warnings] .map((w, i) => ({ w, i })) .sort((a, b) => rank(a.w) - rank(b.w) || a.i - b.i) - .map(({ w }) => seriesWarningText(w, kind, row.scope, title)) + .map(({ w }) => seriesWarningText(w, kind, scope, title)) .filter((line) => line !== ""); } @@ -221,7 +233,19 @@ export function seriesWarningText( case "repeat_day_changes": return repeatDayQuestion(title, w, scope); case "start_moved": - return `The first occurrence of ${t} moves from ${formatDateOnly(w.from)} to ${formatDateOnly(w.to)}.`; + // From one occurrence on, only a new repeat moves where the new part starts (C11). + return scope === "following" + ? `From this occurrence on, the new part of ${t} starts on ${formatDateOnly(w.to)}, the first day the new repeat allows.` + : `The first occurrence of ${t} moves from ${formatDateOnly(w.from)} to ${formatDateOnly(w.to)}.`; + case "becomes_one_event": + // "Does not repeat" (C11): never "a new repeating event" (C11 critique 2 M2). + return scope === "following" + ? `From this occurrence on, ${t} becomes a separate event that doesn't repeat: this occurrence stays, and every one after it goes.` + : `Only the occurrence on ${formatDateOnly(w.day)} stays, as an event that doesn't repeat. Every other occurrence of ${t} goes.`; + case "changes_lost": + return w.count === 1 + ? `One occurrence of ${t} you changed on its own falls on a day the new repeat skips, so that change goes.` + : `${w.count} occurrences of ${t} you changed on their own fall on days the new repeat skips, so those changes go.`; case "length_changes": return scope === "following" ? "Every occurrence from this one on will last as long as this one." diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index 3875d6c9..be2c5f83 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -23,6 +23,13 @@ export interface ChangelogEntry { } export const CHANGELOG: ChangelogEntry[] = [ + { + version: "3.153.1-alpha", + date: "2026-10-20", + highlights: [ + "Under the hood, PM can now change how a repeating event repeats, for every occurrence or from one on, ready for the editor in the next update. Stopping a repeat comes after that.", + ], + }, { version: "3.153.0-alpha", date: "2026-10-20", diff --git a/src/lib/types.ts b/src/lib/types.ts index d625a83b..c10f8807 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1580,8 +1580,14 @@ export interface EventPatchDraft { time?: TimeDraft | null; show_as?: ShowAs | null; visibility?: EventVisibility | null; + /** How it repeats from now on; omitted or null leaves it as it is (C11). The editor sends none + * yet: changing how an event repeats is the next step. */ + recurrence?: RepeatDraft | null; } +/** A change to how an event repeats: a rule from the editor's set, or "Does not repeat". */ +export type RepeatDraft = { kind: "rule"; rule: RepeatRule } | { kind: "once" }; + /** A new event, for creating an event: every field as the editor holds it (empty text is left out * of what goes to Google). No guests, reminders or colour yet. */ export interface NewEventDraft { @@ -1710,6 +1716,13 @@ export interface SeriesInfo { first: boolean; /** How it repeats, as Google Calendar says it ("Weekly on Monday"). */ repeats: string; + /** The rule as one from the repeat menu's set, or null for one PM can only show (`repeats` says + * it), which can only be replaced. */ + rule: RepeatRule | null; + /** Its first occurrence's day (YYYY-MM-DD), in the zone it repeats in. */ + starts: string; + /** The zone it repeats in; null for an all-day one, which repeats by days. */ + zone: string | null; } /** What the choices for a change to a repeating occurrence are asked for: an open editor's draft, or @@ -1735,7 +1748,11 @@ export type SeriesWarning = } | { warning: "length_changes" } | { warning: "not_carried"; attachments: boolean; conference: boolean; label: boolean } - | { warning: "new_identity" }; + | { warning: "new_identity" } + /** It stops repeating: only the occurrence on `day` stays, as an event of its own. */ + | { warning: "becomes_one_event"; day: string } + /** This many occurrences changed on their own fall on days a new repeat skips: their changes go. */ + | { warning: "changes_lost"; count: number }; /** One of "This event", "This and following" and "All events". `token` names the plan in the save. */ export type ScopeRow =