Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions scripts/check-calendar-write-fence.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
92 changes: 92 additions & 0 deletions src-tauri/src/calendar_write/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<Box<MilestoneMoves>>,
},
/// Google already holds exactly this; nothing was sent.
NoChange,
Expand Down Expand Up @@ -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<String>,
/// 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<String>,
/// 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<String>,
/// 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<MilestoneOffer>,
}

/// 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).
Expand Down Expand Up @@ -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"]}"#
Expand All @@ -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"]}"#
Expand Down Expand Up @@ -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"]}"#,
),
Expand Down Expand Up @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/calendar_write/series/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ pub fn floor_of<'a>(moments: impl IntoIterator<Item = &'a str>) -> Option<String

/// Where a mirror row sits in its series: its `original_start` (or its start, for a row without one),
/// as the mirror normalises them (`…Z`, or a bare date).
fn row_point(row: &CalendarEvent) -> Option<Point> {
pub fn row_point(row: &CalendarEvent) -> Option<Point> {
mirror_point(row.original_start.as_deref().unwrap_or(&row.start))
}

Expand Down
61 changes: 54 additions & 7 deletions src-tauri/src/calendar_write/series/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,13 @@ pub enum End {
reply: Option<Value>,
date: Option<DateTime<Utc>>,
},
/// 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,
}
Expand Down Expand Up @@ -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<End> },
/// 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.
Expand All @@ -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<usize> {
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<usize> {
self.left_out(|e| *e == End::Skipped)
}

fn left_out(&self, of: impl Fn(&End) -> bool) -> Vec<usize> {
self.ends()
.iter()
.enumerate()
.filter(|(_, e)| **e == End::Skipped)
.filter(|(_, e)| of(e))
.map(|(i, _)| i)
.collect()
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
Loading
Loading