From 9763a1884a9b57e8260c1065ad63200bb87b4511 Mon Sep 17 00:00:00 2001 From: Thomas SharedInbox Date: Fri, 15 May 2026 22:03:36 +0200 Subject: [PATCH] feat(sync-log): add per-mailbox timing to sync log (#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track how long each mailbox takes to sync and display it in the sync log expanded view (e.g. "2 new · 5 up-to-date · 1.3s"). - Add optional `duration` field to `MailboxSyncStats` - Capture per-mailbox start/end time in both IMAP and JMAP sync loops - Store as `duration_ms` in `sync_log_mailboxes` (schema v30 migration) - Read back and reconstruct `Duration` in repository - Show timing alongside fetch/skip counts in per-mailbox breakdown - Extract `_fmtDuration` helper, reuse for the existing total duration Co-Authored-By: Claude Sonnet 4.6 --- .../repositories/sync_log_repository.dart | 2 + lib/core/sync/account_sync_manager.dart | 4 ++ lib/data/db/database.dart | 7 ++- .../sync_log_repository_impl.dart | 4 ++ lib/ui/screens/sync_log_screen.dart | 14 ++++-- test/unit/migration_test.dart | 46 ++++++++++++++++++- test/unit/sync_log_repository_impl_test.dart | 43 +++++++++++++++++ 7 files changed, 113 insertions(+), 7 deletions(-) diff --git a/lib/core/repositories/sync_log_repository.dart b/lib/core/repositories/sync_log_repository.dart index aeea970..d328077 100644 --- a/lib/core/repositories/sync_log_repository.dart +++ b/lib/core/repositories/sync_log_repository.dart @@ -4,12 +4,14 @@ class MailboxSyncStats { required this.fetched, required this.skipped, required this.bytesTransferred, + this.duration, }); final String mailboxPath; final int fetched; final int skipped; final int bytesTransferred; + final Duration? duration; } class SyncLogEntry { diff --git a/lib/core/sync/account_sync_manager.dart b/lib/core/sync/account_sync_manager.dart index e409109..770ba1d 100644 --- a/lib/core/sync/account_sync_manager.dart +++ b/lib/core/sync/account_sync_manager.dart @@ -347,6 +347,7 @@ class _AccountSync implements _SyncLoop { final mailboxStats = []; for (final mailbox in mailboxes) { if (!_running) break; + final mailboxStart = DateTime.now(); final r = await _emails.syncEmails(account.id, mailbox.path); emailResult += r; mailboxStats.add( @@ -355,6 +356,7 @@ class _AccountSync implements _SyncLoop { fetched: r.fetched, skipped: r.skipped, bytesTransferred: r.bytesTransferred, + duration: DateTime.now().difference(mailboxStart), ), ); } @@ -598,6 +600,7 @@ class _JmapAccountSync implements _SyncLoop { final mailboxStats = []; for (final mailbox in mailboxes) { if (!_running) break; + final mailboxStart = DateTime.now(); final r = await _emails.syncEmails(account.id, mailbox.path); emailResult += r; mailboxStats.add( @@ -606,6 +609,7 @@ class _JmapAccountSync implements _SyncLoop { fetched: r.fetched, skipped: r.skipped, bytesTransferred: r.bytesTransferred, + duration: DateTime.now().difference(mailboxStart), ), ); } diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index 74b2185..c8166f4 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -204,6 +204,8 @@ class SyncLogMailboxes extends Table { IntColumn get fetched => integer().withDefault(const Constant(0))(); IntColumn get skipped => integer().withDefault(const Constant(0))(); IntColumn get bytesTransferred => integer().withDefault(const Constant(0))(); + // Added in schema v30: how long this mailbox took to sync, in milliseconds. + IntColumn get durationMs => integer().nullable()(); } /// Stores the result of the periodic "ground truth" verification. @@ -290,7 +292,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 29; + int get schemaVersion => 30; Future _createEmailFts() async { await customStatement(''' @@ -522,6 +524,9 @@ class AppDatabase extends _$AppDatabase { if (from < 29) { await m.createTable(localSieveScripts); } + if (from >= 12 && from < 30) { + await m.addColumn(syncLogMailboxes, syncLogMailboxes.durationMs); + } }, ); } diff --git a/lib/data/repositories/sync_log_repository_impl.dart b/lib/data/repositories/sync_log_repository_impl.dart index 582f553..aa402ae 100644 --- a/lib/data/repositories/sync_log_repository_impl.dart +++ b/lib/data/repositories/sync_log_repository_impl.dart @@ -49,6 +49,7 @@ class SyncLogRepositoryImpl implements SyncLogRepository { fetched: Value(s.fetched), skipped: Value(s.skipped), bytesTransferred: Value(s.bytesTransferred), + durationMs: Value(s.duration?.inMilliseconds), ), ); } @@ -90,6 +91,9 @@ class SyncLogRepositoryImpl implements SyncLogRepository { fetched: m.fetched, skipped: m.skipped, bytesTransferred: m.bytesTransferred, + duration: m.durationMs != null + ? Duration(milliseconds: m.durationMs!) + : null, ), ) .toList(), diff --git a/lib/ui/screens/sync_log_screen.dart b/lib/ui/screens/sync_log_screen.dart index d8a3220..32d87fc 100644 --- a/lib/ui/screens/sync_log_screen.dart +++ b/lib/ui/screens/sync_log_screen.dart @@ -9,6 +9,11 @@ import 'package:sharedinbox/di.dart'; final _timeFmt = DateFormat('MMM d, HH:mm:ss'); +String _fmtDuration(Duration d) { + final ms = d.inMilliseconds; + return ms < 1000 ? '${ms}ms' : '${(ms / 1000).toStringAsFixed(1)}s'; +} + String _fmtBytes(int bytes) { if (bytes <= 0) return '0 B'; if (bytes < 1024) return '$bytes B'; @@ -104,9 +109,7 @@ class _SyncLogTile extends StatelessWidget { @override Widget build(BuildContext context) { - final ms = entry.duration.inMilliseconds; - final durationLabel = - ms < 1000 ? '${ms}ms' : '${(ms / 1000).toStringAsFixed(1)}s'; + final durationLabel = _fmtDuration(entry.duration); final proto = entry.protocol.isEmpty ? '' : ' · ${entry.protocol.toUpperCase()}'; final theme = Theme.of(context); @@ -154,7 +157,10 @@ class _SyncLogTile extends StatelessWidget { for (final m in entry.mailboxStats) _row( ' ${m.mailboxPath}', - '${m.fetched} new · ${m.skipped} up-to-date', + [ + '${m.fetched} new · ${m.skipped} up-to-date', + if (m.duration != null) _fmtDuration(m.duration!), + ].join(' · '), ), ], if (entry.errorMessage != null) diff --git a/test/unit/migration_test.dart b/test/unit/migration_test.dart index 6100968..55d2d19 100644 --- a/test/unit/migration_test.dart +++ b/test/unit/migration_test.dart @@ -14,7 +14,7 @@ void main() { group('Migration', () { test('schemaVersion matches expected value', () async { final db = AppDatabase(NativeDatabase.memory()); - expect(db.schemaVersion, 29); + expect(db.schemaVersion, 30); await db.close(); }); @@ -186,6 +186,11 @@ void main() { // v29: local_sieve_scripts table. await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get(); + // v30: duration_ms column on sync_log_mailboxes. + final syncLogMailboxColumns = + await _tableColumns(db, 'sync_log_mailboxes'); + expect(syncLogMailboxColumns, contains('duration_ms')); + await db.close(); if (dbFile.existsSync()) dbFile.deleteSync(); }); @@ -293,6 +298,33 @@ void main() { headers_json TEXT NULL ); '''); + rawDb.execute(''' + CREATE TABLE sync_logs ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + account_id TEXT NOT NULL, + result TEXT NOT NULL, + error_message TEXT NULL, + protocol TEXT NOT NULL DEFAULT '', + items_synced INTEGER NOT NULL DEFAULT 0, + mailboxes_synced INTEGER NOT NULL DEFAULT 0, + pending_flushed INTEGER NOT NULL DEFAULT 0, + emails_skipped INTEGER NOT NULL DEFAULT 0, + bytes_transferred INTEGER NOT NULL DEFAULT 0, + started_at INTEGER NOT NULL, + finished_at INTEGER NOT NULL, + protocol_log TEXT NULL + ); + '''); + rawDb.execute(''' + CREATE TABLE sync_log_mailboxes ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + sync_log_id INTEGER NOT NULL REFERENCES sync_logs (id) ON DELETE CASCADE, + mailbox_path TEXT NOT NULL, + fetched INTEGER NOT NULL DEFAULT 0, + skipped INTEGER NOT NULL DEFAULT 0, + bytes_transferred INTEGER NOT NULL DEFAULT 0 + ); + '''); rawDb.execute('PRAGMA user_version = 22;'); rawDb.close(); @@ -341,11 +373,16 @@ void main() { // v29: local_sieve_scripts table. await db.customSelect('SELECT count(*) FROM local_sieve_scripts').get(); + // v30: duration_ms column on sync_log_mailboxes. + final syncLogMailboxColumns = + await _tableColumns(db, 'sync_log_mailboxes'); + expect(syncLogMailboxColumns, contains('duration_ms')); + await db.close(); if (dbFile.existsSync()) dbFile.deleteSync(); }); - test('fresh install creates all tables at schemaVersion 29', () async { + test('fresh install creates all tables at schemaVersion 30', () async { final db = AppDatabase(NativeDatabase.memory()); await db.select(db.accounts).get(); @@ -379,6 +416,11 @@ void main() { final draftColumns = await _tableColumns(db, 'drafts'); expect(draftColumns, contains('imap_server_id')); + // v30: duration_ms column on sync_log_mailboxes. + final syncLogMailboxColumns = + await _tableColumns(db, 'sync_log_mailboxes'); + expect(syncLogMailboxColumns, contains('duration_ms')); + await db.close(); }); }); diff --git a/test/unit/sync_log_repository_impl_test.dart b/test/unit/sync_log_repository_impl_test.dart index 30c3ea2..fece982 100644 --- a/test/unit/sync_log_repository_impl_test.dart +++ b/test/unit/sync_log_repository_impl_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:sharedinbox/core/repositories/sync_log_repository.dart'; import 'package:sharedinbox/data/db/database.dart'; import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart'; @@ -56,6 +57,48 @@ void main() { expect(rows.first.pendingFlushed, 0); }); + test('stores and retrieves per-mailbox duration', () async { + final repo = SyncLogRepositoryImpl(db); + final start = DateTime(2024, 2, 1, 10); + final end = DateTime(2024, 2, 1, 10, 0, 8); + + await repo.log( + accountId: 'acc1', + success: true, + protocol: 'imap', + emailsFetched: 3, + emailsSkipped: 1, + mailboxesSynced: 2, + pendingFlushed: 0, + bytesTransferred: 1024, + startedAt: start, + finishedAt: end, + mailboxStats: const [ + MailboxSyncStats( + mailboxPath: 'INBOX', + fetched: 2, + skipped: 1, + bytesTransferred: 512, + duration: Duration(milliseconds: 3200), + ), + MailboxSyncStats( + mailboxPath: 'Sent', + fetched: 1, + skipped: 0, + bytesTransferred: 512, + ), + ], + ); + + final entries = await repo.observeSyncLogs('acc1').first; + final latest = entries.first; + expect(latest.mailboxStats, hasLength(2)); + expect(latest.mailboxStats[0].mailboxPath, 'INBOX'); + expect(latest.mailboxStats[0].duration, const Duration(milliseconds: 3200)); + expect(latest.mailboxStats[1].mailboxPath, 'Sent'); + expect(latest.mailboxStats[1].duration, isNull); + }); + test('logs error entry with message', () async { final repo = SyncLogRepositoryImpl(db); final start = DateTime(2024, 1, 1, 11);