chore: commit all pending changes

This commit is contained in:
Trey t
2026-02-10 18:15:36 -06:00
parent b993ed3613
commit 53cc532ca9
51 changed files with 2583 additions and 268 deletions

View File

@@ -15,6 +15,17 @@ import os
@MainActor
final class BackgroundSyncManager {
enum SyncTriggerError: Error, LocalizedError {
case modelContainerNotConfigured
var errorDescription: String? {
switch self {
case .modelContainerNotConfigured:
return "Sync is unavailable because the model container is not configured."
}
}
}
// MARK: - Task Identifiers
/// Background app refresh task - runs periodically (system decides frequency)
@@ -163,7 +174,7 @@ final class BackgroundSyncManager {
task.setTaskCompleted(success: true) // Partial success - progress was saved
} else {
logger.info("Background refresh completed: \(result.totalUpdated) items updated")
task.setTaskCompleted(success: !result.isEmpty)
task.setTaskCompleted(success: true)
}
} catch {
currentCancellationToken = nil
@@ -212,7 +223,7 @@ final class BackgroundSyncManager {
task.setTaskCompleted(success: true) // Partial success
} else {
logger.info("Background processing completed: sync=\(syncResult.totalUpdated), cleanup=\(cleanupSuccess)")
task.setTaskCompleted(success: !syncResult.isEmpty || cleanupSuccess)
task.setTaskCompleted(success: true)
}
} catch {
currentCancellationToken = nil
@@ -291,6 +302,191 @@ final class BackgroundSyncManager {
}
}
/// Trigger sync from a CloudKit push notification.
/// Returns true if local data changed and UI should refresh.
@MainActor
func triggerSyncFromPushNotification(subscriptionID: String?) async -> Bool {
guard let container = modelContainer else {
logger.error("Push sync: No model container configured")
return false
}
if let subscriptionID {
logger.info("Triggering sync from CloudKit push (\(subscriptionID, privacy: .public))")
} else {
logger.info("Triggering sync from CloudKit push")
}
do {
let result = try await performSync(context: container.mainContext)
return !result.isEmpty || result.wasCancelled
} catch {
logger.error("Push-triggered sync failed: \(error.localizedDescription)")
return false
}
}
/// Apply a targeted local deletion for records removed remotely.
/// This covers deletions which are not returned by date-based delta queries.
@MainActor
func applyDeletionHint(recordType: String?, recordName: String) async -> Bool {
guard let container = modelContainer else {
logger.error("Deletion hint: No model container configured")
return false
}
let context = container.mainContext
var changed = false
do {
switch recordType {
case CKRecordType.game:
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { game in
game.canonicalId == recordName && game.deprecatedAt == nil
}
)
if let game = try context.fetch(descriptor).first {
game.deprecatedAt = Date()
game.deprecationReason = "Deleted in CloudKit"
changed = true
}
case CKRecordType.team:
let descriptor = FetchDescriptor<CanonicalTeam>(
predicate: #Predicate<CanonicalTeam> { team in
team.canonicalId == recordName && team.deprecatedAt == nil
}
)
if let team = try context.fetch(descriptor).first {
team.deprecatedAt = Date()
team.deprecationReason = "Deleted in CloudKit"
changed = true
}
// Avoid dangling schedule entries when a referenced team disappears.
let impactedGames = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { game in
game.deprecatedAt == nil &&
(game.homeTeamCanonicalId == recordName || game.awayTeamCanonicalId == recordName)
}
)
for game in try context.fetch(impactedGames) {
game.deprecatedAt = Date()
game.deprecationReason = "Dependent team deleted in CloudKit"
changed = true
}
case CKRecordType.stadium:
let descriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate<CanonicalStadium> { stadium in
stadium.canonicalId == recordName && stadium.deprecatedAt == nil
}
)
if let stadium = try context.fetch(descriptor).first {
stadium.deprecatedAt = Date()
stadium.deprecationReason = "Deleted in CloudKit"
changed = true
}
// Avoid dangling schedule entries when a referenced stadium disappears.
let impactedGames = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { game in
game.deprecatedAt == nil && game.stadiumCanonicalId == recordName
}
)
for game in try context.fetch(impactedGames) {
game.deprecatedAt = Date()
game.deprecationReason = "Dependent stadium deleted in CloudKit"
changed = true
}
case CKRecordType.leagueStructure:
let descriptor = FetchDescriptor<LeagueStructureModel>(
predicate: #Predicate<LeagueStructureModel> { $0.id == recordName }
)
let records = try context.fetch(descriptor)
for record in records {
context.delete(record)
changed = true
}
case CKRecordType.teamAlias:
let descriptor = FetchDescriptor<TeamAlias>(
predicate: #Predicate<TeamAlias> { $0.id == recordName }
)
let records = try context.fetch(descriptor)
for record in records {
context.delete(record)
changed = true
}
case CKRecordType.stadiumAlias:
let descriptor = FetchDescriptor<StadiumAlias>(
predicate: #Predicate<StadiumAlias> { $0.aliasName == recordName }
)
let records = try context.fetch(descriptor)
for record in records {
context.delete(record)
changed = true
}
case CKRecordType.sport:
let descriptor = FetchDescriptor<CanonicalSport>(
predicate: #Predicate<CanonicalSport> { $0.id == recordName }
)
if let sport = try context.fetch(descriptor).first, sport.isActive {
sport.isActive = false
sport.lastModified = Date()
changed = true
}
default:
break
}
guard changed else { return false }
try context.save()
await AppDataProvider.shared.loadInitialData()
logger.info("Applied CloudKit deletion hint for \(recordType ?? "unknown", privacy: .public):\(recordName, privacy: .public)")
return true
} catch {
logger.error("Failed to apply deletion hint (\(recordType ?? "unknown", privacy: .public):\(recordName, privacy: .public)): \(error.localizedDescription)")
return false
}
}
/// Trigger a manual full sync from settings or other explicit user action.
@MainActor
func triggerManualSync() async throws -> CanonicalSyncService.SyncResult {
guard let container = modelContainer else {
throw SyncTriggerError.modelContainerNotConfigured
}
let context = container.mainContext
let syncService = CanonicalSyncService()
let syncState = SyncState.current(in: context)
if !syncState.syncEnabled {
syncService.resumeSync(context: context)
}
return try await performSync(context: context)
}
/// Register CloudKit subscriptions for canonical data updates.
@MainActor
func ensureCanonicalSubscriptions() async {
do {
try await CloudKitService.shared.subscribeToAllUpdates()
logger.info("CloudKit subscriptions ensured for canonical sync")
} catch {
logger.error("Failed to register CloudKit subscriptions: \(error.localizedDescription)")
}
}
/// Clean up old game data (older than 1 year).
@MainActor
private func performCleanup(context: ModelContext) async -> Bool {