From b736be436510a9a43f616dae7db91e50ea729115 Mon Sep 17 00:00:00 2001 From: Bobby Yu <127560404+BBYU26@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:50:04 +0100 Subject: [PATCH 1/7] =?UTF-8?q?chore(calendar):=20the=20repeating-event=20?= =?UTF-8?q?planners=20take=20a=20new=20rule,=20not=20yet=20sent=20?= =?UTF-8?q?=E2=86=92=203.153.1-alpha=20(C11c=20of=20#884)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The series planners now take a new repeat rule or "Does not repeat" for every occurrence or from one on: the rule is rebuilt at its own start, deleted days and changed occurrences follow it where they still fall, it never repeats past a later part, and stopping keeps the first live occurrence as one event. Changing how it repeats is refused for a single occurrence and, for now, for a one-off event. Landing handles a series that became one event, and the save choices word the new warnings. Part of #884 Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 4 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/calendar_recur/expand.rs | 51 + src-tauri/src/calendar_recur/spec.rs | 10 +- src-tauri/src/calendar_write/dto.rs | 73 +- src-tauri/src/calendar_write/patch.rs | 2 + src-tauri/src/calendar_write/series/diff.rs | 16 +- src-tauri/src/calendar_write/series/mod.rs | 265 ++- src-tauri/src/calendar_write/series/plan.rs | 499 ++-- src-tauri/src/calendar_write/series/rule.rs | 2036 +++++++++++++++++ src-tauri/src/calendar_write/series/scope.rs | 157 +- src-tauri/src/commands/calendar_edit.rs | 831 ++++++- src-tauri/tauri.conf.json | 2 +- .../calendar/edit/EventEditor.test.tsx | 12 +- src/lib/calendarEdit/saveChoices.test.ts | 53 +- src/lib/calendarEdit/saveChoices.ts | 16 +- src/lib/changelog.ts | 7 + src/lib/types.ts | 19 +- 20 files changed, 3796 insertions(+), 263 deletions(-) create mode 100644 src-tauri/src/calendar_write/series/rule.rs 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..9face73e 100644 --- a/src-tauri/src/calendar_recur/expand.rs +++ b/src-tauri/src/calendar_recur/expand.rs @@ -287,6 +287,31 @@ pub fn last_before( 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 +778,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/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/mod.rs b/src-tauri/src/calendar_write/series/mod.rs index 50c24908..578c8450 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::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,20 @@ 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, + /// 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), } impl fmt::Display for SeriesRefusal { @@ -713,10 +730,44 @@ 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::NothingToKeep => write!( + f, + "PM can't tell which occurrence should stay once it stops repeating. Change this \ + occurrence on its own." + ), + 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") + ), } } } +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 +823,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 +853,100 @@ 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), + /// 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). +pub fn later_part(parts: &Listing, master: &Master, series: &[String]) -> LaterPart { + if !parts.complete { + return LaterPart::Unread; + } + let zone = master.start.zone(); + 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); + } + } + points + .into_iter() + .min_by(|a, b| a.cmp_in(b, zone)) + .map_or(LaterPart::None, LaterPart::At) +} + +/// 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 +1094,7 @@ pub(crate) mod fixtures { pub exceptions: Exceptions, pub instances: Option, pub sibling: bool, + pub later: LaterPart, pub guests_allowed: bool, } @@ -980,6 +1108,7 @@ pub(crate) mod fixtures { exceptions: complete(Vec::new()), instances: None, sibling: false, + later: LaterPart::None, guests_allowed: false, } } @@ -1009,6 +1138,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 +1707,93 @@ 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, &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, &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, + &series(&["abc", "xyz_R20261019T080000", "abc_R20261102T090000"]) + ), + LaterPart::At(point("2026-11-02T09:00:00Z")) + ); + assert_eq!( + later_part(&two, &weekly_m, &series(&["abc_R20261012T080000Z"])), + LaterPart::At(point("2026-10-12T08:00:00Z")) + ); + assert_eq!( + later_part(&parts(Vec::new()), &weekly_m, &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, + &series(&["abc", "abc_R20261005T080000", "abc_R20261019T080000"]) + ), + LaterPart::None + ); + assert_eq!( + later_part( + &parts(Vec::new()), + &google_part, + &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, &series(&["abc_Rsoon"])), + LaterPart::Unread + ); + let short = Listing { + items: Vec::new(), + complete: false, + }; + assert_eq!(later_part(&short, &weekly_m, &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..4988b98d 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. @@ -499,7 +508,8 @@ pub enum SeriesWarning { /// whole weeks included. 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 +522,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 +550,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 +714,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 +749,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 +881,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 +920,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 +1005,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 +1016,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 +1050,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 +1062,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 +1092,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 +1117,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 +1143,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 +1160,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 +1227,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 +1311,7 @@ pub fn plan_series_delete( series: master.id.clone(), }, start: master.start, + rule_start: master.start, guests_allowed: input.guests_allowed, }) }; @@ -1306,6 +1345,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 +1369,7 @@ pub fn plan_series_delete( left: Vec::new(), }, start: master.start, + rule_start: master.start, guests_allowed: input.guests_allowed, }) } @@ -1372,7 +1413,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 +1432,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 +1469,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 +1521,7 @@ impl SeriesPlan { expect: Vec::new(), removes: Removes::Nothing, start, + rule_start: start, guests_allowed: false, }) } @@ -1484,6 +1536,7 @@ fn probe(start: &SeriesStart) -> SeriesPlan { expect: Vec::new(), removes: Removes::Nothing, start: *start, + rule_start: *start, guests_allowed: false, } } @@ -1534,6 +1587,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 +1630,7 @@ fn plan_this( expect: Vec::new(), removes: Removes::Nothing, start: master.start, + rule_start: master.start, guests_allowed: input.guests_allowed, }) } @@ -1583,8 +1642,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 +1743,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 +1820,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 +1840,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 +1888,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 +1954,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 +2065,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..08c4ca85 --- /dev/null +++ b/src-tauri/src/calendar_write/series/rule.rs @@ -0,0 +1,2036 @@ +// 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::{ + 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, + /// How many days the change moves every occurrence (a day move with the new rule, C11 critique + /// 2 M4): a deleted day is looked for that much later. + pub shift: i64, + /// 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 start isn't on the day asked. + pub moved: bool, +} + +/// 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)?; + // A deleted day stays deleted wherever the new rule still makes it (from its start on). An + // EXDATE it doesn't make is never written: Google refuses one that "doesn't point to a valid + // instance". + let old_zone = ask.old_start.zone(); + let mut deleted = Vec::new(); + for p in ask.deleted { + let Some(day) = moved_day(p.day_in(old_zone), ask.shift) else { + continue; + }; + let Some(candidate) = on_its_day(&synced, day) else { + continue; + }; + if is_occurrence(&facts, &synced, candidate)? { + deleted.push(candidate); + } + } + // 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()); + } + 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, + }) +} + +/// 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 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). +fn clear_of_later(later: LaterPart, rebuilt: &Rebuilt, old_zone: Tz) -> Result<(), NotPlanned> { + let at = match later { + LaterPart::None => return Ok(()), + LaterPart::Unread => return Err(SeriesRefusal::LaterPartUnread.into()), + LaterPart::At(at) => at, + }; + 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(SeriesRefusal::RepeatsPastPart(day).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)?; + // 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. + let from = original.day_in(zone); + let shift = asked + .as_ref() + .map_or(0, |a| (a.start.date() - from).num_days()); + let (wall, length, end_zone) = match &asked { + Some(a) => (a.start, a.length, a.end_zone), + None => (master.start, master.length, master.end_zone), + }; + let first_day = master.first.day_in(zone); + 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"]), + }); + let out_of_range = || NotPlanned::from(SeriesRefusal::Unreadable); + match ask { + RuleAsk::New(spec) => { + let asked_day = moved_day(from, shift).ok_or_else(out_of_range)?; + let day = match spec.pattern { + Pattern::Yearly(YearlyOn::Date) => yearly_anchor(first_day, asked_day), + _ => moved_day(first_day, shift).ok_or_else(out_of_range)?, + }; + 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, + shift, + 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())); + } + if shift != 0 { + 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, + 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, + 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, + mut warnings, + undo, + notify, + } = ask; + let master_json = input.fresh.master.json(); + let zone = master.start.zone(); + let (first, start_zone, end_zone, length) = match (asked, own_time) { + (Some(a), _) => { + let day = moved_day(kept.day_in(zone), 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(), + }); + let mut plan = all_plan(input, master, body, warnings, undo, notify, rule_start)?; + // Every other occurrence goes, as its own words say; what Google does with the ones changed on + // their own is P-R2's to show, and their landing keeps any it still shows. + plan.warnings + .retain(|w| !matches!(w, SeriesWarning::ExceptionsMayReset { .. })); + 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); + let shift = (start.date() - at_day).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, + shift, + extra: &extra, + })?; + // The rest can't start where the part before it still reaches (A29). + let head = last_kept( + master, + &facts, + at, + &cancelled_points(master, input.exceptions)?, + )?; + if head.is_some_and(|h| rebuilt.start.date() <= 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); + let target = moved_day(p.day_in(zone), shift) + .and_then(|day| on_its_day(&rebuilt.start, day)); + let target = match target { + Some(q) if is_occurrence(&rebuilt.facts, &rebuilt.start, q)? => Some(q), + _ => None, + }; + 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, 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`, with `deleted`, + /// no extra dates and no shift. + fn ask<'a>( + spec: &'a RecurrenceSpec, + start: SeriesStart, + deleted: &'a [Point], + old: &'a SeriesStart, + ) -> RebuildAsk<'a> { + RebuildAsk { + spec, + start, + length: Length::Timed(TimeDelta::hours(1)), + end_zone: London, + deleted, + old_start: old, + shift: 0, + 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" + ] + ); + // A day the change moves every occurrence by moves the deleted day with it. + let shifted = RebuildAsk { + shift: 1, + ..ask(&spec, london(2026, 10, 5, 9), &deleted, &old) + }; + assert_eq!( + rebuild(&shifted).unwrap().lines, + [ + "RRULE:FREQ=DAILY", + "EXDATE:20261020T080000Z,20261103T090000Z" + ] + ); + } + + /// 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 + ))) + )); + } + } + + /// 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)).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"])); + } + + 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) + ))) + ); + } + + /// 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, and a deleted day with it. + 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:20261027T090000Z"]) + ); + // 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()); + } + + /// 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)) + ); + } + + /// 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 of its own, which the series' PATCH can't keep, refuses in words. + let coloured = with(plain("2026-10-05T09:00:00+01:00"), "colorId", json!("5")); + assert_eq!( + Case::new(weekly(), monday_19()) + .exceptions(vec![coloured]) + .edit(Scope::All, stop()), + Err(NotPlanned::Refused(SeriesRefusal::KeptHasOwn(day( + 2026, 10, 5 + )))) + ); + } + + /// 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)) + ); + } + + /// 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 + ] + ); + } + + /// 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)) + ))) + ); + } + + /// §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" + ); + } + + /// §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..8b812683 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::{ @@ -67,8 +68,8 @@ use crate::calendar_write::series::scope::{ is_first, scope_options, Change, Row, 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,8 @@ async fn gather( &master, &window, ); + // The same parts, and Google's own `_R` ones the mirror holds, as where a new rule must stop. + let later = later_part(&parts, &master, &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. @@ -2338,6 +2367,7 @@ async fn gather( counted, sibling, parts, + later, #[cfg(debug_assertions)] listing_complete, #[cfg(debug_assertions)] @@ -2471,6 +2501,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 +2733,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 }, } } @@ -3310,6 +3345,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 +3473,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 pending(); + } + 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 +4498,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 +4682,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 +4708,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,7 +4722,7 @@ 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; } @@ -4581,16 +4736,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,21 +4747,26 @@ 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) }); + let outside = outside.then_some(WriteWarning::OutsideMirror); match landed { Ok(()) => { host.landed(cal); - series_saved(Vec::new(), left_behind) + series_saved(outside.into_iter().collect(), left_behind) } Err(_) => { drop_hash(host, cal); @@ -4624,6 +4775,27 @@ async fn land_split_done( } } +/// 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. #[derive(Debug, Clone, PartialEq, Eq)] enum TailCheck { @@ -6748,6 +6920,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 +7493,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 +7525,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] @@ -9756,6 +10002,400 @@ mod tests { assert_eq!(outcome, WriteOutcome::NeedsChoice); } + // --- 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 choices_for_a_rule_change_refuse_this_event_and_offer_the_rest() { + let (series, instance) = (weekly(), monday_19()); + let script = Script::new(october_reads()); + 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(), + }; + assert!(following == past && all == past, "both refused in words"); + } + + /// 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 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 (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"); + } + + /// 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(); + 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, 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"]); + } + + /// 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] @@ -10261,6 +10901,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] @@ -13446,6 +14133,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 +14269,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 +14353,30 @@ mod tests { SeriesRefusal::ZoneChange, SeriesRefusal::SplitOverlap(day), SeriesRefusal::PartsUnread, + SeriesRefusal::RuleIsSeriesWide, + SeriesRefusal::RepeatsPastPart(day), + SeriesRefusal::LaterPartUnread, + SeriesRefusal::NothingToKeep, + SeriesRefusal::KeptHasOwn(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, @@ -13663,6 +14408,12 @@ mod tests { assert!(SeriesRefusal::CannotCarry(day) .to_string() .contains("19-10-2026")); + for dated in [ + SeriesRefusal::RepeatsPastPart(day), + SeriesRefusal::KeptHasOwn(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/EventEditor.test.tsx b/src/components/calendar/edit/EventEditor.test.tsx index 1f69e77d..3ddfb811 100644 --- a/src/components/calendar/edit/EventEditor.test.tsx +++ b/src/components/calendar/edit/EventEditor.test.tsx @@ -800,7 +800,17 @@ describe("EventEditor, a repeating event", () => { 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(); diff --git a/src/lib/calendarEdit/saveChoices.test.ts b/src/lib/calendarEdit/saveChoices.test.ts index 2f894aff..e3870f1b 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( @@ -325,6 +327,55 @@ 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, + 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("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..9166f279 100644 --- a/src/lib/calendarEdit/saveChoices.ts +++ b/src/lib/calendarEdit/saveChoices.ts @@ -183,7 +183,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", @@ -221,7 +223,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..bdc885b9 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, or stop it repeating, ready for the editor in the next update.", + ], + }, { 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 = From 4fcef418560a82c6c1ec2763f103ea568a825334 Mon Sep 17 00:00:00 2001 From: Bobby Yu <127560404+BBYU26@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:00:29 +0100 Subject: [PATCH 2/7] fix(calendar): review round 1 of C11c A deleted extra date stays deleted when a new rule keeps it: the rebuild writes its EXDATE again, for every occurrence and from one on. A yearly-on-its-date rule picked with the first occurrence moved earlier starts where it was put, not a year later. A "this and following" row that changes every occurrence words its warnings as All, so it never speaks of a new part that isn't made. "Does not repeat" for every occurrence keeps the hedge for the other occurrences changed on their own, and says the kept one moves when the draft moved this occurrence to another day. A new daily rule with a moved day asks nothing about which days it repeats on; where it starts says it. The carry of an occurrence's own time onto a new part, the stop's own time and the all-day later-part check now have tests, and the refusal when nothing is left to keep points to Google Calendar instead of a choice that is refused. Part of #884 Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/calendar_write/series/mod.rs | 4 +- src-tauri/src/calendar_write/series/plan.rs | 3 +- src-tauri/src/calendar_write/series/rule.rs | 397 +++++++++++++++++++- src-tauri/src/commands/calendar_edit.rs | 28 ++ src/lib/calendarEdit/saveChoices.test.ts | 23 ++ src/lib/calendarEdit/saveChoices.ts | 12 +- 6 files changed, 445 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/calendar_write/series/mod.rs b/src-tauri/src/calendar_write/series/mod.rs index 578c8450..898b5909 100644 --- a/src-tauri/src/calendar_write/series/mod.rs +++ b/src-tauri/src/calendar_write/series/mod.rs @@ -748,8 +748,8 @@ impl fmt::Display for SeriesRefusal { ), SeriesRefusal::NothingToKeep => write!( f, - "PM can't tell which occurrence should stay once it stops repeating. Change this \ - occurrence on its own." + "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, diff --git a/src-tauri/src/calendar_write/series/plan.rs b/src-tauri/src/calendar_write/series/plan.rs index 4988b98d..f9669c41 100644 --- a/src-tauri/src/calendar_write/series/plan.rs +++ b/src-tauri/src/calendar_write/series/plan.rs @@ -505,7 +505,8 @@ 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 that keeps its rule raises no `StartMoved`, so diff --git a/src-tauri/src/calendar_write/series/rule.rs b/src-tauri/src/calendar_write/series/rule.rs index 08c4ca85..a85bf483 100644 --- a/src-tauri/src/calendar_write/series/rule.rs +++ b/src-tauri/src/calendar_write/series/rule.rs @@ -25,7 +25,7 @@ use crate::calendar_recur::expand::{first_kept, is_occurrence, occurrences_from, use crate::calendar_recur::lines::{self, Kind, RuleFacts, TextOnly}; use crate::calendar_recur::remap::{exdate_lines, RetimeError}; use crate::calendar_recur::spec::{ - rule_from_spec, spec_of, Ends, Pattern, RecurrenceSpec, SpecError, YearlyOn, + 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}; @@ -134,12 +134,35 @@ pub(super) fn rebuild(ask: &RebuildAsk) -> Result { 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)?; - // A deleted day stays deleted wherever the new rule still makes it (from its start on). An - // EXDATE it doesn't make is never written: Google refuses one that "doesn't point to a valid - // instance". + // 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)?, + ); + } + } + } + // 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 old_zone = ask.old_start.zone(); let mut deleted = Vec::new(); for p in ask.deleted { + if extra_points.contains(p) { + deleted.push(*p); + continue; + } let Some(day) = moved_day(p.day_in(old_zone), ask.shift) else { continue; }; @@ -150,12 +173,6 @@ pub(super) fn rebuild(ask: &RebuildAsk) -> Result { deleted.push(candidate); } } - // 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()); - } let mut out = vec![rule]; out.extend(exdate_lines(&deleted).into_iter().map(|l| l.raw)); out.extend(ask.extra.iter().cloned()); @@ -425,9 +442,11 @@ pub(super) fn plan_all_rule( 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 { - Pattern::Yearly(YearlyOn::Date) => yearly_anchor(first_day, asked_day), - _ => moved_day(first_day, shift).ok_or_else(out_of_range)?, + // 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() @@ -458,7 +477,8 @@ pub(super) fn plan_all_rule( if !same { body.insert("recurrence".into(), Value::from(rebuilt.lines.clone())); } - if shift != 0 { + // 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, @@ -585,11 +605,35 @@ fn stop_body(ask: StopAsk) -> Result { 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_in(zone), + 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; what Google does with the ones changed on - // their own is P-R2's to show, and their landing keeps any it still shows. + // 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) } @@ -1128,6 +1172,38 @@ mod tests { } } + /// 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() { @@ -1219,12 +1295,29 @@ mod tests { plain("2026-10-12T09:00:00+01:00"), ); let yearly = rule(RepeatPattern::YearlyDate, 1, RepeatEnds::Never); - let plan = case.edit(Scope::All, repeat(yearly)).unwrap(); + 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"; @@ -1403,6 +1496,15 @@ mod tests { sent["recurrence"], json!(["RRULE:FREQ=DAILY", "EXDATE:20261027T090000Z"]) ); + // 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 { @@ -1438,6 +1540,59 @@ mod tests { 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 + ]) + ); + } + } + /// 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() { @@ -1519,6 +1674,90 @@ mod tests { Case::new(only_extra, extra).edit(Scope::All, stop()), Err(NotPlanned::Refused(SeriesRefusal::NothingToKeep)) ); + // 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. @@ -1865,6 +2104,114 @@ mod tests { ); } + /// 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()); + } + } + /// 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() { @@ -1987,6 +2334,24 @@ mod tests { ); } + /// "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) + }] + ); + } + /// §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() { diff --git a/src-tauri/src/commands/calendar_edit.rs b/src-tauri/src/commands/calendar_edit.rs index 8b812683..6ca4c974 100644 --- a/src-tauri/src/commands/calendar_edit.rs +++ b/src-tauri/src/commands/calendar_edit.rs @@ -10118,6 +10118,34 @@ mod tests { assert!(following == past && all == past, "both refused in words"); } + /// 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 daily_from_tuesday = EventPatchDraft { + recurrence: Some(crate::calendar_write::dto::RepeatDraft::Rule { rule: every_day() }), + ..at("09:00", "2026-10-20") + }; + 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" + ); + } + /// 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] diff --git a/src/lib/calendarEdit/saveChoices.test.ts b/src/lib/calendarEdit/saveChoices.test.ts index e3870f1b..e7875e08 100644 --- a/src/lib/calendarEdit/saveChoices.test.ts +++ b/src/lib/calendarEdit/saveChoices.test.ts @@ -309,6 +309,7 @@ describe("the warnings", () => { label: "This and following events", detail: "", selectable: true, + asAll: false, token: "f", warnings: [...every].reverse(), }; @@ -355,6 +356,7 @@ describe("the warnings", () => { label: "This and following events", detail: "", selectable: true, + asAll: false, token: "f", warnings: [ { warning: "new_identity" }, @@ -376,6 +378,27 @@ describe("the warnings", () => { ]); }); + 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 9166f279..91cbec90 100644 --- a/src/lib/calendarEdit/saveChoices.ts +++ b/src/lib/calendarEdit/saveChoices.ts @@ -45,6 +45,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 +85,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 +93,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, }; @@ -194,13 +198,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 !== ""); } From 3795aa9de4e90a0d081854b7bbe63e6139f04616 Mon Sep 17 00:00:00 2001 From: Bobby Yu <127560404+BBYU26@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:50:15 +0100 Subject: [PATCH 3/7] fix(calendar): review round 1 of C11c, part 2 A series that is itself a part of one changed "this and following" before, by PM's note or Google's id, is held to where its own rule ends now: a new rule for it, or from one of its occurrences on, never repeats on a day after its last occurrence, since PM can't list the parts of the series it came from. One that repeats for ever has no part after it. A new rule keeps an occurrence deleted or changed on its own on its own day wherever the rule still makes that day. Only a day it doesn't make moves with the day the draft moved, and never onto a day the old series had an occurrence of its own. The occurrence opened still goes where the draft put it, and an extra date kept takes its own changes where it is. "This and following" into another time zone measures the part before in the series' own zone, so the new part can't start on the day it still reaches. A lost answer to a new rule or to "Does not repeat" says what may be half done instead of speaking of moved occurrences. A record of "Does not repeat" from one occurrence on, left from before a restart, says to delete the event that doesn't repeat, and that occurrences changed on their own may still show. The notice after a save that left its event outside the months PM syncs says so, warns, and drops "edit it again"; "this and following" with "Does not repeat" is worded as one event that doesn't repeat. The rule paths' own guards (R2 on the occurrence and on the series, guests, formatting, the new part's id, what the kept occurrence carries, an unreadable line on a stop) now have tests. Part of #884 Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/calendar_recur/expand.rs | 15 + .../src/calendar_write/series/journal.rs | 11 + src-tauri/src/calendar_write/series/mod.rs | 188 +++++- src-tauri/src/calendar_write/series/rule.rs | 545 ++++++++++++++++-- src-tauri/src/commands/calendar_edit.rs | 172 +++++- .../calendar/edit/EventEditor.test.tsx | 2 + src/components/calendar/edit/EventEditor.tsx | 8 +- .../calendar/edit/useEventWrites.ts | 5 +- src/lib/calendarEdit/editReasons.test.ts | 29 + src/lib/calendarEdit/editReasons.ts | 33 +- 10 files changed, 936 insertions(+), 72 deletions(-) diff --git a/src-tauri/src/calendar_recur/expand.rs b/src-tauri/src/calendar_recur/expand.rs index 9face73e..4cc67988 100644 --- a/src-tauri/src/calendar_recur/expand.rs +++ b/src-tauri/src/calendar_recur/expand.rs @@ -287,6 +287,21 @@ 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. 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 898b5909..8694a2ac 100644 --- a/src-tauri/src/calendar_write/series/mod.rs +++ b/src-tauri/src/calendar_write/series/mod.rs @@ -38,7 +38,7 @@ 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; @@ -637,6 +637,9 @@ pub enum SeriesRefusal { 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. @@ -746,6 +749,13 @@ impl fmt::Display for SeriesRefusal { "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 \ @@ -888,6 +898,10 @@ pub enum LaterPart { 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, @@ -896,11 +910,29 @@ pub enum LaterPart { /// 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). -pub fn later_part(parts: &Listing, master: &Master, series: &[String]) -> LaterPart { +/// +/// 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 { @@ -922,10 +954,39 @@ pub fn later_part(parts: &Listing, master: &Master, series: &[String]) -> LaterP points.push(p); } } - points - .into_iter() - .min_by(|a, b| a.cmp_in(b, zone)) - .map_or(LaterPart::None, LaterPart::At) + 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) = lines::series_rule(&parsed).map_err(RecurError::TextOnly)?; + 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))) } /// A series id as Google's split names it: its base, and the stamp after `_R` when it has one. @@ -1727,7 +1788,7 @@ mod tests { 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, &none), + later_part(&parts(Vec::new()), &weekly_m, false, &none), LaterPart::None ); // The earlier of two of PM's, a cancelled one none. @@ -1741,7 +1802,7 @@ mod tests { ), ]); assert_eq!( - later_part(&two, &weekly_m, &none), + 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. @@ -1750,16 +1811,22 @@ mod tests { 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, &series(&["abc_R20261012T080000Z"])), + 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, &series(&["abc_R20261102"])), + 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. @@ -1773,6 +1840,7 @@ mod tests { later_part( &parts(Vec::new()), &google_part, + false, &series(&["abc", "abc_R20261005T080000", "abc_R20261019T080000"]) ), LaterPart::None @@ -1781,19 +1849,115 @@ mod tests { 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, &series(&["abc_Rsoon"])), + 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, &none), LaterPart::Unread); + 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 + ); } } diff --git a/src-tauri/src/calendar_write/series/rule.rs b/src-tauri/src/calendar_write/series/rule.rs index a85bf483..05af6243 100644 --- a/src-tauri/src/calendar_write/series/rule.rs +++ b/src-tauri/src/calendar_write/series/rule.rs @@ -102,8 +102,10 @@ pub(super) struct RebuildAsk<'a> { 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): a deleted day is looked for that much later. + /// 2 M4): a deleted day the new rule doesn't make is looked for that much later ([`lands_at`]). pub shift: i64, /// The old series' RDATE lines, as Google wrote them. pub extra: &'a [String], @@ -120,6 +122,8 @@ pub(super) struct Rebuilt { 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, } @@ -157,20 +161,15 @@ pub(super) fn rebuild(ask: &RebuildAsk) -> Result { // 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 old_zone = ask.old_start.zone(); + let old = ask.old_rule.as_ref().map(|f| (f, ask.old_start)); let mut deleted = Vec::new(); for p in ask.deleted { if extra_points.contains(p) { deleted.push(*p); continue; } - let Some(day) = moved_day(p.day_in(old_zone), ask.shift) else { - continue; - }; - let Some(candidate) = on_its_day(&synced, day) else { - continue; - }; - if is_occurrence(&facts, &synced, candidate)? { - deleted.push(candidate); + if let Some(q) = lands_at(&facts, &synced, old, p.day_in(old_zone), ask.shift)? { + deleted.push(q); } } let mut out = vec![rule]; @@ -184,6 +183,7 @@ pub(super) fn rebuild(ask: &RebuildAsk) -> Result { nodes, lines: out, facts, + extra: extra_points, }) } @@ -225,6 +225,40 @@ fn on_its_day(start: &SeriesStart, day: NaiveDate) -> Option { } } +/// Where an occurrence the old series had on `day`, deleted or changed on its own, is in the series +/// rebuilt from `start` under `facts` (C11c review): 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 (`shift`), that +/// many days on, but only where the old series (`old`, its rule and start) had no occurrence of its +/// own, since which of the two it is would then be a guess; else nowhere. +fn lands_at( + facts: &RuleFacts, + start: &SeriesStart, + old: Option<(&RuleFacts, &SeriesStart)>, + day: NaiveDate, + shift: i64, +) -> Result, RecurError> { + let made = |d: NaiveDate| -> Result, RecurError> { + match on_its_day(start, d) { + Some(q) if is_occurrence(facts, start, q)? => Ok(Some(q)), + _ => Ok(None), + } + }; + if let Some(q) = made(day)? { + return Ok(Some(q)); + } + let (Some((old_facts, old_start)), Some(moved)) = (old, moved_day(day, shift)) else { + return Ok(None); + }; + let taken = match on_its_day(old_start, moved) { + Some(q) => is_occurrence(old_facts, old_start, q)?, + None => true, + }; + if taken { + return Ok(None); + } + made(moved) +} + /// 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 { @@ -252,12 +286,17 @@ fn same_wall_time(a: &SeriesStart, b: &SeriesStart) -> bool { /// 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). +/// 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 = match later { + let (at, refusal) = match later { LaterPart::None => return Ok(()), LaterPart::Unread => return Err(SeriesRefusal::LaterPartUnread.into()), - LaterPart::At(at) => at, + 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) { @@ -273,7 +312,7 @@ fn clear_of_later(later: LaterPart, rebuilt: &Rebuilt, old_zone: Tz) -> Result<( if occurrences_from(&rebuilt.facts, &rebuilt.start, from, 1)?.is_empty() { Ok(()) } else { - Err(SeriesRefusal::RepeatsPastPart(day).into()) + Err(refusal.into()) } } @@ -460,6 +499,9 @@ pub(super) fn plan_all_rule( end_zone, deleted: &deleted, old_start: &master.start, + old_rule: lines::series_rule(&lines::parse(&master.recurrence)) + .ok() + .map(|(_, facts)| facts), shift, extra: &extra, })?; @@ -729,17 +771,24 @@ pub(super) fn plan_split_rule( end_zone, deleted: &deleted, old_start: &master.start, + old_rule: Some(facts.clone()), shift, extra: &extra, })?; - // The rest can't start where the part before it still reaches (A29). + // 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)?, )?; - if head.is_some_and(|h| rebuilt.start.date() <= h.day_in(zone)) { + 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)?; @@ -840,11 +889,26 @@ pub(super) fn plan_split_rule( let moved_time = split_itself && asked.is_some(); let carried = carried_fields(item, &carry, split_itself, &draft_keys, moved_time, all_day); - let target = moved_day(p.day_in(zone), shift) - .and_then(|day| on_its_day(&rebuilt.start, day)); - let target = match target { - Some(q) if is_occurrence(&rebuilt.facts, &rebuilt.start, q)? => Some(q), - _ => None, + // 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 ([`lands_at`]). + let target = if rebuilt.extra.contains(p) { + Some(*p) + } else if split_itself { + match moved_day(p.day_in(zone), shift) + .and_then(|day| on_its_day(&rebuilt.start, day)) + { + Some(q) if is_occurrence(&rebuilt.facts, &rebuilt.start, q)? => Some(q), + _ => None, + } + } else { + lands_at( + &rebuilt.facts, + &rebuilt.start, + Some((&facts, &master.start)), + p.day_in(zone), + shift, + )? }; match (carried.is_empty(), target) { (true, _) => None, @@ -1053,14 +1117,15 @@ mod tests { london(2026, 10, 5, 9) } - /// `ask` for `spec` from `start` an hour long, the old series starting at `old`, with `deleted`, - /// no extra dates and no shift. + /// `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. 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"]); RebuildAsk { spec, start, @@ -1068,6 +1133,7 @@ mod tests { end_zone: London, deleted, old_start: old, + old_rule: lines::series_rule(&mondays).ok().map(|(_, facts)| facts), shift: 0, extra: &[], } @@ -1112,18 +1178,55 @@ mod tests { "EXDATE:20261019T090000Z,20261102T100000Z" ] ); - // A day the change moves every occurrence by moves the deleted day with it. + // 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, 5, 9), &deleted, &old) + ..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"] + ); + // Nor when PM can't read the old rule. + let unread = RebuildAsk { + shift: 1, + old_rule: None, + ..ask(&tuesdays, london(2026, 10, 6, 9), &deleted, &old) + }; + assert_eq!( + rebuild(&unread).unwrap().lines, + ["RRULE:FREQ=WEEKLY;BYDAY=TU"] + ); } /// An extra date (RDATE) is kept as Google wrote it while the series' times stay; moved, it's @@ -1478,7 +1581,8 @@ mod tests { } ] ); - // A daily rule names no day: the series' first moves by as much, and a deleted day with it. + // 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", @@ -1494,7 +1598,7 @@ mod tests { assert_eq!(sent["start"], timed_node("2026-10-06T09:00:00+01:00")); assert_eq!( sent["recurrence"], - json!(["RRULE:FREQ=DAILY", "EXDATE:20261027T090000Z"]) + 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. @@ -1674,6 +1778,18 @@ mod tests { 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(), @@ -1793,16 +1909,27 @@ mod tests { body(&case.edit(Scope::All, retro).unwrap(), 0)["summary"], "Retro" ); - // A colour of its own, which the series' PATCH can't keep, refuses in words. - let coloured = with(plain("2026-10-05T09:00:00+01:00"), "colorId", json!("5")); - assert_eq!( - Case::new(weekly(), monday_19()) - .exceptions(vec![coloured]) - .edit(Scope::All, stop()), - Err(NotPlanned::Refused(SeriesRefusal::KeptHasOwn(day( - 2026, 10, 5 - )))) - ); + // 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 @@ -1841,6 +1968,40 @@ mod tests { ); } + /// 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. @@ -2104,6 +2265,126 @@ mod tests { ); } + /// 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" + )); + } + + /// 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( @@ -2289,6 +2570,89 @@ mod tests { ); } + /// 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. @@ -2352,6 +2716,115 @@ mod tests { ); } + // --- 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() { diff --git a/src-tauri/src/commands/calendar_edit.rs b/src-tauri/src/commands/calendar_edit.rs index 6ca4c974..abe1979f 100644 --- a/src-tauri/src/commands/calendar_edit.rs +++ b/src-tauri/src/commands/calendar_edit.rs @@ -65,7 +65,7 @@ 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::{ later_part, original_of, parts_overlap, text_only, Exceptions, FreshEvent, LaterPart, Listing, @@ -2320,8 +2320,14 @@ async fn gather( &master, &window, ); - // The same parts, and Google's own `_R` ones the mirror holds, as where a new rule must stop. - let later = later_part(&parts, &master, &mirror.series_ids); + // 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. @@ -2356,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, @@ -2771,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 @@ -3294,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 @@ -5244,12 +5258,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() @@ -5268,6 +5291,7 @@ fn journal_entry( before: lines(old.get("recurrence"))?, cut: lines(sent.get("recurrence"))?, cleanup: plan.steps.len() > facts.insert + 2, + one_off, }) } @@ -5327,6 +5351,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 \ @@ -9583,6 +9619,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] @@ -10118,6 +10185,50 @@ mod tests { 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" } }), + ); + 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] @@ -10307,8 +10418,14 @@ mod tests { .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))), + 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))), ]); @@ -12173,6 +12290,7 @@ mod tests { before: lines(&old["recurrence"]), cut: lines(&sent["recurrence"]), cleanup, + one_off: false, } } @@ -12280,6 +12398,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. @@ -12295,6 +12438,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 { @@ -14384,6 +14528,7 @@ mod tests { SeriesRefusal::RuleIsSeriesWide, SeriesRefusal::RepeatsPastPart(day), SeriesRefusal::LaterPartUnread, + SeriesRefusal::RepeatsPastItsEnd(day), SeriesRefusal::NothingToKeep, SeriesRefusal::KeptHasOwn(day), ]) @@ -14420,7 +14565,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"] { @@ -14438,6 +14587,7 @@ mod tests { .contains("19-10-2026")); for dated in [ SeriesRefusal::RepeatsPastPart(day), + SeriesRefusal::RepeatsPastItsEnd(day), SeriesRefusal::KeptHasOwn(day), ] { assert!(dated.to_string().contains("19-10-2026")); diff --git a/src/components/calendar/edit/EventEditor.test.tsx b/src/components/calendar/edit/EventEditor.test.tsx index 3ddfb811..7e955261 100644 --- a/src/components/calendar/edit/EventEditor.test.tsx +++ b/src/components/calendar/edit/EventEditor.test.tsx @@ -203,6 +203,7 @@ describe("EventEditor", () => { { outcome: "saved", warnings: [] }, null, "2026-10-12", + false, ); expect(choicesForEdit).not.toHaveBeenCalled(); }); @@ -794,6 +795,7 @@ describe("EventEditor, a repeating event", () => { { outcome: "saved", warnings: [] }, "all", "2026-10-12", + false, ); }); diff --git a/src/components/calendar/edit/EventEditor.tsx b/src/components/calendar/edit/EventEditor.tsx index 3fe38ceb..fc97b5c3 100644 --- a/src/components/calendar/edit/EventEditor.tsx +++ b/src/components/calendar/edit/EventEditor.tsx @@ -499,7 +499,13 @@ 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", + ); stayOpen = false; onClose(); return; diff --git a/src/components/calendar/edit/useEventWrites.ts b/src/components/calendar/edit/useEventWrites.ts index 22bbbc17..bbd2c6b2 100644 --- a/src/components/calendar/edit/useEventWrites.ts +++ b/src/components/calendar/edit/useEventWrites.ts @@ -540,15 +540,16 @@ 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". */ export function noteSaved( summary: string, outcome: WriteOutcome, scope: SeriesScope | null = null, from?: string, + stops = false, ) { if (outcome.outcome !== "saved") return; - put({ id: nextNotice++, ...savedNotice(summary, outcome, scope, from) }); + put({ id: nextNotice++, ...savedNotice(summary, outcome, scope, from, stops) }); offerMoves(outcome); } diff --git a/src/lib/calendarEdit/editReasons.test.ts b/src/lib/calendarEdit/editReasons.test.ts index 3e3ae8ef..1a49fe2c 100644 --- a/src/lib/calendarEdit/editReasons.test.ts +++ b/src/lib/calendarEdit/editReasons.test.ts @@ -332,6 +332,35 @@ 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.", + }); + expect( + savedNotice( + "Standup", + saved(["outside_mirror", "mirror_refresh_pending"]), + "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.", + }); + }); }); describe("settling a change PM couldn't finish", () => { diff --git a/src/lib/calendarEdit/editReasons.ts b/src/lib/calendarEdit/editReasons.ts index 8f19ba93..1e7dfb75 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. @@ -421,15 +421,21 @@ 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. 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, ): { tone: Tone; text: string } { const t = quote(title); - const pending = outcome.warnings.includes("mirror_refresh_pending"); + const outside = outcome.warnings.includes("outside_mirror"); + // Off PM's calendar, there's nothing for a refresh to show. + const pending = outcome.warnings.includes("mirror_refresh_pending") && !outside; const left = outcome.warnings.includes("left_on_old_series"); let first: string; let back: string | null; @@ -442,11 +448,16 @@ 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 day = from ? formatDateOnly(from) : "this occurrence"; + first = stops + ? `Saved ${t} from ${day} on, as one event that doesn't repeat.` + : `Saved ${t} from ${day} 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.`; back = "To change it back, edit it again."; @@ -455,11 +466,13 @@ export function savedNotice( return unreachable(scope); } const parts = [first]; + if (outside) parts.push(writeWarningText("outside_mirror", "save")); 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. + // Occurrences left on the earlier part come first: changing it back isn't the whole story. An + // event PM's calendar won't show can't be edited again here. 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(" ") }; + else if (back && !outside) parts.push(back); + return { tone: left || outside ? "warn" : "ok", text: parts.join(" ") }; } // --- creating an event (C10) --- From f7ea0cf25a97cb0d4652f661d083d08be4dd6eb0 Mon Sep 17 00:00:00 2001 From: Bobby Yu <127560404+BBYU26@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:51:05 +0100 Subject: [PATCH 4/7] fix(calendar): C11c's What's New doesn't promise stopping a repeat C11d keeps "Does not repeat" on a repeating event for dev builds until the live probe records what Google does with occurrences changed on their own, so the next update can't stop a repeat yet. Part of #884 Co-Authored-By: Claude Opus 5.5 --- src/lib/changelog.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index bdc885b9..be2c5f83 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -27,7 +27,7 @@ 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, or stop it repeating, ready for the editor in the next update.", + "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.", ], }, { From d85c2f58ed89a8291a0a8be957469d5061c73d78 Mon Sep 17 00:00:00 2001 From: Bobby Yu <127560404+BBYU26@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:58:35 +0100 Subject: [PATCH 5/7] fix(calendar): review round 2 of C11c A new rule keeps the slot the draft gives the occurrence opened for that occurrence alone: an occurrence deleted on that day writes no EXDATE there, and one changed on its own there is counted as lost rather than carried over it, for every occurrence and from this one on. A move into another time zone reads each occurrence's day, and how far the day moved, in the zone it moves into, so a move that keeps every moment keeps each deletion and each change at its own moment. With an old rule PM can only show as text, a deletion that would move with the days onto a day the new rule makes is refused in words, never dropped, since PM can't tell whether that day had an occurrence of its own. A part whose rule PM can only show as text, and that repeats for ever, has no later part, so a new rule for it is offered again. "This and following" picked on the first occurrence saves every occurrence, and its notice now says so; with "Does not repeat" it names the day of the one event left. The scope and token sent are unchanged. An event saved outside the months PM syncs is said to be outside even when PM's calendar couldn't be written or the re-fetch never showed the change, and the notice keeps saying the calendar will catch up after the next refresh, since the old occurrences still show until then. Part of #884 Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/calendar_recur/lines.rs | 6 + src-tauri/src/calendar_write/series/mod.rs | 49 +- src-tauri/src/calendar_write/series/rule.rs | 432 +++++++++++++++--- src-tauri/src/commands/calendar_edit.rs | 128 +++++- .../calendar/edit/DeleteEventDialog.test.tsx | 6 +- .../calendar/edit/EventEditor.test.tsx | 31 +- src/components/calendar/edit/EventEditor.tsx | 1 + .../calendar/edit/SaveChoicesDialog.test.tsx | 13 +- .../calendar/edit/SaveChoicesDialog.tsx | 2 +- .../calendar/edit/useEventWrites.test.ts | 14 + .../calendar/edit/useEventWrites.ts | 6 +- src/lib/calendarEdit/editReasons.test.ts | 44 +- src/lib/calendarEdit/editReasons.ts | 34 +- src/lib/calendarEdit/saveChoices.ts | 4 + 14 files changed, 674 insertions(+), 96 deletions(-) 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_write/series/mod.rs b/src-tauri/src/calendar_write/series/mod.rs index 8694a2ac..15faa31e 100644 --- a/src-tauri/src/calendar_write/series/mod.rs +++ b/src-tauri/src/calendar_write/series/mod.rs @@ -644,6 +644,9 @@ pub enum SeriesRefusal { 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 { @@ -768,6 +771,13 @@ impl fmt::Display for SeriesRefusal { 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") + ), } } } @@ -967,7 +977,13 @@ pub fn later_part( /// 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) = lines::series_rule(&parsed).map_err(RecurError::TextOnly)?; + 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); } @@ -989,6 +1005,14 @@ fn own_end(master: &Master) -> Result, RecurError> { 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") { @@ -1959,5 +1983,28 @@ mod tests { 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/rule.rs b/src-tauri/src/calendar_write/series/rule.rs index 05af6243..6f140cd0 100644 --- a/src-tauri/src/calendar_write/series/rule.rs +++ b/src-tauri/src/calendar_write/series/rule.rs @@ -105,8 +105,12 @@ pub(super) struct RebuildAsk<'a> { /// 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): a deleted day the new rule doesn't make is looked for that much later ([`lands_at`]). + /// 2 M4), counted in the days [`day_zone`] reads: a deleted day the new rule doesn't make is + /// looked for that much later ([`Landing`]). pub shift: i64, + /// 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). + pub opened: NaiveDate, /// The old series' RDATE lines, as Google wrote them. pub extra: &'a [String], } @@ -126,6 +130,11 @@ pub(super) struct Rebuilt { pub extra: Vec, /// The start isn't on the day asked. pub moved: bool, + /// The zone an old occurrence's day is read in ([`day_zone`]). + pub frame: Tz, + /// 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 @@ -157,18 +166,29 @@ pub(super) fn rebuild(ask: &RebuildAsk) -> Result { } } } + let opened = match on_its_day(&synced, ask.opened) { + Some(q) if is_occurrence(&facts, &synced, q)? => Some(q), + _ => None, + }; + let frame = day_zone(ask.old_start, &synced); // 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 old_zone = ask.old_start.zone(); - let old = ask.old_rule.as_ref().map(|f| (f, ask.old_start)); + let landing = Landing { + facts: &facts, + start: &synced, + old: ask.old_rule.as_ref().map(|f| (f, ask.old_start)), + shift: ask.shift, + frame, + opened, + }; let mut deleted = Vec::new(); for p in ask.deleted { if extra_points.contains(p) { deleted.push(*p); continue; } - if let Some(q) = lands_at(&facts, &synced, old, p.day_in(old_zone), ask.shift)? { + if let Some(q) = landing.at(*p)? { deleted.push(q); } } @@ -184,9 +204,22 @@ pub(super) fn rebuild(ask: &RebuildAsk) -> Result { lines: out, facts, extra: extra_points, + frame, + opened, }) } +/// The zone an old occurrence's day is read in, to place it in the series rebuilt from `new`: the +/// rebuilt series' own when both are timed, so a move into another zone that keeps every moment +/// moves no day (C11c review); the old series' across a change between timed and all-day, whose +/// day is its wall day. +fn day_zone(old: &SeriesStart, new: &SeriesStart) -> Tz { + match (old, new) { + (SeriesStart::Timed { .. }, SeriesStart::Timed { zone, .. }) => *zone, + _ => old.zone(), + } +} + /// 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. @@ -225,38 +258,76 @@ fn on_its_day(start: &SeriesStart, day: NaiveDate) -> Option { } } -/// Where an occurrence the old series had on `day`, deleted or changed on its own, is in the series -/// rebuilt from `start` under `facts` (C11c review): 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 (`shift`), that -/// many days on, but only where the old series (`old`, its rule and start) had no occurrence of its -/// own, since which of the two it is would then be a guess; else nowhere. -fn lands_at( - facts: &RuleFacts, - start: &SeriesStart, - old: Option<(&RuleFacts, &SeriesStart)>, - day: NaiveDate, +/// 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 `frame`'s days. shift: i64, -) -> Result, RecurError> { - let made = |d: NaiveDate| -> Result, RecurError> { - match on_its_day(start, d) { - Some(q) if is_occurrence(facts, start, q)? => Ok(Some(q)), + /// The zone an occurrence's day is read in ([`day_zone`]). + frame: Tz, + /// 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. + fn at(&self, p: Point) -> Result, NotPlanned> { + let day = p.day_in(self.frame); + 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 has_day(facts, start, moved, self.frame)? => None, + Some(_) => self.made(moved)?, + None => match self.made(moved)? { + Some(_) => return Err(SeriesRefusal::DeletedDayUnread(day).into()), + None => None, + }, + }, + }, + }; + Ok(landed.filter(|q| Some(*q) != self.opened)) + } + + /// 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), } - }; - if let Some(q) = made(day)? { - return Ok(Some(q)); } - let (Some((old_facts, old_start)), Some(moved)) = (old, moved_day(day, shift)) else { - return Ok(None); - }; - let taken = match on_its_day(old_start, moved) { - Some(q) => is_occurrence(old_facts, old_start, q)?, - None => true, +} + +/// Whether the series from `start` under `facts` has an occurrence on `day`, read in `frame`. 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, + frame: Tz, +) -> Result { + let from = match start { + SeriesStart::AllDay(_) => Point::Day(day), + SeriesStart::Timed { .. } => match rfc_instant(frame, day.and_time(chrono::NaiveTime::MIN)) + { + Some(t) => Point::At(t), + None => return Ok(true), + }, }; - if taken { - return Ok(None); - } - made(moved) + Ok(occurrences_from(facts, start, from, 1)? + .first() + .is_some_and(|q| q.day_in(frame) == day)) } /// Where one event that doesn't repeat starts, at `start`: a time the clocks skip that day is refused @@ -459,17 +530,19 @@ pub(super) fn plan_all_rule( let mut body = build_patch(master_json, &text)?; let zone = master.start.zone(); let asked = asked_time(draft, instance)?; - // 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. - let from = original.day_in(zone); - let shift = asked - .as_ref() - .map_or(0, |a| (a.start.date() - from).num_days()); let (wall, length, end_zone) = match &asked { Some(a) => (a.start, a.length, a.end_zone), None => (master.start, master.length, master.end_zone), }; - let first_day = master.first.day_in(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 zone it + // moves into (C11c review). + let frame = day_zone(&master.start, &wall); + let from = original.day_in(frame); + let shift = asked + .as_ref() + .map_or(0, |a| (a.start.date() - from).num_days()); + let first_day = master.first.day_in(frame); let deleted = deleted_points(master, input)?; let mut warnings = Vec::new(); let undo = Some(Undo::RestoreMaster { @@ -503,6 +576,7 @@ pub(super) fn plan_all_rule( .ok() .map(|(_, facts)| facts), shift, + opened: asked_day, extra: &extra, })?; clear_of_later(input.later, &rebuilt, zone)?; @@ -578,6 +652,7 @@ pub(super) fn plan_all_rule( own_time, asked: asked.as_ref(), shift, + frame, warnings, undo, notify, @@ -608,6 +683,8 @@ struct StopAsk<'a> { own_time: Option<&'a Value>, asked: Option<&'a Asked>, shift: i64, + /// The zone `shift`'s days are read in ([`day_zone`]). + frame: Tz, warnings: Vec, undo: Option, notify: Notify, @@ -625,6 +702,7 @@ fn stop_body(ask: StopAsk) -> Result { own_time, asked, shift, + frame, mut warnings, undo, notify, @@ -633,7 +711,7 @@ fn stop_body(ask: StopAsk) -> Result { let zone = master.start.zone(); let (first, start_zone, end_zone, length) = match (asked, own_time) { (Some(a), _) => { - let day = moved_day(kept.day_in(zone), shift).ok_or(SeriesRefusal::Unreadable)?; + let day = moved_day(kept.day_in(frame), shift).ok_or(SeriesRefusal::Unreadable)?; let start = on_day(&a.start, day); (one_event_at(&start)?, start.zone(), a.end_zone, a.length) } @@ -651,7 +729,7 @@ fn stop_body(ask: StopAsk) -> Result { // says it leaves its own day (`shift` is the draft's, 0 without one). if shift != 0 { warnings.push(SeriesWarning::StartMoved { - from: kept.day_in(zone), + from: kept.day_in(frame), to: rule_start.date(), }); } @@ -719,7 +797,9 @@ pub(super) fn plan_split_rule( None => (rest, master.length, master.end_zone), }; let at_day = at.day_in(zone); - let shift = (start.date() - at_day).num_days(); + // In the days of the zone the rest moves into, as for every occurrence (C11c review). + let frame = day_zone(&master.start, &start); + let shift = (start.date() - at.day_in(frame)).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 @@ -773,6 +853,7 @@ pub(super) fn plan_split_rule( old_start: &master.start, old_rule: Some(facts.clone()), shift, + opened: start.date(), extra: &extra, })?; // The rest can't start where the part before it still reaches (A29): both days read in @@ -891,24 +972,21 @@ pub(super) fn plan_split_rule( 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 ([`lands_at`]). + // 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 { - match moved_day(p.day_in(zone), shift) - .and_then(|day| on_its_day(&rebuilt.start, day)) - { - Some(q) if is_occurrence(&rebuilt.facts, &rebuilt.start, q)? => Some(q), - _ => None, - } + rebuilt.opened } else { - lands_at( - &rebuilt.facts, - &rebuilt.start, - Some((&facts, &master.start)), - p.day_in(zone), + Landing { + facts: &rebuilt.facts, + start: &rebuilt.start, + old: Some((&facts, &master.start)), shift, - )? + frame: rebuilt.frame, + opened: rebuilt.opened, + } + .at(*p)? }; match (carried.is_empty(), target) { (true, _) => None, @@ -1039,7 +1117,7 @@ mod tests { use super::super::{Exceptions, Listing}; use super::*; use crate::calendar_recur::lines::Applied; - use crate::calendar_recur::wire::{DayCode, RepeatEnds, RepeatPattern, RepeatRule}; + use crate::calendar_recur::wire::{DayCode, NthCode, RepeatEnds, RepeatPattern, RepeatRule}; use chrono::NaiveDateTime; use chrono_tz::Europe::London; use serde_json::json; @@ -1118,7 +1196,8 @@ mod tests { } /// `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. + /// Monday), with `deleted`, no extra dates and no shift, the occurrence opened put on its first + /// day. fn ask<'a>( spec: &'a RecurrenceSpec, start: SeriesStart, @@ -1135,6 +1214,7 @@ mod tests { old_start: old, old_rule: lines::series_rule(&mondays).ok().map(|(_, facts)| facts), shift: 0, + opened: start.date(), extra: &[], } } @@ -1217,15 +1297,27 @@ mod tests { rebuild(&guessed).unwrap().lines, ["RRULE:FREQ=WEEKLY;BYDAY=WE"] ); - // Nor when PM can't read the old rule. + // 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(&unread).unwrap().lines, - ["RRULE:FREQ=WEEKLY;BYDAY=TU"] + rebuild(&nowhere).unwrap().lines, + ["RRULE:FREQ=WEEKLY;BYDAY=TH"] ); } @@ -1553,6 +1645,51 @@ mod tests { ); } + /// 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"]) + ); + } + /// 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] @@ -2356,6 +2493,183 @@ mod tests { )); } + /// 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(TimeDraft::Timed { + start_date: "2026-10-20".into(), + start_time: "07:30".into(), + start_zone: "Asia/Tokyo".into(), + end_date: "2026-10-20".into(), + end_time: "08:30".into(), + end_zone: "Asia/Tokyo".into(), + }), + recurrence: Some(RepeatDraft::Rule { + rule: weekly_on(&[DayCode::Tue, DayCode::Wed]), + }), + ..Default::default() + }; + (late, draft) + } + + 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(TimeDraft::Timed { + start_date: "2026-10-21".into(), + start_time: "07:30".into(), + start_zone: "Asia/Tokyo".into(), + end_date: "2026-10-21".into(), + end_time: "08:30".into(), + end_zone: "Asia/Tokyo".into(), + }), + 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 { .. }))); + } + /// 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] diff --git a/src-tauri/src/commands/calendar_edit.rs b/src-tauri/src/commands/calendar_edit.rs index abe1979f..28a1efc8 100644 --- a/src-tauri/src/commands/calendar_edit.rs +++ b/src-tauri/src/commands/calendar_edit.rs @@ -3564,7 +3564,7 @@ async fn land_one_event( record(conn, host.edit(), cal, upserts, &created, gone) }); if landed.is_err() { - return pending(); + return said_outside(pending(), outside); } host.landed(cal); let mut warnings = Vec::new(); @@ -4742,7 +4742,7 @@ async fn land_split_done( } } 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()])?; @@ -4776,17 +4776,28 @@ async fn land_split_done( deletes.retain(|d| !upserts.iter().any(|u| u.id == d.row_id)); record(conn, host.edit(), cal, upserts, &created, deletes) }); - let outside = outside.then_some(WriteWarning::OutsideMirror); - match landed { + let saved = match landed { Ok(()) => { host.landed(cal); - series_saved(outside.into_iter().collect(), left_behind) + series_saved(Vec::new(), left_behind) } Err(_) => { 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 @@ -10406,6 +10417,36 @@ mod tests { 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 @@ -10497,6 +10538,81 @@ mod tests { 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] @@ -14531,6 +14647,7 @@ mod tests { 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( @@ -14589,6 +14706,7 @@ mod tests { SeriesRefusal::RepeatsPastPart(day), SeriesRefusal::RepeatsPastItsEnd(day), SeriesRefusal::KeptHasOwn(day), + SeriesRefusal::DeletedDayUnread(day), ] { assert!(dated.to_string().contains("19-10-2026")); } 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 7e955261..a0d04aa7 100644 --- a/src/components/calendar/edit/EventEditor.test.tsx +++ b/src/components/calendar/edit/EventEditor.test.tsx @@ -204,6 +204,7 @@ describe("EventEditor", () => { null, "2026-10-12", false, + false, ); expect(choicesForEdit).not.toHaveBeenCalled(); }); @@ -784,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. @@ -796,6 +797,32 @@ describe("EventEditor, a repeating event", () => { "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, ); }); @@ -913,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 fc97b5c3..e4e224f8 100644 --- a/src/components/calendar/edit/EventEditor.tsx +++ b/src/components/calendar/edit/EventEditor.tsx @@ -505,6 +505,7 @@ export function EventEditor({ row, calendar, account, milestone, onDelete, onClo picked?.scope ?? null, what.from, what.changes.recurrence?.kind === "once", + picked?.asAll ?? false, ); stayOpen = false; onClose(); 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 bbd2c6b2..0e815923 100644 --- a/src/components/calendar/edit/useEventWrites.ts +++ b/src/components/calendar/edit/useEventWrites.ts @@ -540,16 +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; `stops`: the save made it "Does not repeat". */ + * 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, stops) }); + 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 1a49fe2c..a92fa4e4 100644 --- a/src/lib/calendarEdit/editReasons.test.ts +++ b/src/lib/calendarEdit/editReasons.test.ts @@ -348,18 +348,42 @@ describe("a repeating event's save, said once the editor has closed", () => { tone: "warn", text: "Saved “Standup” for every occurrence. It starts outside the months PM syncs, so PM's calendar won't show it.", }); - expect( - savedNotice( - "Standup", - saved(["outside_mirror", "mirror_refresh_pending"]), - "following", - "2026-12-06", - true, - ), - ).toEqual({ + }); + + // 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.", + 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.", + ); }); }); diff --git a/src/lib/calendarEdit/editReasons.ts b/src/lib/calendarEdit/editReasons.ts index 1e7dfb75..a04afe5a 100644 --- a/src/lib/calendarEdit/editReasons.ts +++ b/src/lib/calendarEdit/editReasons.ts @@ -422,24 +422,28 @@ export function settleText(result: WriteOutcome, title: string): { tone: Tone; t * 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). `stops`: - * the save made it "Does not repeat", so from `from` on it's one event, not a repeating one. One - * that left the event outside the months PM syncs says so, and never to edit it again here - * (C11c review). */ + * 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 outside = outcome.warnings.includes("outside_mirror"); - // Off PM's calendar, there's nothing for a refresh to show. - const pending = outcome.warnings.includes("mirror_refresh_pending") && !outside; + 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; @@ -449,25 +453,31 @@ export function savedNotice( back = "To change it back, edit it again."; break; case "following": { - const day = from ? formatDateOnly(from) : "this occurrence"; + const since = day ?? "this occurrence"; first = stops - ? `Saved ${t} from ${day} on, as one event that doesn't repeat.` - : `Saved ${t} from ${day} on, as a repeating event of its own.`; + ? `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 (outside) parts.push(writeWarningText("outside_mirror", "save")); - if (pending) parts.push(writeWarningText("mirror_refresh_pending", "save")); + // Off PM's calendar the saved event won't show after a refresh, but what it replaced still does + // until then: the calendar catches up, in a delete's words (C11c review). + if (pending) parts.push(writeWarningText("mirror_refresh_pending", outside ? "delete" : "save")); // Occurrences left on the earlier part come first: changing it back isn't the whole story. An // event PM's calendar won't show can't be edited again here. if (left) parts.push(writeWarningText("left_on_old_series", "save", outcome.left_behind)); diff --git a/src/lib/calendarEdit/saveChoices.ts b/src/lib/calendarEdit/saveChoices.ts index 91cbec90..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 { From ab55db4c18d0da3e455d80018b3d26d712425d8b Mon Sep 17 00:00:00 2001 From: Bobby Yu <127560404+BBYU26@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:38:02 +0100 Subject: [PATCH 6/7] fix(calendar): review round 3 of C11c A new rule for every occurrence, opened on an extra date that is kept, no longer takes the rule's slot that day for it: the extra date stays where it is, so an occurrence deleted at the rule's time that day stays deleted. A move into another time zone now reads each old occurrence's day as its day in the series' own zone, moved on as far as the occurrence opened moves between the two zones. Both rules repeat by the day on the clock in their own zone, so across a clock change each occurrence keeps its place in the week: a deletion stays on its own occurrence, never on the live one beside it, and a change goes onto its own. The same reading places the one event "Does not repeat" keeps, where a new rule starts, and how far a day move takes every occurrence, for every occurrence and from this one on. Whether the old series had an occurrence of its own on a day is read on that day in its own zone. A deletion PM can't place is named by its day in the series' own zone. A Finish that lands outside the months PM syncs now says the calendar will catch up after the next refresh, as a save does, and warns: one helper words a saved outcome's warnings for both. Part of #884 Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/calendar_write/series/rule.rs | 503 +++++++++++++++++--- src/lib/calendarEdit/editReasons.test.ts | 15 + src/lib/calendarEdit/editReasons.ts | 47 +- 3 files changed, 475 insertions(+), 90 deletions(-) diff --git a/src-tauri/src/calendar_write/series/rule.rs b/src-tauri/src/calendar_write/series/rule.rs index 6f140cd0..5ab37e3d 100644 --- a/src-tauri/src/calendar_write/series/rule.rs +++ b/src-tauri/src/calendar_write/series/rule.rs @@ -105,11 +105,16 @@ pub(super) struct RebuildAsk<'a> { /// 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 the days [`day_zone`] reads: a deleted day the new rule doesn't make is - /// looked for that much later ([`Landing`]). + /// 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). + /// 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], @@ -130,8 +135,8 @@ pub(super) struct Rebuilt { pub extra: Vec, /// The start isn't on the day asked. pub moved: bool, - /// The zone an old occurrence's day is read in ([`day_zone`]). - pub frame: Tz, + /// 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, @@ -166,11 +171,14 @@ pub(super) fn rebuild(ask: &RebuildAsk) -> Result { } } } + // 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 is_occurrence(&facts, &synced, q)? => Some(q), + Some(q) if !extra_points.contains(&ask.opened_at) && is_occurrence(&facts, &synced, q)? => { + Some(q) + } _ => None, }; - let frame = day_zone(ask.old_start, &synced); // 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". @@ -179,7 +187,7 @@ pub(super) fn rebuild(ask: &RebuildAsk) -> Result { start: &synced, old: ask.old_rule.as_ref().map(|f| (f, ask.old_start)), shift: ask.shift, - frame, + days: ask.days, opened, }; let mut deleted = Vec::new(); @@ -204,19 +212,48 @@ pub(super) fn rebuild(ask: &RebuildAsk) -> Result { lines: out, facts, extra: extra_points, - frame, + days: ask.days, opened, }) } -/// The zone an old occurrence's day is read in, to place it in the series rebuilt from `new`: the -/// rebuilt series' own when both are timed, so a move into another zone that keeps every moment -/// moves no day (C11c review); the old series' across a change between timed and all-day, whose -/// day is its wall day. -fn day_zone(old: &SeriesStart, new: &SeriesStart) -> Tz { - match (old, new) { - (SeriesStart::Timed { .. }, SeriesStart::Timed { zone, .. }) => *zone, - _ => old.zone(), +/// 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) } } @@ -266,10 +303,10 @@ struct Landing<'a> { 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 `frame`'s days. + /// How many days the change moves every occurrence, in `days`. shift: i64, - /// The zone an occurrence's day is read in ([`day_zone`]). - frame: Tz, + /// 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, } @@ -280,18 +317,22 @@ impl Landing<'_> { /// 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. + /// 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 = p.day_in(self.frame); + 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 has_day(facts, start, moved, self.frame)? => None, + Some((facts, start)) if self.had(facts, start, moved)? => None, Some(_) => self.made(moved)?, None => match self.made(moved)? { - Some(_) => return Err(SeriesRefusal::DeletedDayUnread(day).into()), + Some(_) => { + let own = p.day_in(self.days.zone); + return Err(SeriesRefusal::DeletedDayUnread(own).into()); + } None => None, }, }, @@ -300,6 +341,20 @@ impl Landing<'_> { 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) { @@ -309,25 +364,22 @@ impl Landing<'_> { } } -/// Whether the series from `start` under `facts` has an occurrence on `day`, read in `frame`. 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, - frame: Tz, -) -> Result { +/// 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(frame, day.and_time(chrono::NaiveTime::MIN)) - { - Some(t) => Point::At(t), - None => return Ok(true), - }, + 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(frame) == day)) + .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 @@ -535,14 +587,15 @@ pub(super) fn plan_all_rule( 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 zone it - // moves into (C11c review). - let frame = day_zone(&master.start, &wall); - let from = original.day_in(frame); + // 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 = master.first.day_in(frame); + 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 { @@ -550,7 +603,6 @@ pub(super) fn plan_all_rule( event: master.id.clone(), body: snapshot(master_json, &["start", "end", "recurrence"]), }); - let out_of_range = || NotPlanned::from(SeriesRefusal::Unreadable); match ask { RuleAsk::New(spec) => { let asked_day = moved_day(from, shift).ok_or_else(out_of_range)?; @@ -576,6 +628,8 @@ pub(super) fn plan_all_rule( .ok() .map(|(_, facts)| facts), shift, + days, + opened_at: original, opened: asked_day, extra: &extra, })?; @@ -652,7 +706,7 @@ pub(super) fn plan_all_rule( own_time, asked: asked.as_ref(), shift, - frame, + days, warnings, undo, notify, @@ -683,8 +737,8 @@ struct StopAsk<'a> { own_time: Option<&'a Value>, asked: Option<&'a Asked>, shift: i64, - /// The zone `shift`'s days are read in ([`day_zone`]). - frame: Tz, + /// The days `shift` is counted in ([`Days`]). + days: Days, warnings: Vec, undo: Option, notify: Notify, @@ -702,16 +756,18 @@ fn stop_body(ask: StopAsk) -> Result { own_time, asked, shift, - frame, + 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_in(frame), shift).ok_or(SeriesRefusal::Unreadable)?; + 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) } @@ -729,7 +785,7 @@ fn stop_body(ask: StopAsk) -> Result { // says it leaves its own day (`shift` is the draft's, 0 without one). if shift != 0 { warnings.push(SeriesWarning::StartMoved { - from: kept.day_in(frame), + from: kept_day, to: rule_start.date(), }); } @@ -797,9 +853,9 @@ pub(super) fn plan_split_rule( None => (rest, master.length, master.end_zone), }; let at_day = at.day_in(zone); - // In the days of the zone the rest moves into, as for every occurrence (C11c review). - let frame = day_zone(&master.start, &start); - let shift = (start.date() - at.day_in(frame)).num_days(); + // 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 @@ -853,6 +909,8 @@ pub(super) fn plan_split_rule( old_start: &master.start, old_rule: Some(facts.clone()), shift, + days, + opened_at: at, opened: start.date(), extra: &extra, })?; @@ -983,7 +1041,7 @@ pub(super) fn plan_split_rule( start: &rebuilt.start, old: Some((&facts, &master.start)), shift, - frame: rebuilt.frame, + days: rebuilt.days, opened: rebuilt.opened, } .at(*p)? @@ -1196,8 +1254,8 @@ mod tests { } /// `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 put on its first - /// day. + /// 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, @@ -1205,6 +1263,7 @@ mod tests { old: &'a SeriesStart, ) -> RebuildAsk<'a> { let mondays = lines::parse(&["RRULE:FREQ=WEEKLY;BYDAY=MO"]); + let first = old.point().unwrap(); RebuildAsk { spec, start, @@ -1214,6 +1273,8 @@ mod tests { 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: &[], } @@ -1688,6 +1749,31 @@ mod tests { 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 @@ -1834,6 +1920,30 @@ mod tests { } } + /// 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() { @@ -2576,14 +2686,7 @@ mod tests { "2026-10-06T00:30:00+01:00", ); let draft = EventPatchDraft { - time: Some(TimeDraft::Timed { - start_date: "2026-10-20".into(), - start_time: "07:30".into(), - start_zone: "Asia/Tokyo".into(), - end_date: "2026-10-20".into(), - end_time: "08:30".into(), - end_zone: "Asia/Tokyo".into(), - }), + 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]), }), @@ -2592,6 +2695,18 @@ mod tests { (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" }) } @@ -2622,14 +2737,7 @@ mod tests { // occurrence of its own, so nothing is deleted on it. let (late, _) = into_tokyo(); let wednesday = EventPatchDraft { - time: Some(TimeDraft::Timed { - start_date: "2026-10-21".into(), - start_time: "07:30".into(), - start_zone: "Asia/Tokyo".into(), - end_date: "2026-10-21".into(), - end_time: "08:30".into(), - end_zone: "Asia/Tokyo".into(), - }), + 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]), }), @@ -2670,6 +2778,257 @@ mod tests { .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] diff --git a/src/lib/calendarEdit/editReasons.test.ts b/src/lib/calendarEdit/editReasons.test.ts index a92fa4e4..4e596d06 100644 --- a/src/lib/calendarEdit/editReasons.test.ts +++ b/src/lib/calendarEdit/editReasons.test.ts @@ -468,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 a04afe5a..42881a17 100644 --- a/src/lib/calendarEdit/editReasons.ts +++ b/src/lib/calendarEdit/editReasons.ts @@ -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": @@ -436,9 +433,7 @@ export function savedNotice( asAll = false, ): { tone: Tone; text: string } { const t = quote(title); - 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 day = from ? formatDateOnly(from) : null; const saved: SeriesScope | null = asAll && scope === "following" ? "all" : scope; let first: string; @@ -473,16 +468,32 @@ export function savedNotice( default: return unreachable(saved); } - const parts = [first]; - if (outside) parts.push(writeWarningText("outside_mirror", "save")); - // Off PM's calendar the saved event won't show after a refresh, but what it replaced still does - // until then: the calendar catches up, in a delete's words (C11c review). - if (pending) parts.push(writeWarningText("mirror_refresh_pending", outside ? "delete" : "save")); - // Occurrences left on the earlier part come first: changing it back isn't the whole story. An - // event PM's calendar won't show can't be edited again here. - if (left) parts.push(writeWarningText("left_on_old_series", "save", outcome.left_behind)); - else if (back && !outside) parts.push(back); - return { tone: left || outside ? "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) --- From dfc7fe5c8cf6af5478f54305d68f36d39adafc5a Mon Sep 17 00:00:00 2001 From: Bobby Yu <127560404+BBYU26@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:54:15 +0100 Subject: [PATCH 7/7] test(calendar): review round 4 of C11c 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, Monday 19-10 made all-day and "Weekly on Sunday and Monday": for every occurrence the EXDATE stays on 12-10 and nothing warns that the repeat day changes; from this one on, Monday 26-10's own change goes onto the new part's Monday 26-10. Days::new comparing the zones for every kind of start moves both onto the Sunday before. Part of #884 Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/calendar_write/series/rule.rs | 59 +++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src-tauri/src/calendar_write/series/rule.rs b/src-tauri/src/calendar_write/series/rule.rs index 5ab37e3d..184525e5 100644 --- a/src-tauri/src/calendar_write/series/rule.rs +++ b/src-tauri/src/calendar_write/series/rule.rs @@ -3166,6 +3166,65 @@ mod tests { } } + /// 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() {