feat(sync): add CloudKit sync for dynamic sports
- Add CKSport model to parse CloudKit Sport records - Add fetchSportsForSync() to CloudKitService for delta fetching - Add syncSports() and mergeSport() to CanonicalSyncService - Update DataProvider with dynamicSports support and allSports computed property - Update MockAppDataProvider with matching dynamic sports support - Add comprehensive documentation for adding new sports The app can now sync sport definitions from CloudKit, enabling new sports to be added without app updates. Sports are fetched, merged into SwiftData, and exposed via AppDataProvider.allSports alongside built-in Sport enum cases. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -43,12 +43,13 @@ actor CanonicalSyncService {
|
||||
let leagueStructuresUpdated: Int
|
||||
let teamAliasesUpdated: Int
|
||||
let stadiumAliasesUpdated: Int
|
||||
let sportsUpdated: Int
|
||||
let skippedIncompatible: Int
|
||||
let skippedOlder: Int
|
||||
let duration: TimeInterval
|
||||
|
||||
var totalUpdated: Int {
|
||||
stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated + stadiumAliasesUpdated
|
||||
stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated + stadiumAliasesUpdated + sportsUpdated
|
||||
}
|
||||
|
||||
var isEmpty: Bool { totalUpdated == 0 }
|
||||
@@ -83,7 +84,7 @@ actor CanonicalSyncService {
|
||||
return SyncResult(
|
||||
stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0,
|
||||
leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0,
|
||||
skippedIncompatible: 0, skippedOlder: 0,
|
||||
sportsUpdated: 0, skippedIncompatible: 0, skippedOlder: 0,
|
||||
duration: 0
|
||||
)
|
||||
}
|
||||
@@ -103,6 +104,7 @@ actor CanonicalSyncService {
|
||||
var totalLeagueStructures = 0
|
||||
var totalTeamAliases = 0
|
||||
var totalStadiumAliases = 0
|
||||
var totalSports = 0
|
||||
var totalSkippedIncompatible = 0
|
||||
var totalSkippedOlder = 0
|
||||
|
||||
@@ -156,6 +158,14 @@ actor CanonicalSyncService {
|
||||
totalSkippedIncompatible += skipIncompat6
|
||||
totalSkippedOlder += skipOlder6
|
||||
|
||||
let (sports, skipIncompat7, skipOlder7) = try await syncSports(
|
||||
context: context,
|
||||
since: syncState.lastSuccessfulSync
|
||||
)
|
||||
totalSports = sports
|
||||
totalSkippedIncompatible += skipIncompat7
|
||||
totalSkippedOlder += skipOlder7
|
||||
|
||||
// Mark sync successful
|
||||
syncState.syncInProgress = false
|
||||
syncState.lastSuccessfulSync = Date()
|
||||
@@ -187,6 +197,7 @@ actor CanonicalSyncService {
|
||||
leagueStructuresUpdated: totalLeagueStructures,
|
||||
teamAliasesUpdated: totalTeamAliases,
|
||||
stadiumAliasesUpdated: totalStadiumAliases,
|
||||
sportsUpdated: totalSports,
|
||||
skippedIncompatible: totalSkippedIncompatible,
|
||||
skippedOlder: totalSkippedOlder,
|
||||
duration: Date().timeIntervalSince(startTime)
|
||||
@@ -371,6 +382,30 @@ actor CanonicalSyncService {
|
||||
return (updated, skippedIncompatible, skippedOlder)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func syncSports(
|
||||
context: ModelContext,
|
||||
since lastSync: Date?
|
||||
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
||||
let remoteSports = try await cloudKitService.fetchSportsForSync(since: lastSync)
|
||||
|
||||
var updated = 0
|
||||
var skippedIncompatible = 0
|
||||
var skippedOlder = 0
|
||||
|
||||
for remoteSport in remoteSports {
|
||||
let result = try mergeSport(remoteSport, context: context)
|
||||
|
||||
switch result {
|
||||
case .applied: updated += 1
|
||||
case .skippedIncompatible: skippedIncompatible += 1
|
||||
case .skippedOlder: skippedOlder += 1
|
||||
}
|
||||
}
|
||||
|
||||
return (updated, skippedIncompatible, skippedOlder)
|
||||
}
|
||||
|
||||
// MARK: - Merge Logic
|
||||
|
||||
private enum MergeResult {
|
||||
@@ -672,4 +707,46 @@ actor CanonicalSyncService {
|
||||
return .applied
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func mergeSport(
|
||||
_ remote: CanonicalSport,
|
||||
context: ModelContext
|
||||
) throws -> MergeResult {
|
||||
// Schema version check
|
||||
guard remote.schemaVersion <= SchemaVersion.current else {
|
||||
return .skippedIncompatible
|
||||
}
|
||||
|
||||
let remoteId = remote.id
|
||||
let descriptor = FetchDescriptor<CanonicalSport>(
|
||||
predicate: #Predicate { $0.id == remoteId }
|
||||
)
|
||||
let existing = try context.fetch(descriptor).first
|
||||
|
||||
if let existing = existing {
|
||||
// lastModified check
|
||||
guard remote.lastModified > existing.lastModified else {
|
||||
return .skippedOlder
|
||||
}
|
||||
|
||||
// Update all fields (no user fields on CanonicalSport)
|
||||
existing.abbreviation = remote.abbreviation
|
||||
existing.displayName = remote.displayName
|
||||
existing.iconName = remote.iconName
|
||||
existing.colorHex = remote.colorHex
|
||||
existing.seasonStartMonth = remote.seasonStartMonth
|
||||
existing.seasonEndMonth = remote.seasonEndMonth
|
||||
existing.isActive = remote.isActive
|
||||
existing.schemaVersion = remote.schemaVersion
|
||||
existing.lastModified = remote.lastModified
|
||||
existing.sourceRaw = DataSource.cloudKit.rawValue
|
||||
|
||||
return .applied
|
||||
} else {
|
||||
// Insert new
|
||||
context.insert(remote)
|
||||
return .applied
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user