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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
152
SportsTime/Core/Services/SyncStatusMonitor.swift
Normal file
152
SportsTime/Core/Services/SyncStatusMonitor.swift
Normal file
@@ -0,0 +1,152 @@
|
||||
//
|
||||
// SyncStatusMonitor.swift
|
||||
// SportsTime
|
||||
//
|
||||
// In-memory sync status tracking for debug builds.
|
||||
// Shows per-entity sync status in Settings.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
#if DEBUG
|
||||
|
||||
// MARK: - Entity Types
|
||||
|
||||
enum SyncEntityType: String, CaseIterable, Identifiable {
|
||||
case stadium = "Stadium"
|
||||
case leagueStructure = "League Structure"
|
||||
case team = "Team"
|
||||
case teamAlias = "Team Alias"
|
||||
case stadiumAlias = "Stadium Alias"
|
||||
case game = "Game"
|
||||
case sport = "Sport"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .stadium: return "building.2"
|
||||
case .leagueStructure: return "list.bullet.indent"
|
||||
case .team: return "person.3"
|
||||
case .teamAlias: return "person.text.rectangle"
|
||||
case .stadiumAlias: return "building"
|
||||
case .game: return "sportscourt"
|
||||
case .sport: return "figure.run"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Entity Sync Status
|
||||
|
||||
struct EntitySyncStatus: Identifiable {
|
||||
let id: SyncEntityType
|
||||
let entityType: SyncEntityType
|
||||
let lastSyncTime: Date?
|
||||
let isSuccess: Bool
|
||||
let recordCount: Int
|
||||
let errorMessage: String?
|
||||
let duration: TimeInterval?
|
||||
|
||||
init(
|
||||
entityType: SyncEntityType,
|
||||
lastSyncTime: Date? = nil,
|
||||
isSuccess: Bool = false,
|
||||
recordCount: Int = 0,
|
||||
errorMessage: String? = nil,
|
||||
duration: TimeInterval? = nil
|
||||
) {
|
||||
self.id = entityType
|
||||
self.entityType = entityType
|
||||
self.lastSyncTime = lastSyncTime
|
||||
self.isSuccess = isSuccess
|
||||
self.recordCount = recordCount
|
||||
self.errorMessage = errorMessage
|
||||
self.duration = duration
|
||||
}
|
||||
|
||||
/// Status that hasn't been synced yet
|
||||
static func pending(_ entityType: SyncEntityType) -> EntitySyncStatus {
|
||||
EntitySyncStatus(entityType: entityType)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sync Status Monitor
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SyncStatusMonitor {
|
||||
static let shared = SyncStatusMonitor()
|
||||
|
||||
private(set) var entityStatuses: [SyncEntityType: EntitySyncStatus] = [:]
|
||||
private(set) var overallSyncInProgress: Bool = false
|
||||
private(set) var lastFullSyncTime: Date?
|
||||
private(set) var lastFullSyncDuration: TimeInterval?
|
||||
|
||||
private init() {
|
||||
// Initialize all entities with pending status
|
||||
for entityType in SyncEntityType.allCases {
|
||||
entityStatuses[entityType] = .pending(entityType)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Status Updates
|
||||
|
||||
/// Called when a full sync cycle begins
|
||||
func syncStarted() {
|
||||
overallSyncInProgress = true
|
||||
}
|
||||
|
||||
/// Called after each entity sync completes
|
||||
func updateEntityStatus(
|
||||
_ entityType: SyncEntityType,
|
||||
success: Bool,
|
||||
recordCount: Int,
|
||||
duration: TimeInterval,
|
||||
errorMessage: String? = nil
|
||||
) {
|
||||
entityStatuses[entityType] = EntitySyncStatus(
|
||||
entityType: entityType,
|
||||
lastSyncTime: Date(),
|
||||
isSuccess: success,
|
||||
recordCount: recordCount,
|
||||
errorMessage: errorMessage,
|
||||
duration: duration
|
||||
)
|
||||
}
|
||||
|
||||
/// Called when the full sync cycle completes
|
||||
func syncCompleted(totalDuration: TimeInterval) {
|
||||
overallSyncInProgress = false
|
||||
lastFullSyncTime = Date()
|
||||
lastFullSyncDuration = totalDuration
|
||||
}
|
||||
|
||||
/// Called when sync fails before completing
|
||||
func syncFailed(error: Error) {
|
||||
overallSyncInProgress = false
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// All entity statuses in sync order
|
||||
var orderedStatuses: [EntitySyncStatus] {
|
||||
SyncEntityType.allCases.compactMap { entityStatuses[$0] }
|
||||
}
|
||||
|
||||
/// Whether all synced entities succeeded
|
||||
var allSuccessful: Bool {
|
||||
entityStatuses.values.allSatisfy { $0.isSuccess || $0.lastSyncTime == nil }
|
||||
}
|
||||
|
||||
/// Count of entities that failed
|
||||
var failedCount: Int {
|
||||
entityStatuses.values.filter { !$0.isSuccess && $0.lastSyncTime != nil }.count
|
||||
}
|
||||
|
||||
/// Total records synced across all entities
|
||||
var totalRecordsSynced: Int {
|
||||
entityStatuses.values.reduce(0) { $0 + $1.recordCount }
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user