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:
+38
@@ -10,6 +10,15 @@ use crate::infrastructure::api::ApiClient;
|
|||||||
use crate::infrastructure::db::Db;
|
use crate::infrastructure::db::Db;
|
||||||
use crate::ui::{Focus, NetworkStatus, Popup};
|
use crate::ui::{Focus, NetworkStatus, Popup};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct SyncStats {
|
||||||
|
pub last_sync_time: Option<chrono::NaiveDateTime>,
|
||||||
|
pub last_pull_time: Option<chrono::NaiveDateTime>,
|
||||||
|
pub lists_changed: usize,
|
||||||
|
pub tasks_changed: usize,
|
||||||
|
pub version: u64,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub lists: Vec<TaskList>,
|
pub lists: Vec<TaskList>,
|
||||||
pub tasks: Vec<Task>,
|
pub tasks: Vec<Task>,
|
||||||
@@ -29,6 +38,8 @@ pub struct App {
|
|||||||
pub api_client: Arc<ApiClient>,
|
pub api_client: Arc<ApiClient>,
|
||||||
pub needs_auth: bool,
|
pub needs_auth: bool,
|
||||||
pub auth_error: Option<String>,
|
pub auth_error: Option<String>,
|
||||||
|
pub sync_stats: SyncStats,
|
||||||
|
last_sync_version: u64,
|
||||||
auth_tx: std_mpsc::Sender<AuthEvent>,
|
auth_tx: std_mpsc::Sender<AuthEvent>,
|
||||||
auth_rx: std_mpsc::Receiver<AuthEvent>,
|
auth_rx: std_mpsc::Receiver<AuthEvent>,
|
||||||
sync_tx: mpsc::Sender<SyncCommand>,
|
sync_tx: mpsc::Sender<SyncCommand>,
|
||||||
@@ -42,6 +53,7 @@ enum AuthEvent {
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub enum SyncCommand {
|
pub enum SyncCommand {
|
||||||
TriggerSync,
|
TriggerSync,
|
||||||
|
FullSync,
|
||||||
InitialSync,
|
InitialSync,
|
||||||
Shutdown,
|
Shutdown,
|
||||||
}
|
}
|
||||||
@@ -86,6 +98,8 @@ impl App {
|
|||||||
api_client,
|
api_client,
|
||||||
needs_auth: !has_token,
|
needs_auth: !has_token,
|
||||||
auth_error: None,
|
auth_error: None,
|
||||||
|
sync_stats: SyncStats::default(),
|
||||||
|
last_sync_version: 0,
|
||||||
auth_tx,
|
auth_tx,
|
||||||
auth_rx,
|
auth_rx,
|
||||||
sync_tx,
|
sync_tx,
|
||||||
@@ -142,10 +156,31 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn refresh_if_needed(&mut self) {
|
||||||
|
if self.sync_stats.version != self.last_sync_version {
|
||||||
|
self.last_sync_version = self.sync_stats.version;
|
||||||
|
self.load_lists();
|
||||||
|
if !self.lists.is_empty() && self.selected_list < self.lists.len() {
|
||||||
|
self.tasks = self.db.get_tasks(&self.lists[self.selected_list].id);
|
||||||
|
} else {
|
||||||
|
self.tasks.clear();
|
||||||
|
}
|
||||||
|
if self.selected_task >= self.tasks.len() && !self.tasks.is_empty() {
|
||||||
|
self.selected_task = self.tasks.len() - 1;
|
||||||
|
} else if self.tasks.is_empty() {
|
||||||
|
self.selected_task = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn trigger_sync(&self) {
|
fn trigger_sync(&self) {
|
||||||
let _ = self.sync_tx.try_send(SyncCommand::TriggerSync);
|
let _ = self.sync_tx.try_send(SyncCommand::TriggerSync);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn trigger_full_sync(&self) {
|
||||||
|
let _ = self.sync_tx.try_send(SyncCommand::FullSync);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn handle_key(&mut self, key: KeyEvent) {
|
pub fn handle_key(&mut self, key: KeyEvent) {
|
||||||
if let Some(ref popup) = self.show_popup.clone() {
|
if let Some(ref popup) = self.show_popup.clone() {
|
||||||
self.handle_popup_key(key, popup);
|
self.handle_popup_key(key, popup);
|
||||||
@@ -238,6 +273,9 @@ impl App {
|
|||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
self.show_popup = None;
|
self.show_popup = None;
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
self.trigger_full_sync();
|
||||||
|
}
|
||||||
KeyCode::Char('q') | KeyCode::Char('Q') => {
|
KeyCode::Char('q') | KeyCode::Char('Q') => {
|
||||||
self.should_quit = true;
|
self.should_quit = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,4 +40,7 @@ pub struct SyncQueueItem {
|
|||||||
pub list_id: String,
|
pub list_id: String,
|
||||||
pub payload: String,
|
pub payload: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
|
pub retries: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const MAX_SYNC_RETRIES: i32 = 3;
|
||||||
|
|||||||
+133
-29
@@ -97,6 +97,12 @@ impl ApiClient {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::Network(e.to_string()))?;
|
.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
|
let data: serde_json::Value = resp
|
||||||
.json()
|
.json()
|
||||||
.await
|
.await
|
||||||
@@ -118,27 +124,128 @@ impl ApiClient {
|
|||||||
pub async fn fetch_tasks(&self, list_id: &str) -> Result<Vec<Task>, ApiError> {
|
pub async fn fetch_tasks(&self, list_id: &str) -> Result<Vec<Task>, ApiError> {
|
||||||
let token = self.get_token().await?;
|
let token = self.get_token().await?;
|
||||||
|
|
||||||
let url = format!(
|
let mut all_items: Vec<serde_json::Value> = Vec::new();
|
||||||
"https://tasks.googleapis.com/tasks/v1/lists/{}/tasks?showCompleted=true&showHidden=true",
|
let mut page_token: Option<String> = None;
|
||||||
list_id
|
|
||||||
);
|
|
||||||
|
|
||||||
let resp = self
|
loop {
|
||||||
.client
|
let mut url = format!(
|
||||||
.get(&url)
|
"https://tasks.googleapis.com/tasks/v1/lists/{}/tasks?showCompleted=true&showHidden=true&maxResults=100",
|
||||||
.bearer_auth(&token)
|
list_id
|
||||||
.send()
|
);
|
||||||
.await
|
if let Some(ref pt) = page_token {
|
||||||
.map_err(|e| ApiError::Network(e.to_string()))?;
|
url.push_str(&format!("&pageToken={}", pt));
|
||||||
|
}
|
||||||
|
|
||||||
let data: serde_json::Value = resp
|
let resp = self
|
||||||
.json()
|
.client
|
||||||
.await
|
.get(&url)
|
||||||
.map_err(|e| ApiError::Api(e.to_string()))?;
|
.bearer_auth(&token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Network(e.to_string()))?;
|
||||||
|
|
||||||
let empty = vec![];
|
if !resp.status().is_success() {
|
||||||
let items = data["items"].as_array().unwrap_or(&empty);
|
let status = resp.status();
|
||||||
let tasks = items
|
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()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, item)| {
|
.map(|(i, item)| {
|
||||||
@@ -293,28 +400,25 @@ impl ApiClient {
|
|||||||
) -> Result<(), ApiError> {
|
) -> Result<(), ApiError> {
|
||||||
let token = self.get_token().await?;
|
let token = self.get_token().await?;
|
||||||
|
|
||||||
let mut url = format!(
|
let url = format!(
|
||||||
"https://tasks.googleapis.com/tasks/v1/lists/{}/tasks/{}/move",
|
"https://tasks.googleapis.com/tasks/v1/lists/{}/tasks/{}/move",
|
||||||
list_id, task_id
|
list_id, task_id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let mut req = self.client.post(&url).bearer_auth(&token);
|
||||||
if let Some(p) = prev {
|
if let Some(p) = prev {
|
||||||
url.push_str(&format!("&previous={}", p));
|
req = req.query(&[("previous", p)]);
|
||||||
}
|
}
|
||||||
if let Some(s) = sibling {
|
if let Some(s) = sibling {
|
||||||
url.push_str(&format!("&destinationTaskList={}", s));
|
req = req.query(&[("destinationTaskList", s)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let resp = self
|
let resp = req.send().await.map_err(|e| ApiError::Network(e.to_string()))?;
|
||||||
.client
|
|
||||||
.post(&url)
|
|
||||||
.bearer_auth(&token)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ApiError::Network(e.to_string()))?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
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(())
|
Ok(())
|
||||||
|
|||||||
+40
-15
@@ -37,9 +37,14 @@ impl Db {
|
|||||||
task_id TEXT NOT NULL,
|
task_id TEXT NOT NULL,
|
||||||
list_id TEXT NOT NULL,
|
list_id TEXT NOT NULL,
|
||||||
payload TEXT NOT NULL,
|
payload TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL,
|
||||||
|
retries INTEGER NOT NULL DEFAULT 0
|
||||||
);",
|
);",
|
||||||
)?;
|
)?;
|
||||||
|
conn.execute_batch(
|
||||||
|
"ALTER TABLE sync_queue ADD COLUMN retries INTEGER NOT NULL DEFAULT 0;",
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
Ok(Self { conn: Mutex::new(conn) })
|
Ok(Self { conn: Mutex::new(conn) })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,16 +206,6 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn replace_all_lists(&self, lists: &[TaskList]) -> SqlResult<()> {
|
|
||||||
let conn = self.conn.lock().unwrap();
|
|
||||||
conn.execute("DELETE FROM task_lists", [])?;
|
|
||||||
drop(conn);
|
|
||||||
for list in lists {
|
|
||||||
self.insert_list(list)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn replace_all_tasks(&self, list_id: &str, tasks: &[Task]) -> SqlResult<()> {
|
pub fn replace_all_tasks(&self, list_id: &str, tasks: &[Task]) -> SqlResult<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
conn.execute("DELETE FROM tasks WHERE list_id = ?1", params![list_id])?;
|
conn.execute("DELETE FROM tasks WHERE list_id = ?1", params![list_id])?;
|
||||||
@@ -226,7 +221,27 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn push_sync(&self, action: SyncAction, task_id: &str, list_id: &str, payload: &str) -> SqlResult<()> {
|
pub fn push_sync(
|
||||||
|
&self,
|
||||||
|
action: SyncAction,
|
||||||
|
task_id: &str,
|
||||||
|
list_id: &str,
|
||||||
|
payload: &str,
|
||||||
|
) -> SqlResult<()> {
|
||||||
|
self.push_sync_with_retry(action, task_id, list_id, payload, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_sync_with_retry(
|
||||||
|
&self,
|
||||||
|
action: SyncAction,
|
||||||
|
task_id: &str,
|
||||||
|
list_id: &str,
|
||||||
|
payload: &str,
|
||||||
|
retries: i32,
|
||||||
|
) -> SqlResult<()> {
|
||||||
|
if retries > MAX_SYNC_RETRIES {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let action_str = match action {
|
let action_str = match action {
|
||||||
SyncAction::Create => "Create",
|
SyncAction::Create => "Create",
|
||||||
SyncAction::Update => "Update",
|
SyncAction::Update => "Update",
|
||||||
@@ -235,24 +250,33 @@ impl Db {
|
|||||||
};
|
};
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO sync_queue (action, task_id, list_id, payload, created_at)
|
"INSERT INTO sync_queue (action, task_id, list_id, payload, created_at, retries)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
params![
|
params![
|
||||||
action_str,
|
action_str,
|
||||||
task_id,
|
task_id,
|
||||||
list_id,
|
list_id,
|
||||||
payload,
|
payload,
|
||||||
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
|
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||||
|
retries,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn has_pending_sync(&self) -> bool {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM sync_queue", [], |row| row.get(0))
|
||||||
|
.unwrap_or(0);
|
||||||
|
count > 0
|
||||||
|
}
|
||||||
|
|
||||||
pub fn drain_sync(&self) -> Vec<SyncQueueItem> {
|
pub fn drain_sync(&self) -> Vec<SyncQueueItem> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let items: Vec<SyncQueueItem> = {
|
let items: Vec<SyncQueueItem> = {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare("SELECT id, action, task_id, list_id, payload, created_at FROM sync_queue ORDER BY id")
|
.prepare("SELECT id, action, task_id, list_id, payload, created_at, retries FROM sync_queue ORDER BY id")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
stmt.query_map([], |row| {
|
stmt.query_map([], |row| {
|
||||||
let action_str: String = row.get(1)?;
|
let action_str: String = row.get(1)?;
|
||||||
@@ -270,6 +294,7 @@ impl Db {
|
|||||||
list_id: row.get(3)?,
|
list_id: row.get(3)?,
|
||||||
payload: row.get(4)?,
|
payload: row.get(4)?,
|
||||||
created_at: row.get(5)?,
|
created_at: row.get(5)?,
|
||||||
|
retries: row.get(6)?,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
+147
-21
@@ -14,7 +14,7 @@ use ratatui::backend::CrosstermBackend;
|
|||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use crate::app::{App, SyncCommand};
|
use crate::app::{App, SyncCommand, SyncStats};
|
||||||
use crate::domain::models::*;
|
use crate::domain::models::*;
|
||||||
use crate::infrastructure::api::ApiClient;
|
use crate::infrastructure::api::ApiClient;
|
||||||
use crate::infrastructure::db::Db;
|
use crate::infrastructure::db::Db;
|
||||||
@@ -82,18 +82,20 @@ fn main() -> io::Result<()> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let network_status = Arc::new(Mutex::new(NetworkStatus::Online));
|
let network_status = Arc::new(Mutex::new(NetworkStatus::Online));
|
||||||
|
let sync_stats = Arc::new(Mutex::new(SyncStats::default()));
|
||||||
let (sync_tx, mut sync_rx) = tokio::sync::mpsc::channel::<SyncCommand>(32);
|
let (sync_tx, mut sync_rx) = tokio::sync::mpsc::channel::<SyncCommand>(32);
|
||||||
|
|
||||||
let mut app = App::new(db.clone(), api_client.clone(), sync_tx.clone());
|
let mut app = App::new(db.clone(), api_client.clone(), sync_tx.clone());
|
||||||
|
|
||||||
let network_clone = network_status.clone();
|
let network_clone = network_status.clone();
|
||||||
|
let stats_clone = sync_stats.clone();
|
||||||
let db_clone = db.clone();
|
let db_clone = db.clone();
|
||||||
let api_clone = api_client.clone();
|
let api_clone = api_client.clone();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
rt.block_on(async move {
|
rt.block_on(async move {
|
||||||
run_sync_engine(db_clone, api_clone, network_clone, &mut sync_rx).await;
|
run_sync_engine(db_clone, api_clone, network_clone, stats_clone, &mut sync_rx).await;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,6 +118,14 @@ fn main() -> io::Result<()> {
|
|||||||
};
|
};
|
||||||
app.network_status = status;
|
app.network_status = status;
|
||||||
|
|
||||||
|
{
|
||||||
|
let guard = sync_stats.blocking_lock();
|
||||||
|
app.sync_stats = guard.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload lists/tasks if sync engine changed data in background
|
||||||
|
app.refresh_if_needed();
|
||||||
|
|
||||||
let view = AppView {
|
let view = AppView {
|
||||||
lists: &app.lists,
|
lists: &app.lists,
|
||||||
tasks: &app.tasks,
|
tasks: &app.tasks,
|
||||||
@@ -130,12 +140,15 @@ fn main() -> io::Result<()> {
|
|||||||
task_list_scroll: app.task_list_scroll,
|
task_list_scroll: app.task_list_scroll,
|
||||||
detail_scroll: app.detail_scroll,
|
detail_scroll: app.detail_scroll,
|
||||||
auth_error: app.auth_error.as_deref(),
|
auth_error: app.auth_error.as_deref(),
|
||||||
|
sync_stats: &app.sync_stats,
|
||||||
};
|
};
|
||||||
draw(frame, view);
|
draw(frame, view);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if let Event::Key(key) = event::read()? {
|
if event::poll(std::time::Duration::from_millis(100))? {
|
||||||
app.handle_key(key);
|
if let Event::Key(key) = event::read()? {
|
||||||
|
app.handle_key(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,22 +161,32 @@ async fn run_sync_engine(
|
|||||||
db: Arc<Db>,
|
db: Arc<Db>,
|
||||||
api: Arc<ApiClient>,
|
api: Arc<ApiClient>,
|
||||||
network_status: Arc<Mutex<NetworkStatus>>,
|
network_status: Arc<Mutex<NetworkStatus>>,
|
||||||
|
sync_stats: Arc<Mutex<SyncStats>>,
|
||||||
rx: &mut tokio::sync::mpsc::Receiver<SyncCommand>,
|
rx: &mut tokio::sync::mpsc::Receiver<SyncCommand>,
|
||||||
) {
|
) {
|
||||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
|
let mut push_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
|
||||||
|
let mut pull_interval = tokio::time::interval(tokio::time::Duration::from_secs(300));
|
||||||
|
pull_interval.tick().await;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = interval.tick() => {
|
_ = push_interval.tick() => {
|
||||||
process_sync_queue(&db, &api, &network_status).await;
|
push_sync(&db, &api, &network_status, &sync_stats).await;
|
||||||
|
}
|
||||||
|
_ = pull_interval.tick() => {
|
||||||
|
pull_sync(&db, &api, &network_status, &sync_stats, false).await;
|
||||||
}
|
}
|
||||||
cmd = rx.recv() => {
|
cmd = rx.recv() => {
|
||||||
match cmd {
|
match cmd {
|
||||||
Some(SyncCommand::TriggerSync) => {
|
Some(SyncCommand::TriggerSync) => {
|
||||||
process_sync_queue(&db, &api, &network_status).await;
|
push_sync(&db, &api, &network_status, &sync_stats).await;
|
||||||
|
}
|
||||||
|
Some(SyncCommand::FullSync) => {
|
||||||
|
push_sync(&db, &api, &network_status, &sync_stats).await;
|
||||||
|
pull_sync(&db, &api, &network_status, &sync_stats, true).await;
|
||||||
}
|
}
|
||||||
Some(SyncCommand::InitialSync) => {
|
Some(SyncCommand::InitialSync) => {
|
||||||
run_initial_sync(&db, &api, &network_status).await;
|
run_initial_sync(&db, &api, &network_status, &sync_stats).await;
|
||||||
}
|
}
|
||||||
Some(SyncCommand::Shutdown) | None => break,
|
Some(SyncCommand::Shutdown) | None => break,
|
||||||
}
|
}
|
||||||
@@ -176,14 +199,22 @@ async fn run_initial_sync(
|
|||||||
db: &Arc<Db>,
|
db: &Arc<Db>,
|
||||||
api: &Arc<ApiClient>,
|
api: &Arc<ApiClient>,
|
||||||
network_status: &Arc<Mutex<NetworkStatus>>,
|
network_status: &Arc<Mutex<NetworkStatus>>,
|
||||||
|
sync_stats: &Arc<Mutex<SyncStats>>,
|
||||||
) {
|
) {
|
||||||
*network_status.lock().await = NetworkStatus::Syncing;
|
*network_status.lock().await = NetworkStatus::Syncing;
|
||||||
|
|
||||||
|
let mut total_lists = 0usize;
|
||||||
|
let mut total_tasks = 0usize;
|
||||||
|
|
||||||
match api.fetch_lists().await {
|
match api.fetch_lists().await {
|
||||||
Ok(lists) => {
|
Ok(lists) => {
|
||||||
db.replace_all_lists(&lists).ok();
|
total_lists = lists.len();
|
||||||
|
for list in &lists {
|
||||||
|
db.insert_list(list).ok();
|
||||||
|
}
|
||||||
for list in &lists {
|
for list in &lists {
|
||||||
if let Ok(tasks) = api.fetch_tasks(&list.id).await {
|
if let Ok(tasks) = api.fetch_tasks(&list.id).await {
|
||||||
|
total_tasks += tasks.len();
|
||||||
db.replace_all_tasks(&list.id, &tasks).ok();
|
db.replace_all_tasks(&list.id, &tasks).ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,12 +224,21 @@ async fn run_initial_sync(
|
|||||||
*network_status.lock().await = NetworkStatus::Offline;
|
*network_status.lock().await = NetworkStatus::Offline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let now = chrono::Local::now().naive_local();
|
||||||
|
let mut stats = sync_stats.lock().await;
|
||||||
|
stats.last_sync_time = Some(now);
|
||||||
|
stats.last_pull_time = Some(now);
|
||||||
|
stats.lists_changed = total_lists;
|
||||||
|
stats.tasks_changed = total_tasks;
|
||||||
|
stats.version += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn process_sync_queue(
|
async fn push_sync(
|
||||||
db: &Arc<Db>,
|
db: &Arc<Db>,
|
||||||
api: &Arc<ApiClient>,
|
api: &Arc<ApiClient>,
|
||||||
network_status: &Arc<Mutex<NetworkStatus>>,
|
network_status: &Arc<Mutex<NetworkStatus>>,
|
||||||
|
sync_stats: &Arc<Mutex<SyncStats>>,
|
||||||
) {
|
) {
|
||||||
if !api.has_token() {
|
if !api.has_token() {
|
||||||
*network_status.lock().await = NetworkStatus::Offline;
|
*network_status.lock().await = NetworkStatus::Offline;
|
||||||
@@ -206,8 +246,9 @@ async fn process_sync_queue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let items = db.drain_sync();
|
let items = db.drain_sync();
|
||||||
|
let count = items.len();
|
||||||
|
|
||||||
if items.is_empty() {
|
if count == 0 {
|
||||||
*network_status.lock().await = NetworkStatus::Online;
|
*network_status.lock().await = NetworkStatus::Online;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -249,21 +290,106 @@ async fn process_sync_queue(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if result.is_err() {
|
if let Err(err) = result {
|
||||||
let _ = db.push_sync(
|
eprintln!("[task_app] Sync failed (retry {}/{}): action={:?} task={} error={}",
|
||||||
item.action.clone(),
|
item.retries, MAX_SYNC_RETRIES, item.action, item.task_id, err);
|
||||||
&item.task_id,
|
if item.retries < MAX_SYNC_RETRIES {
|
||||||
&item.list_id,
|
let _ = db.push_sync_with_retry(
|
||||||
&item.payload,
|
item.action.clone(),
|
||||||
);
|
&item.task_id,
|
||||||
|
&item.list_id,
|
||||||
|
&item.payload,
|
||||||
|
item.retries + 1,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
eprintln!("[task_app] Dropping sync item after {} failed attempts: action={:?} task={}",
|
||||||
|
MAX_SYNC_RETRIES, item.action, item.task_id);
|
||||||
|
}
|
||||||
all_ok = false;
|
all_ok = false;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
*network_status.lock().await = if all_ok {
|
*network_status.lock().await = if all_ok {
|
||||||
NetworkStatus::Online
|
NetworkStatus::Online
|
||||||
} else {
|
} else {
|
||||||
NetworkStatus::Offline
|
let remaining = db.has_pending_sync();
|
||||||
|
if remaining {
|
||||||
|
NetworkStatus::Offline
|
||||||
|
} else {
|
||||||
|
NetworkStatus::Online
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut stats = sync_stats.lock().await;
|
||||||
|
stats.last_sync_time = Some(chrono::Local::now().naive_local());
|
||||||
|
stats.lists_changed = 0;
|
||||||
|
stats.tasks_changed = count;
|
||||||
|
stats.version += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pull_sync(
|
||||||
|
db: &Arc<Db>,
|
||||||
|
api: &Arc<ApiClient>,
|
||||||
|
network_status: &Arc<Mutex<NetworkStatus>>,
|
||||||
|
sync_stats: &Arc<Mutex<SyncStats>>,
|
||||||
|
force_full: bool,
|
||||||
|
) {
|
||||||
|
if !api.has_token() {
|
||||||
|
*network_status.lock().await = NetworkStatus::Offline;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if db.has_pending_sync() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
*network_status.lock().await = NetworkStatus::Syncing;
|
||||||
|
|
||||||
|
let mut total_lists = 0usize;
|
||||||
|
let mut total_tasks = 0usize;
|
||||||
|
|
||||||
|
let last_pull = {
|
||||||
|
let stats = sync_stats.lock().await;
|
||||||
|
stats.last_pull_time
|
||||||
|
};
|
||||||
|
|
||||||
|
let use_incremental = !force_full && last_pull.is_some();
|
||||||
|
|
||||||
|
match api.fetch_lists().await {
|
||||||
|
Ok(lists) => {
|
||||||
|
total_lists = lists.len();
|
||||||
|
for list in &lists {
|
||||||
|
db.insert_list(list).ok();
|
||||||
|
}
|
||||||
|
for list in &lists {
|
||||||
|
let result = if use_incremental {
|
||||||
|
api.fetch_tasks_since(&list.id, last_pull.as_ref().unwrap()).await
|
||||||
|
} else {
|
||||||
|
api.fetch_tasks(&list.id).await
|
||||||
|
};
|
||||||
|
if let Ok(tasks) = result {
|
||||||
|
total_tasks += tasks.len();
|
||||||
|
if use_incremental {
|
||||||
|
for task in &tasks {
|
||||||
|
db.insert_task(task).ok();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
db.replace_all_tasks(&list.id, &tasks).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*network_status.lock().await = NetworkStatus::Online;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
*network_status.lock().await = NetworkStatus::Offline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = chrono::Local::now().naive_local();
|
||||||
|
let mut stats = sync_stats.lock().await;
|
||||||
|
stats.last_sync_time = Some(now);
|
||||||
|
stats.last_pull_time = Some(now);
|
||||||
|
stats.lists_changed = total_lists;
|
||||||
|
stats.tasks_changed = total_tasks;
|
||||||
|
stats.version += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-3
@@ -5,6 +5,7 @@ use ratatui::layout::{Alignment, Rect};
|
|||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
use crate::domain::models::*;
|
use crate::domain::models::*;
|
||||||
|
use crate::app::SyncStats;
|
||||||
use super::NetworkStatus;
|
use super::NetworkStatus;
|
||||||
|
|
||||||
const TAB_COLOR: Color = Color::Cyan;
|
const TAB_COLOR: Color = Color::Cyan;
|
||||||
@@ -56,6 +57,10 @@ pub fn render_task_list(
|
|||||||
focused: bool,
|
focused: bool,
|
||||||
_scroll: u16,
|
_scroll: u16,
|
||||||
) {
|
) {
|
||||||
|
let total = tasks.len();
|
||||||
|
let done = tasks.iter().filter(|t| t.status == TaskStatus::Completed).count();
|
||||||
|
let todo = total - done;
|
||||||
|
|
||||||
let items: Vec<ListItem> = tasks
|
let items: Vec<ListItem> = tasks
|
||||||
.iter()
|
.iter()
|
||||||
.map(|task| {
|
.map(|task| {
|
||||||
@@ -100,7 +105,7 @@ pub fn render_task_list(
|
|||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(Style::default().fg(if focused { FOCUS_COLOR } else { Color::DarkGray }))
|
.border_style(Style::default().fg(if focused { FOCUS_COLOR } else { Color::DarkGray }))
|
||||||
.title(" Tasks ")
|
.title(format!(" Tasks ({} todo / {} done) ", todo, done))
|
||||||
.title_alignment(Alignment::Left);
|
.title_alignment(Alignment::Left);
|
||||||
|
|
||||||
let list = List::new(items)
|
let list = List::new(items)
|
||||||
@@ -185,18 +190,40 @@ pub fn render_status_bar(
|
|||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
status: &NetworkStatus,
|
status: &NetworkStatus,
|
||||||
|
sync_stats: &SyncStats,
|
||||||
) {
|
) {
|
||||||
let (text, color) = match status {
|
let (status_text, color) = match status {
|
||||||
NetworkStatus::Online => (" ONLINE ", STATUS_ONLINE),
|
NetworkStatus::Online => (" ONLINE ", STATUS_ONLINE),
|
||||||
NetworkStatus::Offline => (" OFFLINE ", STATUS_OFFLINE),
|
NetworkStatus::Offline => (" OFFLINE ", STATUS_OFFLINE),
|
||||||
NetworkStatus::Syncing => (" SYNCING... ", STATUS_SYNC),
|
NetworkStatus::Syncing => (" SYNCING... ", STATUS_SYNC),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let right_text = match sync_stats.last_sync_time {
|
||||||
|
Some(time) => {
|
||||||
|
let time_str = time.format("%H:%M:%S").to_string();
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
if sync_stats.lists_changed > 0 {
|
||||||
|
parts.push(format!("{} lists", sync_stats.lists_changed));
|
||||||
|
}
|
||||||
|
if sync_stats.tasks_changed > 0 {
|
||||||
|
parts.push(format!("{} tasks", sync_stats.tasks_changed));
|
||||||
|
}
|
||||||
|
if parts.is_empty() {
|
||||||
|
format!(" {} last sync ", time_str)
|
||||||
|
} else {
|
||||||
|
format!(" {} last sync: {} ", time_str, parts.join(", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let full_text = format!("{}{}", status_text, right_text);
|
||||||
|
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.style(Style::default().bg(color).fg(Color::Black));
|
.style(Style::default().bg(color).fg(Color::Black));
|
||||||
|
|
||||||
let paragraph = Paragraph::new(Line::from(Span::styled(
|
let paragraph = Paragraph::new(Line::from(Span::styled(
|
||||||
text,
|
full_text,
|
||||||
Style::default().fg(Color::Black).add_modifier(Modifier::BOLD),
|
Style::default().fg(Color::Black).add_modifier(Modifier::BOLD),
|
||||||
)))
|
)))
|
||||||
.block(block)
|
.block(block)
|
||||||
|
|||||||
+3
-1
@@ -3,6 +3,7 @@ pub mod components;
|
|||||||
use ratatui::layout::{Constraint, Direction, Layout};
|
use ratatui::layout::{Constraint, Direction, Layout};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
|
use crate::app::SyncStats;
|
||||||
use crate::domain::models::*;
|
use crate::domain::models::*;
|
||||||
use components::*;
|
use components::*;
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ pub struct AppView<'a> {
|
|||||||
pub task_list_scroll: u16,
|
pub task_list_scroll: u16,
|
||||||
pub detail_scroll: u16,
|
pub detail_scroll: u16,
|
||||||
pub auth_error: Option<&'a str>,
|
pub auth_error: Option<&'a str>,
|
||||||
|
pub sync_stats: &'a SyncStats,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn draw(frame: &mut Frame, view: AppView) {
|
pub fn draw(frame: &mut Frame, view: AppView) {
|
||||||
@@ -87,7 +89,7 @@ pub fn draw(frame: &mut Frame, view: AppView) {
|
|||||||
view.detail_scroll,
|
view.detail_scroll,
|
||||||
);
|
);
|
||||||
|
|
||||||
render_status_bar(frame, status_area, view.network_status);
|
render_status_bar(frame, status_area, view.network_status, view.sync_stats);
|
||||||
|
|
||||||
if let Some(popup) = view.show_popup {
|
if let Some(popup) = view.show_popup {
|
||||||
match popup {
|
match popup {
|
||||||
|
|||||||
Reference in New Issue
Block a user