From 51419fccf2c39ae97445bb3ca66af397f9e36d8c Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 20 Jan 2026 13:12:56 -0600 Subject: [PATCH] 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 --- .../Core/Services/CanonicalSyncService.swift | 53 ++++ .../Core/Services/SyncStatusMonitor.swift | 152 +++++++++++ .../Settings/Views/SettingsView.swift | 237 ++++++++++++++++++ 3 files changed, 442 insertions(+) create mode 100644 SportsTime/Core/Services/SyncStatusMonitor.swift diff --git a/SportsTime/Core/Services/CanonicalSyncService.swift b/SportsTime/Core/Services/CanonicalSyncService.swift index c27175a..33a9d58 100644 --- a/SportsTime/Core/Services/CanonicalSyncService.swift +++ b/SportsTime/Core/Services/CanonicalSyncService.swift @@ -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 } diff --git a/SportsTime/Core/Services/SyncStatusMonitor.swift b/SportsTime/Core/Services/SyncStatusMonitor.swift new file mode 100644 index 0000000..d15a55a --- /dev/null +++ b/SportsTime/Core/Services/SyncStatusMonitor.swift @@ -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 diff --git a/SportsTime/Features/Settings/Views/SettingsView.swift b/SportsTime/Features/Settings/Views/SettingsView.swift index b5c4d6e..31e06d5 100644 --- a/SportsTime/Features/Settings/Views/SettingsView.swift +++ b/SportsTime/Features/Settings/Views/SettingsView.swift @@ -7,10 +7,14 @@ import SwiftUI struct SettingsView: View { @Environment(\.colorScheme) private var colorScheme + @Environment(\.modelContext) private var modelContext @State private var viewModel = SettingsViewModel() @State private var showResetConfirmation = false @State private var showPaywall = false @State private var showOnboardingPaywall = false + #if DEBUG + @State private var selectedSyncStatus: EntitySyncStatus? + #endif var body: some View { List { @@ -41,6 +45,9 @@ struct SettingsView: View { #if DEBUG // Debug debugSection + + // Sync Status + syncStatusSection #endif } .scrollContentBackground(.hidden) @@ -339,6 +346,103 @@ struct SettingsView: View { } .listRowBackground(Theme.cardBackground(colorScheme)) } + + private var syncStatusSection: some View { + Section { + // Check if sync is disabled + let syncState = SyncState.current(in: modelContext) + if !syncState.syncEnabled { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "pause.circle.fill") + .foregroundStyle(.orange) + Text("Sync Paused") + .font(.headline) + .foregroundStyle(.orange) + } + if let reason = syncState.syncPausedReason { + Text(reason) + .font(.caption) + .foregroundStyle(.secondary) + } + if let lastError = syncState.lastSyncError { + Text("Last error: \(lastError)") + .font(.caption) + .foregroundStyle(.red) + } + Text("Consecutive failures: \(syncState.consecutiveFailures)") + .font(.caption) + .foregroundStyle(.secondary) + } + + Button { + Task { + let syncService = CanonicalSyncService() + await syncService.resumeSync(context: modelContext) + print("[SyncDebug] Sync re-enabled by user") + } + } label: { + Label("Re-enable Sync", systemImage: "arrow.clockwise") + } + .tint(.blue) + } + + // Overall status header + if SyncStatusMonitor.shared.overallSyncInProgress { + HStack { + ProgressView() + .scaleEffect(0.8) + Text("Sync in progress...") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } else if let lastSync = SyncStatusMonitor.shared.lastFullSyncTime { + HStack { + Image(systemName: SyncStatusMonitor.shared.allSuccessful ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .foregroundStyle(SyncStatusMonitor.shared.allSuccessful ? .green : .orange) + Text("Last sync: \(lastSync.formatted(date: .omitted, time: .shortened))") + .font(.subheadline) + .foregroundStyle(.secondary) + if let duration = SyncStatusMonitor.shared.lastFullSyncDuration { + Text("(\(String(format: "%.1fs", duration)))") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + + // Per-entity status rows + ForEach(SyncStatusMonitor.shared.orderedStatuses) { status in + SyncStatusRow(status: status) { + selectedSyncStatus = status + } + } + + // Manual sync trigger + Button { + Task { + let syncService = CanonicalSyncService() + print("[SyncDebug] Manual sync triggered by user") + do { + let result = try await syncService.syncAll(context: modelContext) + print("[SyncDebug] Manual sync completed: \(result.totalUpdated) records") + } catch { + print("[SyncDebug] Manual sync failed: \(error)") + } + } + } label: { + Label("Trigger Sync Now", systemImage: "arrow.triangle.2.circlepath") + } + } header: { + Text("Sync Status") + } footer: { + Text("Shows CloudKit sync status for each record type. Updated on app foreground.") + } + .listRowBackground(Theme.cardBackground(colorScheme)) + .sheet(item: $selectedSyncStatus) { status in + SyncStatusDetailSheet(status: status) + } + } #endif // MARK: - Subscription Section @@ -430,3 +534,136 @@ struct SettingsView: View { SettingsView() } } + +// MARK: - Debug Sync Status Views + +#if DEBUG + +/// A row showing sync status for a single entity type +struct SyncStatusRow: View { + let status: EntitySyncStatus + let onInfoTapped: () -> Void + + var body: some View { + HStack(spacing: 12) { + // Status indicator + Image(systemName: statusIcon) + .foregroundStyle(statusColor) + .font(.system(size: 14)) + .frame(width: 20) + + // Entity icon and name + Image(systemName: status.entityType.iconName) + .foregroundStyle(.secondary) + .font(.system(size: 14)) + .frame(width: 20) + + Text(status.entityType.rawValue) + .font(.body) + + Spacer() + + // Record count (if synced) + if status.lastSyncTime != nil { + Text("\(status.recordCount)") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.1)) + .clipShape(Capsule()) + } + + // Info button + Button { + onInfoTapped() + } label: { + Image(systemName: "info.circle") + .foregroundStyle(.blue) + } + .buttonStyle(.plain) + } + } + + private var statusIcon: String { + guard status.lastSyncTime != nil else { + return "circle.dotted" + } + return status.isSuccess ? "checkmark.circle.fill" : "xmark.circle.fill" + } + + private var statusColor: Color { + guard status.lastSyncTime != nil else { + return .secondary + } + return status.isSuccess ? .green : .red + } +} + +/// Detail sheet showing full sync information for an entity +struct SyncStatusDetailSheet: View { + let status: EntitySyncStatus + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section { + DetailRow(label: "Entity Type", value: status.entityType.rawValue) + DetailRow(label: "Status", value: status.isSuccess ? "Success" : "Failed", valueColor: status.isSuccess ? .green : .red) + } + + Section { + if let syncTime = status.lastSyncTime { + DetailRow(label: "Last Sync", value: syncTime.formatted(date: .abbreviated, time: .standard)) + } else { + DetailRow(label: "Last Sync", value: "Never") + } + + DetailRow(label: "Records Synced", value: "\(status.recordCount)") + + if let duration = status.duration { + DetailRow(label: "Duration", value: String(format: "%.2f seconds", duration)) + } + } + + if let errorMessage = status.errorMessage { + Section("Error") { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + } + } + } + .navigationTitle(status.entityType.rawValue) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + dismiss() + } + } + } + } + .presentationDetents([.medium]) + } +} + +/// Helper view for detail rows +private struct DetailRow: View { + let label: String + let value: String + var valueColor: Color = .primary + + var body: some View { + HStack { + Text(label) + .foregroundStyle(.secondary) + Spacer() + Text(value) + .foregroundStyle(valueColor) + } + } +} + +#endif