diff --git a/eslint.config.js b/eslint.config.js index 4b19a4ee..bd9564a7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -41,6 +41,7 @@ const CALENDAR_WRITES = { "cancelCalendarDelete", "listHeldDeletes", "seriesChoices", + "getSeriesSummary", "settleSeriesSplit", ], message: "Calendar edits go only through src/components/calendar/edit/useEventWrites.ts (#884).", diff --git a/package-lock.json b/package-lock.json index b81c75e0..92dc429f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pm", - "version": "3.151.0-alpha", + "version": "3.152.0-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pm", - "version": "3.151.0-alpha", + "version": "3.152.0-alpha", "license": "AGPL-3.0-or-later", "dependencies": { "@fontsource/atkinson-hyperlegible": "^5.3.0", diff --git a/package.json b/package.json index 5bed7a12..b9e282d1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pm", "private": true, - "version": "3.151.0-alpha", + "version": "3.152.0-alpha", "license": "AGPL-3.0-or-later", "type": "module", "engines": { diff --git a/scripts/check-calendar-write-fence.mjs b/scripts/check-calendar-write-fence.mjs index ec2dfdf3..1ec238f7 100644 --- a/scripts/check-calendar-write-fence.mjs +++ b/scripts/check-calendar-write-fence.mjs @@ -48,6 +48,7 @@ export const WRITE_COMMANDS = [ ["cancel_calendar_delete", "cancelCalendarDelete"], ["list_held_deletes", "listHeldDeletes"], ["series_choices", "seriesChoices"], + ["get_series_summary", "getSeriesSummary"], ["settle_series_split", "settleSeriesSplit"], ]; export const COMMANDS = WRITE_COMMANDS.map(([command]) => command); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0c72d3b6..9c20a3f0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3072,7 +3072,7 @@ dependencies = [ [[package]] name = "pm" -version = "3.151.0-alpha" +version = "3.152.0-alpha" dependencies = [ "argon2", "base64 0.23.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c5f6d832..ce25a851 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pm" -version = "3.151.0-alpha" +version = "3.152.0-alpha" description = "PM — a local-first personal manager (Archivist + Personal Assistant)" authors = ["Bobby Yu"] edition = "2021" diff --git a/src-tauri/src/calendar_write/dto.rs b/src-tauri/src/calendar_write/dto.rs index 69dff243..a76e0462 100644 --- a/src-tauri/src/calendar_write/dto.rs +++ b/src-tauri/src/calendar_write/dto.rs @@ -216,6 +216,13 @@ pub enum WriteOutcome { /// zone): they may still show. Empty, and left out, for every other save. #[serde(skip_serializing_if = "Vec::is_empty")] left_behind: Vec, + /// A "this and following" change that landed, with milestones linked to the earlier part + /// dated on or after the day it starts, except one on an occurrence it left there: the + /// webview asks about each (DECISIONS 17-10 #2). + /// `None`, and left out, for every other save, and for a split with nothing to offer. Boxed, + /// so every outcome (and every `Result` that carries one as its error) stays small. + #[serde(skip_serializing_if = "Option::is_none")] + milestone_moves: Option>, }, /// Google already holds exactly this; nothing was sent. NoChange, @@ -332,6 +339,44 @@ pub enum WriteWarning { LeftOnOldSeries, /// Saved in Google, but it starts outside the months PM mirrors, so PM's calendar won't show it. OutsideMirror, + /// A "this and following" change landed, and PM couldn't tell which repeating event Google made + /// of the new part, so it can't offer to move the milestones `Saved.milestone_moves` names: they + /// stay on the earlier part (C9b-2 critique C2-M1). + MilestoneNotOffered, +} + +/// What moving milestones to the new part of a "this and following" change needs (C9b-2). Dates +/// are `YYYY-MM-DD` in the user's zone, as the milestone list resolves them. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MilestoneMoves { + /// The new part's iCal UID (Google mints it; the insert never sets one), where Move sends a + /// milestone. `None`: PM couldn't tell, so nothing can be moved ([`WriteWarning::MilestoneNotOffered`]). + pub new_series_ical: Option, + /// The earlier part's, where Move back sends it. + pub earlier_series_ical: String, + /// The new part's title, and the earlier part's, as Google holds them after the change. + pub new_title: String, + pub earlier_title: String, + /// The new part's first occurrence, the date a moved milestone shows until the next read; `None` + /// when PM can't say. + pub first_day: Option, + /// The earlier part's last occurrence before the change: a milestone left on it reads overdue + /// from then. `None` when PM can't say. + pub earlier_last_day: Option, + /// Today, as the change landed: whether that last day has passed yet, for the words (C9b-2 + /// review round 3, R3-F3). + pub today: String, + /// Never empty. + pub milestones: Vec, +} + +/// A milestone PM may move: PM's own id, its words, and its date before the change. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MilestoneOffer { + pub id: i64, + pub label: String, + pub project: String, + pub date: String, } /// One event as the editor shows it, read from a fresh copy (never the mirror, which clips text). @@ -518,6 +563,7 @@ mod tests { serde_json::to_string(&WriteOutcome::Saved { warnings: vec![WriteWarning::MirrorRefreshPending], left_behind: Vec::new(), + milestone_moves: None, }) .unwrap(), r#"{"outcome":"saved","warnings":["mirror_refresh_pending"]}"# @@ -526,6 +572,7 @@ mod tests { serde_json::to_string(&WriteOutcome::Saved { warnings: vec![WriteWarning::LeftOnOldSeries], left_behind: vec!["2026-11-02".into()], + milestone_moves: None, }) .unwrap(), r#"{"outcome":"saved","warnings":["left_on_old_series"],"left_behind":["2026-11-02"]}"# @@ -574,6 +621,7 @@ mod tests { WriteOutcome::Saved { warnings: vec![WriteWarning::OutsideMirror], left_behind: Vec::new(), + milestone_moves: None, }, r#"{"outcome":"saved","warnings":["outside_mirror"]}"#, ), @@ -635,6 +683,50 @@ mod tests { assert!(matches!(draft, TimeDraft::AllDay { .. })); } + /// C9b-2: a split's milestone offers, in the shape the webview reads; a new part PM couldn't + /// name is `null` beside its warning. + #[test] + fn saved_with_milestone_moves_serialises_as_the_webview_reads_it() { + let moves = MilestoneMoves { + new_series_ical: Some("tail@google.com".into()), + earlier_series_ical: "abc@google.com".into(), + new_title: "Gym".into(), + earlier_title: "Gym".into(), + first_day: Some("2026-10-19".into()), + earlier_last_day: Some("2026-10-12".into()), + today: "2026-10-13".into(), + milestones: vec![MilestoneOffer { + id: 7, + label: "Launch".into(), + project: "Atlas".into(), + date: "2026-10-19".into(), + }], + }; + assert_eq!( + serde_json::to_string(&WriteOutcome::Saved { + warnings: Vec::new(), + left_behind: Vec::new(), + milestone_moves: Some(Box::new(moves.clone())), + }) + .unwrap(), + r#"{"outcome":"saved","warnings":[],"milestone_moves":{"new_series_ical":"tail@google.com","earlier_series_ical":"abc@google.com","new_title":"Gym","earlier_title":"Gym","first_day":"2026-10-19","earlier_last_day":"2026-10-12","today":"2026-10-13","milestones":[{"id":7,"label":"Launch","project":"Atlas","date":"2026-10-19"}]}}"# + ); + assert_eq!( + serde_json::to_string(&WriteOutcome::Saved { + warnings: vec![WriteWarning::MilestoneNotOffered], + left_behind: Vec::new(), + milestone_moves: Some(Box::new(MilestoneMoves { + new_series_ical: None, + first_day: None, + earlier_last_day: None, + ..moves + })), + }) + .unwrap(), + r#"{"outcome":"saved","warnings":["milestone_not_offered"],"milestone_moves":{"new_series_ical":null,"earlier_series_ical":"abc@google.com","new_title":"Gym","earlier_title":"Gym","first_day":null,"earlier_last_day":null,"today":"2026-10-13","milestones":[{"id":7,"label":"Launch","project":"Atlas","date":"2026-10-19"}]}}"# + ); + } + /// The series choices, in the shapes the webview reads and sends. #[test] fn series_choices_serialise_to_the_strings_the_dialog_names() { diff --git a/src-tauri/src/calendar_write/series/diff.rs b/src-tauri/src/calendar_write/series/diff.rs index 23fb10d5..cdaf423b 100644 --- a/src-tauri/src/calendar_write/series/diff.rs +++ b/src-tauri/src/calendar_write/series/diff.rs @@ -113,7 +113,7 @@ pub fn floor_of<'a>(moments: impl IntoIterator) -> Option Option { +pub fn row_point(row: &CalendarEvent) -> Option { mirror_point(row.original_start.as_deref().unwrap_or(&row.start)) } diff --git a/src-tauri/src/calendar_write/series/run.rs b/src-tauri/src/calendar_write/series/run.rs index 9d2575b8..6f8e09f7 100644 --- a/src-tauri/src/calendar_write/series/run.rs +++ b/src-tauri/src/calendar_write/series/run.rs @@ -129,9 +129,13 @@ pub enum End { reply: Option, date: Option>, }, - /// A cleanup step not done or unknown, or one requiring such a step: left out, and reported - /// ([`SeriesPlan::expect_after`] and [`SeriesPlan::removes_for`] take these). + /// A cleanup step proved not done, or one requiring a step that isn't done, so never sent: left + /// out, and reported ([`SeriesPlan::expect_after`] and [`SeriesPlan::removes_for`] take these). Skipped, + /// A cleanup step whose answer PM couldn't read, a look included: it may have landed. Left out + /// and reported as [`End::Skipped`] is ([`Ran::skipped`]), but never taken for one that wasn't + /// done ([`Ran::not_done`], C9b-2 review round 2). + Unanswered, /// Landed, then undone by a rollback. Undone, } @@ -174,7 +178,8 @@ pub enum Rollback { /// How a run ended. #[derive(Debug, Clone, PartialEq)] pub enum Ran { - /// Every commit step landed; cleanup steps that didn't are [`End::Skipped`]. + /// Every commit step landed; cleanup steps that didn't are [`End::Skipped`] or + /// [`End::Unanswered`]. Done { ends: Vec }, /// Commit step `step` provably isn't in Google (it didn't land, or its event is gone), and the /// steps before it were rolled back. @@ -200,12 +205,23 @@ impl Ran { } } - /// The steps left out, for [`SeriesPlan::expect_after`] and [`SeriesPlan::removes_for`]. + /// The steps left out, for [`SeriesPlan::expect_after`] and [`SeriesPlan::removes_for`]: those + /// not done, and those whose answer was lost. pub fn skipped(&self) -> Vec { + self.left_out(|e| matches!(e, End::Skipped | End::Unanswered)) + } + + /// The steps left out that surely didn't land: proved not done, or never sent. Never one whose + /// answer was lost. + pub fn not_done(&self) -> Vec { + self.left_out(|e| *e == End::Skipped) + } + + fn left_out(&self, of: impl Fn(&End) -> bool) -> Vec { self.ends() .iter() .enumerate() - .filter(|(_, e)| **e == End::Skipped) + .filter(|(_, e)| of(e)) .map(|(i, _)| i) .collect() } @@ -820,7 +836,10 @@ impl Run { Read::NotDone(_) | Read::Unknown if self.plan.steps[self.at].stage == Stage::Cleanup => { - self.ends[self.at] = End::Skipped; + self.ends[self.at] = match read { + Read::Unknown => End::Unanswered, + _ => End::Skipped, + }; self.at += 1; self.phase = Phase::Start; None @@ -3598,8 +3617,36 @@ mod tests { }); assert_eq!(ran.skipped(), vec![2, 3]); assert!(matches!(ran, Ran::Done { .. })); - // The cancel that required the carry was never sent. + // The cancel that required the carry was never sent: that one surely isn't done, while the + // carry, whose answer was lost, may be. assert!(!sent.iter().any(|r| to(r, "abc_20261102T090000Z"))); + assert!(matches!(ran.ends(), [_, _, End::Unanswered, End::Skipped])); + assert_eq!(ran.not_done(), vec![3]); + } + + /// C9b-2 review round 2: a cleanup cancel whose answer was lost, a look included, may have + /// landed, so it's never counted as not done; one Google refused is. + #[test] + fn a_cleanup_whose_answer_was_lost_is_never_taken_for_not_done() { + let (case, plan) = split_case(vec![renamed()]); + let tail = tail_of(&plan, "\"t1\""); + for (cancel, unanswered) in [(lost(), true), (status(Verdict::ReadOnly), false)] { + let (ran, sent, _) = drive(&mut Run::new(plan.clone()).unwrap(), |r| { + if to(r, "abc_20261102T090000Z") { + return cancel.clone(); + } + finished_split(&case, &plan, &tail, r) + }); + assert!(sent.iter().any(|r| to(r, "abc_20261102T090000Z"))); + assert_eq!(ran.skipped(), vec![3]); + if unanswered { + assert!(matches!(ran.ends()[3], End::Unanswered), "the lost one"); + assert!(ran.not_done().is_empty()); + } else { + assert!(matches!(ran.ends()[3], End::Skipped), "the refused one"); + assert_eq!(ran.not_done(), vec![3]); + } + } } #[test] diff --git a/src-tauri/src/commands/assistant.rs b/src-tauri/src/commands/assistant.rs index 1ad1e0b0..a8784ef4 100644 --- a/src-tauri/src/commands/assistant.rs +++ b/src-tauri/src/commands/assistant.rs @@ -93,20 +93,49 @@ pub fn update_milestone( } /// Link a milestone to a calendar event (`event_uid` Some, `cached_date` seeds the offline cache) -/// or unlink it (None — the date becomes editable again). +/// or unlink it (None — the date becomes editable again). `only_if_linked_to`: only while it's +/// still linked to that event (the calendar's move between the two parts of a repeating event, +/// C9b-2). An id no milestone has, or a link changed since, is an error, so nothing is said to have +/// moved that didn't. A relink moves the date that governs the project, so the briefing looks again. #[tauri::command] pub fn set_milestone_event( state: State<'_, AppState>, id: i64, event_uid: Option, cached_date: Option, + only_if_linked_to: Option, ) -> Result<()> { let conn = state.conn()?; - milestones::set_event(&conn, id, event_uid, cached_date)?; - touch_milestone_project(&conn, id)?; + relink( + &conn, + id, + event_uid, + cached_date, + only_if_linked_to.as_deref(), + )?; + briefing::nudge(&state); Ok(()) } +/// [`set_milestone_event`] but the briefing's nudge: a refusal is in the words the user sees, and +/// changes nothing. +fn relink( + conn: &Connection, + id: i64, + event_uid: Option, + cached_date: Option, + only_if_linked_to: Option<&str>, +) -> Result<()> { + if !milestones::set_event(conn, id, event_uid, cached_date, only_if_linked_to)? { + let words = match milestones::project_of(conn, id)? { + None => "That milestone no longer exists.", + Some(_) => "That milestone changed since; nothing was moved.", + }; + return Err(Error::Other(words.into())); + } + touch_milestone_project(conn, id) +} + /// Mark a milestone met or unmet. #[tauri::command] pub fn set_milestone_state(state: State<'_, AppState>, id: i64, met: bool) -> Result<()> { @@ -538,4 +567,83 @@ mod tests { ("Atlas", "milestone", Some(id)) ); } + + /// C9b-2 review F6: a relink that finds no milestone with the id, or finds it linked elsewhere + /// since, is refused in the words the calendar shows, and changes nothing, its project's activity + /// included; the move as offered links it and seeds its date. + #[test] + fn a_relink_is_refused_in_the_users_words_and_changes_nothing() { + const DB_KEY: &str = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; + let dir = tempfile::tempdir().unwrap(); + let conn = crate::db::open(&dir.path().join("pm.sqlite"), DB_KEY).unwrap(); + let id = milestones::add( + &conn, + "Atlas", + "Launch", + Some("2026-10-12".into()), + Some("abc@x".into()), + ) + .unwrap(); + let row = || -> (Option, Option, i64) { + conn.query_row( + "SELECT event_uid, due_date, (SELECT COUNT(*) FROM project_activity) \ + FROM project_milestones WHERE id = ?1", + params![id], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap() + }; + let to_tail = |m: i64, only: &str| { + relink( + &conn, + m, + Some("tail@x".into()), + Some("2026-10-19".into()), + Some(only), + ) + }; + let refused = |result: Result<()>| match result { + Err(Error::Other(words)) => words, + _ => String::new(), + }; + let before = row(); + assert!( + refused(to_tail(id + 99, "abc@x")) == "That milestone no longer exists.", + "an id no milestone has" + ); + assert!( + refused(to_tail(id, "other@x")) == "That milestone changed since; nothing was moved.", + "a link changed since" + ); + assert!(row() == before, "neither changes anything"); + assert!(to_tail(id, "abc@x").is_ok(), "the move as offered"); + let after = row(); + assert!( + after.0.as_deref() == Some("tail@x") + && after.1.as_deref() == Some("2026-10-19") + && after.2 == before.2 + 1, + "linked, dated, and its project touched" + ); + } + + /// The command nudges the briefing after [`relink`], and only once it's done: a refusal returns + /// first, and a relink moves the date that governs the project. No test can build the command's + /// state, so this reads the file's own text (the C9b-1 C1-M8 precedent). + #[test] + fn a_relink_nudges_the_briefing() { + let source = include_str!("assistant.rs"); + let at = source + .find("pub fn set_milestone_event(") + .expect("the command is defined"); + let body = &source[at..]; + let body = &body[..body.find("\n}\n").unwrap_or(body.len())]; + let relinked = body.find("relink(").expect("the command relinks"); + let nudged = body[relinked..] + .find("briefing::nudge(&state);") + .expect("then nudges the briefing"); + assert!( + body[relinked..relinked + nudged].contains(")?;"), + "only once the relink is done" + ); + } } diff --git a/src-tauri/src/commands/calendar_edit.rs b/src-tauri/src/commands/calendar_edit.rs index ad64960a..beaa2ee2 100644 --- a/src-tauri/src/commands/calendar_edit.rs +++ b/src-tauri/src/commands/calendar_edit.rs @@ -5,7 +5,9 @@ //! changed, delete it. The decisions are the pure core's (`crate::calendar_write`); this module does //! the I/O around them, in a fixed order every write follows: //! -//! 1. the main window and the vault owner only (`require_main_window`, `require_owner`); +//! 1. the main window and the vault owner only (`require_main_window`, `require_owner`). The one +//! read that isn't for a write, how a repeating event repeats ([`series_summary`], for the event +//! popover), checks the window alone (C9 design scope-L3); //! 2. a short DB read: which Google calendar and event the mirror row stands for, and the calendar's //! facts. The webview only ever names PM's own row ids and the session ids PM minted; //! 3. a fresh GET from Google, with no lock held, and the gate again on that copy (the mirror can be @@ -42,9 +44,9 @@ use crate::calendar_recur::Point; use crate::calendar_write::classify::{Method, Verdict}; use crate::calendar_write::dto::{ ChoiceRequest, DeleteSettled, DeleteStart, EditLoad, EventForEdit, EventPatchDraft, - FieldPermissions, HeldDeleteInfo, NewEventDraft, Notify, ReadOnlyReason, ScopeRow, SeenSummary, - SeriesChoices, SeriesInfo, SeriesScope, SeriesWarningDto, ShowAs, SplitAction, TimeDraft, - Visibility, WriteOutcome, WriteWarning, + FieldPermissions, HeldDeleteInfo, MilestoneMoves, MilestoneOffer, NewEventDraft, Notify, + ReadOnlyReason, ScopeRow, SeenSummary, SeriesChoices, SeriesInfo, SeriesScope, + SeriesWarningDto, ShowAs, SplitAction, TimeDraft, Visibility, WriteOutcome, WriteWarning, }; use crate::calendar_write::gate::{self, EditFacts}; use crate::calendar_write::patch::{self, DeleteCheck, UpdateCheck}; @@ -69,14 +71,14 @@ use crate::calendar_write::series::{ }; use crate::calendar_write::{plan, time}; use crate::error::{Error, Result}; -use crate::{briefing, google, secrets, AppState}; +use crate::{briefing, google, milestones, secrets, AppState}; use super::shared::require_main_window; use super::vaults::require_vault_owner; /// What editing keeps in memory, in [`AppState`]. Lock order everywhere: the DB, then `recent`; -/// `partials`, `splitting`, `creates` and `in_flight` are each taken alone, never with the DB or -/// another of these held. +/// `partials`, `splitting`, `creates`, `in_flight` and `summaries` are each taken alone, never with +/// the DB or another of these held. #[derive(Default)] pub struct CalendarEditState { /// Saves of the last ten minutes, laid over each sync's fetch (rule R4). @@ -105,6 +107,9 @@ pub struct CalendarEditState { /// What the change check knows about each Google calendar (F3). Lock order: the DB, then this; /// never held across an await. pub(crate) fresh: Mutex, + /// Each repeating event's words for the popover, by [`series_key`] (C9b-2). Forgotten by any + /// write to the series ([`InFlight`]'s and [`HeldClaim`]'s drop). + summaries: Mutex, } /// An open editor: which event, and the copy of it the user saw. @@ -643,14 +648,7 @@ async fn open_master(wire: &W, target: &Target, fresh: &Value) -> Optio .get("recurringEventId") .and_then(Value::as_str) .filter(|id| is_plain_segment(id))?; - match get(wire, &target.remote_calendar, id).await { - Fetched::Event(master) - if !patch::is_gone(&master) && master.get("id").and_then(Value::as_str) == Some(id) => - { - Some(master) - } - _ => None, - } + series_copy(get(wire, &target.remote_calendar, id).await, id) } /// Where the occurrence `fresh` sits in its series `master`, or `None` for a series PM can't read. @@ -1314,6 +1312,7 @@ fn land_created(host: &impl Host, target: &CalendarTarget, copy: &Value) -> Writ return WriteOutcome::Saved { warnings: vec![WriteWarning::OutsideMirror], left_behind: Vec::new(), + milestone_moves: None, }; } let id = row.id.clone(); @@ -1321,6 +1320,7 @@ fn land_created(host: &impl Host, target: &CalendarTarget, copy: &Value) -> Writ Ok(()) => WriteOutcome::Saved { warnings: Vec::new(), left_behind: Vec::new(), + milestone_moves: None, }, Err(_) => created_pending(host, target), } @@ -1332,6 +1332,7 @@ fn created_pending(host: &impl Host, target: &CalendarTarget) -> WriteOutcome { WriteOutcome::Saved { warnings: vec![WriteWarning::MirrorRefreshPending], left_behind: Vec::new(), + milestone_moves: None, } } @@ -1798,6 +1799,203 @@ pub async fn series_choices( .await) } +// --- how a repeating event repeats, for the event popover (C9b-2) --- + +/// How the repeating event a mirror row belongs to repeats, in Google Calendar's words ("Weekly on +/// Monday"), for the event popover. `None` when PM can't say: a one-off, a row that isn't Google's, a +/// series Google won't send or PM can't read, no network. Only a GET goes to Google. A read, so no +/// owner check (C9 design scope-L3): on a shared vault, someone else's profile simply has no sign-in +/// here, and the line reads "Repeats". `Err` only for the window and a DB PM can't open. +#[tauri::command] +pub async fn get_series_summary( + app: AppHandle, + window: tauri::Window, + event_id: String, +) -> Result> { + require_main_window(&window)?; + let state = app.state::(); + let ask = { + let conn = state.conn()?; + summary_ask(&conn, &event_id) + }; + let Some(ask) = ask else { return Ok(None) }; + let token_key = ask.token_key.clone(); + Ok(series_summary(&GoogleWire(&token_key), &state.calendar_edit, &ask).await) +} + +/// What a summary needs from the mirror: Google's calendar and series ids, the clicked row's etag, +/// and the sign-in to read with. +struct SummaryAsk { + remote_calendar: String, + series: String, + /// `None` for a row mirrored before v57: never kept. + etag: Option, + token_key: String, +} + +/// `None` for a row PM can't ask about: gone from the mirror, a one-off, not Google's, an id that +/// isn't a plain path segment, or a DB read that failed. The popover then just says "Repeats". +fn summary_ask(conn: &Connection, row_id: &str) -> Option { + let row = calendar::event_by_id(conn, row_id).ok().flatten()?; + let series = row.series_id.filter(|s| is_plain_segment(s))?; + let target = resolve_target(conn, row_id).ok()?.ok()?; + Some(SummaryAsk { + remote_calendar: target.remote_calendar, + series, + etag: row.etag, + token_key: target.token_key, + }) +} + +/// Google's copy of the series `id` from a GET, when it is that series and not deleted. +fn series_copy(fetched: Fetched, id: &str) -> Option { + match fetched { + Fetched::Event(master) + if !patch::is_gone(&master) && master.get("id").and_then(Value::as_str) == Some(id) => + { + Some(master) + } + _ => None, + } +} + +/// The series' words: kept ones while they still stand for this row, else one GET. The editor's +/// "repeats" line comes from the same [`describe_lines`], so the two never disagree. +async fn series_summary( + wire: &W, + edit: &CalendarEditState, + ask: &SummaryAsk, +) -> Option { + let key = series_key(&ask.remote_calendar, &ask.series); + let etag = ask.etag.as_deref(); + if let Some(words) = known_summary(edit, &key, etag, Instant::now()) { + return Some(words); + } + let since = summary_writes(edit); + let master = series_copy( + get(wire, &ask.remote_calendar, &ask.series).await, + &ask.series, + )?; + let read = Master::read(&FreshEvent::from_google(master)).ok()?; + let words = describe_lines(&read.recurrence, &read.start); + keep_summary(edit, key, etag, &words, since, Instant::now()); + Some(words) +} + +/// How long a series' words stand, whatever the row etags say (C9b-2 §0.4.1: whether Google moves an +/// occurrence's etag when only its series' rule changes is unproven). +const SUMMARY_TTL: Duration = Duration::from_secs(15 * 60); +/// At most this many series' words are kept; past it the stalest go first. +const SUMMARY_KEEP: usize = 200; + +/// The popover's kept words, and how many writes have let go of what they claimed. +#[derive(Default)] +struct Summaries { + known: HashMap, + /// Moves each time a write lets go ([`series_written`]): words read while one ended are never + /// kept, nor words read while one still holds the series (C9b-2 critiques C1-M2, C2-L3). One + /// count for every series, so an unrelated write only costs a read of Google again. + writes: u64, +} + +/// A series' words, and the row etags they were read for. +#[derive(Clone)] +struct KnownSummary { + words: String, + etags: HashSet, + read: Instant, +} + +/// The words for `key`, if read within [`SUMMARY_TTL`] of `now` for a row at `etag`. A row with no +/// etag never hits, and a lock PM can't take reads as nothing kept. +fn known_summary( + edit: &CalendarEditState, + key: &str, + etag: Option<&str>, + now: Instant, +) -> Option { + let etag = etag?; + let summaries = edit.summaries.lock().ok()?; + summaries + .known + .get(key) + .filter(|k| k.etags.contains(etag) && now.saturating_duration_since(k.read) < SUMMARY_TTL) + .map(|k| k.words.clone()) +} + +/// Where the writes stood when an ask began, for [`keep_summary`]. +fn summary_writes(edit: &CalendarEditState) -> Option { + edit.summaries.lock().ok().map(|s| s.writes) +} + +/// Keep `words` for `key`, for a row at `etag` (nothing kept without one), unless a write let go of +/// what it claimed since the ask began (`since`), or holds this series now. The same words read again +/// add the etag; different words replace the entry. Past [`SUMMARY_KEEP`], entries older than the TTL +/// go, then the oldest. +fn keep_summary( + edit: &CalendarEditState, + key: String, + etag: Option<&str>, + words: &str, + since: Option, + now: Instant, +) { + let Some(etag) = etag else { return }; + // Taken alone, and let go before `summaries` is. + let unclaimed = edit.in_flight.lock().is_ok_and(|held| !held.contains(&key)); + if !unclaimed { + return; + } + let Ok(mut summaries) = edit.summaries.lock() else { + return; + }; + if since != Some(summaries.writes) { + return; + } + if let Some(known) = summaries.known.get_mut(&key) { + if known.words == words { + known.etags.insert(etag.to_string()); + known.read = now; + return; + } + } + if !summaries.known.contains_key(&key) && summaries.known.len() >= SUMMARY_KEEP { + summaries + .known + .retain(|_, k| now.saturating_duration_since(k.read) < SUMMARY_TTL); + if summaries.known.len() >= SUMMARY_KEEP { + let oldest = summaries + .known + .iter() + .min_by_key(|(_, k)| k.read) + .map(|(k, _)| k.clone()); + if let Some(oldest) = oldest { + summaries.known.remove(&oldest); + } + } + } + summaries.known.insert( + key, + KnownSummary { + words: words.to_string(), + etags: HashSet::from([etag.to_string()]), + read: now, + }, + ); +} + +/// A write let go of `keys`, after its landing: the words of every series among them go (row ids +/// match nothing), so a popover opened from now on reads Google again, and an ask on its way keeps +/// nothing it read. A lock PM can't take does nothing. +fn series_written(summaries: &Mutex, keys: &[String]) { + if let Ok(mut summaries) = summaries.lock() { + summaries.writes = summaries.writes.wrapping_add(1); + for key in keys { + summaries.known.remove(key); + } + } +} + impl EditSession { /// What the user opened, as the choices check against it: the occurrence, and its series. fn opened(&self) -> (&Value, Option<&Value>) { @@ -2696,7 +2894,7 @@ async fn send_series( landing: Landing<'_>, ) -> WriteOutcome { // A "this and following" change lands and settles by facts read once, now. - let facts = match new_series(&plan) { + let mut facts = match new_series(&plan) { None => None, Some(_) => match split_facts(planning, &plan) { Some(facts) => Some(facts), @@ -2736,6 +2934,8 @@ async fn send_series( // are new (review M4). A new series has none yet. A split's record is kept in the same lock, // before anything goes out, so a quit mid-change leaves it (review H3); failing that, nothing // is sent. The record is this run's from before it's written to the end of the landing and look. + // The milestones a split may offer to move are read in the same lock (C9b-2): a read that fails + // stops the change, as a record that can't be kept does, rather than strand them unsaid. let _splitting = entry .as_ref() .map(|e| Splitting::mark(ctx.host.edit(), series_key(&e.calendar, &e.tail))); @@ -2745,12 +2945,21 @@ async fn send_series( &ctx.target.calendar_id, &[planning.series.master.id.as_str()], )?; + let movable = match &facts { + Some(facts) => read_movable(conn, ctx.host, planning, run.plan(), facts, &rows)?, + None => None, + }; if let Some(entry) = &entry { journal_add(conn, entry)?; } - Ok(rows) + Ok((rows, movable)) }) { - Ok(rows) => rows, + Ok((rows, movable)) => { + if let Some(facts) = facts.as_mut() { + facts.movable = movable; + } + rows + } Err(e) => { return WriteOutcome::Failed { message: e.to_string(), @@ -2959,6 +3168,7 @@ async fn land_series( WriteOutcome::Saved { warnings: Vec::new(), left_behind: Vec::new(), + milestone_moves: None, } } Err(_) => saved_pending(host, target), @@ -3095,6 +3305,7 @@ fn series_saved(mut warnings: Vec, left_behind: Vec) -> Wr WriteOutcome::Saved { warnings, left_behind, + milestone_moves: None, } } @@ -3414,6 +3625,230 @@ struct SplitFacts { marks: Vec<(String, serde_json::Map)>, /// The original's iCal UID, to list its exceptions again. ical: String, + /// The milestones it may offer to move, read before its first send; `None`: none (C9b-2). + movable: Option, +} + +/// The milestones a split may offer to move to its new part, read before its first send: after the +/// cut, the original's iCal UID resolves only to rows the earlier part keeps, and the landing makes +/// the calendar read milestones again before the save returns (C9b-2 §0.4.2). +#[derive(Clone)] +struct Movable { + /// The user's zone, the one the milestone list resolves dates in: every day here is in it. + zone: chrono_tz::Tz, + /// The original's title and the new part's, as the plan sends them. + earlier_title: String, + new_title: String, + /// Never empty. + milestones: Vec, +} + +/// A milestone a split may offer to move, and where the occurrences on its date sit in the series. +#[derive(Clone)] +struct MovableMilestone { + offer: MilestoneOffer, + /// The slots (`originalStartTime`) of the original's rows on its date that the split moves to + /// the new part: what the landing looks for on the earlier part. Empty when none was on it. + slots: Vec, +} + +/// What `facts`' split may offer to move, read under the DB lock the send's snapshot holds, `rows` +/// being the original's rows then: the unmet milestones on its iCal UID dated on or after the day +/// the change starts, as decided (DECISIONS 17-10 #2): "Earlier dates stay put with no prompt", even +/// one on an occurrence moved there from a later week, which the split takes to the new part. That +/// day is the one the user splits from as they see it, the "this day" the dialog names: the earlier +/// of the occurrence's place in the series and the day it shows on, when it was moved (C9b-2 review +/// round 3, R3-F1). Each keeps the slots of the occurrences on its date that the split moves, so the +/// landing can leave one whose occurrence stayed on the earlier part unasked ([`with_moves`], +/// C1-L9). Days are in the user's zone, the one the milestone list resolves dates in (the series' +/// zone stays for the dialog's words, C9b-2 critique C1-L8). +fn read_movable( + conn: &Connection, + host: &impl Host, + planning: &Planning, + plan: &SeriesPlan, + facts: &SplitFacts, + rows: &[CalendarEvent], +) -> Result> { + let zone = super::shared::resolve_zone(conn); + let slot_day = facts.at.day_in(zone).format("%Y-%m-%d").to_string(); + let split_day = start_day(planning.opened_instance.json(), zone) + .filter(|shown| *shown < slot_day) + .unwrap_or(slot_day); + let found = + milestones::linked_on_or_after(conn, &facts.ical, &split_day, &host.today_in(zone))?; + let milestones: Vec = found + .into_iter() + .filter_map(|m| { + let date = m.due_date?.get(..10)?.to_string(); + let slots: Vec = rows + .iter() + .filter(|r| crate::clock::zone_date_of(&r.start, zone) == date) + .filter_map(diff::row_point) + .filter(|p| !p.cmp_in(&facts.at, facts.zone).is_lt()) + .collect(); + Some(MovableMilestone { + offer: MilestoneOffer { + date, + id: m.id, + label: m.label, + project: m.project_name, + }, + slots, + }) + }) + .collect(); + if milestones.is_empty() { + return Ok(None); + } + let text = |v: Option<&Value>| v.and_then(Value::as_str).unwrap_or("").to_string(); + let inserted = match plan.steps.get(facts.insert).map(|s| &s.op) { + Some(Op::Insert { body, .. }) => body.get("summary"), + _ => None, + }; + Ok(Some(Movable { + zone, + earlier_title: text(planning.series.master_fresh.json().get("summary")), + new_title: text(inserted), + milestones, + })) +} + +/// A landed split's `Saved`, with the milestones it offers to move to its new part (C9b-2). The new +/// part is named from what PM already holds (the insert's answer, then its landed rows), else by +/// one GET of it; one PM can't name still lists the milestones, beside a warning that they stayed +/// on the earlier part (C9b-2 critique C2-M1). A milestone on an occurrence left on the earlier part +/// stays there unasked (C1-L9): one the split was to move that the landing saw still there, or, when +/// the landing couldn't read Google, one whose cancel surely never landed (never sent, or refused). +/// One whose cancel's answer was lost, with nothing read since, may have gone, so it is asked about +/// and none goes overdue unsaid (C9b-2 review F5, round 2). Any other outcome, or nothing to offer, +/// passes through. +async fn with_moves( + ctx: &SeriesWrite<'_, W, H>, + plan: &SeriesPlan, + facts: &SplitFacts, + ran: &Ran, + outcome: WriteOutcome, +) -> WriteOutcome { + let Some(movable) = &facts.movable else { + return outcome; + }; + let (mut warnings, left_behind) = match outcome { + WriteOutcome::Saved { + warnings, + left_behind, + milestone_moves: None, + } => (warnings, left_behind), + other => return other, + }; + let zone = movable.zone; + let cal = ctx.target.calendar_id.as_str(); + // Each part's rows as landed (none when the landing couldn't show them). + let (tail_rows, earlier_rows) = ctx + .host + .with_conn(|conn| { + Ok(( + calendar::series_rows(conn, cal, &[facts.tail.as_str()])?, + calendar::series_rows(conn, cal, &[facts.master.as_str()])?, + )) + }) + .unwrap_or_default(); + // Where the earlier part's occurrences sit in the series: as the landing read them from Google, + // else, as one pending keeps rows PM can't vouch for, those whose cancel surely never landed. + let seen: Vec = if warnings.contains(&WriteWarning::MirrorRefreshPending) { + plan.left_behind(&ran.not_done()) + } else { + earlier_rows.iter().filter_map(diff::row_point).collect() + }; + let milestones: Vec = movable + .milestones + .iter() + .filter(|m| m.slots.is_empty() || m.slots.iter().any(|s| !seen.contains(s))) + .map(|m| m.offer.clone()) + .collect(); + if milestones.is_empty() { + return WriteOutcome::Saved { + warnings, + left_behind, + milestone_moves: None, + }; + } + let mut copy = inserted_copy(ran, facts.insert).cloned(); + let mut named = copy.as_ref().and_then(ical_of).or_else(|| { + tail_rows + .iter() + .find_map(|r| r.uid.as_deref().and_then(plain_text)) + }); + if named.is_none() { + if let Fetched::Event(fetched) = + get(ctx.wire, &ctx.target.remote_calendar, &facts.tail).await + { + if !patch::is_gone(&fetched) { + named = ical_of(&fetched); + copy = Some(fetched); + } + } + } + let new_series_ical = named.filter(|ical| *ical != facts.ical); + if new_series_ical.is_none() { + warnings.push(WriteWarning::MilestoneNotOffered); + } + // The new part's first occurrence, and the earlier part's last, as the user's calendar shows them: + // the earlier part's by where its rows sit in the series, so one left from the split on, which + // may still show, isn't its last. + let day_of = + |r: &CalendarEvent| Some(crate::clock::zone_date_of(&r.start, zone)).filter(|d| is_day(d)); + let first_day = tail_rows + .iter() + .filter_map(day_of) + .min() + .or_else(|| copy.as_ref().and_then(|c| start_day(c, zone))); + let earlier_last_day = earlier_rows + .iter() + .filter(|r| diff::row_point(r).is_some_and(|p| p.cmp_in(&facts.at, facts.zone).is_lt())) + .filter_map(day_of) + .max(); + WriteOutcome::Saved { + warnings, + left_behind, + milestone_moves: Some(Box::new(MilestoneMoves { + new_series_ical, + earlier_series_ical: facts.ical.clone(), + new_title: movable.new_title.clone(), + earlier_title: movable.earlier_title.clone(), + first_day, + earlier_last_day, + today: ctx.host.today_in(zone), + milestones, + })), + } +} + +/// A copy's iCal UID, when it has a usable one. +fn ical_of(copy: &Value) -> Option { + copy.get("iCalUID") + .and_then(Value::as_str) + .and_then(plain_text) +} + +/// `s` trimmed, unless that leaves nothing. +fn plain_text(s: &str) -> Option { + Some(s.trim()).filter(|s| !s.is_empty()).map(str::to_string) +} + +/// Whether `s` is a `YYYY-MM-DD` day. +fn is_day(s: &str) -> bool { + chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok() +} + +/// The day a Google copy starts on in `zone`, `YYYY-MM-DD`. +fn start_day(copy: &Value, zone: chrono_tz::Tz) -> Option { + let start = copy.get("start")?; + let day = match start.get("dateTime").and_then(Value::as_str) { + Some(at) => crate::clock::zone_date_of(at, zone), + None => start.get("date").and_then(Value::as_str)?.to_string(), + }; + is_day(&day).then_some(day) } /// `None` for a plan not shaped as a split, or a series without an iCal UID (whose exceptions @@ -3445,6 +3880,7 @@ fn split_facts(planning: &Planning, plan: &SeriesPlan) -> Option { .and_then(Value::as_str)? .to_string(), master, + movable: None, }) } @@ -3826,7 +4262,10 @@ async fn land_split( return look_and_hold(ctx, plan, facts, ran, kind, &snapshot, token).await; } match &ran { - Ran::Done { .. } => land_split_done(ctx, mirror, &plan, &facts, &ran, snapshot).await, + Ran::Done { .. } => { + let saved = land_split_done(ctx, mirror, &plan, &facts, &ran, snapshot).await; + with_moves(ctx, &plan, &facts, &ran, saved).await + } Ran::NotDone { step, why, @@ -5203,6 +5642,7 @@ fn outcome_for(verdict: Verdict, editing: EditingStatus) -> WriteOutcome { Verdict::Ok => WriteOutcome::Saved { warnings: Vec::new(), left_behind: Vec::new(), + milestone_moves: None, }, Verdict::Conflict => WriteOutcome::Conflict { fields: Vec::new() }, Verdict::Gone => WriteOutcome::Gone, @@ -5313,6 +5753,11 @@ trait Host: Sync { Ok(Ok(())) } } + /// Today in `zone`, `YYYY-MM-DD`: the day a split's linked milestones are resolved on (C9b-2; + /// the tests fix it). + fn today_in(&self, zone: chrono_tz::Tz) -> String { + crate::clock::today_sql_in(zone) + } } impl Host for AppHandle { @@ -5419,6 +5864,7 @@ fn saved_pending(host: &impl Host, target: &Target) -> WriteOutcome { WriteOutcome::Saved { warnings: vec![WriteWarning::MirrorRefreshPending], left_behind: Vec::new(), + milestone_moves: None, } } @@ -5460,6 +5906,7 @@ fn land_deleted(host: &impl Host, target: &Target, deleted: &Value) -> WriteOutc Ok(()) => WriteOutcome::Saved { warnings: Vec::new(), left_behind: Vec::new(), + milestone_moves: None, }, Err(_) => saved_pending(host, target), } @@ -5484,6 +5931,7 @@ fn land_saved( Some(Ok(())) => WriteOutcome::Saved { warnings: Vec::new(), left_behind: Vec::new(), + milestone_moves: None, }, // Saved, but not as a row PM could mirror, or the mirror write failed: the next sync shows it. _ => saved_pending(host, target), @@ -5529,12 +5977,21 @@ impl<'a> InFlight<'a> { impl Drop for InFlight<'_> { fn drop(&mut self) { - if let Ok(mut held) = self.edit.in_flight.lock() { - for key in &self.keys { - held.remove(key); - } + release_claims(self.edit, &self.keys); + } +} + +/// Let go of `keys`, then, after the landing, forget the words of every series among them, so a +/// popover opened from now on reads Google again. Both kinds of claim end here. The claims' lock goes +/// before the words' is taken, each alone ([`CalendarEditState`]): words an ask keeps in between go +/// with the rest, and an ask that keeps after sees the writes moved ([`keep_summary`]). +fn release_claims(edit: &CalendarEditState, keys: &[String]) { + if let Ok(mut held) = edit.in_flight.lock() { + for key in keys { + held.remove(key); } } + series_written(&edit.summaries, keys); } /// A claim that outlives the command that took it ([`InFlight::hand_off`]); released on drop. @@ -5545,11 +6002,10 @@ struct HeldClaim { impl Drop for HeldClaim { fn drop(&mut self) { - if let Ok(mut held) = self.app.state::().calendar_edit.in_flight.lock() { - for key in &self.keys { - held.remove(key); - } - } + release_claims( + &self.app.state::().inner().calendar_edit, + &self.keys, + ); } } @@ -7878,6 +8334,8 @@ mod tests { editing: Mutex, /// What a dev build's allow-list says of a settle. allowed: Mutex>, + /// The instant linked milestones are resolved at, its day read in the zone asked for. + now: Mutex>, _dir: tempfile::TempDir, } @@ -7899,10 +8357,21 @@ mod tests { told: Mutex::new(Vec::new()), editing: Mutex::new(EditingStatus::On), allowed: Mutex::new(Ok(())), + now: Mutex::new(noon("2026-10-12")), _dir: dir, }) } + /// Today is `day` (`YYYY-MM-DD`) in every zone these tests use (UTC, London, Honolulu). + fn today_is(&self, day: &str) { + *self.now.lock().unwrap() = noon(day); + } + + /// It's `at` (RFC 3339): a test can put the user's day and the series' apart. + fn now_is(&self, at: &str) { + *self.now.lock().unwrap() = at.parse().unwrap(); + } + /// The series' rows, in time order. fn rows(&self) -> Vec { self.with_conn(|conn| calendar::series_rows(conn, "cal", &["abc"])) @@ -7983,6 +8452,15 @@ mod tests { fn settle_allowed(&self, _remote: &str) -> Result> { Ok(*self.allowed.lock().unwrap()) } + + fn today_in(&self, zone: chrono_tz::Tz) -> String { + crate::clock::today_sql_in_at(zone, *self.now.lock().unwrap()) + } + } + + /// Noon UTC on `day`: that day from Honolulu (02:00) to London (13:00). + fn noon(day: &str) -> chrono::DateTime { + format!("{day}T12:00:00Z").parse().unwrap() } fn rows_of(events: &[Value]) -> Vec { @@ -8025,6 +8503,7 @@ mod tests { WriteOutcome::Saved { warnings: Vec::new(), left_behind: Vec::new(), + milestone_moves: None, } } @@ -8032,6 +8511,7 @@ mod tests { WriteOutcome::Saved { warnings: vec![WriteWarning::MirrorRefreshPending], left_behind: Vec::new(), + milestone_moves: None, } } @@ -8760,6 +9240,7 @@ mod tests { WriteOutcome::Saved { warnings: vec![WriteWarning::LeftOnOldSeries], left_behind: vec!["2026-11-02".into()], + milestone_moves: None, } ); assert_eq!( @@ -10266,6 +10747,7 @@ mod tests { moments: Vec::new(), marks: Vec::new(), ical: "abc@google.com".into(), + movable: None, }, offers: vec![SplitAction::Check], tail_seen: None, @@ -10749,6 +11231,7 @@ mod tests { WriteOutcome::Saved { warnings: vec![WriteWarning::LeftOnOldSeries], left_behind: vec!["2026-11-02".into()], + milestone_moves: None, } ); assert!(split_rows(&host) @@ -11929,6 +12412,7 @@ mod tests { moments: Vec::new(), marks: Vec::new(), ical: "abc@google.com".into(), + movable: None, }, offers: Vec::new(), tail_seen: Some(tail.clone()), @@ -13709,6 +14193,7 @@ mod tests { == WriteOutcome::Saved { warnings: vec![WriteWarning::OutsideMirror], left_behind: Vec::new(), + milestone_moves: None, }, "saved in Google, and said to be outside" ); @@ -14280,4 +14765,1439 @@ mod tests { let missing = resolve_calendar(&conn, "gcal:me@x.com:nope", "That calendar is gone."); assert!(matches!(missing, Err(Error::Other(m)) if m == "That calendar is gone.")); } + + // --- how a repeating event repeats, for the popover (C9b-2) --- + + /// The popover's ask about a row of series "abc" at `etag`. + fn summary_ask_at(etag: Option<&str>) -> SummaryAsk { + SummaryAsk { + remote_calendar: CALENDAR.into(), + series: "abc".into(), + etag: etag.map(str::to_string), + token_key: "key".into(), + } + } + + /// What PM keeps for series "abc": its words, and the row etags they stand for. + fn kept(edit: &CalendarEditState) -> Option<(String, Vec)> { + let summaries = edit.summaries.lock().unwrap(); + summaries.known.get(&series_key(CALENDAR, "abc")).map(|k| { + let mut etags: Vec = k.etags.iter().cloned().collect(); + etags.sort(); + (k.words.clone(), etags) + }) + } + + /// One GET of the series, in the editor's own words; a second ask for the same row sends + /// nothing (the script has no second step). + #[tokio::test] + async fn a_summary_reads_the_series_once_and_describes_it() { + let edit = CalendarEditState::default(); + let ask = summary_ask_at(Some("\"e1\"")); + let script = Script::new(vec![event_read("abc", Scripted::Json(weekly()))]); + let words = series_summary(&script, &edit, &ask).await; + assert!( + words.as_deref() == Some("Weekly on Monday"), + "Google's series, described" + ); + let again = series_summary(&script, &edit, &ask).await; + assert!(again == words, "the kept words"); + script.finished(); + assert!(script.sent().len() == 1, "one GET"); + } + + /// Occurrences A, B, then A again: two GETs, and one entry holding both rows' etags. + #[tokio::test] + async fn another_occurrence_of_the_same_words_is_kept_beside_it() { + let edit = CalendarEditState::default(); + let script = Script::new(vec![ + event_read("abc", Scripted::Json(weekly())), + event_read("abc", Scripted::Json(weekly())), + ]); + for etag in ["\"e1\"", "\"e2\"", "\"e1\""] { + let words = series_summary(&script, &edit, &summary_ask_at(Some(etag))).await; + assert!(words.as_deref() == Some("Weekly on Monday"), "{etag}"); + } + script.finished(); + assert!( + kept(&edit) + == Some(( + "Weekly on Monday".into(), + vec!["\"e1\"".into(), "\"e2\"".into()] + )), + "one entry, both etags" + ); + } + + /// The row changed (a new etag): the series is read again, and its new words replace the old. + #[tokio::test] + async fn a_new_row_etag_reads_the_series_again() { + let edit = CalendarEditState::default(); + let script = Script::new(vec![ + event_read("abc", Scripted::Json(weekly())), + event_read("abc", Scripted::Json(master(&["RRULE:FREQ=DAILY"]))), + ]); + series_summary(&script, &edit, &summary_ask_at(Some("\"e1\""))).await; + let words = series_summary(&script, &edit, &summary_ask_at(Some("\"e2\""))).await; + script.finished(); + assert!(words.as_deref() == Some("Daily"), "read again"); + assert!( + kept(&edit) == Some(("Daily".into(), vec!["\"e2\"".into()])), + "the new words, for the new etag only" + ); + } + + /// Words stand for [`SUMMARY_TTL`] whatever the etags say, then are read again. + #[test] + fn words_older_than_their_time_are_read_again() { + let edit = CalendarEditState::default(); + let key = series_key(CALENDAR, "abc"); + let now = Instant::now(); + let since = summary_writes(&edit); + keep_summary(&edit, key.clone(), Some("\"e1\""), "Daily", since, now); + let at = |later: Duration| known_summary(&edit, &key, Some("\"e1\""), now + later); + assert!(at(SUMMARY_TTL - Duration::from_secs(1)).as_deref() == Some("Daily")); + assert!(at(SUMMARY_TTL + Duration::from_secs(1)).is_none()); + } + + /// Past [`SUMMARY_KEEP`] series, every entry older than the TTL goes; with none that old, the + /// one read longest ago goes, and only that one. + #[test] + fn past_the_cap_the_stale_go_then_the_oldest() { + let edit = CalendarEditState::default(); + let since = summary_writes(&edit); + let base = Instant::now(); + let fresh = base + SUMMARY_TTL; + let secs = |n: usize| Duration::from_secs(n as u64); + let key = |name: &str| series_key(CALENDAR, name); + let keep = |name: &str, now: Instant| { + keep_summary(&edit, key(name), Some("\"e1\""), "Daily", since, now); + }; + keep("stale-0", base); + keep("stale-1", base); + for n in 2..SUMMARY_KEEP { + keep(&format!("s{n}"), fresh + secs(n)); + } + let held = |name: &str| { + edit.summaries + .lock() + .unwrap() + .known + .contains_key(&key(name)) + }; + let count = || edit.summaries.lock().unwrap().known.len(); + assert!(count() == SUMMARY_KEEP, "full"); + keep("new-a", fresh + secs(SUMMARY_KEEP)); + assert!( + !held("stale-0") && !held("stale-1") && held("new-a"), + "both stale entries go" + ); + assert!(count() == SUMMARY_KEEP - 1, "and only they"); + keep("new-b", fresh + secs(SUMMARY_KEEP + 1)); + assert!(count() == SUMMARY_KEEP, "full again, all of it fresh"); + keep("new-c", fresh + secs(SUMMARY_KEEP + 2)); + assert!( + !held("s2") && held("s3") && held("new-b") && held("new-c"), + "the one read longest ago goes" + ); + assert!(count() == SUMMARY_KEEP, "and the cap holds"); + } + + /// A row mirrored before etags were kept is asked about every time, and nothing is kept. + #[tokio::test] + async fn a_row_without_an_etag_is_never_kept() { + let edit = CalendarEditState::default(); + let script = Script::new(vec![ + event_read("abc", Scripted::Json(weekly())), + event_read("abc", Scripted::Json(weekly())), + ]); + for _ in 0..2 { + let words = series_summary(&script, &edit, &summary_ask_at(None)).await; + assert!(words.as_deref() == Some("Weekly on Monday")); + } + script.finished(); + assert!(kept(&edit).is_none()); + } + + /// Whatever stops the read is `None` (the popover says "Repeats"), and nothing is kept. + #[tokio::test] + async fn a_summary_that_fails_is_none_and_never_kept() { + let mut bare = weekly(); + bare.as_object_mut().unwrap().remove("recurrence"); + let mut zoneless = weekly(); + zoneless["start"] = json!({ "dateTime": "2026-10-05T09:00:00+01:00" }); + let answers = vec![ + ("404", Scripted::Status(404)), + ("410", Scripted::Status(410)), + ("signed out", Scripted::Status(401)), + ("rate-limited", Scripted::Status(429)), + ("server error", Scripted::Status(500)), + ( + "unreadable", + Scripted::Reply { + status: 200, + body: None, + date: None, + maybe_applied: false, + }, + ), + ( + "cancelled", + Scripted::Json(with(weekly(), "status", json!("cancelled"))), + ), + ( + "another series", + Scripted::Json(with(weekly(), "id", json!("xyz"))), + ), + ("no rule", Scripted::Json(bare)), + ("no zone", Scripted::Json(zoneless)), + ]; + for (what, answer) in answers { + let edit = CalendarEditState::default(); + let script = Script::new(vec![event_read("abc", answer)]); + let words = series_summary(&script, &edit, &summary_ask_at(Some("\"e1\""))).await; + script.finished(); + assert!(words.is_none(), "{what}: no words"); + assert!(kept(&edit).is_none(), "{what}: nothing kept"); + } + } + + /// A rule the editor can't make is said plainly, never guessed at. + #[tokio::test] + async fn a_rule_outside_the_editors_set_says_so() { + let edit = CalendarEditState::default(); + let two = master(&[ + "RRULE:FREQ=WEEKLY;BYDAY=MO", + "RRULE:FREQ=MONTHLY;BYMONTHDAY=1", + ]); + let script = Script::new(vec![event_read("abc", Scripted::Json(two))]); + let words = series_summary(&script, &edit, &summary_ask_at(Some("\"e1\""))).await; + script.finished(); + assert!(words.as_deref() == Some(crate::calendar_recur::spec::CUSTOM_RULE)); + } + + /// Only a Google occurrence is asked about: a one-off, a feed's row, a series id that isn't one + /// path segment, and a row that's gone ask nothing, and none of them is an error. + #[test] + fn a_one_off_an_ical_row_and_a_missing_row_ask_nothing() { + let (_d, conn) = store(); + let cal = "gcal:me@x.com:primary"; + add_calendar(&conn, "gcal:me@x.com", "google", cal, Some("me@x.com")); + let one_off = CalendarEvent { + id: format!("{cal}:solo"), + calendar_id: cal.into(), + summary: "Dentist".into(), + start: "2026-10-12T09:00:00Z".into(), + organizer_self: true, + ..Default::default() + }; + let occurrence = CalendarEvent { + id: format!("{cal}:abc_20261012T090000Z"), + series_id: Some("abc".into()), + recurring: true, + etag: Some("\"e1\"".into()), + ..one_off.clone() + }; + let odd = CalendarEvent { + id: format!("{cal}:odd_20261012T090000Z"), + series_id: Some("..".into()), + ..occurrence.clone() + }; + calendar::replace_events( + &conn, + cal, + &[one_off.clone(), occurrence.clone(), odd.clone()], + true, + ) + .unwrap(); + add_calendar(&conn, "ics:feed", "other", "ics:feed", None); + let feed = CalendarEvent { + id: "ics:feed:uid1:2026-10-12".into(), + calendar_id: "ics:feed".into(), + series_id: Some("uid1".into()), + recurrence_summary: Some("Weekly".into()), + ..occurrence.clone() + }; + calendar::replace_events(&conn, "ics:feed", std::slice::from_ref(&feed), true).unwrap(); + for nothing in [&one_off.id, &odd.id, &feed.id, &format!("{cal}:gone")] { + assert!(summary_ask(&conn, nothing).is_none(), "{nothing}"); + } + let ask = summary_ask(&conn, &occurrence.id).expect("a Google occurrence is asked about"); + assert!( + ask.remote_calendar == "me@x.com" + && ask.series == "abc" + && ask.etag.as_deref() == Some("\"e1\"") + && ask.token_key == "google_oauth_token_calendar::me@x.com", + "its calendar, series, etag and sign-in" + ); + } + + /// A write to a series forgets its words once it lets go of it; another series' stay. + #[test] + fn a_series_write_forgets_its_words() { + let edit = CalendarEditState::default(); + let now = Instant::now(); + for series in ["abc", "other"] { + let since = summary_writes(&edit); + keep_summary( + &edit, + series_key(CALENDAR, series), + Some("\"e1\""), + "Daily", + since, + now, + ); + } + let claim = InFlight::claim_all( + &edit, + &[ + "cal:abc_20261019T080000Z".to_string(), + series_key(CALENDAR, "abc"), + ], + ); + assert!(claim.is_some()); + drop(claim); + let known = + |series: &str| known_summary(&edit, &series_key(CALENDAR, series), Some("\"e1\""), now); + assert!(known("abc").is_none(), "the written series' words go"); + assert!(known("other").is_some(), "another series' stay"); + } + + /// A held delete's claim ends in [`HeldClaim`], which no test can build (it holds the app), so + /// its release is read from this file's own text (the C9b-1 C1-M8 precedent): it lets go the + /// way [`InFlight`] does, which the test above runs, the words going after the claims' lock + /// does, never while it's held, as [`CalendarEditState`] says of every lock but the DB's + /// (C9b-2 review round 2). + #[test] + fn a_held_claim_lets_go_as_a_write_does() { + let source = include_str!("calendar_edit.rs"); + let shipped = source + .split("#[cfg(test)]\nmod tests {") + .next() + .unwrap_or_default(); + let body = |of: &str| { + let at = shipped.find(of).expect("the drop is defined"); + let body = &shipped[at..]; + body[..body.find("\n}\n").unwrap_or(body.len())].to_string() + }; + for of in ["impl Drop for HeldClaim {", "impl Drop for InFlight<'_> {"] { + assert!( + body(of).contains("release_claims("), + "every claim ends in the one release" + ); + } + // Outside the block that holds the claims' lock: the release's last line, once it closes. + assert!( + body("fn release_claims(") + .trim_end() + .ends_with(" }\n }\n series_written(&edit.summaries, keys);"), + "and the release forgets the series' words once the claims' lock is gone" + ); + } + + /// C9b-2 critiques C1-M2, C2-L3: an ask a write overlaps keeps nothing, whether the write let go + /// while the GET was out or still holds the series when it answers. + #[tokio::test] + async fn words_read_across_a_write_are_never_kept() { + let edit: &'static CalendarEditState = Box::leak(Box::default()); + let key = series_key(CALENDAR, "abc"); + let ask = summary_ask_at(Some("\"e1\"")); + let writing = Mutex::new(InFlight::claim_all(edit, std::slice::from_ref(&key))); + let script = Script::new(vec![event_read("abc", Scripted::Json(weekly())).then( + move || { + writing.lock().unwrap().take(); + }, + )]); + let words = series_summary(&script, edit, &ask).await; + script.finished(); + assert!(words.is_some(), "the answer still shows"); + assert!(kept(edit).is_none(), "but it isn't kept"); + assert!( + edit.in_flight.lock().unwrap().is_empty(), + "the write let go" + ); + let _writing = InFlight::claim_all(edit, &[key]); + let script = Script::new(vec![event_read("abc", Scripted::Json(weekly()))]); + series_summary(&script, edit, &ask).await; + script.finished(); + assert!(kept(edit).is_none(), "nor while a write holds the series"); + } + + /// C9 design scope-L3: the summary is a read, so the main window only, with no owner check and + /// none of editing's gates. Read from this file's own text (the C9b-1 C1-M8 precedent). + #[test] + fn the_summary_checks_the_window_and_not_the_owner() { + let source = include_str!("calendar_edit.rs"); + let shipped = source + .split("#[cfg(test)]\nmod tests {") + .next() + .unwrap_or_default(); + let at = shipped + .find("pub async fn get_series_summary(") + .expect("the command is defined"); + let body = &shipped[at..]; + let body = &body[..body.find("\n}\n").unwrap_or(body.len())]; + assert!( + body.contains("require_main_window(&window)?;"), + "the main window only" + ); + assert!( + !body.contains("require_owner") && !body.contains("require_vault_owner"), + "a read checks no owner" + ); + assert!( + !body.contains("editing") && !body.contains("dev_allow"), + "nor editing's gates" + ); + } + + // --- the milestones a "this and following" change offers to move (C9b-2) --- + + /// The series' occurrences `events` as Google lists them, each carrying the series' iCal UID, so + /// a milestone linked to it resolves through the mirror. + fn linked(events: Vec) -> Vec { + events + .into_iter() + .map(|e| with(e, "iCalUID", json!("abc@google.com"))) + .collect() + } + + /// The October series, [`linked`]: 12-10, 19-10 and 26-10. + fn linked_october() -> Vec { + linked(october()) + } + + /// A host whose mirror holds [`linked_october`] and resolves milestones on `today`, with + /// "Launch" in "Atlas" linked to the series: its id. + fn linked_host(today: &str) -> (Arc, i64) { + linked_host_holding(october(), today) + } + + /// [`linked_host`], its mirror holding `events`, [`linked`]. + fn linked_host_holding(events: Vec, today: &str) -> (Arc, i64) { + let host = TestHost::holding(&linked(events)); + host.today_is(today); + let launch = host + .with_conn(|conn| { + milestones::add(conn, "Atlas", "Launch", None, Some("abc@google.com".into())) + }) + .unwrap(); + (host, launch) + } + + /// A split from Monday 19-10 that runs and lands, the original re-fetched as its 12-10 + /// occurrence alone. + fn landed_split(plan: &SeriesPlan, insert: Value) -> Vec { + vec![ + insert_step(Scripted::Json(insert)), + series_patch(Scripted::Json(cut_copy(plan))), + refetch_read(Scripted::Json(page( + vec![linked_october()[0].clone()], + None, + ))), + tail_refetch(Scripted::Json(page(tail_october(), None))), + ] + } + + fn launch_offer(id: i64, date: &str) -> MilestoneOffer { + MilestoneOffer { + id, + label: "Launch".into(), + project: "Atlas".into(), + date: date.into(), + } + } + + /// The moves a `Saved` offers, if any. + fn moves_of(outcome: &WriteOutcome) -> Option<&MilestoneMoves> { + match outcome { + WriteOutcome::Saved { + milestone_moves, .. + } => milestone_moves.as_deref(), + _ => None, + } + } + + /// DECISIONS 17-10 #2: today 13-10, "Launch" takes the series' next occurrence, 19-10, the day + /// the change starts, so it's offered, with both parts named by their titles, the new part's + /// first day and the earlier part's last. A ticked-off one, and one on another event, aren't. + #[tokio::test] + async fn a_split_offers_the_milestones_dated_from_its_first_day() { + let (host, launch) = linked_host("2026-10-13"); + host.with_conn(|conn| { + milestones::add(conn, "Atlas", "Elsewhere", None, Some("else@x".into()))?; + let done = milestones::add(conn, "Atlas", "Done", None, Some("abc@google.com".into()))?; + milestones::set_state(conn, done, true) + }) + .unwrap(); + let plan = split_plan(Vec::new()); + let insert = tail_copy(&plan, "\"t1\"", SAVED_AT); + let (outcome, _, _) = split_save(&host, landed_split(&plan, insert)).await; + let want = MilestoneMoves { + new_series_ical: Some(TAIL_ICAL.into()), + earlier_series_ical: "abc@google.com".into(), + new_title: "Retro".into(), + earlier_title: "Standup".into(), + first_day: Some("2026-10-19".into()), + earlier_last_day: Some("2026-10-12".into()), + today: "2026-10-13".into(), + milestones: vec![launch_offer(launch, "2026-10-19")], + }; + assert!( + matches!(&outcome, WriteOutcome::Saved { warnings, left_behind, milestone_moves: Some(moves) } + if warnings.is_empty() && left_behind.is_empty() && **moves == want), + "Launch is offered, the new part named from the insert's answer" + ); + } + + /// §0.4.2: after the landing the series' iCal UID resolves only to the earlier part, so a read + /// then would offer nothing; the one made before the first send still does. + #[tokio::test] + async fn the_milestones_are_read_before_the_first_send() { + let (host, launch) = linked_host("2026-10-13"); + let plan = split_plan(Vec::new()); + let insert = tail_copy(&plan, "\"t1\"", SAVED_AT); + let (outcome, _, _) = split_save(&host, landed_split(&plan, insert)).await; + let after = host + .with_conn(|conn| { + milestones::linked_on_or_after(conn, "abc@google.com", "2026-10-19", "2026-10-13") + }) + .unwrap(); + assert!(after.is_empty(), "a read after the landing finds nothing"); + let offered: Vec = moves_of(&outcome) + .map(|m| m.milestones.iter().map(|o| o.id).collect()) + .unwrap_or_default(); + assert!( + offered == [launch], + "the read before the send still offers it" + ); + } + + /// No linked milestone to offer: exactly the split's own requests, and no moves. + #[tokio::test] + async fn a_split_with_nothing_to_offer_reads_nothing_more() { + let host = TestHost::holding(&linked_october()); + host.today_is("2026-10-13"); + host.with_conn(|conn| { + milestones::add(conn, "Atlas", "Elsewhere", None, Some("else@x".into()))?; + let done = milestones::add(conn, "Atlas", "Done", None, Some("abc@google.com".into()))?; + milestones::set_state(conn, done, true) + }) + .unwrap(); + let plan = split_plan(Vec::new()); + let insert = with(tail_copy(&plan, "\"t1\"", SAVED_AT), "iCalUID", Value::Null); + // The script holds no GET of the new part: one would panic as unscripted. + let (outcome, _, _) = split_save(&host, landed_split(&plan, insert)).await; + assert!(outcome == saved(), "saved, nothing offered"); + } + + /// DECISIONS 17-10 #2's other half: today 12-10, "Launch" takes the 12-10 occurrence, a week + /// before the change starts, so it stays put and nothing is asked. + #[tokio::test] + async fn a_split_from_a_later_occurrence_offers_nothing() { + let (host, _) = linked_host("2026-10-12"); + let resolved = host + .with_conn(|conn| milestones::list_all(conn, "2026-10-12")) + .unwrap(); + assert!( + resolved.len() == 1 && resolved[0].due_date.as_deref() == Some("2026-10-12"), + "the fixture's rows put Launch on 12-10" + ); + let plan = split_plan(Vec::new()); + let insert = tail_copy(&plan, "\"t1\"", SAVED_AT); + let (outcome, _, _) = split_save(&host, landed_split(&plan, insert)).await; + assert!(outcome == saved(), "saved, nothing offered"); + } + + /// C9b-2 critique C2-M1: an insert answer without the new part's iCal UID names it from its + /// landed rows; with neither, one GET of the new part does. + #[tokio::test] + async fn the_new_part_is_named_by_one_get_when_the_insert_said_nothing() { + let plan = split_plan(Vec::new()); + let quiet = with(tail_copy(&plan, "\"t1\"", SAVED_AT), "iCalUID", Value::Null); + // The new part's rows carry it: no GET. + let (host, launch) = linked_host("2026-10-13"); + let mut steps = landed_split(&plan, quiet.clone()); + let listed: Vec = tail_october() + .into_iter() + .map(|e| with(e, "iCalUID", json!(TAIL_ICAL))) + .collect(); + steps[3] = tail_refetch(Scripted::Json(page(listed, None))); + let (outcome, _, _) = split_save(&host, steps).await; + assert!( + moves_of(&outcome).and_then(|m| m.new_series_ical.as_deref()) == Some(TAIL_ICAL), + "named from its rows" + ); + // Neither: one GET of it. + let (host, _) = linked_host("2026-10-13"); + let mut steps = landed_split(&plan, quiet); + steps.push(event_read( + TAIL, + Scripted::Json(tail_copy(&plan, "\"t1\"", SAVED_AT)), + )); + let (outcome, script, _) = split_save(&host, steps).await; + assert!( + script + .sent() + .last() + .is_some_and(|r| r.method == Method::Get), + "the last request reads the new part" + ); + assert!( + matches!(&outcome, WriteOutcome::Saved { warnings, milestone_moves: Some(m), .. } + if warnings.is_empty() && m.new_series_ical.as_deref() == Some(TAIL_ICAL) + && m.milestones == [launch_offer(launch, "2026-10-19")]), + "named by the GET" + ); + } + + /// C9b-2 critique C2-M1: a new part PM can't name still lists the milestones, so the notice + /// can say they stayed on the earlier part, beside the warning; nothing can be moved. + #[tokio::test] + async fn an_unnamed_new_part_says_which_milestones_stayed() { + let (host, launch) = linked_host("2026-10-13"); + let plan = split_plan(Vec::new()); + let quiet = with(tail_copy(&plan, "\"t1\"", SAVED_AT), "iCalUID", Value::Null); + let mut steps = landed_split(&plan, quiet); + steps.push(event_read(TAIL, Scripted::Status(500))); + let (outcome, _, _) = split_save(&host, steps).await; + assert!( + matches!(&outcome, WriteOutcome::Saved { warnings, milestone_moves: Some(m), .. } + if *warnings == [WriteWarning::MilestoneNotOffered] + && m.new_series_ical.is_none() + && m.milestones == [launch_offer(launch, "2026-10-19")]), + "saved, the milestone said to stay" + ); + } + + /// C9b-2 critique C1-M4: a split held after its cut was lost keeps what it read at the send. + #[tokio::test] + async fn a_held_split_keeps_movable() { + let (host, launch) = linked_host("2026-10-13"); + let (held, _) = lost_cut(&host, lost()).await; + assert!(matches!(held, WriteOutcome::PartialSplit { .. })); + let movable: Vec = host + .edit + .partials + .lock() + .unwrap() + .values() + .filter_map(|h| h.facts.movable.as_ref()) + .flat_map(|m| m.milestones.iter().map(|o| o.offer.id)) + .collect(); + assert!(movable == [launch], "held with Launch"); + } + + /// C9b-2 critique C1-M4, end to end: the cut's answer lost, the split held, then Finish lands it + /// and offers what the send read, though a read now (today 12-10) would offer nothing. + #[tokio::test] + async fn a_finish_offers_the_milestones_read_at_the_send() { + let (host, launch) = linked_host("2026-10-13"); + let plan = split_plan(Vec::new()); + let (held, _) = lost_cut(&host, lost()).await; + host.today_is("2026-10-12"); + let tail = tail_copy(&plan, "\"t1\"", SAVED_AT); + let mut steps = vec![event_read("abc", Scripted::Json(cut_copy(&plan)))]; + steps.extend(tail_reads(tail, Vec::new())); + steps.push(ical_listing( + "iCalUID=abc%40google.com", + Scripted::Json(page(vec![cut_copy(&plan)], None)), + )); + steps.push(refetch_read(Scripted::Json(page( + vec![linked_october()[0].clone()], + None, + )))); + steps.push(tail_refetch(Scripted::Json(page(tail_october(), None)))); + let (outcome, script) = settle(&host, &held_token(&held), SplitAction::Finish, steps).await; + assert!(script.sent().iter().all(|r| r.method == Method::Get)); + assert!( + matches!(&outcome, WriteOutcome::Saved { milestone_moves: Some(m), .. } + if m.new_series_ical.as_deref() == Some(TAIL_ICAL) + && m.milestones == [launch_offer(launch, "2026-10-19")]), + "Finish offers Launch" + ); + assert!(held_count(&host) == 0); + } + + /// C9b-2 critique C1-L9: a milestone dated on an occurrence left on the earlier part stays + /// there unasked; one dated on a moved occurrence is still offered. (The mirror's rows carry no + /// iCal UID here, so each takes its own last known date.) Whether the landing reads Google or + /// not: Finish sent nothing after the cut, so PM knows the occurrence stayed (review round 2). + #[tokio::test] + async fn a_milestone_on_an_occurrence_left_behind_isnt_offered() { + for looked in [true, false] { + let (outcome, launch) = finish_left_behind(looked).await; + assert!( + matches!(&outcome, WriteOutcome::Saved { warnings, left_behind, milestone_moves: Some(m) } + if *left_behind == ["2026-11-02"] + && warnings.contains(&WriteWarning::MirrorRefreshPending) != looked + && m.milestones == [launch_offer(launch, "2026-10-26")]), + "Launch offered; Prep, on the occurrence left behind, not" + ); + // The occurrence left behind may still show, but it isn't where the earlier part ends. + assert!( + moves_of(&outcome).and_then(|m| m.earlier_last_day.as_deref()) + == Some("2026-10-12"), + "the earlier part ended on 12-10" + ); + } + } + + /// A split from Monday 19-10 with [`off_site`] (02-11) changed in Google while its cut was + /// held, then finished: what Google holds stands, the Off-site occurrence left behind, and the + /// landing reads Google (`looked`) or only ever finds the new part empty. "Launch" is linked on + /// 26-10 (its id is returned) and "Prep" on 02-11. + async fn finish_left_behind(looked: bool) -> (WriteOutcome, i64) { + let (events, mut steps) = with_off_site(); + let host = TestHost::holding(&events); + let (launch, _) = host + .with_conn(|conn| { + let link = Some("abc@google.com".to_string()); + let launch = milestones::add( + conn, + "Atlas", + "Launch", + Some("2026-10-26".into()), + link.clone(), + )?; + let prep = milestones::add(conn, "Atlas", "Prep", Some("2026-11-02".into()), link)?; + Ok((launch, prep)) + }) + .unwrap(); + let plan = split_plan(vec![off_site()]); + let tail = tail_copy(&plan, "\"t1\"", SAVED_AT); + steps.extend([ + insert_step(Scripted::Json(tail.clone())), + series_patch(Scripted::Lost { retried: true }), + event_read("abc", lost()), + event_read("abc", lost()), + ]); + let script = Script::new(steps); + let (held, _) = save( + &host, + &script, + &monday_19(), + &weekly(), + title("Retro"), + Scope::Following, + ) + .await; + script.asked_all(); + let changed_since = with(off_site(), "summary", json!("Changed since")); + let mut steps = vec![event_read("abc", Scripted::Json(cut_copy(&plan)))]; + steps.extend(tail_reads(tail, Vec::new())); + steps.push(ical_listing( + "iCalUID=abc%40google.com", + Scripted::Json(page(vec![cut_copy(&plan), changed_since.clone()], None)), + )); + let original = page(vec![october()[0].clone(), changed_since], None); + if looked { + steps.push(refetch_read(Scripted::Json(original))); + steps.push(tail_refetch(Scripted::Json(page(tail_october(), None)))); + } else { + for _ in 0..3 { + steps.push(refetch_read(Scripted::Json(original.clone()))); + steps.push(tail_refetch(Scripted::Json(page(Vec::new(), None)))); + } + } + let (outcome, _) = settle(&host, &held_token(&held), SplitAction::Finish, steps).await; + (outcome, launch) + } + + /// "Launch"'s date as the milestone list resolves it on `today`. + fn launch_date(host: &TestHost, today: &str) -> Option { + host.with_conn(|conn| milestones::list_all(conn, today)) + .unwrap() + .into_iter() + .find(|m| m.label == "Launch") + .and_then(|m| m.due_date) + } + + /// Save "this and following" retitled from Monday 19-10 against `host`, the series holding + /// `exception` and the mirror `events`, Google answering as `steps` say after the reads. + async fn split_with( + host: &TestHost, + exception: &Value, + events: Vec, + steps: Vec, + ) -> WriteOutcome { + let mut all = reads( + &monday_19(), + &weekly(), + vec![weekly(), exception.clone()], + events, + ); + all.extend(steps); + let script = Script::new(all); + let (outcome, _) = save( + host, + &script, + &monday_19(), + &weekly(), + title("Retro"), + Scope::Following, + ) + .await; + script.asked_all(); + outcome + } + + /// DECISIONS 17-10 #2 as decided (C9b-2 review round 2, R2-F1): the 12-10 occurrence moved to + /// Wednesday 21-10, past the split from 19-10. "Launch" takes it (today 20-10), and its date is + /// after the day the change starts, so it's asked about, though the split leaves that occurrence + /// on the earlier part, whose last day it then is. + #[tokio::test] + async fn a_milestone_on_an_earlier_occurrence_moved_past_the_split_is_offered() { + let moved_12 = moved( + "2026-10-12T09:00:00+01:00", + "2026-10-21T09:00:00+01:00", + "2026-10-21T10:00:00+01:00", + ); + let events = vec![moved_12.clone(), monday_19(), plain("2026-10-26T09:00:00Z")]; + let (host, launch) = linked_host_holding(events.clone(), "2026-10-20"); + assert!( + launch_date(&host, "2026-10-20").as_deref() == Some("2026-10-21"), + "the fixture's rows put Launch on 21-10" + ); + let plan = split_plan(vec![moved_12.clone()]); + let steps = vec![ + insert_step(Scripted::Json(tail_copy(&plan, "\"t1\"", SAVED_AT))), + series_patch(Scripted::Json(cut_copy(&plan))), + refetch_read(Scripted::Json(page(linked(vec![moved_12.clone()]), None))), + tail_refetch(Scripted::Json(page(tail_october(), None))), + ]; + let outcome = split_with(&host, &moved_12, events, steps).await; + assert!( + matches!(&outcome, WriteOutcome::Saved { warnings, milestone_moves: Some(m), .. } + if warnings.is_empty() + && m.milestones == [launch_offer(launch, "2026-10-21")] + && m.earlier_last_day.as_deref() == Some("2026-10-21")), + "Launch offered by its date" + ); + } + + /// The new part's 26-10 occurrence, as the carry looks it up. + fn tail_26_lookup(found: Vec) -> Step { + Step::get( + &format!("/events/{TAIL}/instances"), + "timeMin=2026-10-25T09%3A00%3A00Z timeMax=2026-10-27T09%3A00%3A00Z maxResults=10", + page(found, None), + ) + } + + /// The 26-10 occurrence, moved to `start`–`end` (RFC 3339). + fn moved_26(start: &str, end: &str) -> Value { + moved("2026-10-26T09:00:00Z", start, end) + } + + /// The split carrying `exception` (the 26-10 occurrence, moved) onto the new part, then + /// cancelling it on the original as `cancel` answers, `then` following: the carry's lookup and + /// PATCH, and the cancel. + fn carry_26(plan: &SeriesPlan, exception: &Value, cancel: Scripted) -> Vec { + let found = tail_occurrence("2026-10-26T09:00:00Z", "\"q1\""); + let carried = written( + &found, + &[ + ("start", exception["start"].clone()), + ("end", exception["end"].clone()), + ], + "\"q2\"", + ); + vec![ + insert_step(Scripted::Json(tail_copy(plan, "\"t1\"", SAVED_AT))), + series_patch(Scripted::Json(cut_copy(plan))), + tail_26_lookup(vec![found]), + Step::write( + Method::Patch, + &format!("/events/{TAIL}_20261026T090000Z"), + "\"q1\"", + Scripted::Json(carried), + ), + Step::write( + Method::Patch, + "/events/abc_20261026T090000Z", + exception["etag"].as_str().unwrap(), + cancel, + ), + ] + } + + /// The original and the new part re-fetched after a split carried the 26-10 occurrence, the + /// original listing `original` with its 12-10 occurrence. + fn refetch_after_carry(exception: &Value, original: Option) -> Vec { + let mut listed = linked(vec![plain("2026-10-12T09:00:00+01:00")]); + listed.extend(original); + let carried = written( + &tail_occurrence("2026-10-26T09:00:00Z", "\"q1\""), + &[ + ("start", exception["start"].clone()), + ("end", exception["end"].clone()), + ], + "\"q2\"", + ); + vec![ + refetch_read(Scripted::Json(page(listed, None))), + tail_refetch(Scripted::Json(page( + vec![ + tail_occurrence("2026-10-19T09:00:00+01:00", "\"u0\""), + carried, + ], + None, + ))), + ] + } + + /// DECISIONS 17-10 #2: "Earlier dates stay put with no prompt", even one on an occurrence the + /// split takes to the new part (C9b-2 review round 2, R2-F1): the 26-10 occurrence moved to + /// Friday 16-10, before the split from 19-10. "Launch" takes it (today 14-10), and the split + /// carries it to the new part, but its date is before the day the change starts: nothing is + /// asked. + #[tokio::test] + async fn a_milestone_dated_before_the_split_day_is_never_asked() { + let exception = moved_26("2026-10-16T09:00:00+01:00", "2026-10-16T10:00:00+01:00"); + let events = vec![ + plain("2026-10-12T09:00:00+01:00"), + monday_19(), + exception.clone(), + ]; + let (host, _) = linked_host_holding(events.clone(), "2026-10-14"); + assert!( + launch_date(&host, "2026-10-14").as_deref() == Some("2026-10-16"), + "the fixture's rows put Launch on 16-10" + ); + let plan = split_plan(vec![exception.clone()]); + let cancelled = written( + &with(exception.clone(), "status", json!("cancelled")), + &[], + "\"c2\"", + ); + let mut steps = carry_26(&plan, &exception, Scripted::Json(cancelled)); + steps.extend(refetch_after_carry(&exception, None)); + let outcome = split_with(&host, &exception, events, steps).await; + assert!(outcome == saved(), "saved, nothing asked"); + } + + /// C9b-2 review F5: the 26-10 occurrence moved to Tuesday 27-10, 09:00 UTC, and its carry never + /// found, so its old copy is left on the earlier part (left behind on 26-10, its place in the + /// series). "Launch" takes it (today 20-10) and follows it there: nothing is asked. The user's + /// zone finds it on Launch's date (review round 2, R2-F3): in Honolulu, 10 hours behind, both are + /// 26-10, while in the series' London the occurrence is on 27-10. + #[tokio::test] + async fn a_milestone_on_a_moved_occurrence_left_behind_isnt_offered() { + for (zone, date) in [ + (None, "2026-10-27"), + (Some("Pacific/Honolulu"), "2026-10-26"), + ] { + let exception = moved_26("2026-10-27T09:00:00Z", "2026-10-27T10:00:00Z"); + let events = vec![ + plain("2026-10-12T09:00:00+01:00"), + monday_19(), + exception.clone(), + ]; + let (host, _) = linked_host_holding(events.clone(), "2026-10-20"); + if let Some(zone) = zone { + host.with_conn(|conn| { + crate::db::set_setting(conn, crate::settings::TIME_ZONE_KEY, zone) + }) + .unwrap(); + } + assert!( + launch_date(&host, "2026-10-20").as_deref() == Some(date), + "the fixture's rows put Launch on the occurrence" + ); + let plan = split_plan(vec![exception.clone()]); + let mut steps = vec![ + insert_step(Scripted::Json(tail_copy(&plan, "\"t1\"", SAVED_AT))), + series_patch(Scripted::Json(cut_copy(&plan))), + ]; + steps.extend((0..3).map(|_| tail_26_lookup(Vec::new()))); + let left = linked(vec![exception.clone()]).pop(); + steps.extend(refetch_after_carry(&exception, left)); + let outcome = split_with(&host, &exception, events, steps).await; + assert!( + matches!(&outcome, WriteOutcome::Saved { warnings, left_behind, milestone_moves: None } + if *warnings == [WriteWarning::LeftOnOldSeries] && *left_behind == ["2026-10-26"]), + "saved with its occurrence left behind, nothing offered" + ); + } + } + + /// C9b-2 review round 2, R2-F2: the 26-10 occurrence retitled "Off-site" in Google, and the new + /// part's listing lagging, so the carry never finds its copy there, the cancel that needs it is + /// never sent, and the landing never reads the new part. The landing can't say where "Launch"'s + /// occurrence is, but PM knows it never left the earlier part: nothing is asked. + #[tokio::test] + async fn a_milestone_on_an_occurrence_never_cancelled_isnt_offered_when_the_landing_couldnt_look( + ) { + let exception = with(plain("2026-10-26T09:00:00Z"), "summary", json!("Off-site")); + let events = vec![ + plain("2026-10-12T09:00:00+01:00"), + monday_19(), + exception.clone(), + ]; + let (host, _) = linked_host_holding(events.clone(), "2026-10-20"); + assert!( + launch_date(&host, "2026-10-20").as_deref() == Some("2026-10-26"), + "the fixture's rows put Launch on 26-10" + ); + let plan = split_plan(vec![exception.clone()]); + let mut steps = vec![ + insert_step(Scripted::Json(tail_copy(&plan, "\"t1\"", SAVED_AT))), + series_patch(Scripted::Json(cut_copy(&plan))), + ]; + steps.extend((0..3).map(|_| tail_26_lookup(Vec::new()))); + for _ in 0..3 { + let earlier = linked(vec![plain("2026-10-12T09:00:00+01:00"), exception.clone()]); + steps.push(refetch_read(Scripted::Json(page(earlier, None)))); + steps.push(tail_refetch(Scripted::Json(page(Vec::new(), None)))); + } + let outcome = split_with(&host, &exception, events, steps).await; + assert!( + matches!(&outcome, WriteOutcome::Saved { warnings, left_behind, milestone_moves: None } + if *warnings == [WriteWarning::MirrorRefreshPending, WriteWarning::LeftOnOldSeries] + && *left_behind == ["2026-10-26"]), + "pending, the occurrence left behind, and nothing offered" + ); + } + + /// C9b-2 review F5: the same occurrence's cancel answered nothing PM could read, but the landing + /// sees Google cancelled it, so "Launch" would go overdue on the earlier part: it's asked about. + #[tokio::test] + async fn a_milestone_whose_cleanup_landed_unanswered_is_offered() { + let exception = moved_26("2026-10-27T09:00:00Z", "2026-10-27T10:00:00Z"); + let events = vec![ + plain("2026-10-12T09:00:00+01:00"), + monday_19(), + exception.clone(), + ]; + let (host, launch) = linked_host_holding(events.clone(), "2026-10-20"); + let plan = split_plan(vec![exception.clone()]); + let mut steps = carry_26(&plan, &exception, Scripted::Lost { retried: true }); + steps.push(event_read("abc_20261026T090000Z", lost())); + steps.extend(refetch_after_carry(&exception, None)); + let outcome = split_with(&host, &exception, events, steps).await; + assert!( + matches!(&outcome, WriteOutcome::Saved { left_behind, milestone_moves: Some(m), .. } + if *left_behind == ["2026-10-26"] + && m.milestones == [launch_offer(launch, "2026-10-27")]), + "Launch offered beside the occurrence PM couldn't say it cancelled" + ); + } + + /// C9b-2 review F5: the same cleanup unanswered, and a landing that couldn't read Google either. + /// The mirror keeps that occurrence on the earlier part, as PM can't say it went, which shows + /// nothing about where it is, so "Launch" is still asked about: unlike a cancel never sent, this + /// one may have landed (review round 2, R2-F2). + #[tokio::test] + async fn a_milestone_whose_cleanup_went_unanswered_is_offered_when_the_landing_couldnt_look() { + let exception = moved_26("2026-10-27T09:00:00Z", "2026-10-27T10:00:00Z"); + let events = vec![ + plain("2026-10-12T09:00:00+01:00"), + monday_19(), + exception.clone(), + ]; + let (host, launch) = linked_host_holding(events.clone(), "2026-10-20"); + let plan = split_plan(vec![exception.clone()]); + let mut steps = carry_26(&plan, &exception, Scripted::Lost { retried: true }); + steps.push(event_read("abc_20261026T090000Z", lost())); + for _ in 0..3 { + let earlier = linked(vec![plain("2026-10-12T09:00:00+01:00")]); + steps.push(refetch_read(Scripted::Json(page(earlier, None)))); + steps.push(tail_refetch(Scripted::Json(page(Vec::new(), None)))); + } + let outcome = split_with(&host, &exception, events, steps).await; + let kept = host + .with_conn(|conn| calendar::series_rows(conn, "cal", &["abc"])) + .unwrap() + .iter() + .any(|r| r.start.starts_with("2026-10-27")); + assert!(kept, "the mirror keeps the occurrence on the earlier part"); + assert!( + matches!(&outcome, WriteOutcome::Saved { warnings, left_behind, milestone_moves: Some(m) } + if *warnings == [WriteWarning::MirrorRefreshPending, WriteWarning::LeftOnOldSeries] + && *left_behind == ["2026-10-26"] + && m.milestones == [launch_offer(launch, "2026-10-27")]), + "pending, the occurrence said to be left behind, and Launch offered" + ); + } + + /// C9b-2 review round 3, R3-F2: the same cleanup unanswered, and a landing that reads Google and + /// still finds the occurrence on the earlier part: "Launch" stays there with it, unasked. What PM + /// knows it never sent would say nothing here, as the cancel may have gone: only the landing's + /// own read shows the occurrence stayed. + #[tokio::test] + async fn a_milestone_whose_cleanup_went_unanswered_and_stayed_isnt_offered() { + let exception = moved_26("2026-10-27T09:00:00Z", "2026-10-27T10:00:00Z"); + let events = vec![ + plain("2026-10-12T09:00:00+01:00"), + monday_19(), + exception.clone(), + ]; + let (host, _) = linked_host_holding(events.clone(), "2026-10-20"); + let plan = split_plan(vec![exception.clone()]); + let mut steps = carry_26(&plan, &exception, Scripted::Lost { retried: true }); + steps.push(event_read("abc_20261026T090000Z", lost())); + let left = linked(vec![exception.clone()]).pop(); + steps.extend(refetch_after_carry(&exception, left)); + let outcome = split_with(&host, &exception, events, steps).await; + assert!( + matches!(&outcome, WriteOutcome::Saved { warnings, left_behind, milestone_moves: None } + if !warnings.contains(&WriteWarning::MirrorRefreshPending) + && *left_behind == ["2026-10-26"]), + "saved from Google's read, the occurrence left behind, and nothing offered" + ); + } + + /// C9b-2 review F5: a landing that couldn't read Google keeps the earlier part's rows as they + /// were, which shows nothing about where an occurrence went, so "Launch" is still asked about. + #[tokio::test] + async fn a_milestone_is_offered_when_the_landing_couldnt_look() { + let (host, launch) = linked_host("2026-10-13"); + let plan = split_plan(Vec::new()); + let mut steps = vec![ + insert_step(Scripted::Json(tail_copy(&plan, "\"t1\"", SAVED_AT))), + series_patch(Scripted::Json(cut_copy(&plan))), + ]; + for _ in 0..3 { + steps.push(refetch_read(Scripted::Json(page( + vec![linked_october()[0].clone()], + None, + )))); + steps.push(tail_refetch(Scripted::Json(page(Vec::new(), None)))); + } + let (outcome, _, _) = split_save(&host, steps).await; + assert!( + matches!(&outcome, WriteOutcome::Saved { warnings, milestone_moves: Some(m), .. } + if *warnings == [WriteWarning::MirrorRefreshPending] + && m.milestones == [launch_offer(launch, "2026-10-19")]), + "pending, and Launch offered" + ); + } + + /// A milestone whose event the mirror can't find keeps its own date, on no row of the series: + /// that date decides, from the day the change starts on (DECISIONS 17-10 #2). + #[tokio::test] + async fn a_milestone_on_no_row_goes_by_its_date() { + let host = TestHost::holding(&october()); + host.today_is("2026-10-13"); + let later = host + .with_conn(|conn| { + let link = Some("abc@google.com".to_string()); + milestones::add( + conn, + "Atlas", + "Earlier", + Some("2026-10-10".into()), + link.clone(), + )?; + milestones::add(conn, "Atlas", "Later", Some("2026-10-28".into()), link) + }) + .unwrap(); + let plan = split_plan(Vec::new()); + let insert = tail_copy(&plan, "\"t1\"", SAVED_AT); + let (outcome, _, _) = split_save(&host, landed_split(&plan, insert)).await; + let offered: Vec = moves_of(&outcome) + .map(|m| m.milestones.iter().map(|o| o.id).collect()) + .unwrap_or_default(); + assert!(offered == [later], "Later offered, Earlier not"); + } + + /// C9b-2 review F7: the one GET of the new part finds it deleted in Google: nothing can be moved + /// there, so the milestones are said to stay. + #[tokio::test] + async fn a_new_part_deleted_before_its_get_offers_nothing() { + let (host, launch) = linked_host("2026-10-13"); + let plan = split_plan(Vec::new()); + let quiet = with(tail_copy(&plan, "\"t1\"", SAVED_AT), "iCalUID", Value::Null); + let mut steps = landed_split(&plan, quiet); + let deleted = with( + tail_copy(&plan, "\"t2\"", SAVED_AT), + "status", + json!("cancelled"), + ); + steps.push(event_read(TAIL, Scripted::Json(deleted))); + let (outcome, _, _) = split_save(&host, steps).await; + assert!( + matches!(&outcome, WriteOutcome::Saved { warnings, milestone_moves: Some(m), .. } + if *warnings == [WriteWarning::MilestoneNotOffered] + && m.new_series_ical.is_none() + && m.milestones == [launch_offer(launch, "2026-10-19")]), + "saved, the milestone said to stay" + ); + } + + /// C9b-2 critique C1-L8: the user's zone (Honolulu here, 10 hours behind) decides every day, not + /// the series' (London): the 19-10 occurrence, 08:00 UTC, falls on 18-10 there, so "Launch" + /// takes 18-10 and is offered, the new part starting that day and the earlier part ending on + /// 11-10. + #[tokio::test] + async fn a_split_offers_by_the_users_days() { + let (host, launch) = linked_host("2026-10-13"); + host.with_conn(|conn| { + crate::db::set_setting(conn, crate::settings::TIME_ZONE_KEY, "Pacific/Honolulu") + }) + .unwrap(); + let plan = split_plan(Vec::new()); + let insert = tail_copy(&plan, "\"t1\"", SAVED_AT); + let (outcome, _, _) = split_save(&host, landed_split(&plan, insert)).await; + assert!( + moves_of(&outcome) + .is_some_and(|m| m.milestones == [launch_offer(launch, "2026-10-18")] + && m.first_day.as_deref() == Some("2026-10-18") + && m.earlier_last_day.as_deref() == Some("2026-10-11")), + "Launch offered on the user's 18-10" + ); + } + + /// C9b-2 review round 4: today is the user's day too, not the series'. At 03:00 UTC it's still + /// the evening before in Honolulu, the user's zone, and already the day in London, the series'. + /// On 14-10 "Launch" takes the 19-10 occurrence, the user's 18-10, and is offered with today the + /// user's 13-10, by which the words say whether the earlier part's last day has passed. On 12-10 + /// it takes the 12-10 occurrence, still to come at 22:00 on the user's 11-10, a week before the + /// change starts, so nothing is asked; on the series' 12-10 it would take 19-10 and be asked + /// about. + #[tokio::test] + async fn a_split_reads_today_in_the_users_zone() { + let in_honolulu_at = |now: &str| { + let (host, launch) = linked_host("2026-10-13"); + host.with_conn(|conn| { + crate::db::set_setting(conn, crate::settings::TIME_ZONE_KEY, "Pacific/Honolulu") + }) + .unwrap(); + host.now_is(now); + (host, launch) + }; + let (host, launch) = in_honolulu_at("2026-10-14T03:00:00Z"); + let plan = split_plan(Vec::new()); + let insert = tail_copy(&plan, "\"t1\"", SAVED_AT); + let (outcome, _, _) = split_save(&host, landed_split(&plan, insert)).await; + assert!( + moves_of(&outcome) + .is_some_and(|m| m.milestones == [launch_offer(launch, "2026-10-18")] + && m.today == "2026-10-13"), + "Launch offered, today the user's 13-10" + ); + let (host, _) = in_honolulu_at("2026-10-12T03:00:00Z"); + let insert = tail_copy(&plan, "\"t1\"", SAVED_AT); + let (outcome, _, _) = split_save(&host, landed_split(&plan, insert)).await; + assert!( + outcome == saved(), + "on the user's 11-10: saved, nothing offered" + ); + } + + /// Save "this and following" retitled from `opened`, the 19-10 occurrence moved, against `host`, + /// the mirror holding `events`: the split carries the move onto the new part's 19-10 occurrence, + /// cancels it on the original, and lands, the original re-fetched as its 12-10 occurrence alone. + async fn split_from_moved(host: &TestHost, opened: &Value, events: Vec) -> WriteOutcome { + let plan = Case::new(weekly(), opened.clone()) + .exceptions(vec![opened.clone()]) + .edit(Scope::Following, title("Retro")) + .unwrap(); + let found = tail_occurrence("2026-10-19T09:00:00+01:00", "\"q1\""); + let carried = written( + &found, + &[ + ("start", opened["start"].clone()), + ("end", opened["end"].clone()), + ], + "\"q2\"", + ); + let cancelled = written( + &with(opened.clone(), "status", json!("cancelled")), + &[], + "\"c2\"", + ); + let mut all = reads(opened, &weekly(), vec![weekly(), opened.clone()], events); + all.extend([ + insert_step(Scripted::Json(tail_copy(&plan, "\"t1\"", SAVED_AT))), + series_patch(Scripted::Json(cut_copy(&plan))), + Step::get( + &format!("/events/{TAIL}/instances"), + "timeMin=2026-10-18T08%3A00%3A00Z timeMax=2026-10-20T08%3A00%3A00Z maxResults=10", + page(vec![found], None), + ), + Step::write( + Method::Patch, + &format!("/events/{TAIL}_20261019T080000Z"), + "\"q1\"", + Scripted::Json(carried.clone()), + ), + Step::write( + Method::Patch, + "/events/abc_20261019T080000Z", + opened["etag"].as_str().unwrap(), + Scripted::Json(cancelled), + ), + refetch_read(Scripted::Json(page( + linked(vec![plain("2026-10-12T09:00:00+01:00")]), + None, + ))), + tail_refetch(Scripted::Json(page( + vec![carried, tail_occurrence("2026-10-26T09:00:00Z", "\"u1\"")], + None, + ))), + ]); + let script = Script::new(all); + let (outcome, _) = save( + host, + &script, + opened, + &weekly(), + title("Retro"), + Scope::Following, + ) + .await; + script.asked_all(); + outcome + } + + /// C9b-2 review round 3, R3-F1: the 19-10 occurrence moved to Sunday 18-10, where the user opens + /// it and splits from. "Launch" takes it (today 14-10): its date is the day the split goes from as + /// the user sees it, the "this day" the dialog names, though the occurrence's place in the series + /// is 19-10, so it's asked about, the new part starting on 18-10 where the split carries the move. + #[tokio::test] + async fn a_split_from_an_occurrence_moved_earlier_goes_from_the_day_it_shows() { + let opened = moved( + "2026-10-19T09:00:00+01:00", + "2026-10-18T09:00:00+01:00", + "2026-10-18T10:00:00+01:00", + ); + let events = vec![ + plain("2026-10-12T09:00:00+01:00"), + opened.clone(), + plain("2026-10-26T09:00:00Z"), + ]; + let (host, launch) = linked_host_holding(events.clone(), "2026-10-14"); + assert!( + launch_date(&host, "2026-10-14").as_deref() == Some("2026-10-18"), + "the fixture's rows put Launch on 18-10" + ); + let outcome = split_from_moved(&host, &opened, events).await; + assert!( + matches!(&outcome, WriteOutcome::Saved { warnings, left_behind, milestone_moves: Some(m) } + if warnings.is_empty() && left_behind.is_empty() + && m.milestones == [launch_offer(launch, "2026-10-18")] + && m.first_day.as_deref() == Some("2026-10-18") + && m.earlier_last_day.as_deref() == Some("2026-10-12")), + "Launch offered from the day the occurrence shows" + ); + } + + /// C9b-2 review round 4: the same in Honolulu, the user's zone, 10 hours behind. The occurrence + /// moved to Sunday 18-10, 09:00 London time, shows in Honolulu at 22:00 on Saturday 17-10, + /// though its place in the series falls on the user's 18-10. "Launch" takes it (today 14-10) on + /// 17-10, the day the split goes from as the user sees it, so it's asked about, the new part + /// starting on 17-10 and the earlier part ending on 11-10; by the series' days the split would + /// go from 18-10, after Launch's date. + #[tokio::test] + async fn a_split_from_an_occurrence_moved_earlier_goes_from_the_users_day_it_shows() { + let opened = moved( + "2026-10-19T09:00:00+01:00", + "2026-10-18T09:00:00+01:00", + "2026-10-18T10:00:00+01:00", + ); + let events = vec![ + plain("2026-10-12T09:00:00+01:00"), + opened.clone(), + plain("2026-10-26T09:00:00Z"), + ]; + let (host, launch) = linked_host_holding(events.clone(), "2026-10-14"); + host.with_conn(|conn| { + crate::db::set_setting(conn, crate::settings::TIME_ZONE_KEY, "Pacific/Honolulu") + }) + .unwrap(); + assert!( + launch_date(&host, "2026-10-14").as_deref() == Some("2026-10-17"), + "the fixture's rows put Launch on the user's 17-10" + ); + let outcome = split_from_moved(&host, &opened, events).await; + assert!( + matches!(&outcome, WriteOutcome::Saved { warnings, left_behind, milestone_moves: Some(m) } + if warnings.is_empty() && left_behind.is_empty() + && m.milestones == [launch_offer(launch, "2026-10-17")] + && m.first_day.as_deref() == Some("2026-10-17") + && m.earlier_last_day.as_deref() == Some("2026-10-11")), + "Launch offered from the user's day the occurrence shows" + ); + } + + /// C9b-2 review round 3, R3-F1's other half: the 19-10 occurrence moved to Wednesday 21-10. The + /// split goes from the earlier of the two days, its place in the series, so "Prep", dated 20-10 + /// on no row, is asked about; "Earlier", dated 18-10, isn't. + #[tokio::test] + async fn a_split_from_an_occurrence_moved_later_goes_from_its_place_in_the_series() { + let opened = moved( + "2026-10-19T09:00:00+01:00", + "2026-10-21T09:00:00+01:00", + "2026-10-21T10:00:00+01:00", + ); + let events = vec![ + plain("2026-10-12T09:00:00+01:00"), + opened.clone(), + plain("2026-10-26T09:00:00Z"), + ]; + let host = TestHost::holding(&events); + host.today_is("2026-10-14"); + let prep = host + .with_conn(|conn| { + let link = Some("abc@google.com".to_string()); + milestones::add( + conn, + "Atlas", + "Earlier", + Some("2026-10-18".into()), + link.clone(), + )?; + milestones::add(conn, "Atlas", "Prep", Some("2026-10-20".into()), link) + }) + .unwrap(); + let outcome = split_from_moved(&host, &opened, events).await; + let offered: Vec = moves_of(&outcome) + .map(|m| m.milestones.iter().map(|o| o.id).collect()) + .unwrap_or_default(); + assert!(offered == [prep], "Prep offered, Earlier not"); + } + + /// "This event" and "All events" never offer a move: nothing gets a new identity. + #[tokio::test] + async fn this_and_all_saves_never_offer() { + let (host, _) = linked_host("2026-10-13"); + let (series, instance) = (weekly(), monday_19()); + let mut steps = october_reads(); + steps.push(Step::write( + Method::Patch, + "/events/abc_20261019T080000Z", + instance["etag"].as_str().unwrap(), + Scripted::Json(retitled(&instance, "\"i2\"")), + )); + let script = Script::new(steps); + let (outcome, _) = save( + &host, + &script, + &instance, + &series, + title("Retro"), + Scope::This, + ) + .await; + script.asked_all(); + assert!(outcome == saved(), "this event: saved, nothing offered"); + let (host, _) = linked_host("2026-10-13"); + let mut steps = october_reads(); + steps.push(series_patch(Scripted::Json(retitled(&series, "\"m2\"")))); + steps.push(refetch_read(Scripted::Json(page(retitled_october(), None)))); + let script = Script::new(steps); + let (outcome, _) = save( + &host, + &script, + &instance, + &series, + title("Retro"), + Scope::All, + ) + .await; + script.asked_all(); + assert!(outcome == saved(), "all events: saved, nothing offered"); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a97766b5..e89615ad 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1791,6 +1791,7 @@ pub fn run() { commands::cancel_calendar_delete, commands::list_held_deletes, commands::series_choices, + commands::get_series_summary, commands::settle_series_split, #[cfg(debug_assertions)] commands::dev_series_report, diff --git a/src-tauri/src/milestones.rs b/src-tauri/src/milestones.rs index 8b9277da..c7d7fe98 100644 --- a/src-tauri/src/milestones.rs +++ b/src-tauri/src/milestones.rs @@ -318,6 +318,31 @@ pub fn list_all(conn: &Connection, today: &str) -> Result> { .collect()) } +/// The unmet milestones linked to the calendar event `ical`, resolved as [`list_all`] resolves them +/// (the soonest occurrence on or after `today`, else the latest past one, in the user's zone), whose +/// date is on or after `day` (`YYYY-MM-DD`): what a "this and following" change offers to move to +/// its new part (C9b-2). A ticked-off one isn't offered: moving it would change nothing the user +/// sees. +pub fn linked_on_or_after( + conn: &Connection, + ical: &str, + day: &str, + today: &str, +) -> Result> { + let dates = calendar_dates_by_uid(conn, today)?; + Ok(load_rows(conn, None)? + .into_iter() + .filter(|r| r.event_uid.as_deref() == Some(ical)) + .map(|r| resolve(r, &dates)) + .filter(|m| !m.is_met()) + .filter(|m| { + m.due_date + .as_deref() + .is_some_and(|d| d.get(..10).unwrap_or(d) >= day) + }) + .collect()) +} + /// Every milestone, resolved and grouped by project name — one pass for `list_overviews`. pub fn all_by_project(conn: &Connection, today: &str) -> Result>> { let uid_dates = calendar_dates_by_uid(conn, today)?; @@ -385,33 +410,37 @@ pub fn update(conn: &Connection, id: i64, label: &str, due_date: Option) /// Link a milestone to a calendar event (Some uid; `cached_date` seeds the offline /// cache) or unlink it (None — the date becomes PM-native/editable again, keeping its /// last value). The two provenances share one row, distinguished only by `event_uid`. +/// `only_if_linked_to`: change it only while it is still linked to that event (a move between the +/// two parts of a repeating event, offered a while ago, never undoes a change made since; C9b-2). +/// Whether a milestone was changed: false for an id no milestone has, or one linked elsewhere now. pub fn set_event( conn: &Connection, id: i64, event_uid: Option, cached_date: Option, -) -> Result<()> { + only_if_linked_to: Option<&str>, +) -> Result { let event_uid = clean(event_uid); let cached_date = clean(cached_date); - match event_uid { + let changed = match event_uid { // Link: stamp the uid and seed the cache (keep the old date if none supplied). Some(uid) => conn.execute( "UPDATE project_milestones \ SET event_uid = ?2, due_date = COALESCE(?3, due_date), \ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \ - WHERE id = ?1", - params![id, uid, cached_date], + WHERE id = ?1 AND (?4 IS NULL OR event_uid = ?4)", + params![id, uid, cached_date, only_if_linked_to], )?, // Unlink: clear the uid; the last resolved date stays as the editable value. None => conn.execute( "UPDATE project_milestones \ SET event_uid = NULL, due_date = COALESCE(?2, due_date), \ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \ - WHERE id = ?1", - params![id, cached_date], + WHERE id = ?1 AND (?3 IS NULL OR event_uid = ?3)", + params![id, cached_date, only_if_linked_to], )?, }; - Ok(()) + Ok(changed > 0) } /// Mark a milestone met or unmet (the row's tick-box). Also carries `status` along so the pair @@ -836,6 +865,92 @@ mod tests { assert_eq!(count, 3); } + /// Mirror rows of the calendar event `uid`, one per start. + fn occurrences(conn: &Connection, uid: &str, starts: &[&str]) { + for (i, start) in starts.iter().enumerate() { + conn.execute( + "INSERT INTO calendar_events (id, calendar_id, summary, start, all_day, uid) \ + VALUES (?1, 'cal', 'Gym', ?2, 0, ?3)", + params![format!("{uid}#{i}"), start, uid], + ) + .unwrap(); + } + } + + /// C9b-2: a split offers the milestones whose date, resolved as the list resolves it (the + /// soonest occurrence from today), is on or after the day it starts; a ticked-off one, or one + /// on another event, never. + #[test] + fn linked_on_or_after_resolves_as_the_list_does() { + let (_dir, conn) = store(); + let today = "2026-10-12"; + occurrences( + &conn, + "gym@x", + &[ + "2026-10-05T09:00:00Z", + "2026-10-19T09:00:00Z", + "2026-10-26T09:00:00Z", + ], + ); + occurrences( + &conn, + "past@x", + &["2026-09-28T09:00:00Z", "2026-10-05T09:00:00Z"], + ); + let launch = add(&conn, "Atlas", "Launch", None, Some("gym@x".into())).unwrap(); + let done = add(&conn, "Atlas", "Done", None, Some("gym@x".into())).unwrap(); + set_state(&conn, done, true).unwrap(); + add(&conn, "Atlas", "Other", None, Some("past@x".into())).unwrap(); + let ids = |day: &str, uid: &str| -> Vec<(i64, Option)> { + linked_on_or_after(&conn, uid, day, today) + .unwrap() + .into_iter() + .map(|m| (m.id, m.due_date)) + .collect() + }; + assert_eq!( + ids("2026-10-19", "gym@x"), + vec![(launch, Some("2026-10-19".into()))] + ); + assert!(ids("2026-10-26", "gym@x").is_empty()); + // Only past occurrences: the latest past one, which is before the day. + assert!(ids("2026-10-19", "past@x").is_empty()); + let other = linked_on_or_after(&conn, "past@x", "2026-10-05", today).unwrap(); + assert_eq!(other.len(), 1); + assert_eq!(other[0].due_date.as_deref(), Some("2026-10-05")); + } + + /// C9b-2: linking says whether a milestone had that id, so "Moved" is never said of nothing; + /// with `only_if_linked_to`, only while it's still linked there (a stale offer moves nothing). + #[test] + fn set_event_says_when_no_milestone_has_the_id() { + let (_dir, conn) = store(); + let id = add(&conn, "Atlas", "Launch", None, Some("abc@x".into())).unwrap(); + let link = |m: i64, uid: Option<&str>, only: Option<&str>| { + set_event(&conn, m, uid.map(str::to_string), None, only).unwrap() + }; + let now = || -> Option { + conn.query_row( + "SELECT event_uid FROM project_milestones WHERE id = ?1", + params![id], + |r| r.get(0), + ) + .unwrap() + }; + assert!(!link(id + 99, Some("tail@x"), None)); + assert!(!link(id + 99, None, None)); + assert!(link(id, Some("tail@x"), Some("abc@x"))); + assert_eq!(now().as_deref(), Some("tail@x")); + // An offer made before the move: it's no longer linked there, so nothing changes. + assert!(!link(id, Some("tail@x"), Some("abc@x"))); + assert!(link(id, Some("abc@x"), None)); + // Unlinked meanwhile: a Move pressed now moves nothing, and the link stays as the user set it. + assert!(link(id, None, None)); + assert!(!link(id, Some("tail@x"), Some("abc@x"))); + assert_eq!(now(), None); + } + /// The migration stamps `done` on rows already marked met, so no pre-v42 row starts life in the /// contradictory state the setters are built to prevent. #[test] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9fb30896..211e4447 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.151.0-alpha", + "version": "3.152.0-alpha", "identifier": "org.itsatlas.pm", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/FocusUpcoming.tsx b/src/components/FocusUpcoming.tsx index 8ad9a87f..06aa87d4 100644 --- a/src/components/FocusUpcoming.tsx +++ b/src/components/FocusUpcoming.tsx @@ -35,6 +35,7 @@ import { formatEventWhen } from "../lib/format"; import { Card, SegmentedControl } from "./ui"; import { TimeGridView } from "./calendar/views/TimeGridView"; import { CalendarEventPopover } from "./calendar/parts/CalendarEventPopover"; +import { describeSeries } from "./calendar/edit/useEventWrites"; import { RangeControl } from "./calendar/RangeControl"; import { FOCUS_UPCOMING_DAY_CHOICES, @@ -328,6 +329,8 @@ export function FocusUpcoming({ listEvents, calendars, onOpenProject }: Props) { } : undefined } + // How a repeating event repeats, as on the Calendar tab: one lazy read per click. + describeSeries={describeSeries} /> )} diff --git a/src/components/calendar/CalendarView.tsx b/src/components/calendar/CalendarView.tsx index 3cfc4866..8a576162 100644 --- a/src/components/calendar/CalendarView.tsx +++ b/src/components/calendar/CalendarView.tsx @@ -1143,6 +1143,7 @@ export function CalendarView({ onOpenProject, onOpenPinboard }: CalendarViewProp : undefined } editing={popoverEditing(eventPopup.ev)} + describeSeries={writes.describeSeries} /> )} @@ -1230,6 +1231,19 @@ export function CalendarView({ onOpenProject, onOpenPinboard }: CalendarViewProp : { name: "that calendar", hidden: false }, ) } + // A move writes no calendar row, so no write-outcome follows: the milestones are read + // again here, for the popover's "Open in Project" and the editor's line to follow it. + onMove={(id) => + void writes.moveMilestone(id).then((moved) => { + if (moved) void loadMilestones(); + }) + } + onMoveBack={(id) => + void writes.moveMilestoneBack(id).then((moved) => { + if (moved) void loadMilestones(); + }) + } + onHold={writes.holdMoveBack} /> ); diff --git a/src/components/calendar/edit/EventEditor.test.tsx b/src/components/calendar/edit/EventEditor.test.tsx index bd0f3143..1f69e77d 100644 --- a/src/components/calendar/edit/EventEditor.test.tsx +++ b/src/components/calendar/edit/EventEditor.test.tsx @@ -16,6 +16,7 @@ import type { EventForEdit, EventPatchDraft, FieldPermissions, + Milestone, ScopeRow, SeriesChoices, SplitAction, @@ -1147,3 +1148,48 @@ describe("EventEditor, a repeating event", () => { await waitFor(() => expect(document.activeElement).toBe(editorSave)); }); }); + +// C9b-2: a milestone linked to a repeating event takes its date from the next occurrence, so moving +// one occurrence moves it only when it's that one; a one-off's words would overclaim. +describe("EventEditor, a linked milestone", () => { + const launch: Milestone = { + id: 7, + project_name: "Atlas", + label: "Launch", + due_date: "2026-10-19", + event_uid: "abc@google.com", + calendar_linked: true, + event_missing: false, + state: null, + status: null, + source_type: null, + external_id: null, + sort_order: 0, + }; + const editor = (clicked: CalendarEvent) => ( + {}} + /> + ); + + it("a repeating event's milestone takes its date from the next occurrence", async () => { + openForEdit.mockResolvedValue(ready(event())); + render(editor({ ...row, series_id: "abc", recurring: true })); + expect( + await screen.findByText( + "Linked to the “Launch” milestone in Atlas, which takes its date from the next occurrence.", + ), + ).toBeTruthy(); + cleanup(); + render(editor(row)); + expect( + await screen.findByText( + "Linked to the “Launch” milestone in Atlas: moving this event moves the milestone too.", + ), + ).toBeTruthy(); + }); +}); diff --git a/src/components/calendar/edit/EventEditor.tsx b/src/components/calendar/edit/EventEditor.tsx index 880e1c67..3fe38ceb 100644 --- a/src/components/calendar/edit/EventEditor.tsx +++ b/src/components/calendar/edit/EventEditor.tsx @@ -756,8 +756,11 @@ export function EventEditor({ row, calendar, account, milestone, onDelete, onClo {milestone && (

- Linked to the “{milestone.label}” milestone in {milestone.project_name}: moving this - event moves the milestone too. + {/* A repeating event's milestone follows its next occurrence, so moving this one + moves it only when it's that one: the one-off's words would overclaim. */} + {isRepeating(row) + ? `Linked to the “${milestone.label}” milestone in ${milestone.project_name}, which takes its date from the next occurrence.` + : `Linked to the “${milestone.label}” milestone in ${milestone.project_name}: moving this event moves the milestone too.`}

)} diff --git a/src/components/calendar/edit/WriteNotices.test.tsx b/src/components/calendar/edit/WriteNotices.test.tsx index bff1c37c..daba9036 100644 --- a/src/components/calendar/edit/WriteNotices.test.tsx +++ b/src/components/calendar/edit/WriteNotices.test.tsx @@ -22,6 +22,7 @@ import { Modal } from "../../ui"; import { BriefingPanel } from "../../BriefingPanel"; import { WriteNotices } from "./WriteNotices"; import type { WriteNotice } from "./useEventWrites"; +import type { MilestoneMove } from "../../../lib/types"; afterEach(() => { cleanup(); @@ -184,4 +185,139 @@ describe("WriteNotices", () => { expect(gym.disabled).toBe(true); expect(untitled.disabled).toBe(false); }); + + // C9b-2: a milestone's move after a "this and following" change. + const launch: MilestoneMove = { + milestoneId: 7, + label: "Launch", + project: "Atlas", + newTitle: "Retro", + earlierTitle: "Gym", + toIcal: "tail@google.com", + toDate: "2026-10-19", + backIcal: "abc@google.com", + backDate: "2026-10-19", + earlierLastDay: "2026-10-12", + today: "2026-10-20", + pending: false, + }; + const offer = (moving = false): WriteNotice => ({ + id: 4, + tone: "ok", + text: "Move the “Launch” milestone in Atlas…?", + move: launch, + moving, + }); + const moved = (moving = false): WriteNotice => ({ + id: 4, + tone: "ok", + text: "Moved “Launch” to the new part of “Retro”.", + moveBack: launch, + moving, + }); + + it("an offer shows Move with its name, unpressable while moving, and Keep it there", () => { + const props = { onUndo: vi.fn(), onDismiss: vi.fn(), onMove: vi.fn(), onMoveBack: vi.fn() }; + const view = render(); + const move = screen.getByRole("button", { name: "Move “Launch” to the new part of “Retro”" }); + expect(move.textContent).toBe("Move"); + fireEvent.click(move); + expect(props.onMove).toHaveBeenCalledWith(4); + fireEvent.click( + screen.getByRole("button", { + name: "Keep it there: “Launch” stays on the earlier part of “Gym”", + }), + ); + expect(props.onDismiss).toHaveBeenCalledWith(4); + // Moving: still focusable (aria-disabled, never disabled), and a press does nothing. + move.focus(); + view.rerender(); + const waiting = screen.getByRole("button", { name: /^Move “Launch”/ }) as HTMLButtonElement; + expect(waiting.disabled).toBe(false); + expect(waiting.getAttribute("aria-disabled")).toBe("true"); + fireEvent.click(waiting); + expect(props.onMove).toHaveBeenCalledTimes(1); + // Moved: the very button, now Move back, keeps the focus. + view.rerender(); + const back = screen.getByRole("button", { + name: "Move back “Launch” to the earlier part of “Gym”", + }); + expect(back).toBe(move); + expect(document.activeElement).toBe(move); + expect(back.textContent).toBe("Move back"); + }); + + // WCAG 2.5.3: saying "click Move back" or "click Keep it there" finds the button. + it("starts each move button's name with the words it shows", () => { + const props = { onUndo: vi.fn(), onDismiss: vi.fn(), onMove: vi.fn(), onMoveBack: vi.fn() }; + const view = render(); + const named = () => + screen + .getAllByRole("button") + .filter((b) => b.textContent !== "×") + .map((b) => [b.textContent ?? "", b.getAttribute("aria-label") ?? ""]); + const offered = named(); + view.rerender(); + const all = [...offered, ...named()]; + expect(all.map(([shown]) => shown)).toEqual(["Move", "Keep it there", "Move back"]); + for (const [shown, name] of all) expect(name).toMatch(new RegExp(`^${shown}\\b`)); + }); + + // The second click of a double-click on Move, or Enter held on it, lands on the same button, by + // then Move back. + it("a double-click's second click, or a held Enter, never presses Move back", () => { + const props = { onUndo: vi.fn(), onDismiss: vi.fn(), onMove: vi.fn(), onMoveBack: vi.fn() }; + render(); + const back = screen.getByRole("button", { name: /^Move back/ }); + fireEvent.click(back, { detail: 2 }); + expect(props.onMoveBack).not.toHaveBeenCalled(); + fireEvent.click(back, { detail: 1 }); + expect(props.onMoveBack).toHaveBeenCalledWith(4); + // A repeat of a held Enter presses nothing (false: its default was prevented); a press does. + expect(fireEvent.keyDown(back, { key: "Enter", repeat: true })).toBe(false); + expect(fireEvent.keyDown(back, { key: "Enter" })).toBe(true); + expect(fireEvent.keyDown(back, { key: "Tab", repeat: true })).toBe(true); + }); + + it("Move back shows only on a moved notice, and holds its window while the pointer or focus is on it", () => { + const props = { + onUndo: vi.fn(), + onDismiss: vi.fn(), + onMove: vi.fn(), + onMoveBack: vi.fn(), + onHold: vi.fn(), + }; + const view = render(); + expect(screen.queryByRole("button", { name: /^Move back/ })).toBeNull(); + view.rerender(); + expect(screen.queryByRole("button", { name: /Keep/ })).toBeNull(); + const back = screen.getByRole("button", { name: /^Move back/ }); + fireEvent.click(back); + expect(props.onMoveBack).toHaveBeenCalledWith(4); + const notice = screen.getByRole("status"); + props.onHold.mockClear(); + fireEvent.pointerEnter(notice); + fireEvent.pointerLeave(notice); + fireEvent.focus(back); + // Focus moving within the notice holds on; leaving it lets go. + fireEvent.blur(back, { relatedTarget: screen.getByRole("button", { name: "Dismiss" }) }); + fireEvent.blur(back, { relatedTarget: document.body }); + expect(props.onHold.mock.calls).toEqual([ + [4, "pointer", true], + [4, "pointer", false], + [4, "focus", true], + [4, "focus", false], + ]); + }); + + it("without the handlers, no move buttons", () => { + render( + , + ); + expect(screen.getAllByRole("button").map((b) => b.textContent)).toEqual(["×", "×"]); + }); }); diff --git a/src/components/calendar/edit/WriteNotices.tsx b/src/components/calendar/edit/WriteNotices.tsx index 3ae6b190..ea77143c 100644 --- a/src/components/calendar/edit/WriteNotices.tsx +++ b/src/components/calendar/edit/WriteNotices.tsx @@ -6,7 +6,9 @@ // `--bg` (see Callout) rather than letting the events behind show through. The newest Undo is also // Ctrl+Z (⌘Z on a Mac), which its tooltip says. A "this and following" change PM couldn't finish // keeps its notice, with a button for each thing Google's state allows now, until it's settled; a -// new event PM can't tell was made keeps its Check again. +// new event PM can't tell was made keeps its Check again. After a "this and following" change +// lands, a milestone linked to it is offered a move to the new part (Move, or Keep it there), then +// Move back for a few seconds; Ctrl+Z never moves a milestone. // // They sit under an open dialog's scrim (z-50), so a notice that never goes can't cover an editor's // Cancel and Save on a short window, outside its focus trap: the dialog is what the keyboard is in @@ -14,14 +16,26 @@ // would paint it on top) and the docked reader (z-40), or an Undo or a Check again could sit // unseen and unclickable under a briefing parked at the foot of the window. +import type { FocusEvent } from "react"; import { IS_MAC } from "../../../lib/setupGuide"; import { Button, Callout, TONE_MIX, TONE_TOKEN, Tooltip } from "../../ui"; import type { SplitAction } from "../../../lib/types"; -import { CALLOUT_TONE, SPLIT_ORDER, splitActionLabel } from "../../../lib/calendarEdit/editReasons"; +import { + CALLOUT_TONE, + keepLabel, + moveBackLabel, + moveLabel, + SPLIT_ORDER, + splitActionLabel, +} from "../../../lib/calendarEdit/editReasons"; import { latestUndo, type WriteNotice } from "./useEventWrites"; const UNDO_KEY = IS_MAC ? "⌘Z" : "Ctrl+Z"; +/** A move's button while it's on its way (a moment: it changes PM's own milestone only): it keeps + * focus and says it's unavailable, and the pointer says it can't be pressed. */ +const WAITING = "aria-disabled:cursor-not-allowed"; + interface Props { notices: readonly WriteNotice[]; onUndo: (token: string) => void; @@ -30,9 +44,23 @@ interface Props { onSettle?: (token: string, action: SplitAction, title: string) => void; /** Check again whether a new event PM may already have made was (the notice `id`). */ onCheckCreate?: (id: number) => void; + /** Move the milestone a notice offers to the new part, or back (C9b-2). */ + onMove?: (noticeId: number) => void; + onMoveBack?: (noticeId: number) => void; + /** The pointer or focus came onto a notice with a move, or left it: Move back waits meanwhile. */ + onHold?: (noticeId: number, by: "pointer" | "focus", on: boolean) => void; } -export function WriteNotices({ notices, onUndo, onDismiss, onSettle, onCheckCreate }: Props) { +export function WriteNotices({ + notices, + onUndo, + onDismiss, + onSettle, + onCheckCreate, + onMove, + onMoveBack, + onHold, +}: Props) { if (notices.length === 0) return null; const newest = latestUndo(notices); // With two such notices up, each button names its event, so "Finish" isn't said twice alike. @@ -42,6 +70,24 @@ export function WriteNotices({ notices, onUndo, onDismiss, onSettle, onCheckCrea
{notices.map((n) => { const tone = CALLOUT_TONE[n.tone]; + // A milestone's move, offered or just made: one button that goes Move → Move back in place, + // so focus on it stays put, and does nothing while either is on its way (aria-disabled, + // never disabled, which would drop focus). + const milestone = n.move ?? n.moveBack; + const press = n.move ? onMove : onMoveBack; + const hold = + milestone && onHold + ? { + onPointerEnter: () => onHold(n.id, "pointer", true), + onPointerLeave: () => onHold(n.id, "pointer", false), + onFocus: () => onHold(n.id, "focus", true), + onBlur: (e: FocusEvent) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { + onHold(n.id, "focus", false); + } + }, + } + : {}; return ( {n.text} @@ -96,6 +143,42 @@ export function WriteNotices({ notices, onUndo, onDismiss, onSettle, onCheckCrea Check again )} + {milestone && press && ( + + )} + {/* Saying no out loud, so × isn't the only way: nothing is sent. */} + {n.move && onMove && ( + + )} {/* Every Undo is the same Tooltip > Button, so when the newest goes and an older one becomes it, only its label changes: a focused button isn't swapped for a new one. */} {n.undoToken && ( diff --git a/src/components/calendar/edit/useEventWrites.test.ts b/src/components/calendar/edit/useEventWrites.test.ts index 3432a7a3..37acd825 100644 --- a/src/components/calendar/edit/useEventWrites.test.ts +++ b/src/components/calendar/edit/useEventWrites.test.ts @@ -15,6 +15,7 @@ import type { DeleteStart, EventPatchDraft, HeldDeleteInfo, + MilestoneMoves, NewEventDraft, SeriesChoices, SplitAction, @@ -36,6 +37,11 @@ const settleSeriesSplit = vi.fn<(token: string, action: SplitAction) => Promise< const createCalendarEvent = vi.fn<(calendarId: string, draft: NewEventDraft, token: Scope) => Promise>(); const writeLastCreate = vi.fn<(calendarId: string) => void>(); +const getSeriesSummary = vi.fn<(eventId: string) => Promise>(); +const setMilestoneEvent = + vi.fn< + (id: number, uid: string | null, date: string | null, only: string | null) => Promise + >(); let settledHandler: ((s: DeleteSettled) => void) | null = null; vi.mock("../../../lib/calendarPrefs", async (importOriginal) => ({ @@ -46,6 +52,9 @@ vi.mock("../../../lib/calendarPrefs", async (importOriginal) => ({ vi.mock("../../../lib/ipc", () => ({ // Every argument passes through, so an extra one, or a dropped token, shows in the calls. createCalendarEvent: (...a: [string, NewEventDraft, Scope]) => createCalendarEvent(...a), + getSeriesSummary: (eventId: string) => getSeriesSummary(eventId), + setMilestoneEvent: (id: number, uid: string | null, date: string | null, only: string | null) => + setMilestoneEvent(id, uid, date, only), deleteCalendarEvent: (id: string, seen: unknown, scope: Scope, choice: Scope) => deleteCalendarEvent(id, seen, scope, choice), cancelCalendarDelete: (token: string) => cancelCalendarDelete(token), @@ -1215,3 +1224,336 @@ describe("creating an event", () => { expect(notice.text).toMatch(/The app's channel closed\.$/); }); }); + +// C9b-2: the popover's words, and the milestones a "this and following" change offers to move. +describe("how a repeating event repeats, and moving its milestones", () => { + const moves: MilestoneMoves = { + new_series_ical: "tail@google.com", + earlier_series_ical: "abc@google.com", + new_title: "Retro", + earlier_title: "Gym", + first_day: "2026-10-19", + earlier_last_day: "2026-10-12", + today: "2026-10-20", + milestones: [ + { id: 7, label: "Launch", project: "Atlas", date: "2026-10-19" }, + { id: 8, label: "Review", project: "Atlas", date: "2026-10-26" }, + ], + }; + const one: MilestoneMoves = { ...moves, milestones: [moves.milestones[0]] }; + const landed = (m: MilestoneMoves, warnings: WriteWarning[] = []): WriteOutcome => ({ + outcome: "saved", + warnings, + milestone_moves: m, + }); + + beforeEach(() => { + vi.setSystemTime(new Date("2026-10-20T12:00:00Z")); + setMilestoneEvent.mockResolvedValue(undefined); + }); + + /** A store with Launch's offer up: the store, and the offer's notice id. */ + async function offered() { + const loaded = await load(); + loaded.act(() => loaded.mod.noteSaved("Retro", landed(one), "following", "2026-10-19")); + const offer = loaded.hook.result.current.notices.find((n) => n.move); + const notice = () => loaded.hook.result.current.notices.find((n) => n.id === offer!.id); + return { ...loaded, id: offer!.id, notice }; + } + + it("describeSeries shares one request per row and answers null on a throw", async () => { + const { mod } = await load(); + let answer!: (words: string | null) => void; + getSeriesSummary.mockReturnValueOnce( + new Promise((resolve) => { + answer = resolve; + }), + ); + const first = mod.describeSeries("row"); + const second = mod.describeSeries("row"); + expect(getSeriesSummary).toHaveBeenCalledTimes(1); + answer("Weekly on Monday"); + await expect(first).resolves.toBe("Weekly on Monday"); + await expect(second).resolves.toBe("Weekly on Monday"); + // Answered, so a later call asks again; a throw is null ("Repeats"). + getSeriesSummary.mockRejectedValueOnce(new Error("offline")); + await expect(mod.describeSeries("row")).resolves.toBeNull(); + expect(getSeriesSummary).toHaveBeenCalledTimes(2); + }); + + it("a landed split offers one notice per milestone, that stays", async () => { + const { mod, hook, act } = await load(); + act(() => mod.noteSaved("Retro", landed(moves), "following", "2026-10-19")); + const [, launch, review] = hook.result.current.notices; + expect(launch).toMatchObject({ + tone: "ok", + text: "Move the “Launch” milestone in Atlas to the new part of “Retro”, which starts on 19-10? If you don't, it stays on the earlier part, which ended on 12-10, and shows as overdue.", + move: { + milestoneId: 7, + newTitle: "Retro", + earlierTitle: "Gym", + toIcal: "tail@google.com", + toDate: "2026-10-19", + backIcal: "abc@google.com", + backDate: "2026-10-19", + }, + }); + expect(review.move).toMatchObject({ milestoneId: 8, backDate: "2026-10-26" }); + act(() => { + vi.advanceTimersByTime(60_000); + }); + // The saved notice went by itself; the questions wait for an answer. + expect(hook.result.current.notices.map((n) => n.move?.milestoneId)).toEqual([7, 8]); + }); + + // C9b-2 review round 3, R3-F3: whether the earlier part has ended goes by today as the backend + // read it in the user's zone, the day its dates are in, not this device's clock (20-10 here). + it("words the earlier part's end by the day the change landed", async () => { + const { mod, hook, act } = await load(); + const ahead = { ...one, earlier_last_day: "2026-10-21", today: "2026-10-20" }; + const ended = { ...one, earlier_last_day: "2026-10-21", today: "2026-10-22" }; + act(() => mod.noteSaved("Retro", landed(ahead), "following", "2026-10-19")); + act(() => mod.noteSaved("Retro", landed(ended), "following", "2026-10-19")); + const offers = hook.result.current.notices.filter((n) => n.move).map((n) => n.text); + expect(offers).toEqual([ + expect.stringMatching(/, whose last day is 21-10, and shows as overdue after that\.$/), + expect.stringMatching(/, which ended on 21-10, and shows as overdue\.$/), + ]); + }); + + it("no milestone_moves, no offer", async () => { + settleSeriesSplit.mockResolvedValueOnce({ + outcome: "partial_split", + token: "s1", + offers: ["finish"], + from: "2026-10-19", + }); + const { mod, hook, act } = await load(); + act(() => mod.noteSaved("Retro", { outcome: "saved", warnings: [] }, "following")); + act(() => mod.noteSaved("Retro", { outcome: "failed", message: "No." }, "following")); + await act(() => mod.settleSplit("s1", "check", "Gym")); + expect(hook.result.current.notices.some((n) => n.move || n.moveBack)).toBe(false); + }); + + it("a finished split offers too", async () => { + settleSeriesSplit.mockResolvedValueOnce(landed(one)); + const { mod, hook, act } = await load(); + await act(() => mod.settleSplit("s1", "finish", "Gym")); + expect(hook.result.current.notices.map((n) => n.move?.milestoneId)).toEqual([undefined, 7]); + }); + + it("a new part PM couldn't find says which milestone stayed, with nothing to press", async () => { + const { mod, hook, act } = await load(); + const unnamed = { ...one, new_series_ical: null, earlier_last_day: null }; + act(() => + mod.noteSaved("Retro", landed(unnamed, ["milestone_not_offered"]), "following", "2026-10-19"), + ); + expect(hook.result.current.notices[1]).toEqual({ + id: 2, + tone: "warn", + text: "PM couldn't find the new part of “Retro” in Google, so “Launch” in Atlas stays on the earlier part, which has ended, and shows as overdue. Unlink it in Atlas to set its date yourself.", + }); + }); + + it("Move sends the new part and its first day, then offers Move back for 8 s", async () => { + const { mod, act, id, notice } = await offered(); + await act(async () => { + expect(await mod.moveMilestone(id)).toBe(true); + }); + // Only while it's still on the earlier part. + expect(setMilestoneEvent).toHaveBeenCalledWith( + 7, + "tail@google.com", + "2026-10-19", + "abc@google.com", + ); + expect(notice()).toMatchObject({ + tone: "ok", + text: "Moved “Launch” to the new part of “Retro”.", + moveBack: { milestoneId: 7 }, + }); + expect(notice()?.move).toBeUndefined(); + act(() => { + vi.advanceTimersByTime(7_999); + }); + expect(notice()?.moveBack).toBeDefined(); + act(() => { + vi.advanceTimersByTime(1); + }); + expect(notice()).toEqual({ + id, + tone: "ok", + text: "Moved “Launch” to the new part of “Retro”.", + }); + act(() => { + vi.advanceTimersByTime(5_000); + }); + expect(notice()).toBeUndefined(); + }); + + it("Move back sends the earlier part and the milestone's date", async () => { + const { mod, act, id, notice } = await offered(); + await act(() => mod.moveMilestone(id)); + act(() => { + vi.advanceTimersByTime(500); + }); + await act(async () => { + expect(await mod.moveMilestoneBack(id)).toBe(true); + }); + expect(setMilestoneEvent).toHaveBeenLastCalledWith( + 7, + "abc@google.com", + "2026-10-19", + "tail@google.com", + ); + expect(notice()).toEqual({ + id, + tone: "ok", + text: "“Launch” is back on the earlier part of “Gym”.", + }); + // The window it had is closed: nothing comes back later. + act(() => { + vi.advanceTimersByTime(8_000); + }); + expect(notice()).toBeUndefined(); + }); + + it("Move back waits while the pointer or focus is on its notice, then has what it had left", async () => { + const { mod, act, id, notice } = await offered(); + // Focus on the Move button as it's pressed: the window starts held. + act(() => mod.holdMoveBack(id, "focus", true)); + await act(() => mod.moveMilestone(id)); + act(() => { + vi.advanceTimersByTime(60_000); + }); + expect(notice()?.moveBack).toBeDefined(); + act(() => mod.holdMoveBack(id, "pointer", true)); + act(() => mod.holdMoveBack(id, "focus", false)); + act(() => { + vi.advanceTimersByTime(60_000); + }); + expect(notice()?.moveBack).toBeDefined(); + act(() => mod.holdMoveBack(id, "pointer", false)); + act(() => { + vi.advanceTimersByTime(3_000); + }); + act(() => mod.holdMoveBack(id, "pointer", true)); + act(() => { + vi.advanceTimersByTime(60_000); + }); + act(() => mod.holdMoveBack(id, "pointer", false)); + act(() => { + vi.advanceTimersByTime(4_999); + }); + expect(notice()?.moveBack).toBeDefined(); + act(() => { + vi.advanceTimersByTime(1); + }); + expect(notice()?.moveBack).toBeUndefined(); + }); + + it("a second press while moving sends nothing", async () => { + let finish!: () => void; + setMilestoneEvent.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ); + const { mod, act, id, notice } = await offered(); + let first!: Promise; + act(() => { + first = mod.moveMilestone(id); + }); + expect(notice()?.moving).toBe(true); + await act(async () => { + expect(await mod.moveMilestone(id)).toBe(false); + }); + expect(setMilestoneEvent).toHaveBeenCalledTimes(1); + finish(); + await act(async () => { + expect(await first).toBe(true); + }); + expect(notice()?.moving).toBeFalsy(); + }); + + it("Move back takes no press in its first moment, where one meant for Move lands", async () => { + const { mod, act, id, notice } = await offered(); + await act(() => mod.moveMilestone(id)); + await act(async () => { + expect(await mod.moveMilestoneBack(id)).toBe(false); + }); + act(() => { + vi.advanceTimersByTime(499); + }); + await act(async () => { + expect(await mod.moveMilestoneBack(id)).toBe(false); + }); + expect(setMilestoneEvent).toHaveBeenCalledTimes(1); + expect(notice()?.moveBack).toBeDefined(); + act(() => { + vi.advanceTimersByTime(1); + }); + await act(async () => { + expect(await mod.moveMilestoneBack(id)).toBe(true); + }); + expect(setMilestoneEvent).toHaveBeenCalledTimes(2); + }); + + it("a moved notice dismissed while Move back is offered stays gone", async () => { + const { mod, act, id, notice } = await offered(); + await act(() => mod.moveMilestone(id)); + act(() => mod.dismissNotice(id)); + expect(notice()).toBeUndefined(); + // Its window ends, and nothing comes back, then or later. + act(() => { + vi.advanceTimersByTime(8_000); + }); + expect(notice()).toBeUndefined(); + act(() => { + vi.advanceTimersByTime(5_000); + }); + expect(notice()).toBeUndefined(); + }); + + it("a failed move keeps the offer with the error", async () => { + setMilestoneEvent.mockRejectedValueOnce(new Error("That milestone no longer exists.")); + const { mod, act, id, notice } = await offered(); + await act(async () => { + expect(await mod.moveMilestone(id)).toBe(false); + }); + expect(notice()).toMatchObject({ + tone: "error", + text: "Couldn't move “Launch”. That milestone no longer exists.", + move: { milestoneId: 7 }, + }); + expect(notice()?.moving).toBeFalsy(); + }); + + it("a failed move back says the way out, with nothing to press", async () => { + const { mod, act, id, notice } = await offered(); + await act(() => mod.moveMilestone(id)); + act(() => { + vi.advanceTimersByTime(500); + }); + setMilestoneEvent.mockRejectedValueOnce( + new Error("That milestone changed since; nothing was moved."), + ); + await act(async () => { + expect(await mod.moveMilestoneBack(id)).toBe(false); + }); + expect(notice()).toEqual({ + id, + tone: "error", + text: "Couldn't move “Launch” back. That milestone changed since; nothing was moved. Unlink it in Atlas if it shouldn't follow the new part.", + }); + }); + + it("Ctrl+Z never moves a milestone", async () => { + const { mod, act, id } = await offered(); + expect(mod.undoLatest()).toBe(false); + expect(setMilestoneEvent).not.toHaveBeenCalled(); + await act(() => mod.moveMilestone(id)); + expect(mod.undoLatest()).toBe(false); + expect(setMilestoneEvent).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/calendar/edit/useEventWrites.ts b/src/components/calendar/edit/useEventWrites.ts index 996f8bf7..22bbbc17 100644 --- a/src/components/calendar/edit/useEventWrites.ts +++ b/src/components/calendar/edit/useEventWrites.ts @@ -21,6 +21,11 @@ // isn't there to say it: Created, or one PM can't tell was made, which keeps a Check again button // holding exactly what was sent until Google shows how it ended. A reload loses that notice; the // backend's record of it then lapses, and a copy that landed shows at the next sync. +// +// And two things beside the writes (C9b-2): how a repeating event repeats, for the event popover +// (only a read), and, after a "this and following" change lands, a question per milestone linked to +// it from that day on, whether to move it to the new part (DECISIONS 17-10 #2). Moving one changes +// PM's own milestone, never Google; Move back is offered for a few seconds after. import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { @@ -28,9 +33,11 @@ import { createCalendarEvent, deleteCalendarEvent, getCalendarEventForEdit, + getSeriesSummary, listHeldDeletes, onCalendarDeleteSettled, seriesChoices, + setMilestoneEvent, settleSeriesSplit, updateCalendarEvent, } from "../../../lib/ipc"; @@ -39,6 +46,7 @@ import type { DeleteSettled, EditLoad, EventPatchDraft, + MilestoneMove, NewEventDraft, SeenSummary, SeriesChoices, @@ -52,6 +60,12 @@ import { deleteChangedText, deleteText, heldCreateText, + milestoneStayedText, + movedBackText, + movedText, + moveBackFailedText, + moveFailedText, + moveOfferText, retryAfter, savedNotice, settleText, @@ -81,6 +95,12 @@ export interface WriteNotice { create?: { token: string; sent: CreateSent }; /** Its Check again is on its way: the button is disabled. */ checking?: boolean; + /** Asks whether to move a milestone to the new part (DECISIONS 17-10 #2): Move, and Keep it there. */ + move?: MilestoneMove; + /** A move that just happened: Move back, for {@link MOVE_BACK_MS}. */ + moveBack?: MilestoneMove; + /** A move or move back is on its way: its button does nothing meanwhile. */ + moving?: boolean; } /** What creating an event sent: the calendar (PM's id) and the draft, kept as they were so a Check @@ -157,7 +177,8 @@ const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); * once a view has shown it; the timer only removes the notice it was set for, never a later one * under the same id. A delete still held or being sent isn't done, though its notice has lost its * Undo: that one stays until the delete settles. Nor is a change PM couldn't finish: its notice - * keeps its buttons until it's settled, whatever its tone; nor a new event PM may have made. */ + * keeps its buttons until it's settled, whatever its tone; nor a new event PM may have made. Nor a + * milestone's move offer, which waits for an answer, or its Move back, which has its own window. */ function put(notice: WriteNotice) { const shown = state.notices.some((n) => n.id === notice.id); update({ @@ -166,7 +187,15 @@ function put(notice: WriteNotice) { : [...state.notices, notice], }); const inFlight = state.held.some((h) => h.noticeId === notice.id); - if (notice.tone === "ok" && !notice.undoToken && !notice.split && !notice.create && !inFlight) { + if ( + notice.tone === "ok" && + !notice.undoToken && + !notice.split && + !notice.create && + !notice.move && + !notice.moveBack && + !inFlight + ) { if (subscribers.size > 0) expire(notice); else unseen.add(notice); } @@ -490,6 +519,8 @@ export async function settleSplit( }); } else { put({ id, ...settleText(result, title) }); + // A Finish that lands offers what the first send read, as a save does. + offerMoves(result); } return result; } @@ -518,6 +549,204 @@ export function noteSaved( ) { if (outcome.outcome !== "saved") return; put({ id: nextNotice++, ...savedNotice(summary, outcome, scope, from) }); + offerMoves(outcome); +} + +// --- how a repeating event repeats, and moving its milestones (C9b-2) --- + +/** Asks on their way, by row, so the popover's two effect runs in StrictMode (and two quick clicks) + * send one request. */ +const describing = new Map>(); + +/** How the repeating event a Google row belongs to repeats, in Google Calendar's words, or null when + * PM can't say. Only a read; the backend keeps the answer a while. */ +export function describeSeries(eventId: string): Promise { + const going = describing.get(eventId); + if (going) return going; + const ask = getSeriesSummary(eventId) + .catch(() => null) + .finally(() => describing.delete(eventId)); + describing.set(eventId, ask); + return ask; +} + +/** How long Move back is offered after a move, like a delete's Undo window; paused while the + * pointer or focus is on its notice. */ +export const MOVE_BACK_MS = 8000; + +/** How long Move back ignores a press once it shows. It takes Move's place, so a press meant for + * Move (a second Enter, a quick double-click) lands on it, and would undo the move just made. */ +const MOVE_BACK_ARMS_MS = 500; + +/** After a "this and following" change landed: one question per milestone the backend read as dated + * on or after its first day, or, when PM couldn't find the new part, one notice per milestone + * saying it stayed. Nothing for any other outcome. */ +function offerMoves(outcome: WriteOutcome): void { + if (outcome.outcome !== "saved" || !outcome.milestone_moves) return; + const moves = outcome.milestone_moves; + const pending = outcome.warnings.includes("mirror_refresh_pending"); + for (const m of moves.milestones) { + const move: MilestoneMove = { + milestoneId: m.id, + label: m.label, + project: m.project, + newTitle: moves.new_title, + earlierTitle: moves.earlier_title, + toIcal: moves.new_series_ical ?? "", + toDate: moves.first_day, + backIcal: moves.earlier_series_ical, + backDate: m.date, + earlierLastDay: moves.earlier_last_day, + today: moves.today, + pending, + }; + put( + moves.new_series_ical === null + ? { id: nextNotice++, tone: "warn", text: milestoneStayedText(move) } + : { id: nextNotice++, tone: "ok", text: moveOfferText(move), move }, + ); + } +} + +/** Notices with a move or move back on its way: a second press sends nothing. */ +const movingIds = new Set(); + +/** Where the pointer and focus are, by notice with a move: on one, they hold its Move back open. */ +const holding = new Map(); + +const held = (id: number) => { + const on = holding.get(id); + return !!on && (on.pointer || on.focus); +}; + +/** A Move back window open now: the notice it closes, its timer (null while held), when it ends, + * the time it had left when it was held, and when Move back first takes a press. */ +interface MoveBackWindow { + notice: WriteNotice; + timer: ReturnType | null; + endsAt: number; + left: number; + armsAt: number; +} +const moveBackWindows = new Map(); + +function runMoveBack(open: MoveBackWindow) { + open.endsAt = Date.now() + open.left; + open.timer = setTimeout(() => closeMoveBack(open.notice.id), open.left); +} + +/** Offer Move back on `notice` for {@link MOVE_BACK_MS}, waiting first if it's held already. */ +function openMoveBack(notice: WriteNotice) { + const open: MoveBackWindow = { + notice, + timer: null, + endsAt: 0, + left: MOVE_BACK_MS, + armsAt: Date.now() + MOVE_BACK_ARMS_MS, + }; + moveBackWindows.set(notice.id, open); + if (!held(notice.id)) runMoveBack(open); +} + +/** End the Move back window on notice `id`, if one is open: the notice it was opened on. */ +function endMoveBack(id: number): WriteNotice | null { + const open = moveBackWindows.get(id); + if (!open) return null; + moveBackWindows.delete(id); + if (open.timer) clearTimeout(open.timer); + return open.notice; +} + +/** The window over: Move back goes, and the notice then goes by itself like any done one. Only if + * that very notice is still up. */ +function closeMoveBack(id: number) { + const notice = endMoveBack(id); + if (notice && state.notices.includes(notice)) { + put({ id: notice.id, tone: notice.tone, text: notice.text }); + } +} + +/** The pointer or focus (`by`) is on a notice with a move (`on`), or has left it. Move back waits + * while either is there (WCAG 2.2.1), then goes on with the time it had left. */ +export function holdMoveBack(id: number, by: "pointer" | "focus", on: boolean) { + const where = { ...(holding.get(id) ?? { pointer: false, focus: false }), [by]: on }; + if (where.pointer || where.focus) holding.set(id, where); + else holding.delete(id); + const open = moveBackWindows.get(id); + if (!open) return; + if (held(id) && open.timer) { + clearTimeout(open.timer); + open.timer = null; + open.left = Math.max(0, open.endsAt - Date.now()); + } else if (!held(id) && !open.timer) { + runMoveBack(open); + } +} + +/** A move or move back from the notice `noticeId`: marked on its way, sent, then said. Whether it + * changed the milestone. */ +async function sendMove( + noticeId: number, + pick: (n: WriteNotice) => MilestoneMove | undefined, + send: (m: MilestoneMove) => Promise, + done: (m: MilestoneMove) => void, + failed: (m: MilestoneMove, why: string) => void, +): Promise { + const shown = state.notices.find((n) => n.id === noticeId); + const move = shown && pick(shown); + if (!shown || !move || movingIds.has(noticeId)) return false; + movingIds.add(noticeId); + put({ ...shown, moving: true }); + try { + await send(move); + } catch (e) { + failed(move, message(e)); + return false; + } finally { + movingIds.delete(noticeId); + } + done(move); + return true; +} + +/** Move the milestone a notice offers to the new part: only while it's still linked to the earlier + * one, so an offer left up never undoes a change made since. Move back is then offered for + * {@link MOVE_BACK_MS}. Whether it moved. */ +export function moveMilestone(noticeId: number): Promise { + return sendMove( + noticeId, + (n) => n.move, + (m) => setMilestoneEvent(m.milestoneId, m.toIcal, m.toDate, m.backIcal), + (m) => { + // The notice's one button stays where it was, now Move back. + const notice: WriteNotice = { id: noticeId, tone: "ok", text: movedText(m), moveBack: m }; + put(notice); + openMoveBack(notice); + }, + // The Move button stays, for another try. + (m, why) => put({ id: noticeId, tone: "error", text: moveFailedText(m, why), move: m }), + ); +} + +/** Move it back to the earlier part, while the moved notice offers that: only while it's still + * linked to the new part, and never in its first {@link MOVE_BACK_ARMS_MS}. Whether it moved. */ +export function moveMilestoneBack(noticeId: number): Promise { + const open = moveBackWindows.get(noticeId); + if (open && Date.now() < open.armsAt) return Promise.resolve(false); + return sendMove( + noticeId, + (n) => n.moveBack, + (m) => setMilestoneEvent(m.milestoneId, m.backIcal, m.backDate, m.toIcal), + (m) => { + endMoveBack(noticeId); + put({ id: noticeId, tone: "ok", text: movedBackText(m) }); + }, + // No button: the window was the one chance, and the words name the way out. + (m, why) => { + endMoveBack(noticeId); + put({ id: noticeId, tone: "error", text: moveBackFailedText(m, why) }); + }, + ); } // --- creating an event (C10) --- @@ -757,5 +986,9 @@ export function useEventWrites() { noteSeriesNotice, dismissNotice, pruneRemoved, + describeSeries, + moveMilestone, + moveMilestoneBack, + holdMoveBack, }; } diff --git a/src/components/calendar/parts/CalendarEventPopover.test.tsx b/src/components/calendar/parts/CalendarEventPopover.test.tsx index da3bdddc..f3a5dde6 100644 --- a/src/components/calendar/parts/CalendarEventPopover.test.tsx +++ b/src/components/calendar/parts/CalendarEventPopover.test.tsx @@ -13,7 +13,7 @@ // be wrong. There is no scrim, the calendar behind stays live, and clicking a different event is how // you move between them — Tab must be able to leave. -import { cleanup, fireEvent, render } from "@testing-library/react"; +import { act, cleanup, fireEvent, render } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CalendarEvent } from "../../../lib/types"; @@ -208,7 +208,7 @@ describe("CalendarEventPopover", () => { const second = chip("Standup"); first.focus(); - const { rerender } = render( + const { rerender, getByRole } = render( { />, ); + // Re-pointed, the panel takes focus again, as it did when it opened. + expect(getByRole("dialog").contains(document.activeElement)).toBe(true); fireEvent.keyDown(window, { key: "Escape" }); expect(document.activeElement).toBe(second); }); @@ -312,7 +314,8 @@ describe("CalendarEventPopover", () => { }); // #884 C7: an iCal series says how it repeats; a row still holding a raw rule from before says - // only "Repeats", as does one with no summary (every Google occurrence). + // only "Repeats", as does one with no summary. A Google occurrence holds no rule: its words come + // from Google through `describeSeries` (C9b-2, below), and without it it reads "Repeats" too. it.each([ ["Weekly on Monday", "Weekly on Monday"], ["FREQ=WEEKLY;BYDAY=MO", "Repeats"], @@ -333,6 +336,131 @@ describe("CalendarEventPopover", () => { expect(text).not.toContain("FREQ="); }); + describe("how a Google occurrence repeats (C9b-2)", () => { + const occurrence = (id = "e1") => + calendarEvent({ id, recurring: true, series_id: "abc", summary: "Gym" }); + + /** A `describeSeries` whose answers the test gives, one per call, in order. */ + function deferred() { + const answers: Array<{ resolve: (w: string | null) => void; reject: (e: Error) => void }> = + []; + const ask = vi.fn<(id: string) => Promise>( + () => + new Promise((resolve, reject) => { + answers.push({ resolve, reject }); + }), + ); + return { ask, answers }; + } + + function popover( + event: CalendarEvent, + anchor: DOMRect, + ask?: (id: string) => Promise, + ) { + return ( + {}} + describeSeries={ask} + /> + ); + } + + it('a Google occurrence reads "Repeats", then how it repeats', async () => { + const { ask, answers } = deferred(); + const view = render(popover(occurrence(), rect(100), ask)); + expect(view.getByText("Repeats")).toBeTruthy(); + expect(ask).toHaveBeenCalledTimes(1); + expect(ask).toHaveBeenCalledWith("e1"); + await act(async () => answers[0].resolve("Weekly on Monday")); + expect(view.getByText("Weekly on Monday")).toBeTruthy(); + expect(view.queryByText("Repeats")).toBeNull(); + }); + + it('a failed or empty answer keeps "Repeats"', async () => { + for (const answer of [new Error("offline"), null, " "]) { + const { ask, answers } = deferred(); + const view = render(popover(occurrence(), rect(100), ask)); + await act(async () => + answer instanceof Error ? answers[0].reject(answer) : answers[0].resolve(answer), + ); + expect(view.getByText("Repeats")).toBeTruthy(); + view.unmount(); + } + }); + + it("a row with its own words, or a one-off, never asks", () => { + const { ask } = deferred(); + const feed = calendarEvent({ recurring: true, recurrence_summary: "Weekly on Monday" }); + const view = render(popover(feed, rect(100), ask)); + expect(view.getByText("Weekly on Monday")).toBeTruthy(); + view.unmount(); + const once = render(popover(calendarEvent({ series_id: null }), rect(100), ask)); + expect(once.queryByText("Repeats")).toBeNull(); + expect(ask).not.toHaveBeenCalled(); + }); + + it("re-pointed at another event, the old words never show", async () => { + const { ask, answers } = deferred(); + const view = render(popover(occurrence("e1"), rect(100), ask)); + await act(async () => answers[0].resolve("Weekly on Monday")); + expect(view.getByText("Weekly on Monday")).toBeTruthy(); + // The keyboard path re-points the mounted panel: the next event reads "Repeats" at once. + view.rerender(popover(occurrence("e2"), rect(300), ask)); + expect(view.queryByText("Weekly on Monday")).toBeNull(); + expect(view.getByText("Repeats")).toBeTruthy(); + // And an answer for an event no longer shown is dropped, even one that comes after the shown + // event's own (that one kept by the backend, the older one still out at Google). + view.rerender(popover(occurrence("e3"), rect(500), ask)); + await act(async () => answers[2].resolve("Daily")); + expect(view.getByText("Daily")).toBeTruthy(); + await act(async () => answers[1].resolve("Weekly on Tuesday")); + expect(view.queryByText("Weekly on Tuesday")).toBeNull(); + expect(view.getByText("Daily")).toBeTruthy(); + }); + + it("the words arriving never move focus", async () => { + // jsdom has no layout: the panel is taller once the words are in, and opens flipped above a + // chip near the bottom, so the words move it. + const height = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"); + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + get(this: HTMLElement) { + return this.textContent?.includes("Weekly on Monday") ? 300 : 200; + }, + }); + try { + const { ask, answers } = deferred(); + const view = render(popover(occurrence(), rect(window.innerHeight - 60), ask)); + const panel = view.getByRole("dialog"); + const before = panel.style.top; + const elsewhere = chip("Standup"); + elsewhere.focus(); + await act(async () => answers[0].resolve("Weekly on Monday")); + expect(panel.style.top).not.toBe(before); + expect(document.activeElement).toBe(elsewhere); + } finally { + if (height) Object.defineProperty(HTMLElement.prototype, "offsetHeight", height); + } + }); + + it("re-pointed at another event, the old flags go even when its read fails", async () => { + eventFlags.mockResolvedValueOnce([{ id: 1, type: "prep-needed" }]); + const view = render(popover(calendarEvent({ uid: "u1" }), rect(100))); + await act(async () => {}); + expect(view.getByText("prep needed")).toBeTruthy(); + eventFlags.mockRejectedValueOnce(new Error("locked")); + view.rerender(popover(calendarEvent({ id: "e2", uid: "u2" }), rect(300))); + await act(async () => {}); + expect(view.queryByText("prep needed")).toBeNull(); + }); + }); + // #884: CalendarView passes `editing`; FocusUpcoming doesn't, and must stay as it was. it("offers no editing at all without the editing prop", () => { const { queryByRole, getByRole } = render( diff --git a/src/components/calendar/parts/CalendarEventPopover.tsx b/src/components/calendar/parts/CalendarEventPopover.tsx index b878e24f..63822355 100644 --- a/src/components/calendar/parts/CalendarEventPopover.tsx +++ b/src/components/calendar/parts/CalendarEventPopover.tsx @@ -55,6 +55,9 @@ interface Props { /** Filled with a way to close the panel from outside it (CalendarView's Ctrl+Z), handing focus * back as Escape does. */ closeRef?: MutableRefObject<(() => void) | null>; + /** How the repeating event a Google row belongs to repeats, in Google Calendar's words, or null + * when PM can't say. Without it the line reads "Repeats" (C9b-2). */ + describeSeries?: (eventId: string) => Promise; } /** The popover's editing actions. */ @@ -140,6 +143,7 @@ export function CalendarEventPopover({ onOpenProject, editing, closeRef, + describeSeries, }: Props) { const { showPower } = useDepth(); // The colour the event was given in Google, if any. @@ -147,9 +151,17 @@ export function CalendarEventPopover({ const panelRef = useRef(null); const [pos, setPos] = useState<{ left: number; top: number } | null>(null); const [flags, setFlags] = useState([]); + // How a Google occurrence's repeating event repeats, kept with the event it was asked for. + const [seriesWords, setSeriesWords] = useState<{ id: string; words: string } | null>(null); + // Shown only while that event is: the keyboard path re-points this mounted panel at another chip + // (see the focus block), and the last event's words must never show on the next one, not even + // for one paint. Until they come, and when they can't, the line reads "Repeats". + const words = seriesWords?.id === event.id ? seriesWords.words : null; // Position beside the anchor once measured: clamp horizontally, prefer below, flip above (or clamp - // to the bottom) when there isn't room. Hidden until placed so it never flashes at 0,0. + // to the bottom) when there isn't room. Hidden until placed so it never flashes at 0,0. Measured + // again when the repeat line's words or the flags arrive, since either can make the panel taller, + // and one flipped above its chip would then grow over it; a move only when the numbers change. useLayoutEffect(() => { const el = panelRef.current; if (!el) return; @@ -161,8 +173,9 @@ export function CalendarEventPopover({ const above = anchor.top - 6 - h; top = above >= MARGIN ? above : Math.max(MARGIN, window.innerHeight - h - MARGIN); } - setPos({ left, top }); - }, [anchor]); + setPos((p) => (p && p.left === left && p.top === top ? p : { left, top })); + }, [anchor, words, flags.length]); + const placed = pos !== null; // Focus handling for a NON-MODAL dialog. This panel is deliberately not a `Modal`: there is no // scrim, the calendar behind it stays live, and clicking a different event dismisses this one and @@ -199,15 +212,17 @@ export function CalendarEventPopover({ }; }, [closeRef, restoreFocus, onClose]); - // Move focus onto the panel once it has been PLACED. Keyed on `pos`, which is only set after the - // measuring layout effect above: until then the panel is `visibility: hidden`, and a hidden - // element cannot take focus. + // Move focus onto the panel once it has been PLACED, and again when it's re-pointed at another + // chip. Keyed on `placed`, which is only true after the measuring layout effect above: until then + // the panel is `visibility: hidden`, and a hidden element cannot take focus. Never on a later + // move: the repeat line's words arriving re-place the panel, and focus the user has since taken + // elsewhere (Tab out to the grid) must stay there. useEffect(() => { - if (!pos) return; + if (!placed) return; const el = panelRef.current; // Never steal focus back from a child the user has already reached. if (el && !el.contains(document.activeElement)) el.focus(); - }, [pos]); + }, [anchor, placed]); // Dismiss on Escape or a click/tap outside the panel. useEffect(() => { @@ -225,12 +240,11 @@ export function CalendarEventPopover({ }; }, [dismiss, onClose]); - // Load any PM flags anchored on this event's UID. + // Load any PM flags anchored on this event's UID. The last event's go first, whatever this one's + // read comes to: re-pointed at another chip, a read that fails mustn't leave them up. useEffect(() => { - if (!event.uid) { - setFlags([]); - return; - } + setFlags([]); + if (!event.uid) return; let alive = true; eventFlags(event.uid) .then((f) => { @@ -242,6 +256,23 @@ export function CalendarEventPopover({ }; }, [event.uid]); + // How a Google occurrence's repeating event repeats, in Google Calendar's words: asked once per + // event shown (C9b-2). A feed's row has its own words and never asks. + useEffect(() => { + if (!event.recurring || !event.series_id || !describeSeries) return; + const id = event.id; + let alive = true; + describeSeries(id) + .then((said) => { + const text = said?.trim(); + if (alive && text) setSeriesWords({ id, words: text }); + }) + .catch(() => {}); + return () => { + alive = false; + }; + }, [event.id, event.recurring, event.series_id, describeSeries]); + const attendees = event.attendees ?? []; // A `role="dialog"` with a blank accessible name is announced as an unnamed dialog, and the @@ -287,7 +318,7 @@ export function CalendarEventPopover({
{whenText(event)}
- {event.recurring &&
{repeatsText(event)}
} + {event.recurring &&
{words ?? repeatsText(event)}
}
{event.show_as && {showAsLabel(event.show_as)}} diff --git a/src/components/calendar/parts/whenText.ts b/src/components/calendar/parts/whenText.ts index dfc1ad78..e061fdca 100644 --- a/src/components/calendar/parts/whenText.ts +++ b/src/components/calendar/parts/whenText.ts @@ -33,7 +33,9 @@ export function whenText(ev: CalendarEvent): string { /** How a repeating event repeats, in words ("Weekly on Monday"), or a plain "Repeats" when PM has * no words for it. Until 3.144 an iCal row held its raw rule ("FREQ=WEEKLY;BYDAY=MO"); one that - * hasn't synced since still does, and reads as a plain "Repeats" until it has. */ + * hasn't synced since still does, and reads as a plain "Repeats" until it has. A Google row holds + * no rule: the popover asks Google for its words (its `describeSeries`), and this is what it shows + * until they come, or when they can't. */ export function repeatsText(ev: CalendarEvent): string { const summary = ev.recurrence_summary?.trim(); return summary && !/^(RRULE:)?FREQ=/i.test(summary) ? summary : "Repeats"; diff --git a/src/lib/calendarEdit/editReasons.test.ts b/src/lib/calendarEdit/editReasons.test.ts index 8c524074..3e3ae8ef 100644 --- a/src/lib/calendarEdit/editReasons.test.ts +++ b/src/lib/calendarEdit/editReasons.test.ts @@ -6,7 +6,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ReadOnlyReason, SplitAction, WriteOutcome, WriteWarning } from "../types"; +import type { + MilestoneMove, + ReadOnlyReason, + SplitAction, + WriteOutcome, + WriteWarning, +} from "../types"; import { CALLOUT_TONE, CREATE_CHANGED, @@ -20,9 +26,18 @@ import { heldCreateText, retryAfter, earlierChangeText, + keepLabel, leftBehindText, listFields, loadText, + milestoneStayedText, + moveBackFailedText, + moveBackLabel, + movedBackText, + movedText, + moveFailedText, + moveLabel, + moveOfferText, outcomeText, problemText, reasonText, @@ -226,7 +241,12 @@ const saved = (warnings: WriteWarning[], left_behind?: string[]) => describe("a write's warnings", () => { it("words every warning, for a save and for a delete", () => { - const all: WriteWarning[] = ["mirror_refresh_pending", "left_on_old_series", "outside_mirror"]; + const all: WriteWarning[] = [ + "mirror_refresh_pending", + "left_on_old_series", + "outside_mirror", + "milestone_not_offered", + ]; for (const w of all) { for (const kind of ["save", "delete"] as const) { expect(writeWarningText(w, kind)).toMatch(/^[A-Z].*\.$/); @@ -353,6 +373,11 @@ describe("settling a change PM couldn't finish", () => { "Gym", ).tone, ).toBe("warn"); + // A milestone that stayed has its own notice naming it (C9b-2): not said twice. + expect(settleText({ outcome: "saved", warnings: ["milestone_not_offered"] }, "Gym")).toEqual({ + tone: "ok", + text: "Finished the change to “Gym”.", + }); expect(settleText({ outcome: "split_removed" }, "Gym")).toEqual({ tone: "ok", text: "Removed the new part of “Gym”. The repeating event is as it was.", @@ -529,3 +554,83 @@ describe("creating an event", () => { ); }); }); + +// C9b-2: moving a milestone after a "this and following" change. +describe("move words", () => { + const launch: MilestoneMove = { + milestoneId: 7, + label: "Launch", + project: "Atlas", + newTitle: "Retro", + earlierTitle: "Gym", + toIcal: "tail@google.com", + toDate: "2026-10-19", + backIcal: "abc@google.com", + backDate: "2026-10-19", + earlierLastDay: "2026-10-12", + today: "2026-10-20", + pending: false, + }; + + it("names the new part by its title and first day, and what saying no costs", () => { + expect(moveOfferText(launch)).toBe( + "Move the “Launch” milestone in Atlas to the new part of “Retro”, which starts on 19-10? If you don't, it stays on the earlier part, which ended on 12-10, and shows as overdue.", + ); + // Days PM can't say are left out; a year outside this one is said; a blank title has a name. + expect( + moveOfferText({ ...launch, newTitle: " ", toDate: null, earlierLastDay: "2025-12-29" }), + ).toBe( + "Move the “Launch” milestone in Atlas to the new part of “(no title)”? If you don't, it stays on the earlier part, which ended on 29-12-2025, and shows as overdue.", + ); + expect(moveOfferText({ ...launch, earlierLastDay: null })).toMatch( + /stays on the earlier part, which has ended, and shows as overdue\.$/, + ); + }); + + it("names each part by its own title once the change retitled the new one", () => { + expect(moveLabel(launch)).toBe("Move “Launch” to the new part of “Retro”"); + expect(keepLabel(launch)).toBe("Keep it there: “Launch” stays on the earlier part of “Gym”"); + expect(movedText(launch)).toBe("Moved “Launch” to the new part of “Retro”."); + expect(moveBackLabel(launch)).toBe("Move back “Launch” to the earlier part of “Gym”"); + expect(movedBackText(launch)).toBe("“Launch” is back on the earlier part of “Gym”."); + }); + + it("says when PM's calendar will show a move to a part it hasn't shown yet", () => { + expect(movedText({ ...launch, pending: true })).toBe( + "Moved “Launch” to the new part of “Retro”. PM's calendar will show its new date after the next refresh.", + ); + }); + + it("says why a move failed, and the way out when a move back did", () => { + expect(moveFailedText(launch, "That milestone no longer exists.")).toBe( + "Couldn't move “Launch”. That milestone no longer exists.", + ); + expect(moveBackFailedText(launch, "That milestone changed since; nothing was moved.")).toBe( + "Couldn't move “Launch” back. That milestone changed since; nothing was moved. Unlink it in Atlas if it shouldn't follow the new part.", + ); + }); + + it("says which milestone stayed when PM couldn't find the new part", () => { + expect(milestoneStayedText(launch)).toBe( + "PM couldn't find the new part of “Retro” in Google, so “Launch” in Atlas stays on the earlier part, which ended on 12-10, and shows as overdue. Unlink it in Atlas to set its date yourself.", + ); + }); + + // C9b-2 review round 3, R3-F3: the earlier part's last day still to come, as when an occurrence + // before the change was moved past it. + it("says an earlier part whose last day is today or later hasn't ended", () => { + expect(moveOfferText({ ...launch, earlierLastDay: "2026-10-21" })).toBe( + "Move the “Launch” milestone in Atlas to the new part of “Retro”, which starts on 19-10? If you don't, it stays on the earlier part, whose last day is 21-10, and shows as overdue after that.", + ); + expect(milestoneStayedText({ ...launch, earlierLastDay: "2026-10-21" })).toBe( + "PM couldn't find the new part of “Retro” in Google, so “Launch” in Atlas stays on the earlier part, whose last day is 21-10, and shows as overdue after that. Unlink it in Atlas to set its date yourself.", + ); + // Today is its last day; yesterday's has ended. + expect(moveOfferText({ ...launch, earlierLastDay: "2026-10-20" })).toMatch( + /stays on the earlier part, whose last day is 20-10, and shows as overdue after that\.$/, + ); + expect(milestoneStayedText({ ...launch, earlierLastDay: "2026-10-19" })).toMatch( + /stays on the earlier part, which ended on 19-10, and shows as overdue\. Unlink/, + ); + }); +}); diff --git a/src/lib/calendarEdit/editReasons.ts b/src/lib/calendarEdit/editReasons.ts index 33441214..18ea548f 100644 --- a/src/lib/calendarEdit/editReasons.ts +++ b/src/lib/calendarEdit/editReasons.ts @@ -9,6 +9,7 @@ import type { Tone as CalloutTone } from "../../components/ui/tone"; import { formatDateOnly } from "../format"; import type { EditLoad, + MilestoneMove, ReadOnlyReason, SeriesScope, SplitAction, @@ -145,6 +146,9 @@ export function writeWarningText( // Only a new event comes back with it. case "outside_mirror": return "It starts outside the months PM syncs, so PM's calendar won't show it."; + // A notice per milestone names each one (`milestoneStayedText`); this is the short form. + case "milestone_not_offered": + return "PM couldn't find the new part in Google, so a linked milestone stays on the earlier part."; default: return unreachable(warning); } @@ -365,7 +369,10 @@ export function settleText(result: WriteOutcome, title: string): { tone: Tone; t const t = quote(title); switch (result.outcome) { case "saved": { - const warnings = result.warnings.map((w) => writeWarningText(w, "save", result.left_behind)); + // A milestone that stayed has a notice of its own, naming it. + const warnings = result.warnings + .filter((w) => w !== "milestone_not_offered") + .map((w) => writeWarningText(w, "save", result.left_behind)); return { tone: result.warnings.includes("left_on_old_series") ? "warn" : "ok", text: [`Finished the change to ${t}.`, ...warnings].join(" "), @@ -607,6 +614,67 @@ export function heldCreateText(title: string): { tone: Tone; text: string } { }; } +// --- moving a milestone after a "this and following" change (C9b-2) --- + +/** Where the earlier part ends and what a milestone left on it costs, for the words: a last day + * before today has ended, so it shows as overdue now; one today or later, only after that (C9b-2 + * review round 3, R3-F3). With no last day PM can say, just that it ended. */ +const staysThere = (m: MilestoneMove) => { + const day = m.earlierLastDay; + if (!day) return "which has ended, and shows as overdue"; + return day < m.today + ? `which ended on ${formatDateOnly(day)}, and shows as overdue` + : `whose last day is ${formatDateOnly(day)}, and shows as overdue after that`; +}; + +/** The question after a "this and following" change landed (DECISIONS 17-10 #2): the new part by + * its title and first day, and what saying no costs (C9b-2 critique C2-H1). */ +export function moveOfferText(m: MilestoneMove): string { + const starts = m.toDate ? `, which starts on ${formatDateOnly(m.toDate)}` : ""; + return `Move the “${m.label}” milestone in ${m.project} to the new part of ${quote(m.newTitle)}${starts}? If you don't, it stays on the earlier part, ${staysThere(m)}.`; +} + +/** The Move button's accessible name. Each move button's name starts with the words it shows, so + * saying "click Move back" or "click Keep it there" finds it (WCAG 2.5.3). */ +export function moveLabel(m: MilestoneMove): string { + return `Move “${m.label}” to the new part of ${quote(m.newTitle)}`; +} + +/** The Keep it there button's accessible name. */ +export function keepLabel(m: MilestoneMove): string { + return `Keep it there: “${m.label}” stays on the earlier part of ${quote(m.earlierTitle)}`; +} + +/** A move that landed; a new part PM's calendar doesn't show yet says when it will. */ +export function movedText(m: MilestoneMove): string { + const later = m.pending ? " PM's calendar will show its new date after the next refresh." : ""; + return `Moved “${m.label}” to the new part of ${quote(m.newTitle)}.${later}`; +} + +/** The Move back button's accessible name. */ +export function moveBackLabel(m: MilestoneMove): string { + return `Move back “${m.label}” to the earlier part of ${quote(m.earlierTitle)}`; +} + +export function movedBackText(m: MilestoneMove): string { + return `“${m.label}” is back on the earlier part of ${quote(m.earlierTitle)}.`; +} + +/** `why`: what the move threw ("That milestone no longer exists."). */ +export function moveFailedText(m: MilestoneMove, why: string): string { + return `Couldn't move “${m.label}”. ${why}`; +} + +export function moveBackFailedText(m: MilestoneMove, why: string): string { + return `Couldn't move “${m.label}” back. ${why} Unlink it in ${m.project} if it shouldn't follow the new part.`; +} + +/** A milestone PM couldn't offer to move, because it couldn't find the new part (C9b-2 critique + * C2-M1): where it stays, what that costs, and the way out. */ +export function milestoneStayedText(m: MilestoneMove): string { + return `PM couldn't find the new part of ${quote(m.newTitle)} in Google, so “${m.label}” in ${m.project} stays on the earlier part, ${staysThere(m)}. Unlink it in ${m.project} to set its date yourself.`; +} + /** Why the editor couldn't open. */ export function loadText(load: Exclude): string { switch (load.outcome) { diff --git a/src/lib/calendarEdit/saveChoices.test.ts b/src/lib/calendarEdit/saveChoices.test.ts index a18c0728..2f894aff 100644 --- a/src/lib/calendarEdit/saveChoices.test.ts +++ b/src/lib/calendarEdit/saveChoices.test.ts @@ -286,8 +286,10 @@ describe("the warnings", () => { }); it("says where links go when it becomes a new repeating event, and what an earlier change made", () => { - expect(seriesWarningText({ warning: "new_identity" }, "save", "following", "Gym")).toMatch( - /milestone stays with the earlier part, and reminders to prepare may show again/, + // C9b-2: a milestone is offered a move after saving (hedged: only when PM can make the offer), + // and the flags' warning stays before the save (scope-L9). + expect(seriesWarningText({ warning: "new_identity" }, "save", "following", "Gym")).toBe( + "From this occurrence on, it becomes a new repeating event in Google. If a milestone linked to it falls on or after this day, PM offers to move it to the new part after saving. Reminders to prepare may show again for the new part.", ); const sibling = (kind: "save" | "delete") => seriesWarningText({ warning: "split_sibling" }, kind, "all", "Gym"); diff --git a/src/lib/calendarEdit/saveChoices.ts b/src/lib/calendarEdit/saveChoices.ts index 6392deff..88fa2da6 100644 --- a/src/lib/calendarEdit/saveChoices.ts +++ b/src/lib/calendarEdit/saveChoices.ts @@ -246,7 +246,9 @@ export function seriesWarningText( return `The new repeating event this makes won't have ${andList(missing)}. The earlier part ${keeps}.${call}`; } case "new_identity": - return "From this occurrence on, it becomes a new repeating event in Google. A linked milestone stays with the earlier part, and reminders to prepare may show again for the new part."; + // The flags' warning (C9 design scope-L9) is the last sentence: they aren't moved, and no + // question follows the save for them (DECISIONS 17-10 #2). + return "From this occurrence on, it becomes a new repeating event in Google. If a milestone linked to it falls on or after this day, PM offers to move it to the new part after saving. Reminders to prepare may show again for the new part."; case "split_sibling": { const head = `Part of ${t} was made into a repeating event of its own by an earlier “this and following” change.`; return kind === "save" diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index dcc030d4..fe5a0096 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -23,6 +23,14 @@ export interface ChangelogEntry { } export const CHANGELOG: ChangelogEntry[] = [ + { + version: "3.152.0-alpha", + date: "2026-10-20", + highlights: [ + "Repeating Google events now say how they repeat in their details, such as “Weekly on Monday”, just as Google Calendar does.", + "After you change “this and the following events”, PM offers to move a milestone already linked to that event over to the new part, so the milestone keeps following your schedule instead of being left on the part that has ended. If you move it by mistake, you have a few seconds to move it back.", + ], + }, { version: "3.151.0-alpha", date: "2026-10-20", diff --git a/src/lib/help.ts b/src/lib/help.ts index 03963ac4..1ccd1019 100644 --- a/src/lib/help.ts +++ b/src/lib/help.ts @@ -262,7 +262,7 @@ export const HELP: Record = { // Calendar (unified view, card 8; editing #884) "calendar-view": { title: "Your calendar", - body: "A single view of every calendar you've connected, merged together and colour-coded by source (an event you gave its own colour in Google shows in it, with its calendar's colour down the edge), so you can see your whole schedule in one place; PM uses the same events for your agenda, chat answers and 'Due soon' flags. While PM is open, changes made in Google show within about half a minute. Click an event for its details. On a Google account with editing turned on (Settings → Connectors), Edit and Delete appear there for events you organise without guests. On those accounts, + New event (or press c for the full editor, Shift+C for a quick one) creates an event on a calendar you choose; it goes straight to Google, with the calendar's usual notifications and nobody invited, and PM starts it on the calendar you last created one in. In the quick one, type a title and press Enter; in the day, week, month and agenda views the new event shows as you type (the Year view and the Terminal look show only what's in your calendar), and More options opens the full editor. Clicking elsewhere on the calendar closes the quick one only while it has no title, and a titled one is still there if you switch tabs and come back. For a repeating event PM asks whether you mean this event, this and the following events, or all of them, as Google Calendar does, and says first what that would change. Changes go straight to Google: a save never overwrites a field someone changed there in the meantime, and a delete waits a few seconds for Undo (or Ctrl+Z, ⌘Z on a Mac) and doesn't happen if the title, time or place changed there. Anything else says why it can't be changed. Connect or choose calendars in Settings → Connectors.", + body: "A single view of every calendar you've connected, merged together and colour-coded by source (an event you gave its own colour in Google shows in it, with its calendar's colour down the edge), so you can see your whole schedule in one place; PM uses the same events for your agenda, chat answers and 'Due soon' flags. While PM is open, changes made in Google show within about half a minute. Click an event for its details. On a Google account with editing turned on (Settings → Connectors), Edit and Delete appear there for events you organise without guests. On those accounts, + New event (or press c for the full editor, Shift+C for a quick one) creates an event on a calendar you choose; it goes straight to Google, with the calendar's usual notifications and nobody invited, and PM starts it on the calendar you last created one in. In the quick one, type a title and press Enter; in the day, week, month and agenda views the new event shows as you type (the Year view and the Terminal look show only what's in your calendar), and More options opens the full editor. Clicking elsewhere on the calendar closes the quick one only while it has no title, and a titled one is still there if you switch tabs and come back. For a repeating event PM asks whether you mean this event, this and the following events, or all of them, as Google Calendar does, and says first what that would change. A repeating event's details say how it repeats, as Google Calendar does. After you change “this and the following events”, PM asks whether to move a milestone already linked to it, dated from that day on, to the new part. Changes go straight to Google: a save never overwrites a field someone changed there in the meantime, and a delete waits a few seconds for Undo (or Ctrl+Z, ⌘Z on a Mac) and doesn't happen if the title, time or place changed there. Anything else says why it can't be changed. Connect or choose calendars in Settings → Connectors.", }, "calendar-header": { title: "Move around & choose a view", @@ -586,7 +586,7 @@ export const HELP: Record = { }, "project-milestones": { title: "Milestones", - body: "A project's deadlines — a pitch, a presentation, an internal due date — each with a name and a date. Tick the checkbox on the left to mark one done: it gets a 'Done' tag and a line through it. The nearest one you haven't ticked off drives the 'Due soon' status; tick it and the next takes over. Reorder with the arrows, remove with ×. Link a milestone to a calendar event (📅) and its date stays in sync with your calendar automatically.", + body: "A project's deadlines — a pitch, a presentation, an internal due date — each with a name and a date. Tick the checkbox on the left to mark one done: it gets a 'Done' tag and a line through it. The nearest one you haven't ticked off drives the 'Due soon' status; tick it and the next takes over. Reorder with the arrows, remove with ×. A milestone linked to a calendar event (📅) takes its date from that event; Unlink makes the date yours to edit.", }, "project-resize": { title: "Resize the panel", @@ -598,7 +598,7 @@ export const HELP: Record = { }, "project-milestones-panel": { title: "This project's milestones", - body: "The project's deadlines, editable in place — add one, date it, tick it off, reorder, or link it to a calendar event. The nearest one you haven't ticked drives the project's 'Due soon' status. Drag the divider below to give this panel more or less room against the files list.", + body: "The project's deadlines, editable in place — add one, date it, tick it off, reorder, or unlink one that takes its date from a calendar event. The nearest one you haven't ticked drives the project's 'Due soon' status. Drag the divider below to give this panel more or less room against the files list.", }, "project-split": { title: "Resize milestones vs files", diff --git a/src/lib/ipc.ts b/src/lib/ipc.ts index 52d929d5..908fda04 100644 --- a/src/lib/ipc.ts +++ b/src/lib/ipc.ts @@ -944,9 +944,15 @@ export const addMilestone = ( export const updateMilestone = (id: number, label: string, dueDate: string | null) => invoke("update_milestone", { id, label, dueDate }); -/** Link a milestone to a calendar event (uid + cached date) or unlink it (null uid). */ -export const setMilestoneEvent = (id: number, eventUid: string | null, cachedDate: string | null) => - invoke("set_milestone_event", { id, eventUid, cachedDate }); +/** Link a milestone to a calendar event (uid + cached date) or unlink it (null uid). + * `onlyIfLinkedTo`: only while it's still linked to that event, so an offer made a while ago never + * undoes a change made since; it throws when nothing was changed (the calendar's milestone move). */ +export const setMilestoneEvent = ( + id: number, + eventUid: string | null, + cachedDate: string | null, + onlyIfLinkedTo: string | null = null, +) => invoke("set_milestone_event", { id, eventUid, cachedDate, onlyIfLinkedTo }); /** Set a milestone's progress status — the SINGLE writer for "how far along is this". * @@ -1132,6 +1138,11 @@ export const listHeldDeletes = () => invoke("list_held_deletes export const seriesChoices = (request: ChoiceRequest) => invoke("series_choices", { request }); +/** How the repeating event a mirrored Google row belongs to repeats, in Google Calendar's words + * ("Weekly on Monday"), or null when PM can't say. Only a read goes to Google. */ +export const getSeriesSummary = (eventId: string) => + invoke("get_series_summary", { eventId }); + /** Settle a "this and following" change PM couldn't finish (`token` from a `partial_split` outcome * or a `pending` row): look again, remove the new part, or finish the change. */ export const settleSeriesSplit = (token: string, action: SplitAction) => diff --git a/src/lib/types.ts b/src/lib/types.ts index dce0afd1..d53c4256 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1704,14 +1704,74 @@ export type SeriesChoices = export type SeriesScope = "this" | "following" | "all"; /** `outside_mirror`: a new event saved in Google that starts outside the months PM mirrors, so - * PM's calendar won't show it. */ -export type WriteWarning = "mirror_refresh_pending" | "left_on_old_series" | "outside_mirror"; + * PM's calendar won't show it. `milestone_not_offered`: a "this and following" change landed, but + * PM couldn't tell which repeating event Google made of the new part, so the milestones + * `milestone_moves` lists stay on the earlier part. */ +export type WriteWarning = + "mirror_refresh_pending" | "left_on_old_series" | "outside_mirror" | "milestone_not_offered"; + +/** A milestone PM may move to the new part of a repeating event: its date (YYYY-MM-DD) before the + * change. */ +export interface MilestoneOffer { + id: number; + label: string; + project: string; + date: string; +} + +/** What moving milestones to a "this and following" change's new part needs. Days are YYYY-MM-DD + * in the user's zone; null when PM can't say. */ +export interface MilestoneMoves { + /** Where Move sends a milestone; null: PM couldn't tell (`milestone_not_offered`). */ + new_series_ical: string | null; + /** Where Move back sends it. */ + earlier_series_ical: string; + new_title: string; + earlier_title: string; + /** The new part's first occurrence. */ + first_day: string | null; + /** The earlier part's last occurrence: a milestone left on it reads overdue from then. */ + earlier_last_day: string | null; + /** Today, as the change landed: whether that last day has passed yet, for the words. */ + today: string; + milestones: MilestoneOffer[]; +} + +/** A milestone to move between the two parts of a repeating event a "this and following" change + * made (C9b-2): what Move and Move back send, and the words. */ +export interface MilestoneMove { + milestoneId: number; + label: string; + project: string; + /** Each part's title, as Google holds it after the change. */ + newTitle: string; + earlierTitle: string; + /** Where Move sends it, and the new part's first day: the date it shows until the next read + * (null when PM can't say: it keeps its own). */ + toIcal: string; + toDate: string | null; + /** Where Move back sends it, and its date before the change. */ + backIcal: string; + backDate: string; + /** The earlier part's last day, and today as the change landed, for the words. */ + earlierLastDay: string | null; + today: string; + /** The new part wasn't in PM's calendar yet when the change landed. */ + pending: boolean; +} /** What a save came to. `saved` and `no_change` are the successes. */ export type WriteOutcome = /** `left_behind`: the days (YYYY-MM-DD) of occurrences changed on their own that a series delete - * couldn't cancel, so they may still show. */ - | { outcome: "saved"; warnings: WriteWarning[]; left_behind?: string[] } + * couldn't cancel, so they may still show. `milestone_moves`: a "this and following" change that + * landed, with milestones linked to the earlier part dated from its first day on, but not one on + * an occurrence it left there (C9b-2). */ + | { + outcome: "saved"; + warnings: WriteWarning[]; + left_behind?: string[]; + milestone_moves?: MilestoneMoves; + } | { outcome: "no_change" } /** The repeating event's plan changed since the user chose: ask again; nothing was sent. */ | { outcome: "needs_choice" }