Skip to main content

max / goingson

Fix pre-launch audit backlog (GO-5..GO-14) + broken manual time logging Day planning (GO-6): map the user-local civil day to a UTC instant window instead of treating the date string as UTC midnight; events and tracked time near local midnight now land on the correct day. tz-injectable helper + deterministic FixedOffset tests; new list_unscheduled_due_between. Time tracking (GO-5): repo-layer ownership precheck in log_manual_time so a foreign/bogus task_id is rejected (NOT_FOUND) with no orphan session row. While testing it, fixed a latent bug: the cache UPDATE referenced a nonexistent tasks.updated_at column, so every manual entry errored after inserting the session. Removed the bogus column; added first-ever tests. Email sync (GO-7, GO-8): persistent spinner modal during the multi-minute IMAP fetch (reuses the oauth-waiting pattern), replacing the auto-dismiss toast and blocking re-clicks; re-read the account after the refresh lock so a second waiter no longer fires a redundant OAuth refresh. Backups (GO-9, GO-11, GO-12): clamp backup-settings frequency/retention; uuid-suffixed filenames to prevent same-second overwrite; fall back to modified() when birth-time is unavailable. schedule_task (GO-10): roll back the task schedule if the linked-event write fails (compensating undo; tasks/events are separate repos). Delete UX (GO-13, GO-14): consistent tiers - undo-backed deletes (task, non-recurring event) drop the false "cannot be undone" confirm; project delete (cascades) is now confirm + immediate, no undo. Fix garbled confirm copy in monthly/weekly review.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-12 21:10 UTC
Signed with PGP, not checked
Commit: 2c3fac282ce44b89cedabee8460f29c911fbacec
Parent: 54d2ae6
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(