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:
+147
-21
@@ -14,7 +14,7 @@ use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::app::{App, SyncCommand};
|
||||
use crate::app::{App, SyncCommand, SyncStats};
|
||||
use crate::domain::models::*;
|
||||
use crate::infrastructure::api::ApiClient;
|
||||
use crate::infrastructure::db::Db;
|
||||
@@ -82,18 +82,20 @@ fn main() -> io::Result<()> {
|
||||
);
|
||||
|
||||
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 mut app = App::new(db.clone(), api_client.clone(), sync_tx.clone());
|
||||
|
||||
let network_clone = network_status.clone();
|
||||
let stats_clone = sync_stats.clone();
|
||||
let db_clone = db.clone();
|
||||
let api_clone = api_client.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
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;
|
||||
|
||||
{
|
||||
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 {
|
||||
lists: &app.lists,
|
||||
tasks: &app.tasks,
|
||||
@@ -130,12 +140,15 @@ fn main() -> io::Result<()> {
|
||||
task_list_scroll: app.task_list_scroll,
|
||||
detail_scroll: app.detail_scroll,
|
||||
auth_error: app.auth_error.as_deref(),
|
||||
sync_stats: &app.sync_stats,
|
||||
};
|
||||
draw(frame, view);
|
||||
})?;
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
app.handle_key(key);
|
||||
if event::poll(std::time::Duration::from_millis(100))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
app.handle_key(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,22 +161,32 @@ async fn run_sync_engine(
|
||||
db: Arc<Db>,
|
||||
api: Arc<ApiClient>,
|
||||
network_status: Arc<Mutex<NetworkStatus>>,
|
||||
sync_stats: Arc<Mutex<SyncStats>>,
|
||||
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 {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
process_sync_queue(&db, &api, &network_status).await;
|
||||
_ = push_interval.tick() => {
|
||||
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() => {
|
||||
match cmd {
|
||||
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) => {
|
||||
run_initial_sync(&db, &api, &network_status).await;
|
||||
run_initial_sync(&db, &api, &network_status, &sync_stats).await;
|
||||
}
|
||||
Some(SyncCommand::Shutdown) | None => break,
|
||||
}
|
||||
@@ -176,14 +199,22 @@ async fn run_initial_sync(
|
||||
db: &Arc<Db>,
|
||||
api: &Arc<ApiClient>,
|
||||
network_status: &Arc<Mutex<NetworkStatus>>,
|
||||
sync_stats: &Arc<Mutex<SyncStats>>,
|
||||
) {
|
||||
*network_status.lock().await = NetworkStatus::Syncing;
|
||||
|
||||
let mut total_lists = 0usize;
|
||||
let mut total_tasks = 0usize;
|
||||
|
||||
match api.fetch_lists().await {
|
||||
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 {
|
||||
if let Ok(tasks) = api.fetch_tasks(&list.id).await {
|
||||
total_tasks += tasks.len();
|
||||
db.replace_all_tasks(&list.id, &tasks).ok();
|
||||
}
|
||||
}
|
||||
@@ -193,12 +224,21 @@ async fn run_initial_sync(
|
||||
*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>,
|
||||
api: &Arc<ApiClient>,
|
||||
network_status: &Arc<Mutex<NetworkStatus>>,
|
||||
sync_stats: &Arc<Mutex<SyncStats>>,
|
||||
) {
|
||||
if !api.has_token() {
|
||||
*network_status.lock().await = NetworkStatus::Offline;
|
||||
@@ -206,8 +246,9 @@ async fn process_sync_queue(
|
||||
}
|
||||
|
||||
let items = db.drain_sync();
|
||||
let count = items.len();
|
||||
|
||||
if items.is_empty() {
|
||||
if count == 0 {
|
||||
*network_status.lock().await = NetworkStatus::Online;
|
||||
return;
|
||||
}
|
||||
@@ -249,21 +290,106 @@ async fn process_sync_queue(
|
||||
}
|
||||
};
|
||||
|
||||
if result.is_err() {
|
||||
let _ = db.push_sync(
|
||||
item.action.clone(),
|
||||
&item.task_id,
|
||||
&item.list_id,
|
||||
&item.payload,
|
||||
);
|
||||
if let Err(err) = result {
|
||||
eprintln!("[task_app] Sync failed (retry {}/{}): action={:?} task={} error={}",
|
||||
item.retries, MAX_SYNC_RETRIES, item.action, item.task_id, err);
|
||||
if item.retries < MAX_SYNC_RETRIES {
|
||||
let _ = db.push_sync_with_retry(
|
||||
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;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
*network_status.lock().await = if all_ok {
|
||||
NetworkStatus::Online
|
||||
} 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user