feat(debug): add per-entity CloudKit sync status in Settings

Add debug-only sync status monitoring to help diagnose CloudKit sync issues.
Shows last sync time, success/failure, and record counts for each entity type.
Includes manual sync trigger and re-enable button when sync is paused.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-20 13:12:56 -06:00
parent c49206bb7c
commit 51419fccf2
3 changed files with 442 additions and 0 deletions

View File

@@ -83,6 +83,10 @@ actor CanonicalSyncService {
throw SyncError.syncAlreadyInProgress
}
#if DEBUG
SyncStatusMonitor.shared.syncStarted()
#endif
// Check if sync is enabled
guard syncState.syncEnabled else {
return SyncResult(
@@ -124,6 +128,9 @@ actor CanonicalSyncService {
do {
// Sync in dependency order, checking cancellation between each entity type
// Stadium sync
var entityStartTime = Date()
let (stadiums, skipIncompat1, skipOlder1) = try await syncStadiums(
context: context,
since: syncState.lastStadiumSync ?? syncState.lastSuccessfulSync,
@@ -133,11 +140,16 @@ actor CanonicalSyncService {
totalSkippedIncompatible += skipIncompat1
totalSkippedOlder += skipOlder1
syncState.lastStadiumSync = Date()
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.stadium, success: true, recordCount: stadiums, duration: Date().timeIntervalSince(entityStartTime))
#endif
if try saveProgressAndCheckCancellation() {
wasCancelled = true
throw CancellationError()
}
// League Structure sync
entityStartTime = Date()
let (leagueStructures, skipIncompat2, skipOlder2) = try await syncLeagueStructure(
context: context,
since: syncState.lastLeagueStructureSync ?? syncState.lastSuccessfulSync,
@@ -147,11 +159,16 @@ actor CanonicalSyncService {
totalSkippedIncompatible += skipIncompat2
totalSkippedOlder += skipOlder2
syncState.lastLeagueStructureSync = Date()
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.leagueStructure, success: true, recordCount: leagueStructures, duration: Date().timeIntervalSince(entityStartTime))
#endif
if try saveProgressAndCheckCancellation() {
wasCancelled = true
throw CancellationError()
}
// Team sync
entityStartTime = Date()
let (teams, skipIncompat3, skipOlder3) = try await syncTeams(
context: context,
since: syncState.lastTeamSync ?? syncState.lastSuccessfulSync,
@@ -161,11 +178,16 @@ actor CanonicalSyncService {
totalSkippedIncompatible += skipIncompat3
totalSkippedOlder += skipOlder3
syncState.lastTeamSync = Date()
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.team, success: true, recordCount: teams, duration: Date().timeIntervalSince(entityStartTime))
#endif
if try saveProgressAndCheckCancellation() {
wasCancelled = true
throw CancellationError()
}
// Team Alias sync
entityStartTime = Date()
let (teamAliases, skipIncompat4, skipOlder4) = try await syncTeamAliases(
context: context,
since: syncState.lastTeamAliasSync ?? syncState.lastSuccessfulSync,
@@ -175,11 +197,16 @@ actor CanonicalSyncService {
totalSkippedIncompatible += skipIncompat4
totalSkippedOlder += skipOlder4
syncState.lastTeamAliasSync = Date()
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.teamAlias, success: true, recordCount: teamAliases, duration: Date().timeIntervalSince(entityStartTime))
#endif
if try saveProgressAndCheckCancellation() {
wasCancelled = true
throw CancellationError()
}
// Stadium Alias sync
entityStartTime = Date()
let (stadiumAliases, skipIncompat5, skipOlder5) = try await syncStadiumAliases(
context: context,
since: syncState.lastStadiumAliasSync ?? syncState.lastSuccessfulSync,
@@ -189,11 +216,16 @@ actor CanonicalSyncService {
totalSkippedIncompatible += skipIncompat5
totalSkippedOlder += skipOlder5
syncState.lastStadiumAliasSync = Date()
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.stadiumAlias, success: true, recordCount: stadiumAliases, duration: Date().timeIntervalSince(entityStartTime))
#endif
if try saveProgressAndCheckCancellation() {
wasCancelled = true
throw CancellationError()
}
// Game sync
entityStartTime = Date()
let (games, skipIncompat6, skipOlder6) = try await syncGames(
context: context,
since: syncState.lastGameSync ?? syncState.lastSuccessfulSync,
@@ -203,11 +235,16 @@ actor CanonicalSyncService {
totalSkippedIncompatible += skipIncompat6
totalSkippedOlder += skipOlder6
syncState.lastGameSync = Date()
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.game, success: true, recordCount: games, duration: Date().timeIntervalSince(entityStartTime))
#endif
if try saveProgressAndCheckCancellation() {
wasCancelled = true
throw CancellationError()
}
// Sport sync
entityStartTime = Date()
let (sports, skipIncompat7, skipOlder7) = try await syncSports(
context: context,
since: syncState.lastSportSync ?? syncState.lastSuccessfulSync,
@@ -217,6 +254,9 @@ actor CanonicalSyncService {
totalSkippedIncompatible += skipIncompat7
totalSkippedOlder += skipOlder7
syncState.lastSportSync = Date()
#if DEBUG
SyncStatusMonitor.shared.updateEntityStatus(.sport, success: true, recordCount: sports, duration: Date().timeIntervalSince(entityStartTime))
#endif
// Mark sync successful - clear per-entity timestamps since full sync completed
syncState.syncInProgress = false
@@ -234,12 +274,20 @@ actor CanonicalSyncService {
try context.save()
#if DEBUG
SyncStatusMonitor.shared.syncCompleted(totalDuration: Date().timeIntervalSince(startTime))
#endif
} catch is CancellationError {
// Graceful cancellation - progress already saved
syncState.syncInProgress = false
syncState.lastSyncError = "Sync cancelled - partial progress saved"
try? context.save()
#if DEBUG
SyncStatusMonitor.shared.syncFailed(error: CancellationError())
#endif
return SyncResult(
stadiumsUpdated: totalStadiums,
teamsUpdated: totalTeams,
@@ -266,6 +314,11 @@ actor CanonicalSyncService {
}
try? context.save()
#if DEBUG
SyncStatusMonitor.shared.syncFailed(error: error)
#endif
throw error
}