max / goingson
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
18 files changed,
+379 insertions,
-65 deletions
| @@ -14,6 +14,16 @@ | |||
| 14 | 14 | /// Check interval for automated backups (1 minute) | |
| 15 | 15 | const CHECK_INTERVAL_SECS: u64 = 60; | |
| 16 | 16 | ||
| 17 | + | /// Build a unique backup filename. The second-granular timestamp keeps files | |
| 18 | + | /// human-sortable; the short random suffix prevents a manual backup and the | |
| 19 | + | /// scheduler firing in the same second from colliding and silently overwriting | |
| 20 | + | /// each other (GO-11). | |
| 21 | + | fn backup_filename(now: chrono::DateTime<Utc>) -> String { | |
| 22 | + | let stamp = now.format("%Y%m%d-%H%M%S"); | |
| 23 | + | let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; | |
| 24 | + | format!("goingson-backup-{stamp}-{suffix}.json.gz") | |
| 25 | + | } | |
| 26 | + | ||
| 17 | 27 | /// Starts the background backup scheduler that creates automatic backups | |
| 18 | 28 | /// based on user settings and prunes old backups. | |
| 19 | 29 | pub async fn start_backup_scheduler(app: tauri::AppHandle, cancel: CancellationToken) { | |
| @@ -105,9 +115,7 @@ | |||
| 105 | 115 | std::fs::create_dir_all(&backup_dir) | |
| 106 | 116 | .map_err(|e| format!("Failed to create backup directory: {}", e))?; | |
| 107 | 117 | ||
| 108 | - | // Generate timestamped filename | |
| 109 | - | let timestamp = now.format("%Y%m%d-%H%M%S"); | |
| 110 | - | let filename = format!("goingson-backup-{}.json.gz", timestamp); | |
| 118 | + | let filename = backup_filename(now); | |
| 111 | 119 | let file_path = backup_dir.join(&filename); | |
| 112 | 120 | ||
| 113 | 121 | // Fetch all data | |
| @@ -180,7 +188,7 @@ | |||
| 180 | 188 | entry | |
| 181 | 189 | .metadata() | |
| 182 | 190 | .ok() | |
| 183 | - | .and_then(|m| m.created().ok()) | |
| 191 | + | .and_then(|m| m.created().or_else(|_| m.modified()).ok()) | |
| 184 | 192 | .map(|created| (entry.path(), created)) | |
| 185 | 193 | }) | |
| 186 | 194 | .collect(); | |
| @@ -215,8 +223,7 @@ | |||
| 215 | 223 | std::fs::create_dir_all(&backup_dir) | |
| 216 | 224 | .map_err(|e| format!("Failed to create backup directory: {}", e))?; | |
| 217 | 225 | ||
| 218 | - | let timestamp = now.format("%Y%m%d-%H%M%S"); | |
| 219 | - | let filename = format!("goingson-backup-{}.json.gz", timestamp); | |
| 226 | + | let filename = backup_filename(now); | |
| 220 | 227 | let file_path = backup_dir.join(&filename); | |
| 221 | 228 | ||
| 222 | 229 | // Fetch all data | |
| @@ -268,3 +275,19 @@ | |||
| 268 | 275 | size_bytes, | |
| 269 | 276 | }) | |
| 270 | 277 | } | |
| 278 | + | ||
| 279 | + | #[cfg(test)] | |
| 280 | + | mod tests { | |
| 281 | + | use super::*; | |
| 282 | + | ||
| 283 | + | #[test] | |
| 284 | + | fn backup_filename_is_unique_within_the_same_second() { | |
| 285 | + | // GO-11: a manual backup and the scheduler firing in the same second | |
| 286 | + | // must not produce the same filename (which would silently overwrite). | |
| 287 | + | let now = Utc::now(); | |
| 288 | + | let a = backup_filename(now); | |
| 289 | + | let b = backup_filename(now); | |
| 290 | + | assert_ne!(a, b, "same-second backups must get distinct filenames"); | |
| 291 | + | assert!(a.starts_with("goingson-backup-") && a.ends_with(".json.gz")); | |
| 292 | + | } | |
| 293 | + | } |
| @@ -173,6 +173,11 @@ | |||
| 173 | 173 | /// Lists unscheduled tasks due on a specific date. | |
| 174 | 174 | async fn list_unscheduled_due_on_date(&self, user_id: UserId, date: NaiveDate) -> Result<Vec<Task>>; | |
| 175 | 175 | ||
| 176 | + | /// Lists unscheduled tasks whose `due` falls within a UTC instant window | |
| 177 | + | /// (inclusive upper bound). Lets callers honor a user-local day boundary | |
| 178 | + | /// instead of an implicit UTC one. | |
| 179 | + | async fn list_unscheduled_due_between(&self, user_id: UserId, start: DateTime<Utc>, end: DateTime<Utc>) -> Result<Vec<Task>>; | |
| 180 | + | ||
| 176 | 181 | /// Updates a task's time-block schedule. | |
| 177 | 182 | async fn update_schedule(&self, id: TaskId, user_id: UserId, start: Option<DateTime<Utc>>, duration: Option<i32>) -> Result<Option<Task>>; | |
| 178 | 183 |
| @@ -411,3 +411,42 @@ | |||
| 411 | 411 | assert!(snoozed.is_some()); | |
| 412 | 412 | assert!(snoozed.unwrap().snoozed_until.is_some()); | |
| 413 | 413 | } | |
| 414 | + | ||
| 415 | + | // Backs GO-6: the windowed due query must include a task whose `due` lies | |
| 416 | + | // inside the UTC window and exclude one just outside it. The day_planning | |
| 417 | + | // command maps a user-local civil day to this window so the boundary respects | |
| 418 | + | // the user's timezone, not UTC midnight. | |
| 419 | + | #[tokio::test] | |
| 420 | + | async fn test_list_unscheduled_due_between_respects_window() { | |
| 421 | + | let pool = common::setup_test_db().await; | |
| 422 | + | let user_id = common::create_test_user(&pool).await; | |
| 423 | + | let repo = SqliteTaskRepository::new(pool); | |
| 424 | + | ||
| 425 | + | // UTC-5 local day 2026-06-11 -> [05:00Z 11th, 05:00Z 12th]. | |
| 426 | + | let win_start = "2026-06-11T05:00:00Z".parse::<chrono::DateTime<Utc>>().unwrap(); | |
| 427 | + | let win_end = "2026-06-12T04:59:59Z".parse::<chrono::DateTime<Utc>>().unwrap(); | |
| 428 | + | ||
| 429 | + | // Due 02:00Z on the 12th = 21:00 local on the 11th -> inside the window. | |
| 430 | + | let inside = repo | |
| 431 | + | .create(user_id, NewTask::builder("Due late local 11th") | |
| 432 | + | .due("2026-06-12T02:00:00Z".parse::<chrono::DateTime<Utc>>().unwrap()) | |
| 433 | + | .build()) | |
| 434 | + | .await | |
| 435 | + | .expect("create inside"); | |
| 436 | + | ||
| 437 | + | // Due 06:00Z on the 12th = 01:00 local on the 12th -> outside the window. | |
| 438 | + | let _outside = repo | |
| 439 | + | .create(user_id, NewTask::builder("Due early local 12th") | |
| 440 | + | .due("2026-06-12T06:00:00Z".parse::<chrono::DateTime<Utc>>().unwrap()) | |
| 441 | + | .build()) | |
| 442 | + | .await | |
| 443 | + | .expect("create outside"); | |
| 444 | + | ||
| 445 | + | let found = repo | |
| 446 | + | .list_unscheduled_due_between(user_id, win_start, win_end) | |
| 447 | + | .await | |
| 448 | + | .expect("query window"); | |
| 449 | + | ||
| 450 | + | assert_eq!(found.len(), 1, "only the in-window task should match"); | |
| 451 | + | assert_eq!(found[0].id, inside.id); | |
| 452 | + | } |
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | mod common; | |
| 4 | 4 | ||
| 5 | - | use goingson_core::TaskRepository; | |
| 5 | + | use goingson_core::{CoreError, TaskId, TaskRepository}; | |
| 6 | 6 | use goingson_db_sqlite::SqliteTaskRepository; | |
| 7 | 7 | ||
| 8 | 8 | #[tokio::test] | |
| @@ -252,3 +252,44 @@ | |||
| 252 | 252 | assert!(task.active_session.is_some(), "Task with running timer should have active_session"); | |
| 253 | 253 | assert!(task.has_active_timer()); | |
| 254 | 254 | } | |
| 255 | + | ||
| 256 | + | #[tokio::test] | |
| 257 | + | async fn test_log_manual_time_happy_path() { | |
| 258 | + | let pool = common::setup_test_db().await; | |
| 259 | + | let user_id = common::create_test_user(&pool).await; | |
| 260 | + | let task_id = common::create_test_task(&pool, user_id).await; | |
| 261 | + | let repo = SqliteTaskRepository::new(pool); | |
| 262 | + | ||
| 263 | + | let session = repo | |
| 264 | + | .log_manual_time(task_id, user_id, 30, chrono::Utc::now()) | |
| 265 | + | .await | |
| 266 | + | .expect("logging manual time on an owned task should succeed"); | |
| 267 | + | assert_eq!(session.duration_minutes, Some(30)); | |
| 268 | + | ||
| 269 | + | let task = repo.get_by_id(task_id, user_id).await.unwrap().unwrap(); | |
| 270 | + | assert_eq!(task.actual_minutes, 30, "cache should reflect the logged minutes"); | |
| 271 | + | ||
| 272 | + | let sessions = repo.list_time_sessions(task_id, user_id).await.unwrap(); | |
| 273 | + | assert_eq!(sessions.len(), 1); | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | // GO-5 regression: logging time against a task the caller doesn't own (here a | |
| 277 | + | // nonexistent id) must be rejected with NOT_FOUND and must NOT leave an orphan | |
| 278 | + | // time_sessions row. | |
| 279 | + | #[tokio::test] | |
| 280 | + | async fn test_log_manual_time_foreign_task_rejected_no_orphan() { | |
| 281 | + | let pool = common::setup_test_db().await; | |
| 282 | + | let user_id = common::create_test_user(&pool).await; | |
| 283 | + | let repo = SqliteTaskRepository::new(pool); | |
| 284 | + | ||
| 285 | + | let bogus_task = TaskId::new(); | |
| 286 | + | let result = repo | |
| 287 | + | .log_manual_time(bogus_task, user_id, 30, chrono::Utc::now()) | |
| 288 | + | .await; | |
| 289 | + | ||
| 290 | + | assert!(matches!(result, Err(CoreError::NotFound { .. })), "expected NOT_FOUND, got {result:?}"); | |
| 291 | + | ||
| 292 | + | // No session row should have been written for the bogus task. | |
| 293 | + | let sessions = repo.list_time_sessions(bogus_task, user_id).await.unwrap(); | |
| 294 | + | assert!(sessions.is_empty(), "a rejected log must not leave an orphan session"); | |
| 295 | + | } |
| @@ -223,13 +223,18 @@ | |||
| 223 | 223 | GoingsOn.ui.showToast('No email accounts configured', 'info'); | |
| 224 | 224 | return; | |
| 225 | 225 | } | |
| 226 | - | GoingsOn.ui.showToast('Syncing email accounts...', 'info'); | |
| 226 | + | // Persistent progress modal: this loops a blocking IMAP fetch per | |
| 227 | + | // account and can run for minutes. Without it the app looks frozen. | |
| 228 | + | const plural = accounts.length === 1 ? 'account' : 'accounts'; | |
| 229 | + | GoingsOn.emails.showSyncProgressModal(`Syncing ${accounts.length} email ${plural}...`); | |
| 227 | 230 | for (const account of accounts) { | |
| 228 | 231 | await GoingsOn.api.emailAccounts.sync(account.id, false); | |
| 229 | 232 | } | |
| 233 | + | GoingsOn.ui.closeModal(); | |
| 230 | 234 | GoingsOn.ui.showToast('Email sync complete!', 'success'); | |
| 231 | 235 | GoingsOn.emails.load(); | |
| 232 | 236 | } catch (err) { | |
| 237 | + | GoingsOn.ui.closeModal(); | |
| 233 | 238 | GoingsOn.ui.showToast('Email sync failed: ' + GoingsOn.utils.getErrorMessage(err), 'error', { | |
| 234 | 239 | action: { label: 'Retry', fn: syncAllEmailAccounts }, | |
| 235 | 240 | duration: 8000, |
| @@ -550,7 +550,13 @@ | |||
| 550 | 550 | * @param {boolean} [fullSync=false] - true for full re-sync, false for new-only | |
| 551 | 551 | */ | |
| 552 | 552 | async function syncAccount(id, fullSync = false) { | |
| 553 | - | GoingsOn.ui.showToast(fullSync ? 'Starting full sync...' : 'Starting sync...', 'info'); | |
| 553 | + | // A first full sync pulls thousands of messages over minutes. Show a | |
| 554 | + | // persistent progress modal (reuses the OAuth-waiting pattern) so the | |
| 555 | + | // app doesn't look frozen; the modal also blocks the Sync buttons behind | |
| 556 | + | // it, preventing the double-click-queues-a-second-sync problem. | |
| 557 | + | showSyncProgressModal(fullSync | |
| 558 | + | ? 'Full sync in progress. This can take a few minutes for the first run.' | |
| 559 | + | : 'Syncing new messages...'); | |
| 554 | 560 | ||
| 555 | 561 | try { | |
| 556 | 562 | const result = await GoingsOn.api.emailAccounts.sync(id, fullSync); | |
| @@ -583,6 +589,8 @@ | |||
| 583 | 589 | GoingsOn.emails.load(); | |
| 584 | 590 | } | |
| 585 | 591 | } catch (err) { | |
| 592 | + | // Dismiss the progress modal before surfacing the error toast. | |
| 593 | + | GoingsOn.ui.closeModal(); | |
| 586 | 594 | GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Sync failed'), 'error', { | |
| 587 | 595 | action: { label: 'Retry', fn: () => syncAccount(id, fullSync) }, | |
| 588 | 596 | duration: 8000, | |
| @@ -590,6 +598,23 @@ | |||
| 590 | 598 | } | |
| 591 | 599 | } | |
| 592 | 600 | ||
| 601 | + | /** | |
| 602 | + | * Persistent spinner modal shown while an email sync runs. Replaced by the | |
| 603 | + | * Sync Results modal on success, or dismissed on error. Mirrors | |
| 604 | + | * showOAuthWaitingModal. | |
| 605 | + | * @param {string} message - Status line describing the in-progress sync | |
| 606 | + | */ | |
| 607 | + | function showSyncProgressModal(message) { | |
| 608 | + | const content = ` | |
| 609 | + | <div class="oauth-waiting"> | |
| 610 | + | <div class="oauth-waiting-title">Syncing email...</div> | |
| 611 | + | <div class="oauth-waiting-body">${esc(message)}</div> | |
| 612 | + | <div class="spinner oauth-waiting-spinner"></div> | |
| 613 | + | </div> | |
| 614 | + | `; | |
| 615 | + | GoingsOn.ui.openModal('Email Sync', content); | |
| 616 | + | } | |
| 617 | + | ||
| 593 | 618 | // ============ OAuth Flow ============ | |
| 594 | 619 | ||
| 595 | 620 | // Store OAuth state during flow | |
| @@ -788,6 +813,7 @@ | |||
| 788 | 813 | deleteAccount, | |
| 789 | 814 | testAccount, | |
| 790 | 815 | syncAccount, | |
| 816 | + | showSyncProgressModal, | |
| 791 | 817 | // OAuth | |
| 792 | 818 | startOAuth, | |
| 793 | 819 | cancelOAuth, |
| @@ -679,11 +679,12 @@ | |||
| 679 | 679 | let eventRecord; | |
| 680 | 680 | try { eventRecord = await GoingsOn.api.events.get(id); } catch (_) { /* fetch failed; fall through */ } | |
| 681 | 681 | const isRecurring = !!(eventRecord && eventRecord.recurrence && eventRecord.recurrence !== 'None'); | |
| 682 | + | // Recurring deletes still need the scope picker (which occurrences?). | |
| 683 | + | // Non-recurring deletes are optimistic + undo, so they skip the hard | |
| 684 | + | // confirm (GO-13): the deferred API call means the undo toast is the real | |
| 685 | + | // recovery, and a "cannot be undone" modal would be redundant and false. | |
| 682 | 686 | if (isRecurring) { | |
| 683 | - | // Recurring scope warning replaces the standard confirm dialog. | |
| 684 | 687 | if (!(await confirmRecurringScope(eventRecord, 'delete'))) return; | |
| 685 | - | } else { | |
| 686 | - | if (!await GoingsOn.ui.confirmDelete('event')) return; | |
| 687 | 688 | } | |
| 688 | 689 | ||
| 689 | 690 | GoingsOn.cache.invalidate('events'); |
| @@ -220,7 +220,7 @@ | |||
| 220 | 220 | * @param {string} id - Goal ID | |
| 221 | 221 | */ | |
| 222 | 222 | async function deleteGoal(id) { | |
| 223 | - | const confirmed = await GoingsOn.ui.confirmDelete('Delete this goal?'); | |
| 223 | + | const confirmed = await GoingsOn.ui.confirmDelete('goal'); | |
| 224 | 224 | if (!confirmed) return; | |
| 225 | 225 | ||
| 226 | 226 | await GoingsOn.ui.apiCall( |