From b99c25d8f6241fd4ace711b711f3c8f3cfe23e5f Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 13 Jan 2026 18:36:35 -0600 Subject: [PATCH] feat(sync): add background task system for nightly CloudKit sync Add BGTaskScheduler-based background sync for keeping local data fresh: - BackgroundSyncManager: New singleton managing background tasks - BGAppRefreshTask for periodic CloudKit sync (system-determined frequency) - BGProcessingTask for overnight sync + database cleanup (2 AM) - Auto-archives games older than 1 year during cleanup - Info.plist: Added BGTaskSchedulerPermittedIdentifiers - com.sportstime.app.refresh (periodic sync) - com.sportstime.app.db-cleanup (overnight processing) - SportsTimeApp: Integrated background task lifecycle - Register tasks in init() (required before app finishes launching) - Schedule tasks after bootstrap and when app enters background Co-Authored-By: Claude Opus 4.5 --- .../Core/Services/BackgroundSyncManager.swift | 278 ++++++++++++++++++ SportsTime/Info.plist | 5 + SportsTime/SportsTimeApp.swift | 35 ++- 3 files changed, 310 insertions(+), 8 deletions(-) create mode 100644 SportsTime/Core/Services/BackgroundSyncManager.swift diff --git a/SportsTime/Core/Services/BackgroundSyncManager.swift b/SportsTime/Core/Services/BackgroundSyncManager.swift new file mode 100644 index 0000000..acf7d50 --- /dev/null +++ b/SportsTime/Core/Services/BackgroundSyncManager.swift @@ -0,0 +1,278 @@ +// +// BackgroundSyncManager.swift +// SportsTime +// +// Manages background tasks for nightly CloudKit sync and database cleanup. +// Uses BGTaskScheduler to run sync operations even when app is not active. +// + +import Foundation +import BackgroundTasks +import SwiftData + +/// Manages background refresh and processing tasks for CloudKit sync. +@MainActor +final class BackgroundSyncManager { + + // MARK: - Task Identifiers + + /// Background app refresh task - runs periodically (system decides frequency) + static let refreshTaskIdentifier = "com.sportstime.app.refresh" + + /// Background processing task - runs during optimal conditions (plugged in, overnight) + static let processingTaskIdentifier = "com.sportstime.app.db-cleanup" + + // MARK: - Singleton + + static let shared = BackgroundSyncManager() + + // MARK: - Properties + + private var modelContainer: ModelContainer? + + private init() {} + + // MARK: - Configuration + + /// Configure the manager with the model container. + /// Must be called before registering tasks. + func configure(with container: ModelContainer) { + self.modelContainer = container + } + + // MARK: - Task Registration + + /// Register all background tasks with the system. + /// Must be called early in app lifecycle, before `applicationDidFinishLaunching` returns. + func registerTasks() { + // Register refresh task (for periodic CloudKit sync) + BGTaskScheduler.shared.register( + forTaskWithIdentifier: Self.refreshTaskIdentifier, + using: nil + ) { task in + guard let task = task as? BGAppRefreshTask else { return } + Task { @MainActor in + await self.handleRefreshTask(task) + } + } + + // Register processing task (for overnight heavy sync/cleanup) + BGTaskScheduler.shared.register( + forTaskWithIdentifier: Self.processingTaskIdentifier, + using: nil + ) { task in + guard let task = task as? BGProcessingTask else { return } + Task { @MainActor in + await self.handleProcessingTask(task) + } + } + + print("Background tasks registered") + } + + // MARK: - Task Scheduling + + /// Schedule the next background refresh. + /// System will run this periodically based on app usage patterns. + func scheduleRefresh() { + let request = BGAppRefreshTaskRequest(identifier: Self.refreshTaskIdentifier) + + // Request to run no earlier than 1 hour from now + // System will actually schedule based on usage patterns, battery, network, etc. + request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60) + + do { + try BGTaskScheduler.shared.submit(request) + print("Background refresh scheduled") + } catch { + print("Failed to schedule background refresh: \(error.localizedDescription)") + } + } + + /// Schedule overnight processing task for heavy sync/cleanup. + /// Runs when device is charging and connected to WiFi. + func scheduleProcessingTask() { + let request = BGProcessingTaskRequest(identifier: Self.processingTaskIdentifier) + + // Schedule for overnight - earliest 2 AM + let calendar = Calendar.current + var components = calendar.dateComponents([.year, .month, .day], from: Date()) + components.hour = 2 + components.minute = 0 + + // If it's past 2 AM, schedule for tomorrow + var targetDate = calendar.date(from: components) ?? Date() + if targetDate < Date() { + targetDate = calendar.date(byAdding: .day, value: 1, to: targetDate) ?? Date() + } + + request.earliestBeginDate = targetDate + request.requiresNetworkConnectivity = true + request.requiresExternalPower = false // Don't require power, but prefer it + + do { + try BGTaskScheduler.shared.submit(request) + print("Overnight processing task scheduled for \(targetDate)") + } catch { + print("Failed to schedule processing task: \(error.localizedDescription)") + } + } + + /// Schedule all background tasks. + /// Call this after app finishes launching and when app enters background. + func scheduleAllTasks() { + scheduleRefresh() + scheduleProcessingTask() + } + + // MARK: - Task Handlers + + /// Handle the background refresh task. + @MainActor + private func handleRefreshTask(_ task: BGAppRefreshTask) async { + print("Background refresh task started") + + // Schedule the next refresh before we start (in case we get terminated) + scheduleRefresh() + + // Set up expiration handler + task.expirationHandler = { + print("Background refresh task expired") + task.setTaskCompleted(success: false) + } + + guard let container = modelContainer else { + print("Background refresh: No model container configured") + task.setTaskCompleted(success: false) + return + } + + do { + let success = try await performSync(context: container.mainContext) + task.setTaskCompleted(success: success) + print("Background refresh completed: \(success ? "success" : "no updates")") + } catch { + print("Background refresh failed: \(error.localizedDescription)") + task.setTaskCompleted(success: false) + } + } + + /// Handle the background processing task (overnight heavy sync). + @MainActor + private func handleProcessingTask(_ task: BGProcessingTask) async { + print("Background processing task started") + + // Schedule the next processing task + scheduleProcessingTask() + + // Set up expiration handler + task.expirationHandler = { + print("Background processing task expired") + task.setTaskCompleted(success: false) + } + + guard let container = modelContainer else { + print("Background processing: No model container configured") + task.setTaskCompleted(success: false) + return + } + + do { + // 1. Full sync from CloudKit + let syncSuccess = try await performSync(context: container.mainContext) + + // 2. Clean up old data (games older than 1 year) + let cleanupSuccess = await performCleanup(context: container.mainContext) + + task.setTaskCompleted(success: syncSuccess || cleanupSuccess) + print("Background processing completed: sync=\(syncSuccess), cleanup=\(cleanupSuccess)") + } catch { + print("Background processing failed: \(error.localizedDescription)") + task.setTaskCompleted(success: false) + } + } + + // MARK: - Sync Operations + + /// Perform CloudKit sync operation. + @MainActor + private func performSync(context: ModelContext) async throws -> Bool { + let syncService = CanonicalSyncService() + + do { + let result = try await syncService.syncAll(context: context) + + // Reload DataProvider if data changed + if !result.isEmpty { + await AppDataProvider.shared.loadInitialData() + print("Background sync: \(result.totalUpdated) items updated") + return true + } + + return false + + } catch CanonicalSyncService.SyncError.cloudKitUnavailable { + // No network - this is expected, not a failure + print("Background sync: CloudKit unavailable (offline)") + return false + } catch CanonicalSyncService.SyncError.syncAlreadyInProgress { + // Another sync is running - that's fine + print("Background sync: Already in progress") + return false + } + } + + /// Clean up old game data (older than 1 year). + @MainActor + private func performCleanup(context: ModelContext) async -> Bool { + let oneYearAgo = Calendar.current.date(byAdding: .year, value: -1, to: Date()) ?? Date() + + do { + // Fetch old games + let descriptor = FetchDescriptor( + predicate: #Predicate { game in + game.dateTime < oneYearAgo && game.deprecatedAt == nil + } + ) + + let oldGames = try context.fetch(descriptor) + + guard !oldGames.isEmpty else { + return false + } + + // Soft-delete old games (mark as deprecated) + for game in oldGames { + game.deprecatedAt = Date() + game.deprecationReason = "Auto-archived: game older than 1 year" + } + + try context.save() + print("Background cleanup: Archived \(oldGames.count) old games") + return true + + } catch { + print("Background cleanup failed: \(error.localizedDescription)") + return false + } + } +} + +// MARK: - Debug Helpers + +#if DEBUG +extension BackgroundSyncManager { + /// Manually trigger refresh task for testing. + /// Run in debugger: `e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.sportstime.app.refresh"]` + func debugTriggerRefresh() { + print("Debug: Use lldb command to trigger background refresh:") + print("e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@\"com.sportstime.app.refresh\"]") + } + + /// Manually trigger processing task for testing. + func debugTriggerProcessing() { + print("Debug: Use lldb command to trigger background processing:") + print("e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@\"com.sportstime.app.db-cleanup\"]") + } +} +#endif diff --git a/SportsTime/Info.plist b/SportsTime/Info.plist index 442858f..065f278 100644 --- a/SportsTime/Info.plist +++ b/SportsTime/Info.plist @@ -8,5 +8,10 @@ fetch processing + BGTaskSchedulerPermittedIdentifiers + + com.sportstime.app.refresh + com.sportstime.app.db-cleanup + diff --git a/SportsTime/SportsTimeApp.swift b/SportsTime/SportsTimeApp.swift index 11507bf..0dcb844 100644 --- a/SportsTime/SportsTimeApp.swift +++ b/SportsTime/SportsTimeApp.swift @@ -7,6 +7,7 @@ import SwiftUI import SwiftData +import BackgroundTasks @main struct SportsTimeApp: App { @@ -14,6 +15,10 @@ struct SportsTimeApp: App { private var transactionListener: Task? init() { + // Register background tasks BEFORE app finishes launching + // This must happen synchronously in init or applicationDidFinishLaunching + BackgroundSyncManager.shared.registerTasks() + // Start listening for transactions immediately transactionListener = StoreManager.shared.listenForTransactions() } @@ -105,11 +110,19 @@ struct BootstrappedContentView: View { await performBootstrap() } .onChange(of: scenePhase) { _, newPhase in - // Sync when app comes to foreground (but not on initial launch) - if newPhase == .active && hasCompletedInitialSync { - Task { - await performBackgroundSync(context: modelContainer.mainContext) + switch newPhase { + case .active: + // Sync when app comes to foreground (but not on initial launch) + if hasCompletedInitialSync { + Task { + await performBackgroundSync(context: modelContainer.mainContext) + } } + case .background: + // Schedule background tasks when app goes to background + BackgroundSyncManager.shared.scheduleAllTasks() + default: + break } } } @@ -129,17 +142,23 @@ struct BootstrappedContentView: View { // 2. Configure DataProvider with SwiftData context AppDataProvider.shared.configure(with: context) - // 3. Load data from SwiftData into memory + // 3. Configure BackgroundSyncManager with model container + BackgroundSyncManager.shared.configure(with: modelContainer) + + // 4. Load data from SwiftData into memory await AppDataProvider.shared.loadInitialData() - // 4. Load store products and entitlements + // 5. Load store products and entitlements await StoreManager.shared.loadProducts() await StoreManager.shared.updateEntitlements() - // 5. App is now usable + // 6. App is now usable isBootstrapping = false - // 6. Background: Try to refresh from CloudKit (non-blocking) + // 7. Schedule background tasks for future syncs + BackgroundSyncManager.shared.scheduleAllTasks() + + // 8. Background: Try to refresh from CloudKit (non-blocking) Task.detached(priority: .background) { await self.performBackgroundSync(context: context) await MainActor.run {