Add task count to task list panel header

Show 'X todo / Y done' in the Tasks panel title bar.

Also includes prior uncommitted work:
- Pagination in fetch_tasks (maxResults=100 + pageToken loop)
- fetch_tasks_since for incremental pull sync
- SyncStats struct with version/last_sync/last_pull/changed counts
- Periodic push (30s) and pull (5min) sync engine
- event::poll(100ms) for non-blocking UI refresh
- Ctrl+R full sync (push + pull)
- refresh_if_needed() to reload data after background sync
- Retry mechanism (MAX_SYNC_RETRIES=3) for sync queue items
- HTTP status code checks in fetch_lists/fetch_tasks/fetch_tasks_since
- Fix move_task URL to use reqwest query()
- Remove CASCADE via replace_all_lists (use insert_list instead)
- has_pending_sync() to prevent pull during pending push
This commit is contained in:
Ruben Rosario
2026-06-21 14:21:14 +01:00
parent ae9910bcbc
commit 6eee90f128
7 changed files with 394 additions and 69 deletions
+133 -29
View File
@@ -97,6 +97,12 @@ impl ApiClient {
.await
.map_err(|e| ApiError::Network(e.to_string()))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(ApiError::Api(format!("Fetch lists failed: {} - {}", status, body)));
}
let data: serde_json::Value = resp
.json()
.await
@@ -118,27 +124,128 @@ impl ApiClient {
pub async fn fetch_tasks(&self, list_id: &str) -> Result<Vec<Task>, ApiError> {
let token = self.get_token().await?;
let url = format!(
"https://tasks.googleapis.com/tasks/v1/lists/{}/tasks?showCompleted=true&showHidden=true",
list_id
);
let mut all_items: Vec<serde_json::Value> = Vec::new();
let mut page_token: Option<String> = None;
let resp = self
.client
.get(&url)
.bearer_auth(&token)
.send()
.await
.map_err(|e| ApiError::Network(e.to_string()))?;
loop {
let mut url = format!(
"https://tasks.googleapis.com/tasks/v1/lists/{}/tasks?showCompleted=true&showHidden=true&maxResults=100",
list_id
);
if let Some(ref pt) = page_token {
url.push_str(&format!("&pageToken={}", pt));
}
let data: serde_json::Value = resp
.json()
.await
.map_err(|e| ApiError::Api(e.to_string()))?;
let resp = self
.client
.get(&url)
.bearer_auth(&token)
.send()
.await
.map_err(|e| ApiError::Network(e.to_string()))?;
let empty = vec![];
let items = data["items"].as_array().unwrap_or(&empty);
let tasks = items
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(ApiError::Api(format!("Fetch tasks failed: {} - {}", status, body)));
}
let data: serde_json::Value = resp
.json()
.await
.map_err(|e| ApiError::Api(e.to_string()))?;
if let Some(items) = data["items"].as_array() {
all_items.extend(items.iter().cloned());
}
match data["nextPageToken"].as_str() {
Some(token) if !token.is_empty() => page_token = Some(token.to_string()),
_ => break,
}
}
let tasks = all_items
.iter()
.enumerate()
.map(|(i, item)| {
let due_str = item["due"].as_str().and_then(|s| {
chrono::NaiveDateTime::parse_from_str(
&s.replace("T", " ")
.replace("Z", "")
.chars()
.take(16)
.collect::<String>(),
"%Y-%m-%d %H:%M",
)
.ok()
});
Task {
id: item["id"].as_str().unwrap_or("").to_string(),
list_id: list_id.to_string(),
title: item["title"].as_str().unwrap_or("").to_string(),
notes: item["notes"].as_str().map(|s| s.to_string()),
status: if item["status"].as_str() == Some("completed") {
TaskStatus::Completed
} else {
TaskStatus::NeedsAction
},
due: due_str,
position: i as i64,
}
})
.collect();
Ok(tasks)
}
pub async fn fetch_tasks_since(&self, list_id: &str, since: &chrono::NaiveDateTime) -> Result<Vec<Task>, ApiError> {
let token = self.get_token().await?;
let since_str = since.format("%Y-%m-%dT%H:%M:%S.000Z").to_string();
let mut all_items: Vec<serde_json::Value> = Vec::new();
let mut page_token: Option<String> = None;
loop {
let mut url = format!(
"https://tasks.googleapis.com/tasks/v1/lists/{}/tasks?showCompleted=true&showHidden=true&maxResults=100&updatedMin={}",
list_id, since_str
);
if let Some(ref pt) = page_token {
url.push_str(&format!("&pageToken={}", pt));
}
let resp = self
.client
.get(&url)
.bearer_auth(&token)
.send()
.await
.map_err(|e| ApiError::Network(e.to_string()))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(ApiError::Api(format!("Fetch tasks since failed: {} - {}", status, body)));
}
let data: serde_json::Value = resp
.json()
.await
.map_err(|e| ApiError::Api(e.to_string()))?;
if let Some(items) = data["items"].as_array() {
all_items.extend(items.iter().cloned());
}
match data["nextPageToken"].as_str() {
Some(token) if !token.is_empty() => page_token = Some(token.to_string()),
_ => break,
}
}
let tasks = all_items
.iter()
.enumerate()
.map(|(i, item)| {
@@ -293,28 +400,25 @@ impl ApiClient {
) -> Result<(), ApiError> {
let token = self.get_token().await?;
let mut url = format!(
let url = format!(
"https://tasks.googleapis.com/tasks/v1/lists/{}/tasks/{}/move",
list_id, task_id
);
let mut req = self.client.post(&url).bearer_auth(&token);
if let Some(p) = prev {
url.push_str(&format!("&previous={}", p));
req = req.query(&[("previous", p)]);
}
if let Some(s) = sibling {
url.push_str(&format!("&destinationTaskList={}", s));
req = req.query(&[("destinationTaskList", s)]);
}
let resp = self
.client
.post(&url)
.bearer_auth(&token)
.send()
.await
.map_err(|e| ApiError::Network(e.to_string()))?;
let resp = req.send().await.map_err(|e| ApiError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(ApiError::Api(format!("Move failed: {}", resp.status())));
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(ApiError::Api(format!("Move failed: {} - {}", status, body)));
}
Ok(())