git.lucas.co / cce-list
things-to-remember checklist
git clone https://git.lucas.co/cce-list.git

commitcb878fc7cd0aa6486707c95fca8c71c818b53ef6
parent001c1010d0
authorLucas Galante <[email protected]>
date2026-09-06 13:21
Sync: create server items with the local checkbox state

A row checked off before it ever reached the server was created
needsAction and recorded as not done, so the very next pass saw a phantom
local change and pushed the completion separately. Creates now carry the
local done flag on both backends (STATUS:COMPLETED / status=completed) and
the state records it, so one pass converges. --dry-run marks such creates
with [x].

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/bin/sync.rs | 29 +++++++++++++++++------------
 1 file changed, 17 insertions(+), 12 deletions(-)

diff --git a/src/bin/sync.rs b/src/bin/sync.rs
index 42aa450..ba069cb 100644
--- a/src/bin/sync.rs
+++ b/src/bin/sync.rs
@@ -336,8 +336,10 @@ fn google_create(
     token: &str,
     target: &reqwest::Url,
     text: &str,
+    done: bool,
 ) -> Result<(String, reqwest::Url, String), String> {
-    let body = serde_json::json!({ "title": text, "status": "needsAction" });
+    let status = if done { "completed" } else { "needsAction" };
+    let body = serde_json::json!({ "title": text, "status": status });
     let resp = google_call(client, token, reqwest::Method::POST, target.as_str(), &[], Some(&body))?;
     let id = resp["id"].as_str().ok_or("created task has no id")?.to_string();
     let url = reqwest::Url::parse(&format!("{}/{id}", target.as_str().trim_end_matches('/')))
@@ -505,7 +507,10 @@ fn run_sync(backend: &Backend, dry_run: bool, force_deletes: bool) -> Result<(),
         }
     }
     let mut created: Vec<(String, String)> = Vec::new(); // (text, uid) to annotate
-    for text in &plan.push_creates {
+    for (text, done) in &plan.push_creates {
+        // A row checked off before it ever reached the server is created
+        // completed; recording that in the state is what keeps the next
+        // pass from seeing a phantom local change.
         let made = match (backend, &session) {
             (Backend::ICloud(acc), _) => {
                 let uid = new_uid();
@@ -514,12 +519,12 @@ fn run_sync(backend: &Backend, dry_run: bool, force_deletes: bool) -> Result<(),
                     .join(&format!("{uid}.ics"))
                     .map_err(|e| e.to_string())
                     .and_then(|url| {
-                        put_ics(&client, acc, &url, &new_vtodo(&uid, text, false), None)
+                        put_ics(&client, acc, &url, &new_vtodo(&uid, text, *done), None)
                             .map(|etag| (uid, url, etag))
                     })
             }
             (Backend::Google(_), Session::Google(token)) => {
-                google_create(&client, token, &remote.create_target, text)
+                google_create(&client, token, &remote.create_target, text, *done)
             }
             (Backend::Google(_), Session::ICloud) => unreachable!("session matches backend"),
         };
@@ -530,7 +535,7 @@ fn run_sync(backend: &Backend, dry_run: bool, force_deletes: bool) -> Result<(),
                     etag,
                     account: email.clone(),
                     text: text.clone(),
-                    done: false,
+                    done: *done,
                 });
                 created.push((text.clone(), uid));
             }
@@ -600,8 +605,8 @@ fn print_plan(plan: &Plan, remote: &BTreeMap<String, RemoteTodo>) {
     for uid in &plan.push_updates {
         println!("push change: {uid}");
     }
-    for text in &plan.push_creates {
-        println!("push new:    {text}");
+    for (text, done) in &plan.push_creates {
+        println!("push new:    {}{text}", if *done { "[x] " } else { "" });
     }
     for uid in &plan.push_deletes {
         println!("push delete: {uid}");
@@ -616,8 +621,8 @@ struct Plan {
     pull_updates: Vec<String>,
     pull_deletes: Vec<String>,
     push_updates: Vec<String>,
-    /// Texts of local uid-less rows to create server-side.
-    push_creates: Vec<String>,
+    /// (text, done) of local uid-less rows to create server-side.
+    push_creates: Vec<(String, bool)>,
     push_deletes: Vec<String>,
     /// Server etag moved but content is identical — track it, change nothing.
     refresh_etags: Vec<String>,
@@ -676,11 +681,11 @@ fn plan(local: &[Item], state: &SyncState, remote: &BTreeMap<String, RemoteTodo>
     }
     for item in local {
         match &item.uid {
-            None => plan.push_creates.push(item.text.clone()),
+            None => plan.push_creates.push((item.text.clone(), item.done)),
             // A uid the server never heard of and the state does not track:
             // recreate it under that uid rather than orphaning the row.
             Some(uid) if !remote.contains_key(uid) && !state.items.contains_key(uid) => {
-                plan.push_creates.push(item.text.clone());
+                plan.push_creates.push((item.text.clone(), item.done));
             }
             Some(_) => {}
         }
@@ -1236,7 +1241,7 @@ mod tests {
         assert_eq!(p.pull_updates, vec!["u3"]);
         assert_eq!(p.push_deletes, vec!["u4"]);
         assert_eq!(p.pull_new, vec!["u5"]);
-        assert_eq!(p.push_creates, vec!["fresh local"]);
+        assert_eq!(p.push_creates, vec![("fresh local".to_string(), false)]);
         assert!(p.pull_deletes.is_empty());
     }