max / goingson
- Co-Authored-By
- Claude Opus 4.8 <noreply@anthropic.com>
14 files changed,
+326 insertions,
-73 deletions
| @@ -256,15 +256,24 @@ | |||
| 256 | 256 | .map_err(|e| format!("Failed to get app data dir: {}", e))? | |
| 257 | 257 | .join("backups"); | |
| 258 | 258 | ||
| 259 | - | std::fs::create_dir_all(&backup_dir) | |
| 260 | - | .map_err(|e| format!("Failed to create backup directory: {}", e))?; | |
| 261 | - | ||
| 262 | 259 | let filename = backup_filename(now); | |
| 263 | 260 | let file_path = backup_dir.join(&filename); | |
| 264 | 261 | ||
| 265 | 262 | let export = collect_full_export(state, DESKTOP_USER_ID).await.map_err(|e| e.to_string())?; | |
| 266 | 263 | let item_count = export.total_count(); | |
| 267 | - | let size_bytes = write_backup(&export, &file_path).map_err(|e| format!("Failed to write backup: {}", e))?; | |
| 264 | + | ||
| 265 | + | // Directory creation + gzip serialization are blocking and take seconds on a | |
| 266 | + | // large DB; run them on the blocking pool so the manual "Backup now" action | |
| 267 | + | // doesn't freeze the UI (the scheduled path already does this — Perf S6). | |
| 268 | + | let backup_dir_task = backup_dir.clone(); | |
| 269 | + | let file_path_task = file_path.clone(); | |
| 270 | + | let size_bytes = tokio::task::spawn_blocking(move || -> Result<u64, String> { | |
| 271 | + | std::fs::create_dir_all(&backup_dir_task) | |
| 272 | + | .map_err(|e| format!("Failed to create backup directory: {}", e))?; | |
| 273 | + | write_backup(&export, &file_path_task).map_err(|e| format!("Failed to write backup: {}", e)) | |
| 274 | + | }) | |
| 275 | + | .await | |
| 276 | + | .map_err(|e| format!("Backup task panicked: {}", e))??; | |
| 268 | 277 | ||
| 269 | 278 | // Update last backup timestamp | |
| 270 | 279 | state |
| @@ -91,11 +91,14 @@ | |||
| 91 | 91 | } | |
| 92 | 92 | }; | |
| 93 | 93 | ||
| 94 | + | // read_dir + remove_file are blocking; run on the blocking pool so the | |
| 95 | + | // startup GC doesn't stall the reactor / delay first paint (Perf M4). | |
| 94 | 96 | let blobs_dir = data_dir.join("blobs"); | |
| 95 | - | match reclaim_orphans(&blobs_dir, &referenced) { | |
| 96 | - | Ok(0) => {} | |
| 97 | - | Ok(n) => info!("Blob GC reclaimed {n} orphaned blob(s)"), | |
| 98 | - | Err(e) => warn!("Blob GC: could not scan blob dir: {e}"), | |
| 97 | + | match tokio::task::spawn_blocking(move || reclaim_orphans(&blobs_dir, &referenced)).await { | |
| 98 | + | Ok(Ok(0)) => {} | |
| 99 | + | Ok(Ok(n)) => info!("Blob GC reclaimed {n} orphaned blob(s)"), | |
| 100 | + | Ok(Err(e)) => warn!("Blob GC: could not scan blob dir: {e}"), | |
| 101 | + | Err(e) => warn!("Blob GC task panicked: {e}"), | |
| 99 | 102 | } | |
| 100 | 103 | } | |
| 101 | 104 |
| @@ -66,6 +66,8 @@ | |||
| 66 | 66 | $crate::commands::quick_add_task, | |
| 67 | 67 | $crate::commands::update_task, | |
| 68 | 68 | $crate::commands::delete_task, | |
| 69 | + | $crate::commands::bulk_set_task_project, | |
| 70 | + | $crate::commands::bulk_set_task_priority, | |
| 69 | 71 | $crate::commands::start_task, | |
| 70 | 72 | $crate::commands::complete_task, | |
| 71 | 73 | $crate::commands::list_snoozed_tasks, |
| @@ -99,6 +99,15 @@ | |||
| 99 | 99 | /// Updates an existing task. | |
| 100 | 100 | async fn update(&self, id: TaskId, user_id: UserId, task: UpdateTask) -> Result<Option<Task>>; | |
| 101 | 101 | ||
| 102 | + | /// Sets the project for many tasks in one transaction. Returns rows affected. | |
| 103 | + | /// Avoids the per-task fetch+update round-trips the bulk UI did before | |
| 104 | + | /// (ultra-fuzz Run #27 Perf S4). Project does not affect urgency. | |
| 105 | + | async fn bulk_set_project(&self, user_id: UserId, ids: &[TaskId], project_id: Option<ProjectId>) -> Result<usize>; | |
| 106 | + | ||
| 107 | + | /// Sets the priority for many tasks in one transaction, recomputing each task's | |
| 108 | + | /// urgency (priority is an urgency input). Returns rows affected. | |
| 109 | + | async fn bulk_set_priority(&self, user_id: UserId, ids: &[TaskId], priority: crate::models::Priority) -> Result<usize>; | |
| 110 | + | ||
| 102 | 111 | /// Soft-deletes a task. | |
| 103 | 112 | async fn delete(&self, id: TaskId, user_id: UserId) -> Result<bool>; | |
| 104 | 113 | ||
| @@ -316,9 +325,17 @@ | |||
| 316 | 325 | /// snoozing, and follow-up tracking. | |
| 317 | 326 | #[async_trait] | |
| 318 | 327 | pub trait EmailRepository: Send + Sync { | |
| 319 | - | /// Lists all emails, optionally including archived. | |
| 328 | + | /// Lists all emails, optionally including archived. Selects full bodies and is | |
| 329 | + | /// unbounded -- intended for backup export, NOT for list views (use | |
| 330 | + | /// [`list_metadata`](Self::list_metadata) there to avoid materializing every | |
| 331 | + | /// body at once). | |
| 320 | 332 | async fn list_all(&self, user_id: UserId, include_archived: bool) -> Result<Vec<Email>>; | |
| 321 | 333 | ||
| 334 | + | /// Lists emails for a flat list view: body/html_body are omitted (empty) and the | |
| 335 | + | /// result is capped, so a large mailbox doesn't load every body into memory | |
| 336 | + | /// (ultra-fuzz Run #27 Perf S3). Open an email via `get_by_id` for its body. | |
| 337 | + | async fn list_metadata(&self, user_id: UserId, include_archived: bool) -> Result<Vec<Email>>; | |
| 338 | + | ||
| 322 | 339 | /// Lists emails grouped by thread, with metadata pre-computed and pagination. | |
| 323 | 340 | /// Returns (threads, total_count) sorted by most recent email (newest first). | |
| 324 | 341 | /// Optional `folder` filter restricts to emails from a specific source_folder. |
| @@ -3,8 +3,11 @@ | |||
| 3 | 3 | mod common; | |
| 4 | 4 | ||
| 5 | 5 | use chrono::{Duration, Utc}; | |
| 6 | - | use goingson_core::{NewTask, Priority, Recurrence, TaskFilterQuery, TaskRepository, TaskStatus, UpdateTask}; | |
| 7 | - | use goingson_db_sqlite::SqliteTaskRepository; | |
| 6 | + | use goingson_core::{ | |
| 7 | + | NewProject, NewTask, Priority, ProjectRepository, Recurrence, TaskFilterQuery, TaskRepository, | |
| 8 | + | TaskStatus, UpdateTask, | |
| 9 | + | }; | |
| 10 | + | use goingson_db_sqlite::{SqliteProjectRepository, SqliteTaskRepository}; | |
| 8 | 11 | ||
| 9 | 12 | #[tokio::test] | |
| 10 | 13 | async fn test_create_and_get_task() { | |
| @@ -472,3 +475,53 @@ | |||
| 472 | 475 | assert!(page.is_empty(), "negative limit must not return rows unbounded"); | |
| 473 | 476 | assert_eq!(total, 3, "total count is independent of the clamped limit"); | |
| 474 | 477 | } | |
| 478 | + | ||
| 479 | + | #[tokio::test] | |
| 480 | + | async fn test_bulk_set_priority_recomputes_urgency() { | |
| 481 | + | let pool = common::setup_test_db().await; | |
| 482 | + | let user_id = common::create_test_user(&pool).await; | |
| 483 | + | let repo = SqliteTaskRepository::new(pool); | |
| 484 | + | ||
| 485 | + | let a = repo.create(user_id, NewTask::builder("A").priority(Priority::Low).build()).await.unwrap(); | |
| 486 | + | let b = repo.create(user_id, NewTask::builder("B").priority(Priority::Low).build()).await.unwrap(); | |
| 487 | + | let low_urgency = a.urgency; | |
| 488 | + | ||
| 489 | + | let n = repo.bulk_set_priority(user_id, &[a.id, b.id], Priority::High).await.unwrap(); | |
| 490 | + | assert_eq!(n, 2); | |
| 491 | + | ||
| 492 | + | let a2 = repo.get_by_id(a.id, user_id).await.unwrap().unwrap(); | |
| 493 | + | let b2 = repo.get_by_id(b.id, user_id).await.unwrap().unwrap(); | |
| 494 | + | assert_eq!(a2.priority, Priority::High); | |
| 495 | + | assert_eq!(b2.priority, Priority::High); | |
| 496 | + | assert!(a2.urgency > low_urgency, "urgency recomputed upward for higher priority"); | |
| 497 | + | } | |
| 498 | + | ||
| 499 | + | #[tokio::test] | |
| 500 | + | async fn test_bulk_set_project() { | |
| 501 | + | let pool = common::setup_test_db().await; | |
| 502 | + | let user_id = common::create_test_user(&pool).await; | |
| 503 | + | let tasks = SqliteTaskRepository::new(pool.clone()); | |
| 504 | + | let projects = SqliteProjectRepository::new(pool); | |
| 505 | + | ||
| 506 | + | let project = projects | |
| 507 | + | .create(user_id, NewProject { | |
| 508 | + | name: "Bulk".into(), | |
| 509 | + | description: String::new(), | |
| 510 | + | project_type: Default::default(), | |
| 511 | + | status: Default::default(), | |
| 512 | + | }) | |
| 513 | + | .await | |
| 514 | + | .unwrap(); | |
| 515 | + | ||
| 516 | + | let a = tasks.create(user_id, NewTask::builder("A").build()).await.unwrap(); | |
| 517 | + | let b = tasks.create(user_id, NewTask::builder("B").build()).await.unwrap(); | |
| 518 | + | ||
| 519 | + | let n = tasks.bulk_set_project(user_id, &[a.id, b.id], Some(project.id)).await.unwrap(); | |
| 520 | + | assert_eq!(n, 2); | |
| 521 | + | assert_eq!(tasks.get_by_id(a.id, user_id).await.unwrap().unwrap().project_id, Some(project.id)); | |
| 522 | + | ||
| 523 | + | // Clearing the project (None) also works. | |
| 524 | + | let n2 = tasks.bulk_set_project(user_id, &[a.id], None).await.unwrap(); | |
| 525 | + | assert_eq!(n2, 1); | |
| 526 | + | assert_eq!(tasks.get_by_id(a.id, user_id).await.unwrap().unwrap().project_id, None); | |
| 527 | + | } |
| @@ -40,13 +40,28 @@ | |||
| 40 | 40 | throw new Error('Tauri not available'); | |
| 41 | 41 | } | |
| 42 | 42 | try { | |
| 43 | - | return await tauriInvoke(command, args); | |
| 43 | + | const result = await tauriInvoke(command, args); | |
| 44 | + | // Mark JS-initiated writes so the DB file-watcher can ignore its own echo | |
| 45 | + | // (CHRONIC-E): a successful mutation already refreshed the UI, so the | |
| 46 | + | // db:external-change it triggers is a redundant invalidate-everything storm. | |
| 47 | + | // Reads never mark (so a background-sync write the JS didn't initiate still | |
| 48 | + | // refreshes via db:external-change). Conservative prefix match: only commands | |
| 49 | + | // we are confident write the DB mark. | |
| 50 | + | if (MUTATION_COMMAND.test(command)) { | |
| 51 | + | window.__goLastLocalWriteAt = Date.now(); | |
| 52 | + | } | |
| 53 | + | return result; | |
| 44 | 54 | } catch (err) { | |
| 45 | 55 | console.error(`[api] invoke '${command}' failed:`, err, 'args:', JSON.stringify(args)); | |
| 46 | 56 | throw err; | |
| 47 | 57 | } | |
| 48 | 58 | } | |
| 49 | 59 | ||
| 60 | + | /// Commands that write the local DB. Matching this marks a local write for the | |
| 61 | + | /// file-watcher echo-suppression (CHRONIC-E). Read commands (list_/get_/search/ | |
| 62 | + | /// validate) deliberately do not match. | |
| 63 | + | const MUTATION_COMMAND = /^(create|update|delete|bulk|complete|start|snooze|unsnooze|mark|clear|archive|unarchive|set|toggle|upsert|add|remove|move|promote|convert|reorder|save|restore|import|sync|log|schedule|reanchor|link|unlink)_/; | |
| 64 | + | ||
| 50 | 65 | // ============ API Object ============ | |
| 51 | 66 | ||
| 52 | 67 | const api = { | |
| @@ -70,6 +85,8 @@ | |||
| 70 | 85 | quickAdd: (text) => invoke('quick_add_task', { input: { text } }), // Natural language: "Fix bug +work @tomorrow !high" | |
| 71 | 86 | update: (id, input) => invoke('update_task', { id, input }), | |
| 72 | 87 | delete: (id) => invoke('delete_task', { id }), | |
| 88 | + | bulkSetProject: (ids, projectId) => invoke('bulk_set_task_project', { ids, projectId }), | |
| 89 | + | bulkSetPriority: (ids, priority) => invoke('bulk_set_task_priority', { ids, priority }), | |
| 73 | 90 | start: (id) => invoke('start_task', { id }), // Pending → Started (sets started_at) | |
| 74 | 91 | complete: (id) => invoke('complete_task', { id }), // Spawns next instance for recurring tasks | |
| 75 | 92 | listSnoozed: () => invoke('list_snoozed_tasks'), |
| @@ -6,6 +6,12 @@ | |||
| 6 | 6 | (function() { | |
| 7 | 7 | 'use strict'; | |
| 8 | 8 | ||
| 9 | + | // How long after a JS-initiated DB write to treat a db:external-change event as the | |
| 10 | + | // app's own echo and skip it (CHRONIC-E). Comfortably covers the watcher's debounce | |
| 11 | + | // (it rate-limits to ~1/sec plus a trailing emit) without masking a genuinely | |
| 12 | + | // external write that arrives later. | |
| 13 | + | const LOCAL_WRITE_SUPPRESS_MS = 2500; | |
| 14 | + | ||
| 9 | 15 | // Maps a synced DB table (from the `sync:changes-applied` payload) to the view | |
| 10 | 16 | // cache entities it affects. A table mapping to `[]` has no cached view to bust | |
| 11 | 17 | // (the re-render alone refetches it). Any table NOT present here triggers a full | |
| @@ -165,8 +171,19 @@ | |||
| 165 | 171 | listen('menu:keyboard_shortcuts', () => GoingsOn.keyboard.toggleShortcuts()); | |
| 166 | 172 | listen('menu:about', () => GoingsOn.app.openAboutModal()); | |
| 167 | 173 | ||
| 168 | - | // Database external change detection | |
| 174 | + | // Database external change detection. | |
| 175 | + | // | |
| 176 | + | // The watcher fires on ANY change to the DB file, including this app's own | |
| 177 | + | // writes. A JS-initiated mutation already refreshed the UI, so the echo here | |
| 178 | + | // would be a redundant invalidate-everything storm (CHRONIC-E). Skip it when a | |
| 179 | + | // local write happened in the last LOCAL_WRITE_SUPPRESS_MS. A genuinely external | |
| 180 | + | // write (e.g. the background email-sync scheduler, which runs in Rust and never | |
| 181 | + | // marks a JS write) is NOT recent-marked, so it still refreshes. | |
| 169 | 182 | listen('db:external-change', () => { | |
| 183 | + | const sinceLocalWrite = Date.now() - (window.__goLastLocalWriteAt || 0); | |
| 184 | + | if (sinceLocalWrite < LOCAL_WRITE_SUPPRESS_MS) { | |
| 185 | + | return; | |
| 186 | + | } | |
| 170 | 187 | GoingsOn.cache.invalidateAll(); | |
| 171 | 188 | refreshCurrentViewData(); | |
| 172 | 189 | }); | |
| @@ -195,6 +212,9 @@ | |||
| 195 | 212 | GoingsOn.cache.invalidate(...entities); | |
| 196 | 213 | } | |
| 197 | 214 | } | |
| 215 | + | // Cloud sync already refreshed selectively here; mark it so the redundant | |
| 216 | + | // db:external-change echo from the same writes is suppressed (CHRONIC-E). | |
| 217 | + | window.__goLastLocalWriteAt = Date.now(); | |
| 198 | 218 | refreshCurrentViewData(); | |
| 199 | 219 | }); | |
| 200 | 220 |
| @@ -266,30 +266,18 @@ | |||
| 266 | 266 | )); | |
| 267 | 267 | }, | |
| 268 | 268 | commit: async (ids) => { | |
| 269 | - | const results = await Promise.allSettled(ids.map(async (id) => { | |
| 270 | - | const task = await GoingsOn.api.tasks.get(id); | |
| 271 | - | if (!task) return; | |
| 272 | - | return GoingsOn.api.tasks.update(id, { | |
| 273 | - | description: task.description, | |
| 274 | - | priority: field === 'priority' ? newValue : task.priority, | |
| 275 | - | status: task.status, | |
| 276 | - | projectId: field === 'projectId' ? newValue : task.projectId, | |
| 277 | - | due: task.due, | |
| 278 | - | tags: task.tags, | |
| 279 | - | recurrence: task.recurrence, | |
| 280 | - | estimatedMinutes: task.estimatedMinutes, | |
| 281 | - | contactId: task.contactId, | |
| 282 | - | milestoneId: task.milestoneId, | |
| 283 | - | }); | |
| 284 | - | })); | |
| 285 | - | const failed = results.filter(r => r.status === 'rejected').length; | |
| 286 | - | if (failed === ids.length) { | |
| 287 | - | throw results.find(r => r.status === 'rejected').reason; | |
| 269 | + | // One batched, transactional command instead of the old 2N | |
| 270 | + | // get+update round-trips (Perf S4). The optimistic apply() above | |
| 271 | + | // already updated the visible rows in place. | |
| 272 | + | if (field === 'projectId') { | |
| 273 | + | await GoingsOn.api.tasks.bulkSetProject(ids, newValue); | |
| 274 | + | // Project doesn't affect urgency/sort, so the in-place update stands. | |
| 275 | + | } else if (field === 'priority') { | |
| 276 | + | await GoingsOn.api.tasks.bulkSetPriority(ids, newValue); | |
| 277 | + | // Priority feeds urgency, which is the sort key — one refetch | |
| 278 | + | // re-sorts the list (one round-trip, not 2N). | |
| 279 | + | GoingsOn.tasks.load(); | |
| 288 | 280 | } | |
| 289 | - | if (failed > 0) { | |
| 290 | - | GoingsOn.ui.showToast(`${ids.length - failed} updated, ${failed} failed`, 'warning'); | |
| 291 | - | } | |
| 292 | - | GoingsOn.tasks.load(); | |
| 293 | 281 | }, | |
| 294 | 282 | errorMessage: `Failed to update ${labelNoun}`, | |
| 295 | 283 | }); |