From 32d22e15d43e9eb951c39d44fd7c8934fe1a864f Mon Sep 17 00:00:00 2001 From: Bobby Yu <127560404+BBYU26@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:49:00 +0100 Subject: [PATCH 1/2] =?UTF-8?q?chore(calendar):=20repeat=20rules=20in=20wo?= =?UTF-8?q?rds=20and=20on=20the=20wire,=20and=20the=20backend=20can=20crea?= =?UTF-8?q?te=20a=20repeating=20event=20=E2=86=92=203.152.1-alpha=20(C11a?= =?UTF-8?q?=20of=20#884)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A repeat rule from the editor's set now has a wire form the webview can hold, and two pure commands give the repeat menu its ready-made rules and a rule's words, with the day its first occurrence falls on: "Weekly on Monday and Wednesday" chosen on a Tuesday starts on the Wednesday, and a first occurrence whose start or end the clocks skip is refused in words. A rule that never happens is refused before the rrule crate is asked. The create command takes a rule: the event starts on the first day the rule makes, its end moved with it, with one rule line and its zone on both timed nodes. Google answers with the repeating event itself, which is never a mirror row, so its occurrences are listed (three looks at most) and landed as created rows, or said to be outside the months PM mirrors. A repeating create counts as series-wide to a dev build's allow-list, and an empty rule is no rule wherever PM asks. Nothing in the app sends a rule yet. 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.rs | 27 +- src-tauri/src/calendar_recur/expand.rs | 77 +- src-tauri/src/calendar_recur/mod.rs | 3 + src-tauri/src/calendar_recur/spec.rs | 159 +++-- src-tauri/src/calendar_recur/wire.rs | 672 ++++++++++++++++++ src-tauri/src/calendar_write/dto.rs | 25 +- src-tauri/src/calendar_write/patch.rs | 265 ++++++- src-tauri/src/calendar_write/series/plan.rs | 36 +- src-tauri/src/calendar_write/series/run.rs | 1 + src-tauri/src/calendar_write/time.rs | 166 ++++- src-tauri/src/commands/calendar_edit.rs | 465 +++++++++++- src-tauri/src/commands/calendar_repeat.rs | 306 ++++++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/lib.rs | 2 + src-tauri/tauri.conf.json | 2 +- .../calendar/edit/NewEventEditor.test.tsx | 3 +- .../calendar/edit/QuickCreatePopover.test.tsx | 5 +- .../calendar/edit/WriteNotices.test.tsx | 1 + .../calendar/edit/useEventWrites.test.ts | 1 + src/lib/calendarEdit/eventDraft.test.ts | 1 + src/lib/calendarEdit/eventDraft.ts | 4 +- src/lib/calendarEdit/repeatRules.fixture.json | 134 ++++ src/lib/calendarEdit/repeatRules.test.ts | 81 +++ src/lib/changelog.ts | 7 + src/lib/ipc.ts | 16 + src/lib/types.ts | 59 +- 30 files changed, 2412 insertions(+), 118 deletions(-) create mode 100644 src-tauri/src/calendar_recur/wire.rs create mode 100644 src-tauri/src/commands/calendar_repeat.rs create mode 100644 src/lib/calendarEdit/repeatRules.fixture.json create mode 100644 src/lib/calendarEdit/repeatRules.test.ts diff --git a/package-lock.json b/package-lock.json index 92dc429f..155f25b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pm", - "version": "3.152.0-alpha", + "version": "3.152.1-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pm", - "version": "3.152.0-alpha", + "version": "3.152.1-alpha", "license": "AGPL-3.0-or-later", "dependencies": { "@fontsource/atkinson-hyperlegible": "^5.3.0", diff --git a/package.json b/package.json index b9e282d1..53e8e37b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pm", "private": true, - "version": "3.152.0-alpha", + "version": "3.152.1-alpha", "license": "AGPL-3.0-or-later", "type": "module", "engines": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9c20a3f0..bd5de3f8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3072,7 +3072,7 @@ dependencies = [ [[package]] name = "pm" -version = "3.152.0-alpha" +version = "3.152.1-alpha" dependencies = [ "argon2", "base64 0.23.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ce25a851..b2dbebca 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pm" -version = "3.152.0-alpha" +version = "3.152.1-alpha" description = "PM — a local-first personal manager (Archivist + Personal Assistant)" authors = ["Bobby Yu"] edition = "2021" diff --git a/src-tauri/src/calendar.rs b/src-tauri/src/calendar.rs index db8a43b4..d0018d73 100644 --- a/src-tauri/src/calendar.rs +++ b/src-tauri/src/calendar.rs @@ -1003,6 +1003,17 @@ fn google_conference(it: &serde_json::Value) -> Option { .map(str::to_string) } +/// Whether a Google event resource is a repeating event's master: it carries a rule and no +/// `recurringEventId`. An empty or null `recurrence` is no rule (C11 critique 1 L10), so an event +/// Google echoes with `[]` after its repeat was taken off is a one-off again, here and in every +/// check that asks this. +pub(crate) fn is_master(it: &serde_json::Value) -> bool { + it.get("recurrence") + .and_then(|r| r.as_array()) + .is_some_and(|lines| !lines.is_empty()) + && it.get("recurringEventId").is_none() +} + fn parse_events(calendar_id: &str, value: &serde_json::Value) -> Vec { value .get("items") @@ -1022,14 +1033,14 @@ fn parse_events(calendar_id: &str, value: &serde_json::Value) -> Vec_`). /// -/// A master (it carries the `recurrence` rule and no `recurringEventId`) never becomes a row: the sync -/// asks for `singleEvents=true` and so only ever mirrors occurrences, and a master's `start` is just -/// its first occurrence's. A save that touches a series refetches the occurrences instead. +/// A master ([`is_master`]) never becomes a row: the sync asks for `singleEvents=true` and so only +/// ever mirrors occurrences, and a master's `start` is just its first occurrence's. A save that +/// touches a series refetches the occurrences instead. pub(crate) fn parse_event(calendar_id: &str, it: &serde_json::Value) -> Option { if it.get("status").and_then(|s| s.as_str()) == Some("cancelled") { return None; } - if it.get("recurrence").is_some() && it.get("recurringEventId").is_none() { + if is_master(it) { return None; } let event_id = it @@ -3327,8 +3338,16 @@ mod tests { "recurrence": ["RRULE:FREQ=WEEKLY;BYDAY=MO"] }); assert!(parse_event("cal-1", &master).is_none()); + assert!(is_master(&master)); // An occurrence of it is mirrored as usual. assert!(parse_event("cal-1", &google_occurrence()).is_some()); + // C11 critique 1 L10: an empty or null rule is no rule, so that event is a one-off row. + for none in [serde_json::json!([]), serde_json::Value::Null] { + let mut once = master.clone(); + once["recurrence"] = none; + assert!(!is_master(&once)); + assert!(parse_event("cal-1", &once).is_some_and(|row| row.summary == "Standup")); + } } #[test] diff --git a/src-tauri/src/calendar_recur/expand.rs b/src-tauri/src/calendar_recur/expand.rs index 0c908f19..4fff8455 100644 --- a/src-tauri/src/calendar_recur/expand.rs +++ b/src-tauri/src/calendar_recur/expand.rs @@ -15,11 +15,11 @@ use std::cmp::Ordering; -use chrono::{DateTime, Month, NaiveDate, NaiveTime, TimeZone, Utc}; +use chrono::{DateTime, Datelike, Month, NaiveDate, NaiveTime, TimeZone, Utc}; use rrule::{Frequency, NWeekday, RRule, RRuleSet}; use super::lines::{self, Freq, RuleFacts, Stamp}; -use super::spec::{rule_from_spec, RecurrenceSpec, SpecError}; +use super::spec::{days_in_month, rule_from_spec, MonthlyOn, Pattern, RecurrenceSpec, SpecError}; use super::{end_of_day, first_instant, rfc_instant, Point, RecurError, SeriesStart}; /// How many starts a count walks past before giving up: far more than the 730 Google allows. @@ -295,13 +295,19 @@ pub fn last_before( /// The day comes from the rule run by days, in UTC, where none is ever skipped: in a zone, the crate /// moves an occurrence whose time the clocks skip, and drops one they skip at midnight. The time is /// the one chosen; if the clocks skip it on that day, it's refused, as any new start in a gap is. -#[cfg_attr( - not(test), - expect(dead_code, reason = "first caller lands in C11, repeat rules") -)] +/// +/// The crate is asked for one occurrence only. In the editor's set, only "on day N" of every few +/// months can have none at all (the 31st of every twelfth April), which the crate would look for to +/// its 100,000-step guard; [`lands`] refuses that first, so this is quick for every rule the +/// editor can send (C11 critique 1 L5). pub fn sync_start(spec: &RecurrenceSpec, start: &SeriesStart) -> Result { let by_day = SeriesStart::AllDay(start.date()); let rule = rule_from_spec(spec, &by_day)?; + if let Pattern::Monthly(MonthlyOn::Day(day)) = spec.pattern { + if !lands(day, spec.interval, start.date()) { + return Err(SpecError::NoOccurrence); + } + } let lines = lines::parse(&[rule]); let (_, facts) = lines::series_rule(&lines).map_err(|_| SpecError::NoOccurrence)?; let day = crate_set(&facts, &by_day) @@ -318,10 +324,26 @@ pub fn sync_start(spec: &RecurrenceSpec, start: &SeriesStart) -> Result bool { + let (day, step) = (u32::from(day), i64::from(interval)); + let first = i64::from(from.year()) * 12 + i64::from(from.month0()); + (0..4_800_i64).any(|k| { + let month = first + k * step; + let month_start = i32::try_from(month.div_euclid(12)).ok().and_then(|year| { + NaiveDate::from_ymd_opt(year, u32::try_from(month.rem_euclid(12)).ok()? + 1, 1) + }); + month_start.is_some_and(|m| days_in_month(m) >= day && (k > 0 || day >= from.day())) + }) +} + #[cfg(test)] mod tests { use super::*; - use crate::calendar_recur::spec::{Ends, Pattern}; + use crate::calendar_recur::spec::Ends; use chrono::{NaiveDate, Weekday}; use chrono_tz::Europe::London; @@ -815,4 +837,45 @@ mod tests { Err(SpecError::NoWeekdays) ); } + + /// "On day N" of every few months, where no month it visits has that day, is refused before the + /// crate is asked (it would look 100,000 months for it); one that lands at last is found. + #[test] + fn a_day_of_the_month_no_visited_month_has_is_refused_at_once() { + let monthly = |day: u8, interval: u16| RecurrenceSpec { + pattern: Pattern::Monthly(MonthlyOn::Day(day)), + interval, + ends: Ends::Never, + }; + // The 31st of every twelfth month, from April: April never has one. + assert!(!lands(31, 12, day(2026, 4, 13))); + assert_eq!( + sync_start(&monthly(31, 12), &london(2026, 4, 13, 9)), + Err(SpecError::NoOccurrence) + ); + // Every sixth month from June: June and December, and December has one. + assert!(lands(31, 6, day(2026, 6, 1))); + // Every second month from April: April and June haven't, August has. + assert!(lands(31, 2, day(2026, 4, 13))); + assert_eq!( + sync_start(&monthly(31, 2), &london(2026, 4, 13, 9)), + Ok(london(2026, 8, 31, 9)) + ); + // The 29th of every second February from 2027: odd years, never a leap one. + assert!(!lands(29, 24, day(2027, 2, 1))); + // From 2025 every year: 2028 has one. + assert!(lands(29, 12, day(2025, 2, 1))); + assert_eq!( + sync_start(&monthly(29, 12), &london(2025, 2, 1, 9)), + Ok(london(2028, 2, 29, 9)) + ); + // Every fourth February from 2100, which skips its leap day: 2104 has one. + assert!(lands(29, 48, day(2100, 2, 1))); + // A start's own month counts from its own day on: the 13th, from the 20th, is next month's. + assert!(lands(13, 1, day(2026, 10, 20))); + assert_eq!( + sync_start(&monthly(13, 1), &london(2026, 10, 20, 9)), + Ok(london(2026, 11, 13, 9)) + ); + } } diff --git a/src-tauri/src/calendar_recur/mod.rs b/src-tauri/src/calendar_recur/mod.rs index f2f6abe3..53293db1 100644 --- a/src-tauri/src/calendar_recur/mod.rs +++ b/src-tauri/src/calendar_recur/mod.rs @@ -14,6 +14,8 @@ //! - [`reanchor`]: what a rule becomes when one of its occurrences moves to another day. //! - [`remap`]: moving a whole series by one occurrence, its start, end and EXDATEs with it, proved //! occurrence by occurrence. +//! - [`wire`]: a rule from the editor's set as the webview sends and shows it, and a new series' +//! start read from the editor's fields. //! //! Three traps in rrule 0.14 shape this module (each read in the crate's source): //! - Its `Display` adds BYHOUR, BYMINUTE and BYSECOND to every rule, so a rule it prints pins the @@ -29,6 +31,7 @@ pub mod reanchor; pub mod remap; pub mod spec; pub mod split; +pub mod wire; use std::cmp::Ordering; use std::fmt; diff --git a/src-tauri/src/calendar_recur/spec.rs b/src-tauri/src/calendar_recur/spec.rs index 771d36a4..838247ba 100644 --- a/src-tauri/src/calendar_recur/spec.rs +++ b/src-tauri/src/calendar_recur/spec.rs @@ -137,8 +137,14 @@ pub enum SpecError { NoOccurrence, /// The clocks skip the chosen time on the series' first day. Gap(NaiveDate), + /// The clocks skip the first occurrence's end time on the day it ends (C11 critique 2 L7). + EndGap(NaiveDate), + /// An end date PM can't read. + BadDate, } +/// Shown on the editor's Repeat row, so no "series", "instance", "exception", "split" or "master" +/// (C9b-1 critique 2 M4): the user made a repeating event and its occurrences. impl fmt::Display for SpecError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -149,19 +155,28 @@ impl fmt::Display for SpecError { SpecError::Count => write!(f, "It has to happen at least once."), SpecError::TooMany => write!( f, - "Google allows at most {MAX_OCCURRENCES} occurrences in a series." + "Google allows at most {MAX_OCCURRENCES} occurrences of a repeating event." ), - SpecError::EndsBeforeStart => write!(f, "The end date is before the series starts."), + SpecError::EndsBeforeStart => { + write!(f, "The end date is before the first occurrence.") + } SpecError::NoOccurrence => write!( f, - "That rule has no occurrence between the start and the end date." + "This repeat has no occurrence between the start and the end date." ), SpecError::Gap(day) => write!( f, - "The series would start on {}, when the clocks skip that time. Pick a time outside \ - that hour.", + "The first occurrence would be on {}, when the clocks skip that time. Pick a time \ + outside that hour.", + day.format("%d-%m-%Y") + ), + SpecError::EndGap(day) => write!( + f, + "The first occurrence would end on {}, when the clocks skip that time. Pick an end \ + time outside that hour.", day.format("%d-%m-%Y") ), + SpecError::BadDate => write!(f, "PM couldn't read that end date."), } } } @@ -247,13 +262,6 @@ pub fn spec_from_rule(f: &RuleFacts, start: &SeriesStart) -> Option Result { if !(1..=MAX_INTERVAL).contains(&spec.interval) { return Err(SpecError::Interval); @@ -465,10 +473,6 @@ pub fn day_phrase>(lines: &[S], start: &SeriesStart) -> Option Vec { let weekday = day.weekday(); let mut patterns = vec![Pattern::Daily, Pattern::Weekly(vec![weekday])]; @@ -618,6 +618,56 @@ fn month_name(month: u32) -> &'static str { .unwrap_or("") } +/// T2: the rules Google's editor writes and the words it says them in, against Tue 13-10-2026 +/// 09:00 in London. The webview's repeat-rule tests read the same rows from +/// `src/lib/calendarEdit/repeatRules.fixture.json`, which `wire`'s tests hold to this list. +#[cfg(test)] +pub(super) const T2_RULES: [(&str, &str); 16] = [ + ("RRULE:FREQ=DAILY", "Daily"), + ( + "RRULE:FREQ=DAILY;INTERVAL=3;COUNT=10", + "Every 3 days, 10 times", + ), + ("RRULE:FREQ=WEEKLY", "Weekly on Tuesday"), + ("RRULE:FREQ=WEEKLY;BYDAY=TU", "Weekly on Tuesday"), + ( + "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", + "Every weekday (Monday to Friday)", + ), + ( + "RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;UNTIL=20261231T235959Z", + "Every 2 weeks on Monday and Wednesday, until 31-12-2026", + ), + ( + "RRULE:FREQ=WEEKLY;BYDAY=FR,MO,WE", + "Weekly on Monday, Wednesday and Friday", + ), + ("RRULE:FREQ=MONTHLY", "Monthly on day 13"), + ("RRULE:FREQ=MONTHLY;BYMONTHDAY=13", "Monthly on day 13"), + ( + "RRULE:FREQ=MONTHLY;BYDAY=2TU", + "Monthly on the second Tuesday", + ), + ( + "RRULE:FREQ=MONTHLY;BYDAY=-1TU", + "Monthly on the last Tuesday", + ), + ("RRULE:FREQ=YEARLY", "Annually on 13 October"), + ( + "RRULE:FREQ=YEARLY;BYMONTH=10;BYMONTHDAY=13", + "Annually on 13 October", + ), + ( + "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=2TU", + "Annually on the second Tuesday of October", + ), + ("RRULE:FREQ=DAILY;COUNT=1", "Daily, once"), + ( + "RRULE:FREQ=YEARLY;INTERVAL=2", + "Every 2 years on 13 October", + ), +]; + #[cfg(test)] mod tests { use super::*; @@ -656,52 +706,55 @@ mod tests { #[test] fn rules_read_as_google_says_them() { let t = tuesday(); - for (rule, expected) in [ - ("RRULE:FREQ=DAILY", "Daily"), - ( - "RRULE:FREQ=DAILY;INTERVAL=3;COUNT=10", - "Every 3 days, 10 times", - ), - ("RRULE:FREQ=WEEKLY", "Weekly on Tuesday"), - ("RRULE:FREQ=WEEKLY;BYDAY=TU", "Weekly on Tuesday"), - ( - "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", - "Every weekday (Monday to Friday)", - ), - ( - "RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;UNTIL=20261231T235959Z", - "Every 2 weeks on Monday and Wednesday, until 31-12-2026", - ), + for (rule, expected) in T2_RULES { + assert_eq!(words(rule, &t), expected, "{rule}"); + } + } + + /// Every word a rule can be refused with is one the editor shows as is (C11 §1.3), pinned here + /// as the webview's problem words are pinned there, so the two can't drift. + #[test] + fn spec_error_words_name_no_jargon() { + let skipped = day(2026, 3, 29); + for (e, expected) in [ + (SpecError::Interval, "Repeat every 1 to 99."), + (SpecError::NoWeekdays, "Pick at least one day of the week."), ( - "RRULE:FREQ=WEEKLY;BYDAY=FR,MO,WE", - "Weekly on Monday, Wednesday and Friday", + SpecError::DayOfMonth, + "Pick a day of the month from 1 to 31.", ), - ("RRULE:FREQ=MONTHLY", "Monthly on day 13"), - ("RRULE:FREQ=MONTHLY;BYMONTHDAY=13", "Monthly on day 13"), + (SpecError::Month, "Pick a month."), + (SpecError::Count, "It has to happen at least once."), ( - "RRULE:FREQ=MONTHLY;BYDAY=2TU", - "Monthly on the second Tuesday", + SpecError::TooMany, + "Google allows at most 730 occurrences of a repeating event.", ), ( - "RRULE:FREQ=MONTHLY;BYDAY=-1TU", - "Monthly on the last Tuesday", + SpecError::EndsBeforeStart, + "The end date is before the first occurrence.", ), - ("RRULE:FREQ=YEARLY", "Annually on 13 October"), ( - "RRULE:FREQ=YEARLY;BYMONTH=10;BYMONTHDAY=13", - "Annually on 13 October", + SpecError::NoOccurrence, + "This repeat has no occurrence between the start and the end date.", ), ( - "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=2TU", - "Annually on the second Tuesday of October", + SpecError::Gap(skipped), + "The first occurrence would be on 29-03-2026, when the clocks skip that time. \ + Pick a time outside that hour.", ), - ("RRULE:FREQ=DAILY;COUNT=1", "Daily, once"), ( - "RRULE:FREQ=YEARLY;INTERVAL=2", - "Every 2 years on 13 October", + SpecError::EndGap(skipped), + "The first occurrence would end on 29-03-2026, when the clocks skip that time. \ + Pick an end time outside that hour.", ), + (SpecError::BadDate, "PM couldn't read that end date."), ] { - assert_eq!(words(rule, &t), expected, "{rule}"); + let words = e.to_string(); + assert_eq!(words, expected); + let lower = words.to_lowercase(); + for jargon in ["series", "instance", "exception", "split", "master"] { + assert!(!lower.contains(jargon), "{words}"); + } } } diff --git a/src-tauri/src/calendar_recur/wire.rs b/src-tauri/src/calendar_recur/wire.rs new file mode 100644 index 00000000..ca532928 --- /dev/null +++ b/src-tauri/src/calendar_recur/wire.rs @@ -0,0 +1,672 @@ +// SPDX-FileCopyrightText: 2026 Bobby Yu +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! A repeat rule as the webview holds it (#884, C11): the editor's set ([`super::spec`]) with weekdays +//! and nths as words and dates as `YYYY-MM-DD`, since chrono has no serde feature here (A22). Read by +//! the repeat menu's commands and by the editor's drafts, so both mean one thing by a rule. +//! +//! Where a new series starts is read here too ([`series_start`]), with the one date, time and zone +//! parser the event save reads its times with, so the menu's words and the save can't read a start +//! differently. + +use std::collections::BTreeSet; +use std::fmt; + +use chrono::{NaiveDate, NaiveTime, TimeDelta, Weekday}; +use chrono_tz::Tz; +use serde::{Deserialize, Serialize}; + +use super::spec::{Ends, MonthlyOn, Nth, Pattern, RecurrenceSpec, SpecError, YearlyOn}; +use super::{first_instant, SeriesStart}; + +/// A repeat rule from the editor's set, as the webview sends and shows it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RepeatRule { + pub pattern: RepeatPattern, + /// Every this many days, weeks, months or years: 1 to 99. + pub interval: u16, + pub ends: RepeatEnds, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RepeatPattern { + Daily, + /// Written Monday first, each day once. + Weekly { + days: Vec, + }, + MonthlyDay { + day: u8, + }, + MonthlyWeekday { + nth: NthCode, + weekday: DayCode, + }, + /// On the first occurrence's own date. + YearlyDate, + YearlyWeekday { + month: u8, + nth: NthCode, + weekday: DayCode, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RepeatEnds { + Never, + /// The last day an occurrence can start on, `YYYY-MM-DD`, in the event's zone. + OnDate { + date: String, + }, + After { + count: u16, + }, +} + +/// A day of the week. Ordered Monday first, so a set of them is written in PM's week order. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DayCode { + Mon, + Tue, + Wed, + Thu, + Fri, + Sat, + Sun, +} + +/// Which of a month's weekdays: the first four or the last, never a fifth. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NthCode { + First, + Second, + Third, + Fourth, + Last, +} + +/// Where a new repeating event starts, as the editor's fields hold it. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RepeatStart { + Timed { + date: String, + time: String, + zone: String, + /// Where the first occurrence ends, so the day the rule moves it to is checked for a skipped + /// hour at the end too (C11 critique 2 L7). Left out, only the start is checked. + #[serde(default)] + end: Option, + }, + AllDay { + date: String, + }, +} + +/// A wall-clock time as the editor's fields hold it: `YYYY-MM-DD`, `HH:MM` and an IANA zone. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct WallTime { + pub date: String, + pub time: String, + pub zone: String, +} + +impl WallTime { + /// The day `days` days after this one when the clocks skip this time on it, in this zone: where + /// a first occurrence the rule moved would end, as the save moves its end with its start. `None` + /// when that time happens, or when PM can't read it (the editor's own time check says so). + pub fn skipped_after(&self, days: i64) -> Option { + let day = parse_date(&self.date)?.checked_add_signed(TimeDelta::try_days(days)?)?; + let time = parse_time(&self.time)?; + let zone = parse_zone(&self.zone)?; + first_instant(zone, day.and_time(time)) + .is_none() + .then_some(day) + } +} + +/// One ready-made rule of the repeat menu. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RepeatPreset { + pub label: String, + pub rule: RepeatRule, +} + +/// A rule in words, and where it would start. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "outcome", rename_all = "snake_case")] +pub enum RepeatView { + /// `starts`: the first occurrence's day, `YYYY-MM-DD`; `moved`: it isn't the day asked for. + Ready { + summary: String, + starts: String, + moved: bool, + }, + /// Why it can't be saved, in words (a [`SpecError`] or a start PM can't read). + Refused { message: String }, +} + +/// Why the editor's start can't be read. Shown as is, never echoing the text. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StartError { + Date, + Time, + Zone, +} + +impl fmt::Display for StartError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + StartError::Date => write!(f, "PM couldn't read that date."), + StartError::Time => write!(f, "PM couldn't read that time."), + StartError::Zone => write!(f, "PM doesn't know that time zone."), + } + } +} + +impl RepeatRule { + /// The rule as one from the editor's set. Days are sorted Monday first and deduplicated; an empty + /// day list stays empty for `rule_from_spec` to refuse (`NoWeekdays`). An end date PM can't read + /// is [`SpecError::BadDate`]; every other limit is `rule_from_spec`'s to check. + pub fn to_spec(&self) -> Result { + let pattern = match &self.pattern { + RepeatPattern::Daily => Pattern::Daily, + RepeatPattern::Weekly { days } => { + let days: BTreeSet = days.iter().copied().collect(); + Pattern::Weekly(days.into_iter().map(Weekday::from).collect()) + } + RepeatPattern::MonthlyDay { day } => Pattern::Monthly(MonthlyOn::Day(*day)), + RepeatPattern::MonthlyWeekday { nth, weekday } => { + Pattern::Monthly(MonthlyOn::Weekday((*nth).into(), (*weekday).into())) + } + RepeatPattern::YearlyDate => Pattern::Yearly(YearlyOn::Date), + RepeatPattern::YearlyWeekday { + month, + nth, + weekday, + } => Pattern::Yearly(YearlyOn::Weekday { + month: *month, + nth: (*nth).into(), + weekday: (*weekday).into(), + }), + }; + let ends = match &self.ends { + RepeatEnds::Never => Ends::Never, + RepeatEnds::OnDate { date } => { + Ends::OnDate(parse_date(date).ok_or(SpecError::BadDate)?) + } + RepeatEnds::After { count } => Ends::After(*count), + }; + Ok(RecurrenceSpec { + pattern, + interval: self.interval, + ends, + }) + } + + pub fn from_spec(spec: &RecurrenceSpec) -> RepeatRule { + let pattern = match &spec.pattern { + Pattern::Daily => RepeatPattern::Daily, + Pattern::Weekly(days) => RepeatPattern::Weekly { + days: days.iter().map(|d| DayCode::from(*d)).collect(), + }, + Pattern::Monthly(MonthlyOn::Day(day)) => RepeatPattern::MonthlyDay { day: *day }, + Pattern::Monthly(MonthlyOn::Weekday(nth, weekday)) => RepeatPattern::MonthlyWeekday { + nth: (*nth).into(), + weekday: (*weekday).into(), + }, + Pattern::Yearly(YearlyOn::Date) => RepeatPattern::YearlyDate, + Pattern::Yearly(YearlyOn::Weekday { + month, + nth, + weekday, + }) => RepeatPattern::YearlyWeekday { + month: *month, + nth: (*nth).into(), + weekday: (*weekday).into(), + }, + }; + let ends = match spec.ends { + Ends::Never => RepeatEnds::Never, + Ends::OnDate(day) => RepeatEnds::OnDate { + date: day.format("%Y-%m-%d").to_string(), + }, + Ends::After(count) => RepeatEnds::After { count }, + }; + RepeatRule { + pattern, + interval: spec.interval, + ends, + } + } +} + +impl From for DayCode { + fn from(day: Weekday) -> DayCode { + match day { + Weekday::Mon => DayCode::Mon, + Weekday::Tue => DayCode::Tue, + Weekday::Wed => DayCode::Wed, + Weekday::Thu => DayCode::Thu, + Weekday::Fri => DayCode::Fri, + Weekday::Sat => DayCode::Sat, + Weekday::Sun => DayCode::Sun, + } + } +} + +impl From for Weekday { + fn from(day: DayCode) -> Weekday { + match day { + DayCode::Mon => Weekday::Mon, + DayCode::Tue => Weekday::Tue, + DayCode::Wed => Weekday::Wed, + DayCode::Thu => Weekday::Thu, + DayCode::Fri => Weekday::Fri, + DayCode::Sat => Weekday::Sat, + DayCode::Sun => Weekday::Sun, + } + } +} + +impl From for NthCode { + fn from(nth: Nth) -> NthCode { + match nth { + Nth::First => NthCode::First, + Nth::Second => NthCode::Second, + Nth::Third => NthCode::Third, + Nth::Fourth => NthCode::Fourth, + Nth::Last => NthCode::Last, + } + } +} + +impl From for Nth { + fn from(nth: NthCode) -> Nth { + match nth { + NthCode::First => Nth::First, + NthCode::Second => Nth::Second, + NthCode::Third => Nth::Third, + NthCode::Fourth => Nth::Fourth, + NthCode::Last => Nth::Last, + } + } +} + +/// A `YYYY-MM-DD` date, spaces around it allowed: how PM reads every date the editor sends. +pub fn parse_date(s: &str) -> Option { + NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").ok() +} + +/// An `HH:MM` time (or `HH:MM:SS`), spaces around it allowed: how PM reads every time the editor +/// sends. +pub fn parse_time(s: &str) -> Option { + let s = s.trim(); + NaiveTime::parse_from_str(s, "%H:%M") + .or_else(|_| NaiveTime::parse_from_str(s, "%H:%M:%S")) + .ok() +} + +/// An IANA zone name, spaces around it allowed. +pub fn parse_zone(s: &str) -> Option { + s.trim().parse::().ok() +} + +/// Where a series starts, from the editor's text: a date, time and zone for a timed start, the date +/// alone for an all-day one. A time the clocks skip isn't refused here: the rule may move the start +/// to another day, so `sync_start` checks the day it lands on. +pub fn series_start(start: &RepeatStart) -> Result { + match start { + RepeatStart::Timed { + date, time, zone, .. + } => { + let date = parse_date(date).ok_or(StartError::Date)?; + let time = parse_time(time).ok_or(StartError::Time)?; + let zone = parse_zone(zone).ok_or(StartError::Zone)?; + Ok(SeriesStart::Timed { + local: date.and_time(time), + zone, + }) + } + RepeatStart::AllDay { date } => Ok(SeriesStart::AllDay( + parse_date(date).ok_or(StartError::Date)?, + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::calendar_recur::lines::{self, Kind}; + use crate::calendar_recur::spec::{describe, presets, spec_from_rule, T2_RULES}; + use serde_json::{json, Value}; + + /// The T2 rules, their words and their wire form, shared with the webview's tests. + const FIXTURE: &str = include_str!("../../../src/lib/calendarEdit/repeatRules.fixture.json"); + + fn fixture() -> (RepeatStart, Vec<(String, String, RepeatRule)>) { + let v: Value = serde_json::from_str(FIXTURE).unwrap(); + let start: RepeatStart = serde_json::from_value(v["start"].clone()).unwrap(); + let rows = v["rows"] + .as_array() + .unwrap() + .iter() + .map(|row| { + ( + row["rule"].as_str().unwrap().to_string(), + row["words"].as_str().unwrap().to_string(), + serde_json::from_value(row["spec"].clone()).unwrap(), + ) + }) + .collect(); + (start, rows) + } + + /// `rule` read as one from the editor's set, as a series' words read it. + fn read(rule: &str, start: &SeriesStart) -> Option { + let lines = lines::parse(&[rule]); + let Kind::Rule(rule) = &lines[0].kind else { + return None; + }; + spec_from_rule(&rule.facts().ok()?, start) + } + + fn day(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() + } + + /// Every row of `rules_read_as_google_says_them`, in its order, with the wire form the webview + /// reads it as: the one file both languages test against (C11 §1.5). + #[test] + fn t2_rules_match_the_shared_fixture() { + let (start, rows) = fixture(); + let start = series_start(&start).unwrap(); + let named: Vec<(&str, &str)> = rows + .iter() + .map(|(rule, words, _)| (rule.as_str(), words.as_str())) + .collect(); + assert_eq!(named, T2_RULES, "the fixture holds every T2 row, in order"); + for (rule, words, wire) in &rows { + let spec = read(rule, &start).unwrap(); + assert_eq!(&RepeatRule::from_spec(&spec), wire, "{rule}"); + assert_eq!(&describe(&spec, &start), words, "{rule}"); + } + } + + #[test] + fn every_spec_round_trips_through_the_wire() { + let (_, rows) = fixture(); + for (rule, _, wire) in &rows { + let spec = wire.to_spec().unwrap(); + assert_eq!(&RepeatRule::from_spec(&spec), wire, "{rule}"); + assert_eq!(RepeatRule::from_spec(&spec).to_spec(), Ok(spec), "{rule}"); + } + for d in [ + day(2026, 10, 13), + day(2026, 10, 27), + day(2026, 10, 31), + day(2028, 2, 29), + ] { + for preset in presets(d) { + assert_eq!( + RepeatRule::from_spec(&preset.spec).to_spec(), + Ok(preset.spec.clone()), + "{}", + preset.label + ); + } + } + } + + /// The exact strings the webview's types name. + #[test] + fn the_wire_strings_are_the_ones_the_webview_names() { + let wire = |rule: &RepeatRule| serde_json::to_value(rule).unwrap(); + let cases = [ + ( + RepeatPattern::Weekly { + days: vec![DayCode::Mon, DayCode::Wed], + }, + RepeatEnds::OnDate { + date: "2026-12-31".into(), + }, + json!({"pattern":{"kind":"weekly","days":["mon","wed"]},"interval":2, + "ends":{"kind":"on_date","date":"2026-12-31"}}), + ), + ( + RepeatPattern::Daily, + RepeatEnds::Never, + json!({"pattern":{"kind":"daily"},"interval":2,"ends":{"kind":"never"}}), + ), + ( + RepeatPattern::MonthlyDay { day: 31 }, + RepeatEnds::After { count: 10 }, + json!({"pattern":{"kind":"monthly_day","day":31},"interval":2, + "ends":{"kind":"after","count":10}}), + ), + ( + RepeatPattern::MonthlyWeekday { + nth: NthCode::Last, + weekday: DayCode::Fri, + }, + RepeatEnds::Never, + json!({"pattern":{"kind":"monthly_weekday","nth":"last","weekday":"fri"}, + "interval":2,"ends":{"kind":"never"}}), + ), + ( + RepeatPattern::YearlyDate, + RepeatEnds::Never, + json!({"pattern":{"kind":"yearly_date"},"interval":2,"ends":{"kind":"never"}}), + ), + ( + RepeatPattern::YearlyWeekday { + month: 10, + nth: NthCode::Second, + weekday: DayCode::Tue, + }, + RepeatEnds::Never, + json!({"pattern":{"kind":"yearly_weekday","month":10,"nth":"second", + "weekday":"tue"},"interval":2,"ends":{"kind":"never"}}), + ), + ]; + for (pattern, ends, expected) in cases { + let rule = RepeatRule { + pattern, + interval: 2, + ends, + }; + assert_eq!(wire(&rule), expected); + assert_eq!( + serde_json::from_value::(expected).unwrap(), + rule + ); + } + for (start, expected) in [ + ( + json!({"kind":"timed","date":"2026-10-13","time":"09:00","zone":"Europe/London"}), + RepeatStart::Timed { + date: "2026-10-13".into(), + time: "09:00".into(), + zone: "Europe/London".into(), + end: None, + }, + ), + ( + json!({"kind":"timed","date":"2026-10-13","time":"09:00","zone":"Europe/London", + "end":{"date":"2026-10-13","time":"10:00","zone":"Europe/London"}}), + RepeatStart::Timed { + date: "2026-10-13".into(), + time: "09:00".into(), + zone: "Europe/London".into(), + end: Some(WallTime { + date: "2026-10-13".into(), + time: "10:00".into(), + zone: "Europe/London".into(), + }), + }, + ), + ( + json!({"kind":"all_day","date":"2026-10-13"}), + RepeatStart::AllDay { + date: "2026-10-13".into(), + }, + ), + ] { + assert_eq!( + serde_json::from_value::(start).unwrap(), + expected + ); + } + let views = [ + ( + RepeatView::Ready { + summary: "Daily".into(), + starts: "2026-10-14".into(), + moved: true, + }, + json!({"outcome":"ready","summary":"Daily","starts":"2026-10-14","moved":true}), + ), + ( + RepeatView::Refused { + message: "No.".into(), + }, + json!({"outcome":"refused","message":"No."}), + ), + ]; + for (view, expected) in views { + assert_eq!(serde_json::to_value(&view).unwrap(), expected); + } + // A kind, day or nth PM doesn't know is refused, never guessed. + for unknown in [ + json!({"pattern":{"kind":"hourly"},"interval":1,"ends":{"kind":"never"}}), + json!({"pattern":{"kind":"weekly","days":["monday"]},"interval":1,"ends":{"kind":"never"}}), + json!({"pattern":{"kind":"monthly_weekday","nth":"fifth","weekday":"fri"},"interval":1, + "ends":{"kind":"never"}}), + ] { + assert!(serde_json::from_value::(unknown).is_err()); + } + } + + #[test] + fn weekdays_are_written_monday_first_and_once() { + let rule = RepeatRule { + pattern: RepeatPattern::Weekly { + days: vec![DayCode::Wed, DayCode::Mon, DayCode::Wed], + }, + interval: 1, + ends: RepeatEnds::Never, + }; + assert_eq!( + rule.to_spec().unwrap().pattern, + Pattern::Weekly(vec![Weekday::Mon, Weekday::Wed]) + ); + // No day at all stays empty, for the rule's own check to refuse in words. + let none = RepeatRule { + pattern: RepeatPattern::Weekly { days: Vec::new() }, + ..rule + }; + assert_eq!(none.to_spec().unwrap().pattern, Pattern::Weekly(Vec::new())); + } + + #[test] + fn an_unreadable_end_date_is_refused() { + let ending = |date: &str| RepeatRule { + pattern: RepeatPattern::Daily, + interval: 1, + ends: RepeatEnds::OnDate { date: date.into() }, + }; + assert_eq!(ending("31-12-2026").to_spec(), Err(SpecError::BadDate)); + assert_eq!(ending("").to_spec(), Err(SpecError::BadDate)); + assert_eq!( + ending("2026-12-31").to_spec().unwrap().ends, + Ends::OnDate(day(2026, 12, 31)) + ); + assert_eq!( + SpecError::BadDate.to_string(), + "PM couldn't read that end date." + ); + } + + #[test] + fn a_start_is_read_or_refused_in_words() { + let timed = |date: &str, time: &str, zone: &str| RepeatStart::Timed { + date: date.into(), + time: time.into(), + zone: zone.into(), + end: None, + }; + assert_eq!( + series_start(&timed(" 2026-10-13 ", "09:00:30", "Europe/London")), + Ok(SeriesStart::Timed { + local: day(2026, 10, 13).and_hms_opt(9, 0, 30).unwrap(), + zone: chrono_tz::Europe::London, + }) + ); + assert_eq!( + series_start(&RepeatStart::AllDay { + date: "2026-10-13".into() + }), + Ok(SeriesStart::AllDay(day(2026, 10, 13))) + ); + // A time the clocks skip is the rule's day's question, not the start's. + assert!(series_start(&timed("2026-03-29", "01:30", "Europe/London")).is_ok()); + for (start, why, words) in [ + ( + timed("13/10/2026", "09:00", "Europe/London"), + StartError::Date, + "PM couldn't read that date.", + ), + ( + timed("2026-10-13", "9am", "Europe/London"), + StartError::Time, + "PM couldn't read that time.", + ), + ( + timed("2026-10-13", "09:00", "Mars/Olympus"), + StartError::Zone, + "PM doesn't know that time zone.", + ), + ( + RepeatStart::AllDay { + date: "soon".into(), + }, + StartError::Date, + "PM couldn't read that date.", + ), + ] { + assert_eq!(series_start(&start), Err(why)); + assert_eq!(why.to_string(), words); + } + } + + /// London skips 01:00-02:00 on 29-03-2026: an end at 01:30 the day before is found there once + /// the rule moves it a day on, and not before; an end PM can't read is the editor's to say. + #[test] + fn an_end_the_clocks_skip_is_found_where_the_rule_moves_it() { + let end = |date: &str, time: &str, zone: &str| WallTime { + date: date.into(), + time: time.into(), + zone: zone.into(), + }; + let night = end("2026-03-28", "01:30", "Europe/London"); + assert_eq!(night.skipped_after(1), Some(day(2026, 3, 29))); + assert_eq!(night.skipped_after(0), None); + assert_eq!(night.skipped_after(2), None); + // The same night in New York is an ordinary one. + assert_eq!( + end("2026-03-28", "01:30", "America/New_York").skipped_after(1), + None + ); + for unread in [ + end("28-03-2026", "01:30", "Europe/London"), + end("2026-03-28", "late", "Europe/London"), + end("2026-03-28", "01:30", "Mars/Olympus"), + ] { + assert_eq!(unread.skipped_after(1), None); + } + } +} diff --git a/src-tauri/src/calendar_write/dto.rs b/src-tauri/src/calendar_write/dto.rs index a76e0462..4f461543 100644 --- a/src-tauri/src/calendar_write/dto.rs +++ b/src-tauri/src/calendar_write/dto.rs @@ -8,6 +8,8 @@ use serde::{Deserialize, Serialize}; +use crate::calendar_recur::wire::RepeatRule; + /// Who Google emails about a change (`sendUpdates`). Every write states it; PM never relies on /// Google's default. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -102,8 +104,8 @@ pub struct EventPatchDraft { } /// A new event as the editor holds it (A22: defined with its first caller, the create command). No -/// repeat (C11), guests (C12), reminders or colour (C13): Google gives the calendar's default -/// reminders when none are sent. Empty text is left out of the insert, never sent as "". +/// guests (C12), reminders or colour (C13): Google gives the calendar's default reminders when none +/// are sent. Empty text is left out of the insert, never sent as "". #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] pub struct NewEventDraft { #[serde(default)] @@ -120,6 +122,10 @@ pub struct NewEventDraft { /// `None` or `Default` is the calendar's default, and isn't sent. #[serde(default)] pub visibility: Option, + /// How it repeats; `None` doesn't repeat. Started on the first day the rule makes + /// ([`crate::calendar_recur::expand::sync_start`]), its end moved with it (C11). + #[serde(default)] + pub recurrence: Option, } /// What the user saw of an event when they chose to delete it, taken from the mirror row they @@ -843,7 +849,9 @@ mod tests { r#"{"summary":"Dentist","time":{"kind":"timed","start_date":"2026-10-26", "start_time":"10:00","start_zone":"Europe/London","end_date":"2026-10-26", "end_time":"11:00","end_zone":"Europe/London"},"location":"High St", - "description":"Bring the form","show_as":"free","visibility":"private"}"#, + "description":"Bring the form","show_as":"free","visibility":"private", + "recurrence":{"pattern":{"kind":"weekly","days":["mon"]},"interval":1, + "ends":{"kind":"after","count":10}}}"#, ) .unwrap(); assert!(full.summary == "Dentist" && full.location == "High St"); @@ -851,6 +859,9 @@ mod tests { assert!(matches!(full.time, TimeDraft::Timed { .. })); assert_eq!(full.show_as, Some(ShowAs::Free)); assert_eq!(full.visibility, Some(Visibility::Private)); + assert!(full + .recurrence + .is_some_and(|rule| rule.interval == 1 && rule.to_spec().is_ok())); let bare: NewEventDraft = serde_json::from_str( r#"{"time":{"kind":"all_day","first_day":"2026-10-26","last_day":"2026-10-26"}}"#, @@ -859,6 +870,14 @@ mod tests { assert!(bare.summary.is_empty() && bare.location.is_empty()); assert!(bare.description.is_empty()); assert_eq!((bare.show_as, bare.visibility), (None, None)); + // Left out or null, it doesn't repeat. + assert!(bare.recurrence.is_none()); + let null: NewEventDraft = serde_json::from_str( + r#"{"time":{"kind":"all_day","first_day":"2026-10-26","last_day":"2026-10-26"}, + "recurrence":null}"#, + ) + .unwrap(); + assert!(null.recurrence.is_none()); let unknown = serde_json::from_str::( r#"{"summary":"x","time":{"kind":"floating","day":"2026-10-26"}}"#, diff --git a/src-tauri/src/calendar_write/patch.rs b/src-tauri/src/calendar_write/patch.rs index e40830a7..1d269bea 100644 --- a/src-tauri/src/calendar_write/patch.rs +++ b/src-tauri/src/calendar_write/patch.rs @@ -16,10 +16,14 @@ //! still matches the fresh copy ([`delete_still_matches`]). Re-deleting just because the etag moved //! would delete a version of the event nobody looked at. +use std::fmt; + use serde_json::{Map, Value}; use super::dto::{EventPatchDraft, NewEventDraft, SeenSummary, Visibility}; use super::time::{self, TimeError}; +use crate::calendar_recur::expand::sync_start; +use crate::calendar_recur::spec::{rule_from_spec, SpecError}; /// The only fields a single-event save sends. pub const PATCHABLE: &[&str] = &[ @@ -32,18 +36,73 @@ pub const PATCHABLE: &[&str] = &[ "visibility", ]; -/// The only fields a create sends: [`PATCHABLE`]'s, nothing more (no attendees, recurrence, -/// reminders, conference, colour or private notes until their layers). The same list on purpose: -/// widening what a save sends (C13's reminders) widens creates too, and the create plan's own -/// refusal test then fails, so it is a decision, never a slip (C10 critique 1 L10). -pub const CREATABLE: &[&str] = PATCHABLE; +/// What a create may send: [`PATCHABLE`]'s fields and the repeat rule (C11). Still no attendees, +/// reminders, conference, colour or private notes until their layers. Widening it is a decision, +/// never a slip: `creatable_is_patchable_and_the_rule` pins it to exactly this, and the create +/// plan's own refusal test names what it still refuses (C10 critique 1 L10). +pub const CREATABLE: &[&str] = &[ + "summary", + "location", + "description", + "start", + "end", + "transparency", + "visibility", + "recurrence", +]; + +/// Why a create's body can't be built. Shown to the user as is: the inner words. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InsertError { + Time(TimeError), + Rule(SpecError), +} + +impl fmt::Display for InsertError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + InsertError::Time(e) => e.fmt(f), + InsertError::Rule(e) => e.fmt(f), + } + } +} + +impl From for InsertError { + fn from(e: TimeError) -> Self { + InsertError::Time(e) + } +} + +impl From for InsertError { + fn from(e: SpecError) -> Self { + InsertError::Rule(e) + } +} /// The insert body for `draft`. Google's `start`/`end` come from [`time::resolve`] (explicit zones, /// a skipped hour refused, a repeated hour's first occurrence, an all-day end the day after the /// last day); empty text is left out, so Google keeps no empty strings. No `id`: the insert request /// writes PM's own in ([`super::plan::insert_event`]). -pub fn insert_body(draft: &NewEventDraft) -> Result, TimeError> { - let when = time::resolve(&draft.time)?; +/// +/// A repeating event starts on the first day its rule makes ([`sync_start`]: "Weekly on Monday and +/// Wednesday" chosen on a Tuesday starts on the Wednesday, A27), its end moved the same number of +/// days, and carries one rule line and nothing else (no EXDATE or RDATE). A timed one names its +/// zone on both nodes, as every timed node does; an all-day one sends dates only (probe P-R1). +pub fn insert_body(draft: &NewEventDraft) -> Result, InsertError> { + let (time, rule) = match &draft.recurrence { + None => (draft.time.clone(), None), + Some(rule) => { + let spec = rule.to_spec()?; + let asked = time::series_start(&draft.time)?; + let synced = sync_start(&spec, &asked)?; + let moved = (synced.date() - asked.date()).num_days(); + ( + time::shifted(&draft.time, moved)?, + Some(rule_from_spec(&spec, &synced)?), + ) + } + }; + let when = time::resolve(&time)?; let mut body = Map::new(); for (key, text) in [ ("summary", &draft.summary), @@ -69,6 +128,9 @@ pub fn insert_body(draft: &NewEventDraft) -> Result, TimeErro Value::String(visibility.as_param().into()), ); } + if let Some(line) = rule { + body.insert("recurrence".into(), Value::from(vec![line])); + } Ok(body) } @@ -348,9 +410,14 @@ pub(crate) fn when_eq(a: Option<&Value>, b: Option<&Value>) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::calendar_recur::wire::{DayCode, RepeatEnds, RepeatPattern, RepeatRule}; use crate::calendar_write::dto::{ShowAs, TimeDraft, Visibility}; use serde_json::json; + fn day(y: i32, m: u32, d: u32) -> chrono::NaiveDate { + chrono::NaiveDate::from_ymd_opt(y, m, d).unwrap() + } + /// Google's fresh copy of a one-off event, with fields PM must never touch. fn fresh() -> Value { json!({ @@ -747,17 +814,36 @@ mod tests { description: "Bring the form\nand a pen".into(), show_as: Some(ShowAs::Free), visibility: Some(Visibility::Private), + recurrence: None, + } + } + + /// `new_event` repeating by `pattern`, every time, until `ends`. + fn repeating(time: TimeDraft, pattern: RepeatPattern, ends: RepeatEnds) -> NewEventDraft { + NewEventDraft { + recurrence: Some(RepeatRule { + pattern, + interval: 1, + ends, + }), + ..new_event(time) + } + } + + fn weekly_on(days: &[DayCode]) -> RepeatPattern { + RepeatPattern::Weekly { + days: days.to_vec(), } } + /// Every field a create may send, the rule included, and nothing else. #[test] fn insert_body_sends_only_what_a_create_may() { - let body = insert_body(&new_event(timed( - "2026-10-26", - "10:00", - "2026-10-26", - "11:00", - ))) + let body = insert_body(&repeating( + timed("2026-10-26", "10:00", "2026-10-26", "11:00"), + weekly_on(&[DayCode::Mon]), + RepeatEnds::After { count: 10 }, + )) .unwrap(); let mut keys: Vec<&str> = body.keys().map(String::as_str).collect(); keys.sort_unstable(); @@ -767,6 +853,7 @@ mod tests { "description", "end", "location", + "recurrence", "start", "summary", "transparency", @@ -778,7 +865,152 @@ mod tests { assert_eq!(body["description"], "Bring the form\nand a pen"); assert_eq!(body["transparency"], "transparent"); assert_eq!(body["visibility"], "private"); + assert_eq!( + body["recurrence"], + json!(["RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=10"]) + ); assert!(!body.contains_key("id"), "the insert request writes the id"); + // No rule, no `recurrence` at all. + let once = insert_body(&new_event(timed( + "2026-10-26", + "10:00", + "2026-10-26", + "11:00", + ))) + .unwrap(); + assert!(!once.contains_key("recurrence")); + } + + /// C11 §3.3: what a create may send is a single save's fields and the rule, exactly. + #[test] + fn creatable_is_patchable_and_the_rule() { + let mut expected: Vec<&str> = PATCHABLE.to_vec(); + expected.push("recurrence"); + assert_eq!(CREATABLE, expected.as_slice()); + } + + /// G6: a weekly event on its own weekday sends one rule line, and both nodes name the zone. + #[test] + fn a_repeating_insert_sends_one_rule_and_names_its_zone() { + let body = insert_body(&repeating( + timed("2026-10-13", "09:00", "2026-10-13", "10:00"), + weekly_on(&[DayCode::Tue]), + RepeatEnds::Never, + )) + .unwrap(); + assert_eq!(body["recurrence"], json!(["RRULE:FREQ=WEEKLY;BYDAY=TU"])); + assert_eq!( + body["start"], + json!({ "dateTime": "2026-10-13T09:00:00+01:00", "timeZone": "Europe/London" }) + ); + assert_eq!(body["end"]["timeZone"], "Europe/London"); + } + + /// A27 (live test L-R10's case): Monday and Wednesday chosen on a Tuesday starts on the + /// Wednesday, and the hour's length goes with it. + #[test] + fn a_repeating_insert_starts_on_the_first_day_its_rule_makes() { + let body = insert_body(&repeating( + timed("2026-10-13", "09:00", "2026-10-13", "10:00"), + weekly_on(&[DayCode::Wed, DayCode::Mon]), + RepeatEnds::OnDate { + date: "2026-12-31".into(), + }, + )) + .unwrap(); + assert_eq!( + body["start"], + json!({ "dateTime": "2026-10-14T09:00:00+01:00", "timeZone": "Europe/London" }) + ); + assert_eq!( + body["end"], + json!({ "dateTime": "2026-10-14T10:00:00+01:00", "timeZone": "Europe/London" }) + ); + // The end date's last second in London, as UTC (GMT by then). + assert_eq!( + body["recurrence"], + json!(["RRULE:FREQ=WEEKLY;BYDAY=MO,WE;UNTIL=20261231T235959Z"]) + ); + } + + /// An all-day repeating event names days only (probe P-R1): no zone on either node. + #[test] + fn an_all_day_repeating_insert_sends_dates_only() { + let body = insert_body(&repeating( + TimeDraft::AllDay { + first_day: "2026-10-26".into(), + last_day: "2026-10-26".into(), + }, + RepeatPattern::Daily, + RepeatEnds::Never, + )) + .unwrap(); + assert_eq!(body["start"], json!({ "date": "2026-10-26" })); + assert_eq!(body["end"], json!({ "date": "2026-10-27" })); + assert_eq!(body["recurrence"], json!(["RRULE:FREQ=DAILY"])); + } + + /// London skips 01:00-02:00 on Sunday 29-03-2026. A Sunday rule chosen at 01:30 on the Saturday + /// would start in it; an end the move puts in it is refused as any end in a gap is. + #[test] + fn a_repeating_insert_in_a_gap_is_refused() { + let gap = insert_body(&repeating( + timed("2026-03-28", "01:30", "2026-03-28", "03:00"), + weekly_on(&[DayCode::Sun]), + RepeatEnds::Never, + )); + assert!(gap == Err(InsertError::Rule(SpecError::Gap(day(2026, 3, 29))))); + assert_eq!( + gap.unwrap_err().to_string(), + "The first occurrence would be on 29-03-2026, when the clocks skip that time. Pick a \ + time outside that hour." + ); + let end_gap = insert_body(&repeating( + timed("2026-03-27", "23:00", "2026-03-28", "01:30"), + weekly_on(&[DayCode::Sat]), + RepeatEnds::Never, + )); + assert!(matches!( + end_gap, + Err(InsertError::Time(TimeError::Gap { .. })) + )); + } + + /// Google allows 730 occurrences (Help 37115): 731 builds nothing to send. + #[test] + fn a_rule_past_730_builds_nothing() { + let draft = |count: u16| { + repeating( + timed("2026-10-26", "10:00", "2026-10-26", "11:00"), + RepeatPattern::Daily, + RepeatEnds::After { count }, + ) + }; + assert!(insert_body(&draft(731)) == Err(InsertError::Rule(SpecError::TooMany))); + assert_eq!( + insert_body(&draft(730)).unwrap()["recurrence"], + json!(["RRULE:FREQ=DAILY;COUNT=730"]) + ); + // An end date PM can't read, or a time it can't, is said in its own words. + let unread = NewEventDraft { + recurrence: Some(RepeatRule { + pattern: RepeatPattern::Daily, + interval: 1, + ends: RepeatEnds::OnDate { + date: "31-12-2026".into(), + }, + }), + ..draft(1) + }; + assert!(insert_body(&unread) == Err(InsertError::Rule(SpecError::BadDate))); + let bad_time = repeating( + timed("2026-10-26", "late", "2026-10-26", "11:00"), + RepeatPattern::Daily, + RepeatEnds::Never, + ); + assert!( + insert_body(&bad_time) == Err(InsertError::Time(TimeError::BadTime("late".into()))) + ); } /// Blank text is left out, so Google stores no empty strings; text with words in is sent as @@ -843,11 +1075,14 @@ mod tests { "2026-03-29", "03:00", ))); - assert!(matches!(gap, Err(TimeError::Gap { .. }))); + assert!(matches!(gap, Err(InsertError::Time(TimeError::Gap { .. })))); for end in ["10:00", "09:00"] { let backwards = insert_body(&new_event(timed("2026-10-26", "10:00", "2026-10-26", end))); - assert!(matches!(backwards, Err(TimeError::EndBeforeStart)), "{end}"); + assert!( + matches!(backwards, Err(InsertError::Time(TimeError::EndBeforeStart))), + "{end}" + ); } } diff --git a/src-tauri/src/calendar_write/series/plan.rs b/src-tauri/src/calendar_write/series/plan.rs index cf7465ef..2fa76605 100644 --- a/src-tauri/src/calendar_write/series/plan.rs +++ b/src-tauri/src/calendar_write/series/plan.rs @@ -4017,6 +4017,7 @@ mod tests { description: String::new(), show_as: Some(ShowAs::Busy), visibility: None, + recurrence: None, }) .unwrap() } @@ -4045,12 +4046,11 @@ mod tests { } /// G5: a key a create may not send, a body with no start PM can read, or an id that isn't one - /// path segment builds no plan at all. + /// path segment builds no plan at all. The repeat rule is one a create sends since C11. #[test] fn a_create_plan_refuses_a_key_a_create_may_not_send() { for key in [ "attendees", - "recurrence", "extendedProperties", "reminders", "conferenceData", @@ -4072,6 +4072,38 @@ mod tests { } } + /// C11: a repeating event's body (its one rule line, its zone on both nodes) builds the same + /// one-step plan, the rule in what it sends. + #[test] + fn a_create_plan_sends_a_repeating_events_rule() { + let body = + super::super::super::patch::insert_body(&crate::calendar_write::dto::NewEventDraft { + recurrence: Some(crate::calendar_recur::wire::RepeatRule { + pattern: crate::calendar_recur::wire::RepeatPattern::Daily, + interval: 1, + ends: crate::calendar_recur::wire::RepeatEnds::After { count: 5 }, + }), + summary: "Dentist".into(), + time: TimeDraft::AllDay { + first_day: "2026-10-26".into(), + last_day: "2026-10-26".into(), + }, + location: String::new(), + description: String::new(), + show_as: None, + visibility: None, + }) + .unwrap(); + let plan = SeriesPlan::create(CALENDAR, NEW, body).unwrap(); + let step = &plan.steps[0]; + assert!(step.op.fits()); + assert_eq!(step.notify, Notify::None); + let request = step.op.request(step.notify).unwrap(); + let sent = request.body.unwrap(); + assert_eq!(sent["recurrence"], json!(["RRULE:FREQ=DAILY;COUNT=5"])); + assert_eq!(sent["id"], NEW); + } + /// A live copy under PM's id is this create's, renamed or not; a cancelled one is this create's, /// deleted since; none proves nothing. #[test] diff --git a/src-tauri/src/calendar_write/series/run.rs b/src-tauri/src/calendar_write/series/run.rs index 6f8e09f7..918b7e77 100644 --- a/src-tauri/src/calendar_write/series/run.rs +++ b/src-tauri/src/calendar_write/series/run.rs @@ -2773,6 +2773,7 @@ mod tests { description: String::new(), show_as: None, visibility: None, + recurrence: None, }) .unwrap(); SeriesPlan::create(CALENDAR, NEW, body).unwrap() diff --git a/src-tauri/src/calendar_write/time.rs b/src-tauri/src/calendar_write/time.rs index 3363f430..f0083bca 100644 --- a/src-tauri/src/calendar_write/time.rs +++ b/src-tauri/src/calendar_write/time.rs @@ -12,13 +12,15 @@ use std::fmt; use chrono::{ - DateTime, Days, FixedOffset, LocalResult, NaiveDate, NaiveTime, SecondsFormat, TimeZone, - Timelike, + DateTime, Days, FixedOffset, LocalResult, NaiveDate, NaiveTime, SecondsFormat, TimeDelta, + TimeZone, Timelike, }; use chrono_tz::Tz; use serde_json::{json, Value}; use super::dto::TimeDraft; +use crate::calendar_recur::wire; +use crate::calendar_recur::SeriesStart; /// Why a time can't be saved as given. Shown to the user as is. #[derive(Debug, Clone, PartialEq, Eq)] @@ -65,22 +67,75 @@ pub fn parse_device_zone(zone: Option<&str>) -> Result { } } -/// An IANA zone name as a zone. +/// An IANA zone name as a zone. The three parsers here are the repeat menu's own ([`wire`]), so +/// a save and the menu's words can't read the same text two ways (C11 critique 1 L6). pub fn parse_zone(name: &str) -> Result { - name.trim() - .parse::() - .map_err(|_| TimeError::UnknownZone(name.trim().to_string())) + wire::parse_zone(name).ok_or_else(|| TimeError::UnknownZone(name.trim().to_string())) } fn parse_date(s: &str) -> Result { - NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").map_err(|_| TimeError::BadDate(s.into())) + wire::parse_date(s).ok_or_else(|| TimeError::BadDate(s.into())) } fn parse_time(s: &str) -> Result { - let s = s.trim(); - NaiveTime::parse_from_str(s, "%H:%M") - .or_else(|_| NaiveTime::parse_from_str(s, "%H:%M:%S")) - .map_err(|_| TimeError::BadTime(s.into())) + wire::parse_time(s).ok_or_else(|| TimeError::BadTime(s.trim().into())) +} + +/// Where a draft's event starts, as a series would, for a repeat rule to start from (C11): read as +/// [`wire::series_start`] reads the repeat menu's start, with the same parsers in the same order, +/// so the menu's words and the save start a rule on the same day. A time the clocks skip isn't +/// refused here: the rule may move the start, and the day it lands on is checked there. +pub fn series_start(draft: &TimeDraft) -> Result { + match draft { + TimeDraft::Timed { + start_date, + start_time, + start_zone, + .. + } => { + let (date, time) = (parse_date(start_date)?, parse_time(start_time)?); + Ok(SeriesStart::Timed { + local: date.and_time(time), + zone: parse_zone(start_zone)?, + }) + } + TimeDraft::AllDay { first_day, .. } => Ok(SeriesStart::AllDay(parse_date(first_day)?)), + } +} + +/// The draft moved `days` days later, start and end alike, wall times and zones kept: where a repeat +/// rule moved its start, the end goes with it and the event keeps its length. +pub fn shifted(draft: &TimeDraft, days: i64) -> Result { + let by = |date: &str| -> Result { + TimeDelta::try_days(days) + .and_then(|delta| parse_date(date).ok()?.checked_add_signed(delta)) + .map(|d| d.format("%Y-%m-%d").to_string()) + .ok_or_else(|| TimeError::BadDate(date.into())) + }; + Ok(match draft { + TimeDraft::Timed { + start_date, + start_time, + start_zone, + end_date, + end_time, + end_zone, + } => TimeDraft::Timed { + start_date: by(start_date)?, + start_time: start_time.clone(), + start_zone: start_zone.clone(), + end_date: by(end_date)?, + end_time: end_time.clone(), + end_zone: end_zone.clone(), + }, + TimeDraft::AllDay { + first_day, + last_day, + } => TimeDraft::AllDay { + first_day: by(first_day)?, + last_day: by(last_day)?, + }, + }) } /// The instant a wall-clock time names in `zone`. The skipped hour is an error; a repeated one takes @@ -516,4 +571,93 @@ mod tests { json!({ "dateTime": "2026-07-01T09:00:00+02:00", "timeZone": "Europe/Paris" }) ); } + + // --- a repeating event's start (C11) --- + + /// C11 critique 1 L6: the save reads a start exactly as the repeat menu does, the text it + /// accepts and the part it refuses first alike. + #[test] + fn the_save_reads_a_start_as_the_repeat_menu_does() { + use crate::calendar_recur::wire::{RepeatStart, StartError}; + for (date, time, zone) in [ + ("2026-10-13", "09:00", "Europe/London"), + (" 2026-10-13 ", " 09:00:30 ", " America/New_York "), + ("2026-03-29", "01:30", "Europe/London"), + ("13-10-2026", "09:00", "Europe/London"), + ("2026-10-13", "9am", "Europe/London"), + ("2026-10-13", "09:00", "Mars/Olympus"), + ("soon", "late", "nowhere"), + ("2026-10-13", "25:00", "nowhere"), + ] { + let draft = timed_draft(date, time, zone, date, time, zone); + let menu = wire::series_start(&RepeatStart::Timed { + date: date.into(), + time: time.into(), + zone: zone.into(), + end: None, + }); + let save = series_start(&draft); + match (menu, save) { + (Ok(a), Ok(b)) => assert_eq!(a, b), + (Err(StartError::Date), Err(TimeError::BadDate(_))) + | (Err(StartError::Time), Err(TimeError::BadTime(_))) + | (Err(StartError::Zone), Err(TimeError::UnknownZone(_))) => {} + _ => panic!("the save and the menu read {date} {time} {zone} differently"), + } + } + let all_day = TimeDraft::AllDay { + first_day: "2026-10-31".into(), + last_day: "2026-11-01".into(), + }; + assert_eq!( + series_start(&all_day), + Ok(SeriesStart::AllDay( + NaiveDate::from_ymd_opt(2026, 10, 31).unwrap() + )) + ); + } + + /// A start the rule moved takes its end with it: the same wall times and zones, whole days on. + #[test] + fn a_draft_moves_whole_days_keeping_its_wall_times_and_zones() { + let flight = timed_draft( + "2026-10-13", + "22:00", + "Europe/London", + "2026-10-14", + "06:30", + "America/New_York", + ); + assert_eq!( + shifted(&flight, 1), + Ok(timed_draft( + "2026-10-14", + "22:00", + "Europe/London", + "2026-10-15", + "06:30", + "America/New_York", + )) + ); + assert_eq!(shifted(&flight, 0), Ok(flight)); + let days = TimeDraft::AllDay { + first_day: "2026-10-31".into(), + last_day: "2026-11-01".into(), + }; + assert_eq!( + shifted(&days, 2), + Ok(TimeDraft::AllDay { + first_day: "2026-11-02".into(), + last_day: "2026-11-03".into(), + }) + ); + let unread = TimeDraft::AllDay { + first_day: "2026-10-31".into(), + last_day: "someday".into(), + }; + assert_eq!( + shifted(&unread, 1), + Err(TimeError::BadDate("someday".into())) + ); + } } diff --git a/src-tauri/src/commands/calendar_edit.rs b/src-tauri/src/commands/calendar_edit.rs index beaa2ee2..6a580704 100644 --- a/src-tauri/src/commands/calendar_edit.rs +++ b/src-tauri/src/commands/calendar_edit.rs @@ -38,9 +38,10 @@ use tauri::{AppHandle, Emitter, Manager}; 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::Point; +use crate::calendar_recur::{Point, SeriesStart}; use crate::calendar_write::classify::{Method, Verdict}; use crate::calendar_write::dto::{ ChoiceRequest, DeleteSettled, DeleteStart, EditLoad, EventForEdit, EventPatchDraft, @@ -304,10 +305,10 @@ fn facts_from_fresh<'a>( } /// Google's copy of a single event is now a repeating event's series: it carries a rule and is no -/// occurrence. The sync never mirrors such a copy (`calendar::parse_event`), so only a row that was -/// a one-off can be under one. +/// occurrence ([`calendar::is_master`], where an empty rule is none). The sync never mirrors such a +/// copy (`calendar::parse_event`), so only a row that was a one-off can be under one. fn turned_series(fresh: &Value) -> bool { - fresh.get("recurrence").is_some() && fresh.get("recurringEventId").is_none() + calendar::is_master(fresh) } /// The gate's facts from a mirror row, for the list. Account state isn't read here (that would be @@ -909,8 +910,10 @@ pub async fn create_calendar_event( }; #[cfg(debug_assertions)] { - // A create reaches one new event, never a series. - let allowed = dev_allow::check(&*state.conn()?, &target.remote_calendar, false)?; + // A create reaches one new event, or a whole repeating one: that is series-wide, so an + // unset allow-list refuses it (C11 critique 1 L11). + let series_wide = draft.recurrence.is_some(); + let allowed = dev_allow::check(&*state.conn()?, &target.remote_calendar, series_wide)?; if let Err(message) = allowed { return Ok(WriteOutcome::Failed { message: message.into(), @@ -1067,7 +1070,7 @@ async fn create_event( }; let wire = Posts::new(ctx.wire); match drive(&wire, &mut run).await { - Ran::Done { ends } => landed_create(ctx.host, ctx.target, &ends), + Ran::Done { ends } => landed_create(ctx.wire, ctx.host, ctx.target, &ends).await, // Gone: made, then deleted in Google before PM could look. Ran::NotDone { why, .. } => not_done_outcome(why, ctx.editing, false), Ran::Unknown { .. } => { @@ -1215,7 +1218,7 @@ async fn retry_create( match retry_next(RetryLook::of(get(ctx.wire, calendar, id).await), first) { RetryStep::Land(copy) => { taken.consume(); - return land_created(ctx.host, &held.target, ©); + return land_copy(ctx.wire, ctx.host, &held.target, ©).await; } RetryStep::Gone => { taken.consume(); @@ -1255,7 +1258,7 @@ async fn retry_create( match ran { Ran::Done { ends } => { taken.consume(); - landed_create(ctx.host, &held.target, &ends) + landed_create(ctx.wire, ctx.host, &held.target, &ends).await } Ran::NotDone { why: Why::Gone, .. } => { taken.consume(); @@ -1287,17 +1290,148 @@ async fn retry_create( } /// A create's run proved it landed: Google's copy into the mirror, from the step's end. -fn landed_create(host: &impl Host, target: &CalendarTarget, ends: &[End]) -> WriteOutcome { +async fn landed_create( + wire: &W, + host: &H, + target: &CalendarTarget, + ends: &[End], +) -> WriteOutcome { match ends.first() { Some(End::Done { reply: Some(copy), .. - }) => land_created(host, target, copy), + }) => land_copy(wire, host, target, copy).await, // Proved without a copy to show: under `Shows::Created` every proof carries one, but the // next sync shows it anyway. _ => created_pending(host, target), } } +/// Google's copy of a new event into the mirror. A one-off's copy is its row; a repeating event's is +/// its master, which never becomes a row (`calendar::parse_event`), so its occurrences are listed +/// and landed instead (C11). +async fn land_copy( + wire: &W, + host: &H, + target: &CalendarTarget, + copy: &Value, +) -> WriteOutcome { + if calendar::is_master(copy) { + return land_new_series(wire, host, &CalIds::of(target), copy).await; + } + land_created(host, target, copy) +} + +/// PM's and Google's ids for one calendar, for landing what a write made there. +struct CalIds<'a> { + calendar_id: &'a str, + remote_calendar: &'a str, +} + +impl<'a> CalIds<'a> { + fn of(target: &'a CalendarTarget) -> Self { + CalIds { + calendar_id: &target.calendar_id, + remote_calendar: &target.remote_calendar, + } + } +} + +/// A repeating event Google now holds, from its copy `copy` (its master): its occurrences in the +/// months PM mirrors, listed and landed as CREATED rows, so a list that lags can't take them off the +/// screen (A28). One with no occurrence in those months (it starts after them, or a COUNT or UNTIL +/// ended it before them) is saved and said to be outside, with nothing listed (C10 §6.2, C11 +/// critique 1 L8). Looks up to three times (now, 1 s, 3 s) while the listing is empty or +/// incomplete; then "saved, the calendar will catch up" rather than a guess. No lock is held across +/// a listing; the landing takes the DB, then `recent`. +async fn land_new_series( + wire: &W, + host: &H, + cal: &CalIds<'_>, + copy: &Value, +) -> WriteOutcome { + let pending = || { + drop_hash(host, cal.calendar_id); + WriteOutcome::Saved { + warnings: vec![WriteWarning::MirrorRefreshPending], + left_behind: Vec::new(), + milestone_moves: None, + } + }; + // Its id one path segment, its start and rule readable (never a guessed zone). + let Ok(master) = Master::read(&FreshEvent::from_google(copy.clone())) else { + return pending(); + }; + let Ok(mirror) = host.mirror(cal.calendar_id) else { + return pending(); + }; + let Some(window) = mirror.instants() else { + return pending(); + }; + if series_in_window(&master, window) == Some(false) { + return WriteOutcome::Saved { + warnings: vec![WriteWarning::OutsideMirror], + left_behind: Vec::new(), + milestone_moves: None, + }; + } + let mut listing = Listing::default(); + for pause in [None, Some(REFETCH_AGAIN[0]), Some(REFETCH_AGAIN[1])] { + if let Some(pause) = pause { + wire.wait(pause).await; + } + listing = refetch(wire, cal.remote_calendar, &master.id, &mirror.window, false).await; + if listing.complete && !listing.items.is_empty() { + break; + } + } + if !listing.complete { + return pending(); + } + let rows: Vec = listing + .items + .iter() + .filter_map(|item| calendar::parse_event(cal.calendar_id, item)) + .collect(); + if rows.is_empty() { + return pending(); + } + // Every row is new: the mirror never held one of this event's. + let created: Vec = rows.iter().map(|row| row.id.clone()).collect(); + match land_on(host, cal.calendar_id, rows, &created, Vec::new()) { + Ok(()) => WriteOutcome::Saved { + warnings: Vec::new(), + left_behind: Vec::new(), + milestone_moves: None, + }, + Err(_) => pending(), + } +} + +/// Whether a repeating event has an occurrence in the window PM mirrors (`[from, until)`): its +/// first at or after `from` comes before `until`. `None` when PM can't walk its rule, which lists it +/// anyway. An occurrence begun before `from` that runs into the window doesn't count: at worst the +/// next sync shows it. +fn series_in_window( + master: &Master, + (from, until): (chrono::DateTime, chrono::DateTime), +) -> Option { + let parsed = lines::parse(&master.recurrence); + let (_, facts) = lines::series_rule(&parsed).ok()?; + let (from, until) = match master.start { + SeriesStart::AllDay(_) => ( + Point::Day(from.date_naive()), + Point::Day(until.date_naive()), + ), + SeriesStart::Timed { .. } => (Point::At(from), Point::At(until)), + }; + let first = expand::occurrences_from(&facts, &master.start, from, 1).ok()?; + Some( + first + .first() + .is_some_and(|at| at.cmp_in(&until, master.start.zone()) == std::cmp::Ordering::Less), + ) +} + /// A new event Google holds: its copy into the mirror as a CREATED row (a list that lags can't take /// it off the screen, A28), unless it starts outside the months PM mirrors ([`in_mirror`]). fn land_created(host: &impl Host, target: &CalendarTarget, copy: &Value) -> WriteOutcome { @@ -13774,6 +13908,8 @@ mod tests { // --- creating an event (C10) --- + use crate::calendar_recur::wire::{DayCode, RepeatEnds, RepeatPattern, RepeatRule}; + /// The id PM picks for the new event in these tests. const NEW: &str = "fedcba9876543210fedcba9876543210"; @@ -13797,6 +13933,21 @@ mod tests { description: String::new(), show_as: None, visibility: None, + recurrence: None, + } + } + + /// The dentist from `day`, weekly on its Monday, `count` times. + fn weekly_dentist(day: &str, count: u16) -> NewEventDraft { + NewEventDraft { + recurrence: Some(RepeatRule { + pattern: RepeatPattern::Weekly { + days: vec![DayCode::Mon], + }, + interval: 1, + ends: RepeatEnds::After { count }, + }), + ..on_day(day) } } @@ -14001,11 +14152,12 @@ mod tests { "reminders", "conferenceData", "extendedProperties", - "recurrence", "colorId", ] { assert!(body.get(never).is_none(), "{never}"); } + // A rule goes only with a draft that repeats (C11). + assert!(body.get("recurrence").is_none(), "no rule, no recurrence"); assert_eq!(body["transparency"], "transparent"); assert_eq!(body["visibility"], "private"); } @@ -14606,6 +14758,295 @@ mod tests { assert!(host.nothing_recorded()); } + // --- creating a repeating event (C11) --- + + /// One occurrence of the new repeating event, at `at` for an hour, as an instances listing + /// gives it; `stamp` is its start in an instance id's compact form. + fn new_occurrence(stamp: &str, at: &str, end: &str) -> Value { + json!({ + "kind": "calendar#event", + "id": format!("{NEW}_{stamp}"), + "etag": "\"o1\"", + "status": "confirmed", + "summary": "Dentist", + "recurringEventId": NEW, + "originalStartTime": { "dateTime": at, "timeZone": "Europe/London" }, + "start": { "dateTime": at, "timeZone": "Europe/London" }, + "end": { "dateTime": end, "timeZone": "Europe/London" }, + "iCalUID": "new@google.com", + "updated": SAVED_AT, + }) + } + + /// The weekly dentist from Monday 26-10, three times, as Google lists it. + fn three_mondays() -> Vec { + vec![ + new_occurrence( + "20261026T100000Z", + "2026-10-26T10:00:00Z", + "2026-10-26T11:00:00Z", + ), + new_occurrence( + "20261102T100000Z", + "2026-11-02T10:00:00Z", + "2026-11-02T11:00:00Z", + ), + new_occurrence( + "20261109T100000Z", + "2026-11-09T10:00:00Z", + "2026-11-09T11:00:00Z", + ), + ] + } + + /// The new repeating event's occurrences over the mirror's window, 50 a page. + fn new_listing(answer: Scripted) -> Step { + Step::new( + Method::Get, + &format!("/events/{NEW}/instances"), + "timeMin=2026-09-01T00%3A00%3A00Z timeMax=2027-11-01T00%3A00%3A00Z maxResults=50", + answer, + ) + } + + fn listed(items: Vec) -> Scripted { + Scripted::Json(page(items, None)) + } + + /// The new repeating event's rows in the mirror, in time order. + fn new_rows(host: &TestHost) -> Vec { + ids_of( + host.with_conn(|conn| calendar::series_rows(conn, "cal", &[NEW])) + .unwrap(), + ) + } + + fn three_ids() -> Vec { + ["20261026T100000Z", "20261102T100000Z", "20261109T100000Z"] + .iter() + .map(|stamp| format!("cal:{NEW}_{stamp}")) + .collect() + } + + /// C11 §3.4: Google answers a repeating create with its master, which is never a row, so its + /// occurrences are listed and landed as created rows. Nobody is emailed, and the rule and the + /// zone go out with it. + #[tokio::test] + async fn a_created_series_lands_its_occurrences_from_a_listing() { + let host = TestHost::holding(&[]); + let draft = weekly_dentist("2026-10-26", 3); + let copy = google_copy(&draft, "\"n1\""); + let (outcome, script) = create_on( + &host, + &new_target(), + EditingStatus::On, + vec![ + insert_step(Scripted::Json(copy)), + new_listing(listed(three_mondays())), + ], + draft, + None, + ) + .await; + assert!(outcome == saved(), "saved and landed"); + let sent = script.sent(); + assert!(sent[0] + .url + .ends_with("?sendUpdates=none&conferenceDataVersion=1")); + let body = sent[0].body.as_ref().unwrap(); + assert_eq!( + body["recurrence"], + json!(["RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=3"]) + ); + assert_eq!(body["start"]["timeZone"], "Europe/London"); + assert_eq!(body["end"]["timeZone"], "Europe/London"); + assert!(script.waits().is_empty(), "the first look found them"); + assert_eq!(new_rows(&host), three_ids()); + assert!(new_row(&host).is_none(), "the master itself is never a row"); + assert_eq!(host.told(), vec!["cal".to_string()]); + // Recorded as created: a complete list that doesn't show them yet keeps them on screen. + let shown = ids_of(host.synced(&[], Instant::now())); + assert!(three_ids().iter().all(|id| shown.contains(id))); + assert_eq!(held_creates(&host), 0); + } + + /// A28: a listing that lags shows nothing at first; the second look, a second later, lands it. + #[tokio::test] + async fn a_created_series_whose_listing_lags_lands_on_the_second_look() { + let host = TestHost::holding(&[]); + let draft = weekly_dentist("2026-10-26", 3); + let copy = google_copy(&draft, "\"n1\""); + let (outcome, script) = create_on( + &host, + &new_target(), + EditingStatus::On, + vec![ + insert_step(Scripted::Json(copy)), + new_listing(listed(Vec::new())), + new_listing(listed(three_mondays())), + ], + draft, + None, + ) + .await; + assert!(outcome == saved(), "landed on the second look"); + assert_eq!(script.waits(), vec![REFETCH_AGAIN[0]]); + assert_eq!(new_rows(&host), three_ids()); + } + + /// Three looks that never show it whole: saved, the next sync to show it, and no guess landed. + /// Half a listing is no listing, and one that shows only deleted occurrences lands nothing. + #[tokio::test] + async fn a_created_series_that_never_lists_is_saved_pending() { + let half = vec![ + // A first page, then its second refused. + new_listing(Scripted::Json(page(three_mondays(), Some("p2")))), + new_listing(Scripted::Status(503)), + ]; + let deleted = vec![new_listing(listed( + three_mondays() + .into_iter() + .map(|o| with(o, "status", json!("cancelled"))) + .collect(), + ))]; + for last in [half, deleted] { + let host = TestHost::holding(&[]); + let draft = weekly_dentist("2026-10-26", 3); + let copy = google_copy(&draft, "\"n1\""); + let mut steps = vec![ + insert_step(Scripted::Json(copy)), + new_listing(listed(Vec::new())), + new_listing(Scripted::Status(503)), + ]; + steps.extend(last); + let (outcome, script) = + create_on(&host, &new_target(), EditingStatus::On, steps, draft, None).await; + assert!(outcome == pending(), "saved, the mirror to catch up"); + assert_eq!(script.waits(), REFETCH_AGAIN.to_vec()); + assert!(new_rows(&host).is_empty()); + assert!(host.hash_dropped()); + assert!(host.nothing_recorded()); + } + } + + /// C10 §6.2, C11 critique 1 L8: a new repeating event with no occurrence in the months PM + /// mirrors, after them or ended before them, is saved and said to be outside, with no listing + /// at all. One begun before them that runs into them is listed. + #[tokio::test] + async fn a_created_series_after_the_window_is_saved_not_landed() { + let outside = WriteOutcome::Saved { + warnings: vec![WriteWarning::OutsideMirror], + left_behind: Vec::new(), + milestone_moves: None, + }; + // Monday 10-01-2028 is past the window; Monday 01-06-2026 three times ends before it. + for draft in [ + weekly_dentist("2028-01-10", 3), + weekly_dentist("2026-06-01", 3), + ] { + let host = TestHost::holding(&[]); + let copy = google_copy(&draft, "\"n1\""); + let (outcome, script) = create_on( + &host, + &new_target(), + EditingStatus::On, + vec![insert_step(Scripted::Json(copy))], + draft, + None, + ) + .await; + assert!( + outcome == outside, + "saved in Google, and said to be outside" + ); + assert_eq!(script.sent().len(), 1, "nothing listed"); + assert!(new_rows(&host).is_empty() && host.nothing_recorded()); + assert!(host.told().is_empty()); + } + // From Monday 03-08-2026, ten times: its last five fall in the window, so it is listed (the + // listing's own occurrences are what land, three here). + let host = TestHost::holding(&[]); + let draft = weekly_dentist("2026-08-03", 10); + let copy = google_copy(&draft, "\"n1\""); + let (outcome, _) = create_on( + &host, + &new_target(), + EditingStatus::On, + vec![ + insert_step(Scripted::Json(copy)), + new_listing(listed(three_mondays())), + ], + draft, + None, + ) + .await; + assert!(outcome == saved(), "listed and landed"); + assert_eq!(new_rows(&host).len(), 3); + } + + /// A retry that finds the lost repeating create lands its occurrences, never its master. + #[tokio::test] + async fn a_retried_create_of_a_series_lands_its_occurrences() { + let host = TestHost::holding(&[]); + let draft = weekly_dentist("2026-10-26", 3); + let (outcome, _) = create_on( + &host, + &new_target(), + EditingStatus::On, + vec![ + insert_step(lost()), + new_read(absent()), + new_read(absent()), + insert_step(lost()), + new_read(absent()), + ], + draft.clone(), + None, + ) + .await; + assert!(outcome == ambiguous("ref-1"), "held as ref-1"); + let copy = google_copy(&draft, "\"n1\""); + let (outcome, script) = create_on( + &host, + &new_target(), + EditingStatus::On, + vec![ + new_read(Scripted::Json(copy)), + new_listing(listed(three_mondays())), + ], + draft, + Some("ref-1"), + ) + .await; + assert!(outcome == saved(), "found and landed"); + script.finished(); + assert_eq!(new_rows(&host), three_ids()); + assert_eq!(held_creates(&host), 0); + } + + /// C11 critique 1 L11: a repeating create reaches a whole series, so a dev build's unset + /// allow-list refuses it, as it does every write that reaches more than one event. + #[test] + fn a_repeating_create_is_series_wide_to_the_dev_allow_list() { + let source = include_str!("calendar_edit.rs"); + let shipped = source + .split("#[cfg(test)]\nmod tests {") + .next() + .unwrap_or_default(); + let start = shipped + .find("pub async fn create_calendar_event(") + .expect("the create command"); + let shell = &shipped[start..]; + let shell = &shell[..shell.find("\n}\n").unwrap_or(shell.len())]; + assert!(shell.contains("let series_wide = draft.recurrence.is_some();")); + assert!(shell + .contains("dev_allow::check(&*state.conn()?, &target.remote_calendar, series_wide)")); + assert_eq!( + dev_allow::decide(None, None, "home@group", true), + Err(dev_allow::UNSET) + ); + } + /// Creating events ships in every build (C10b): the command makes the window and owner checks /// every write makes before it reads anything or sends, and no build refuses it outright. #[test] diff --git a/src-tauri/src/commands/calendar_repeat.rs b/src-tauri/src/commands/calendar_repeat.rs new file mode 100644 index 00000000..7b7593cb --- /dev/null +++ b/src-tauri/src/commands/calendar_repeat.rs @@ -0,0 +1,306 @@ +// SPDX-FileCopyrightText: 2026 Bobby Yu +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! The repeat menu's ready-made rules and a rule's words, for the event editor (#884, C11). Pure: no +//! state, no I/O, no window check (they write nothing), so the webview needs no rrule package and PM +//! says a rule one way everywhere. +//! +//! Both are `async`, so they run off the main thread: the editor asks on every change, with no +//! debounce. Each asks the rrule crate for one occurrence at most, and a rule that has none at all is +//! refused before the crate is asked (C11 critique 1 L5), so neither takes more than a moment. + +use crate::calendar_recur::expand::sync_start; +use crate::calendar_recur::spec::{describe, presets, SpecError}; +use crate::calendar_recur::wire::{ + parse_date, series_start, RepeatPreset, RepeatRule, RepeatStart, RepeatView, +}; + +/// The menu's ready-made rules for an event starting on `day` (`YYYY-MM-DD`), as Google offers them. +/// An unreadable day gives none; the menu then has only "Does not repeat" and "Custom…". +#[tauri::command] +pub async fn recurrence_presets(day: String) -> Vec { + parse_date(&day) + .map(presets) + .unwrap_or_default() + .into_iter() + .map(|preset| RepeatPreset { + rule: RepeatRule::from_spec(&preset.spec), + label: preset.label, + }) + .collect() +} + +/// `rule` in words for an event starting at `start`, and the day its first occurrence falls on: +/// "Weekly on Monday and Wednesday" chosen on a Tuesday starts on the Wednesday (A27). A rule outside +/// the limits, or a first occurrence the clocks skip the start or end time of, is refused in words. +#[tauri::command] +pub async fn describe_recurrence(rule: RepeatRule, start: RepeatStart) -> RepeatView { + let refused = |message: String| RepeatView::Refused { message }; + let asked = match series_start(&start) { + Ok(asked) => asked, + Err(e) => return refused(e.to_string()), + }; + let spec = match rule.to_spec() { + Ok(spec) => spec, + Err(e) => return refused(e.to_string()), + }; + let synced = match sync_start(&spec, &asked) { + Ok(synced) => synced, + Err(e) => return refused(e.to_string()), + }; + let moved = (synced.date() - asked.date()).num_days(); + // The save moves the end with the start, so a first occurrence moved onto the night the clocks + // skip its end time would be refused there: say so here (C11 critique 2 L7). + if let RepeatStart::Timed { end: Some(end), .. } = &start { + if let Some(day) = end.skipped_after(moved) { + return refused(SpecError::EndGap(day).to_string()); + } + } + RepeatView::Ready { + summary: describe(&spec, &synced), + starts: synced.date().format("%Y-%m-%d").to_string(), + moved: moved != 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::calendar_recur::wire::{DayCode, RepeatEnds, RepeatPattern, WallTime}; + + fn timed(date: &str, time: &str) -> RepeatStart { + RepeatStart::Timed { + date: date.into(), + time: time.into(), + zone: "Europe/London".into(), + end: None, + } + } + + fn all_day(date: &str) -> RepeatStart { + RepeatStart::AllDay { date: date.into() } + } + + fn weekly(days: &[DayCode], ends: RepeatEnds) -> RepeatRule { + RepeatRule { + pattern: RepeatPattern::Weekly { + days: days.to_vec(), + }, + interval: 1, + ends, + } + } + + fn daily(ends: RepeatEnds) -> RepeatRule { + RepeatRule { + pattern: RepeatPattern::Daily, + interval: 1, + ends, + } + } + + fn ready(summary: &str, starts: &str, moved: bool) -> RepeatView { + RepeatView::Ready { + summary: summary.into(), + starts: starts.into(), + moved, + } + } + + fn refused(message: impl ToString) -> RepeatView { + RepeatView::Refused { + message: message.to_string(), + } + } + + /// T3's five, in the menu's order, each with the rule it stands for. + #[tokio::test] + async fn presets_for_a_day_come_back_labelled() { + let presets = recurrence_presets("2026-10-13".into()).await; + let labels: Vec<&str> = presets.iter().map(|p| p.label.as_str()).collect(); + assert_eq!( + labels, + [ + "Daily", + "Weekly on Tuesday", + "Monthly on the second Tuesday", + "Annually on 13 October", + "Every weekday (Monday to Friday)", + ] + ); + assert!(presets[1].rule == weekly(&[DayCode::Tue], RepeatEnds::Never)); + assert!(presets + .iter() + .all(|p| p.rule.interval == 1 && p.rule.ends == RepeatEnds::Never)); + } + + #[tokio::test] + async fn an_unreadable_day_gives_no_presets() { + for day in ["13-10-2026", "", "2026-02-30", "tomorrow"] { + assert!(recurrence_presets(day.into()).await.is_empty(), "{day}"); + } + } + + /// A27: a rule whose first day isn't the one typed says where it starts. + #[tokio::test] + async fn a_moved_start_is_said() { + let view = describe_recurrence( + weekly(&[DayCode::Mon, DayCode::Wed], RepeatEnds::Never), + timed("2026-10-13", "09:00"), + ) + .await; + assert!(view == ready("Weekly on Monday and Wednesday", "2026-10-14", true)); + let weekdays = [ + DayCode::Mon, + DayCode::Tue, + DayCode::Wed, + DayCode::Thu, + DayCode::Fri, + ]; + let view = + describe_recurrence(weekly(&weekdays, RepeatEnds::Never), all_day("2026-10-31")).await; + assert!(view == ready("Every weekday (Monday to Friday)", "2026-11-02", true)); + // A rule the typed day already fits doesn't move it. + let view = + describe_recurrence(daily(RepeatEnds::Never), timed("2026-10-13", "09:00")).await; + assert!(view == ready("Daily", "2026-10-13", false)); + } + + /// London skips 01:00-02:00 on Sunday 29-03-2026: a Sunday rule chosen at 01:30 on the + /// Saturday would start then. + #[tokio::test] + async fn a_gap_is_refused_in_words() { + let view = describe_recurrence( + weekly(&[DayCode::Sun], RepeatEnds::Never), + timed("2026-03-28", "01:30"), + ) + .await; + assert!( + view == refused( + "The first occurrence would be on 29-03-2026, when the clocks skip that time. \ + Pick a time outside that hour." + ), + "the skipped day in words" + ); + } + + /// C11 critique 2 L7: an end the move puts in the skipped hour is refused too, and only then. + #[tokio::test] + async fn an_end_the_clocks_skip_is_refused() { + let with_end = |time: &str| RepeatStart::Timed { + date: "2026-03-27".into(), + time: "23:00".into(), + zone: "Europe/London".into(), + end: Some(WallTime { + date: "2026-03-28".into(), + time: time.into(), + zone: "Europe/London".into(), + }), + }; + // Friday 23:00 to Saturday 01:30, made to repeat on Saturdays: it starts on the Saturday and + // would end at 01:30 on Sunday 29-03, which London skips. + let saturdays = weekly(&[DayCode::Sat], RepeatEnds::Never); + let view = describe_recurrence(saturdays.clone(), with_end("01:30")).await; + assert!( + view == refused(SpecError::EndGap( + chrono::NaiveDate::from_ymd_opt(2026, 3, 29).unwrap() + )), + "the end's skipped day in words" + ); + let view = describe_recurrence(saturdays, with_end("02:30")).await; + assert!(view == ready("Weekly on Saturday", "2026-03-28", true)); + } + + #[tokio::test] + async fn a_rule_past_730_is_refused() { + let view = describe_recurrence( + daily(RepeatEnds::After { count: 731 }), + timed("2026-10-13", "09:00"), + ) + .await; + assert!(view == refused("Google allows at most 730 occurrences of a repeating event.")); + } + + #[tokio::test] + async fn seven_hundred_thirty_is_accepted() { + let view = describe_recurrence( + daily(RepeatEnds::After { count: 730 }), + timed("2026-10-13", "09:00"), + ) + .await; + assert!(view == ready("Daily, 730 times", "2026-10-13", false)); + } + + #[tokio::test] + async fn an_end_before_the_start_is_refused() { + let view = describe_recurrence( + daily(RepeatEnds::OnDate { + date: "2026-10-12".into(), + }), + timed("2026-10-13", "09:00"), + ) + .await; + assert!(view == refused("The end date is before the first occurrence.")); + // One PM can't read is said so, never guessed. + let view = describe_recurrence( + daily(RepeatEnds::OnDate { + date: "12-10-2026".into(), + }), + timed("2026-10-13", "09:00"), + ) + .await; + assert!(view == refused("PM couldn't read that end date.")); + } + + #[tokio::test] + async fn an_end_on_the_start_day_is_accepted() { + let view = describe_recurrence( + daily(RepeatEnds::OnDate { + date: "2026-10-13".into(), + }), + timed("2026-10-13", "09:00"), + ) + .await; + assert!(view == ready("Daily, until 13-10-2026", "2026-10-13", false)); + } + + /// C11 critique 1 L5: "on day 31" of every twelfth April never happens, and is refused at once + /// rather than looked for; a start PM can't read is said so. + #[tokio::test] + async fn a_rule_with_no_occurrence_or_an_unread_start_is_refused() { + let never = RepeatRule { + pattern: RepeatPattern::MonthlyDay { day: 31 }, + interval: 12, + ends: RepeatEnds::After { count: 5 }, + }; + let view = describe_recurrence(never, timed("2026-04-13", "09:00")).await; + assert!(view == refused(SpecError::NoOccurrence)); + let view = describe_recurrence(daily(RepeatEnds::Never), timed("2026-04-13", "9am")).await; + assert!(view == refused("PM couldn't read that time.")); + } + + /// The summary is `describe`'s words for every T2 rule, from the fixture both languages read. + #[tokio::test] + async fn the_words_are_describes() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../src/lib/calendarEdit/repeatRules.fixture.json" + )) + .unwrap(); + let start: RepeatStart = serde_json::from_value(fixture["start"].clone()).unwrap(); + let asked = series_start(&start).unwrap(); + let rows = fixture["rows"].as_array().unwrap(); + assert!(!rows.is_empty()); + for row in rows { + let rule: RepeatRule = serde_json::from_value(row["spec"].clone()).unwrap(); + let spec = rule.to_spec().unwrap(); + let synced = sync_start(&spec, &asked).unwrap(); + let words = describe(&spec, &synced); + assert_eq!(row["words"].as_str(), Some(words.as_str())); + let view = describe_recurrence(rule, start.clone()).await; + assert!( + matches!(&view, RepeatView::Ready { summary, .. } if *summary == words), + "{words}" + ); + } + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index b7df3e06..8f9ffc5a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -14,6 +14,7 @@ mod archivist; mod assistant; mod backups; mod calendar_edit; +mod calendar_repeat; mod calendars; mod canon; mod connectors; @@ -34,6 +35,7 @@ pub use archivist::*; pub use assistant::*; pub use backups::*; pub use calendar_edit::*; +pub use calendar_repeat::*; pub use calendars::*; pub use canon::*; pub use connectors::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e89615ad..5d70686f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1793,6 +1793,8 @@ pub fn run() { commands::series_choices, commands::get_series_summary, commands::settle_series_split, + commands::recurrence_presets, + commands::describe_recurrence, #[cfg(debug_assertions)] commands::dev_series_report, commands::connect_outlook_calendar, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 211e4447..57497bb5 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.152.0-alpha", + "version": "3.152.1-alpha", "identifier": "org.itsatlas.pm", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/calendar/edit/NewEventEditor.test.tsx b/src/components/calendar/edit/NewEventEditor.test.tsx index 65d0928e..55a5e2d3 100644 --- a/src/components/calendar/edit/NewEventEditor.test.tsx +++ b/src/components/calendar/edit/NewEventEditor.test.tsx @@ -123,7 +123,7 @@ const seed: EditorFields = { /** What the editor sends for the seed titled `summary`, on `calendarId`. */ const sentFor = (summary: string, calendarId = PERSONAL) => ({ calendarId, - draft: { ...seed, summary } as NewEventDraft, + draft: { ...seed, summary, recurrence: null } as NewEventDraft, }); const saved: WriteOutcome = { outcome: "saved", warnings: [], left_behind: [] }; @@ -270,6 +270,7 @@ describe("NewEventEditor", () => { expect(Object.keys(createEvent.mock.calls[0][1]).sort()).toEqual([ "description", "location", + "recurrence", "show_as", "summary", "time", diff --git a/src/components/calendar/edit/QuickCreatePopover.test.tsx b/src/components/calendar/edit/QuickCreatePopover.test.tsx index 1021a60f..a80b88c8 100644 --- a/src/components/calendar/edit/QuickCreatePopover.test.tsx +++ b/src/components/calendar/edit/QuickCreatePopover.test.tsx @@ -128,7 +128,7 @@ const seed: EditorFields = { /** What the box sends for the seed titled `summary`, on Personal. */ const sentFor = (summary: string) => ({ calendarId: PERSONAL, - draft: { ...seed, summary } as NewEventDraft, + draft: { ...seed, summary, recurrence: null } as NewEventDraft, }); const saved: WriteOutcome = { outcome: "saved", warnings: [], left_behind: [] }; @@ -277,11 +277,12 @@ describe("QuickCreatePopover", () => { fireEvent.change(title(), { target: { value: "Dentist" } }); fireEvent.keyDown(title(), { key: "Enter" }); await waitFor(() => expect(onClose).toHaveBeenCalledWith(true)); - // The calendar, every field of the draft, and no token: no one to notify, no guests. + // The calendar, every field of the draft, and no token: no one to notify, no repeat, no guests. expect(createEvent.mock.calls).toEqual([[PERSONAL, sentFor("Dentist").draft, null]]); expect(Object.keys(createEvent.mock.calls[0][1]).sort()).toEqual([ "description", "location", + "recurrence", "show_as", "summary", "time", diff --git a/src/components/calendar/edit/WriteNotices.test.tsx b/src/components/calendar/edit/WriteNotices.test.tsx index daba9036..31986d70 100644 --- a/src/components/calendar/edit/WriteNotices.test.tsx +++ b/src/components/calendar/edit/WriteNotices.test.tsx @@ -166,6 +166,7 @@ describe("WriteNotices", () => { description: "", show_as: "free", visibility: "default", + recurrence: null, }, }, }, diff --git a/src/components/calendar/edit/useEventWrites.test.ts b/src/components/calendar/edit/useEventWrites.test.ts index 37acd825..945fa6cd 100644 --- a/src/components/calendar/edit/useEventWrites.test.ts +++ b/src/components/calendar/edit/useEventWrites.test.ts @@ -1050,6 +1050,7 @@ const draft: NewEventDraft = { description: "", show_as: "busy", visibility: "default", + recurrence: null, }; const sent = { calendarId: CAL, draft }; const shown = { name: "Me", hidden: false }; diff --git a/src/lib/calendarEdit/eventDraft.test.ts b/src/lib/calendarEdit/eventDraft.test.ts index f95a1884..e8b87a6d 100644 --- a/src/lib/calendarEdit/eventDraft.test.ts +++ b/src/lib/calendarEdit/eventDraft.test.ts @@ -468,6 +468,7 @@ describe("a new event's starting fields", () => { description: "Bring the form", show_as: "free", visibility: "private", + recurrence: null, }); // A copy: a later edit to the draft can't change what was sent. expect(sent.time).not.toBe(draft.time); diff --git a/src/lib/calendarEdit/eventDraft.ts b/src/lib/calendarEdit/eventDraft.ts index f1039f35..4c432280 100644 --- a/src/lib/calendarEdit/eventDraft.ts +++ b/src/lib/calendarEdit/eventDraft.ts @@ -546,7 +546,8 @@ export function switchNewKind( }; } -/** What creating an event sends: every field, as the editor holds it. */ +/** What creating an event sends: every field, as the editor holds it. The editor has no repeat menu + * yet, so nothing it creates repeats. */ export function toNewEvent(draft: EditorFields): NewEventDraft { return { summary: draft.summary, @@ -555,6 +556,7 @@ export function toNewEvent(draft: EditorFields): NewEventDraft { description: draft.description, show_as: draft.show_as, visibility: draft.visibility, + recurrence: null, }; } diff --git a/src/lib/calendarEdit/repeatRules.fixture.json b/src/lib/calendarEdit/repeatRules.fixture.json new file mode 100644 index 00000000..ffdd4796 --- /dev/null +++ b/src/lib/calendarEdit/repeatRules.fixture.json @@ -0,0 +1,134 @@ +{ + "about": "T2 (#884, C11 §1.5): the repeat rules Google Calendar's editor writes, the words it says them in, and each as the webview holds it, all read against `start`. Rust's calendar_recur::wire tests hold every row to spec.rs's rules_read_as_google_says_them, in order, and the webview's repeat tests read the same file, so both mean the same rules.", + "start": { "kind": "timed", "date": "2026-10-13", "time": "09:00", "zone": "Europe/London" }, + "rows": [ + { + "rule": "RRULE:FREQ=DAILY", + "words": "Daily", + "spec": { "pattern": { "kind": "daily" }, "interval": 1, "ends": { "kind": "never" } } + }, + { + "rule": "RRULE:FREQ=DAILY;INTERVAL=3;COUNT=10", + "words": "Every 3 days, 10 times", + "spec": { + "pattern": { "kind": "daily" }, + "interval": 3, + "ends": { "kind": "after", "count": 10 } + } + }, + { + "rule": "RRULE:FREQ=WEEKLY", + "words": "Weekly on Tuesday", + "spec": { + "pattern": { "kind": "weekly", "days": ["tue"] }, + "interval": 1, + "ends": { "kind": "never" } + } + }, + { + "rule": "RRULE:FREQ=WEEKLY;BYDAY=TU", + "words": "Weekly on Tuesday", + "spec": { + "pattern": { "kind": "weekly", "days": ["tue"] }, + "interval": 1, + "ends": { "kind": "never" } + } + }, + { + "rule": "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", + "words": "Every weekday (Monday to Friday)", + "spec": { + "pattern": { "kind": "weekly", "days": ["mon", "tue", "wed", "thu", "fri"] }, + "interval": 1, + "ends": { "kind": "never" } + } + }, + { + "rule": "RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;UNTIL=20261231T235959Z", + "words": "Every 2 weeks on Monday and Wednesday, until 31-12-2026", + "spec": { + "pattern": { "kind": "weekly", "days": ["mon", "wed"] }, + "interval": 2, + "ends": { "kind": "on_date", "date": "2026-12-31" } + } + }, + { + "rule": "RRULE:FREQ=WEEKLY;BYDAY=FR,MO,WE", + "words": "Weekly on Monday, Wednesday and Friday", + "spec": { + "pattern": { "kind": "weekly", "days": ["mon", "wed", "fri"] }, + "interval": 1, + "ends": { "kind": "never" } + } + }, + { + "rule": "RRULE:FREQ=MONTHLY", + "words": "Monthly on day 13", + "spec": { + "pattern": { "kind": "monthly_day", "day": 13 }, + "interval": 1, + "ends": { "kind": "never" } + } + }, + { + "rule": "RRULE:FREQ=MONTHLY;BYMONTHDAY=13", + "words": "Monthly on day 13", + "spec": { + "pattern": { "kind": "monthly_day", "day": 13 }, + "interval": 1, + "ends": { "kind": "never" } + } + }, + { + "rule": "RRULE:FREQ=MONTHLY;BYDAY=2TU", + "words": "Monthly on the second Tuesday", + "spec": { + "pattern": { "kind": "monthly_weekday", "nth": "second", "weekday": "tue" }, + "interval": 1, + "ends": { "kind": "never" } + } + }, + { + "rule": "RRULE:FREQ=MONTHLY;BYDAY=-1TU", + "words": "Monthly on the last Tuesday", + "spec": { + "pattern": { "kind": "monthly_weekday", "nth": "last", "weekday": "tue" }, + "interval": 1, + "ends": { "kind": "never" } + } + }, + { + "rule": "RRULE:FREQ=YEARLY", + "words": "Annually on 13 October", + "spec": { "pattern": { "kind": "yearly_date" }, "interval": 1, "ends": { "kind": "never" } } + }, + { + "rule": "RRULE:FREQ=YEARLY;BYMONTH=10;BYMONTHDAY=13", + "words": "Annually on 13 October", + "spec": { "pattern": { "kind": "yearly_date" }, "interval": 1, "ends": { "kind": "never" } } + }, + { + "rule": "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=2TU", + "words": "Annually on the second Tuesday of October", + "spec": { + "pattern": { "kind": "yearly_weekday", "month": 10, "nth": "second", "weekday": "tue" }, + "interval": 1, + "ends": { "kind": "never" } + } + }, + { + "rule": "RRULE:FREQ=DAILY;COUNT=1", + "words": "Daily, once", + "spec": { + "pattern": { "kind": "daily" }, + "interval": 1, + "ends": { "kind": "after", "count": 1 } + } + }, + { + "rule": "RRULE:FREQ=YEARLY;INTERVAL=2", + "words": "Every 2 years on 13 October", + "spec": { "pattern": { "kind": "yearly_date" }, "interval": 2, "ends": { "kind": "never" } } + } + ] +} diff --git a/src/lib/calendarEdit/repeatRules.test.ts b/src/lib/calendarEdit/repeatRules.test.ts new file mode 100644 index 00000000..abc807aa --- /dev/null +++ b/src/lib/calendarEdit/repeatRules.test.ts @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026 Bobby Yu +// SPDX-License-Identifier: AGPL-3.0-or-later + +// The shared T2 fixture (#884, C11 §1.5) as the webview reads it: every row a repeat rule of the +// shapes `types.ts` names, against a start of its own shape. Rust holds the same file to its own +// rule table, so a rule the two sides read differently fails one of them. + +import { describe, expect, it } from "vitest"; +import fixture from "./repeatRules.fixture.json"; +import type { DayCode, NthCode, RepeatRule, RepeatStart } from "../types"; + +interface Row { + rule: string; + words: string; + spec: RepeatRule; +} + +const DAYS: readonly string[] = [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun", +] satisfies DayCode[]; +const NTHS: readonly string[] = ["first", "second", "third", "fourth", "last"] satisfies NthCode[]; + +/** Whether `rule` is one the backend reads: each kind with exactly its own fields, in range. */ +function readable(rule: RepeatRule): boolean { + const p = rule.pattern; + const pattern = (() => { + switch (p.kind) { + case "daily": + case "yearly_date": + return true; + case "weekly": + return p.days.length > 0 && p.days.every((d) => DAYS.includes(d)); + case "monthly_day": + return Number.isInteger(p.day) && p.day >= 1 && p.day <= 31; + case "monthly_weekday": + return NTHS.includes(p.nth) && DAYS.includes(p.weekday); + case "yearly_weekday": + return p.month >= 1 && p.month <= 12 && NTHS.includes(p.nth) && DAYS.includes(p.weekday); + default: + return false; + } + })(); + const e = rule.ends; + const ends = + e.kind === "never" || + (e.kind === "on_date" && /^\d{4}-\d{2}-\d{2}$/.test(e.date)) || + (e.kind === "after" && Number.isInteger(e.count) && e.count >= 1 && e.count <= 730); + return ( + pattern && ends && Number.isInteger(rule.interval) && rule.interval >= 1 && rule.interval <= 99 + ); +} + +describe("the shared repeat-rule fixture", () => { + const rows = fixture.rows as Row[]; + const start = fixture.start as RepeatStart; + + it("reads against the T2 start", () => { + expect(start).toEqual({ + kind: "timed", + date: "2026-10-13", + time: "09:00", + zone: "Europe/London", + }); + }); + + it("holds every T2 row as a rule the backend reads, with Google's words", () => { + expect(rows).toHaveLength(16); + for (const row of rows) { + expect(row.rule.startsWith("RRULE:FREQ="), row.rule).toBe(true); + expect(row.words.length, row.rule).toBeGreaterThan(0); + expect(readable(row.spec), row.rule).toBe(true); + } + expect(new Set(rows.map((r) => r.rule)).size).toBe(rows.length); + }); +}); diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index fe5a0096..f0f397f4 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -23,6 +23,13 @@ export interface ChangelogEntry { } export const CHANGELOG: ChangelogEntry[] = [ + { + version: "3.152.1-alpha", + date: "2026-10-20", + highlights: [ + "Under the hood, PM can now put repeat rules into words and work out the day a repeating event starts, ready for creating repeating events in the next update.", + ], + }, { version: "3.152.0-alpha", date: "2026-10-20", diff --git a/src/lib/ipc.ts b/src/lib/ipc.ts index 908fda04..b3a0a527 100644 --- a/src/lib/ipc.ts +++ b/src/lib/ipc.ts @@ -141,6 +141,10 @@ import type { EventPatchDraft, HeldDeleteInfo, NewEventDraft, + RepeatPreset, + RepeatRule, + RepeatStart, + RepeatView, SeenSummary, SeriesChoices, SeriesScope, @@ -1087,6 +1091,18 @@ export const listAllCalendarEvents = () => invoke("list_all_cal /** The active PM flags anchored on a calendar event's iCal UID — for the event detail popup. */ export const eventFlags = (uid: string) => invoke("event_flags", { uid }); +// Repeat rules (#884, C11): pure reads for the editor's Repeat row. They change nothing. + +/** The repeat menu's ready-made rules for an event starting on `day` (YYYY-MM-DD); none when the day + * can't be read. */ +export const recurrencePresets = (day: string) => + invoke("recurrence_presets", { day }); + +/** A rule in the repeat menu's words for an event starting at `start`, and the day its first + * occurrence falls on, or why it can't be saved. */ +export const describeRecurrence = (rule: RepeatRule, start: RepeatStart) => + invoke("describe_recurrence", { rule, start }); + // Calendar editing (#884). Importable only from src/components/calendar/edit/useEventWrites.ts // (eslint.config.js + scripts/check-calendar-write-fence.mjs); every command is main-window only. diff --git a/src/lib/types.ts b/src/lib/types.ts index d53c4256..d625a83b 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1583,7 +1583,7 @@ export interface EventPatchDraft { } /** 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 repeat, guests, reminders or colour yet. */ + * of what goes to Google). No guests, reminders or colour yet. */ export interface NewEventDraft { summary: string; time: TimeDraft; @@ -1591,8 +1591,65 @@ export interface NewEventDraft { description: string; show_as: ShowAs; visibility: EventVisibility; + /** How it repeats; null doesn't repeat. The backend starts it on the first day the rule makes + * (the repeat menu's words say which) and moves the end with it. */ + recurrence: RepeatRule | null; +} + +// --- Repeat rules (#884, C11; mirrors `calendar_recur::wire`) --- + +/** A day of the week, Monday first. */ +export type DayCode = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun"; +/** Which of a month's weekdays: the first four or the last, never a fifth. */ +export type NthCode = "first" | "second" | "third" | "fourth" | "last"; + +export type RepeatPattern = + | { kind: "daily" } + /** Monday first, each day once. */ + | { kind: "weekly"; days: DayCode[] } + | { kind: "monthly_day"; day: number } + | { kind: "monthly_weekday"; nth: NthCode; weekday: DayCode } + /** On the first occurrence's own date. */ + | { kind: "yearly_date" } + | { kind: "yearly_weekday"; month: number; nth: NthCode; weekday: DayCode }; + +/** `date` is the last day an occurrence can start on (YYYY-MM-DD, in the event's zone); `count` + * is 1 to 730. */ +export type RepeatEnds = + { kind: "never" } | { kind: "on_date"; date: string } | { kind: "after"; count: number }; + +/** A repeat rule from the editor's set. `interval`: every 1 to 99 days, weeks, months or years. */ +export interface RepeatRule { + pattern: RepeatPattern; + interval: number; + ends: RepeatEnds; +} + +/** One ready-made rule of the repeat menu, with the repeat menu's words for it. */ +export interface RepeatPreset { + label: string; + rule: RepeatRule; +} + +/** A wall-clock time as the editor's fields hold it. */ +export interface WallTime { + date: string; + time: string; + zone: string; } +/** Where a new repeating event starts, as the editor's fields hold it. `end`: where its first + * occurrence ends, so an end the rule moves into a skipped hour is said too. */ +export type RepeatStart = + | { kind: "timed"; date: string; time: string; zone: string; end?: WallTime | null } + | { kind: "all_day"; date: string }; + +/** A rule in the repeat menu's words, and the day (YYYY-MM-DD) its first occurrence falls on; + * `moved` when that isn't the day asked for. Or why it can't be saved, in words. */ +export type RepeatView = + | { outcome: "ready"; summary: string; starts: string; moved: boolean } + | { outcome: "refused"; message: string }; + /** What the user saw of an event when they chose to delete it, from the row they clicked. */ export interface SeenSummary { summary: string; From ebdca7d58fb9658f6f4d96248fe18c118debde4e Mon Sep 17 00:00:00 2001 From: Bobby Yu <127560404+BBYU26@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:32:16 +0100 Subject: [PATCH 2/2] fix(calendar): review round 1 of C11a - A test now covers the listing loop's stop condition when a new repeating event's first look is incomplete: page 1 arrives with a next page, page 2 is refused with a 503, and the second look a second later lists all three occurrences and lands them. If the "complete" half of the stop condition is removed, the loop stops after that first look and this test fails. Part of #884 Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/commands/calendar_edit.rs | 40 ++++++++++++------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/commands/calendar_edit.rs b/src-tauri/src/commands/calendar_edit.rs index 6a580704..47de3e75 100644 --- a/src-tauri/src/commands/calendar_edit.rs +++ b/src-tauri/src/commands/calendar_edit.rs @@ -14870,28 +14870,28 @@ mod tests { assert_eq!(held_creates(&host), 0); } - /// A28: a listing that lags shows nothing at first; the second look, a second later, lands it. + /// A28: a listing that lags shows nothing at first, or only its first page before the second is + /// refused; the second look, a second later, lands it. #[tokio::test] async fn a_created_series_whose_listing_lags_lands_on_the_second_look() { - let host = TestHost::holding(&[]); - let draft = weekly_dentist("2026-10-26", 3); - let copy = google_copy(&draft, "\"n1\""); - let (outcome, script) = create_on( - &host, - &new_target(), - EditingStatus::On, - vec![ - insert_step(Scripted::Json(copy)), - new_listing(listed(Vec::new())), - new_listing(listed(three_mondays())), - ], - draft, - None, - ) - .await; - assert!(outcome == saved(), "landed on the second look"); - assert_eq!(script.waits(), vec![REFETCH_AGAIN[0]]); - assert_eq!(new_rows(&host), three_ids()); + let empty = vec![new_listing(listed(Vec::new()))]; + let half = vec![ + new_listing(Scripted::Json(page(three_mondays(), Some("p2")))), + new_listing(Scripted::Status(503)), + ]; + for first in [empty, half] { + let host = TestHost::holding(&[]); + let draft = weekly_dentist("2026-10-26", 3); + let copy = google_copy(&draft, "\"n1\""); + let mut steps = vec![insert_step(Scripted::Json(copy))]; + steps.extend(first); + steps.push(new_listing(listed(three_mondays()))); + let (outcome, script) = + create_on(&host, &new_target(), EditingStatus::On, steps, draft, None).await; + assert!(outcome == saved(), "landed on the second look"); + assert_eq!(script.waits(), vec![REFETCH_AGAIN[0]]); + assert_eq!(new_rows(&host), three_ids()); + } } /// Three looks that never show it whole: saved, the next sync to show it, and no guess landed.