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:
@@ -315,6 +315,57 @@ struct CKLeagueStructure {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CKSport
|
||||
|
||||
struct CKSport {
|
||||
static let idKey = "sportId"
|
||||
static let abbreviationKey = "abbreviation"
|
||||
static let displayNameKey = "displayName"
|
||||
static let iconNameKey = "iconName"
|
||||
static let colorHexKey = "colorHex"
|
||||
static let seasonStartMonthKey = "seasonStartMonth"
|
||||
static let seasonEndMonthKey = "seasonEndMonth"
|
||||
static let isActiveKey = "isActive"
|
||||
static let schemaVersionKey = "schemaVersion"
|
||||
static let lastModifiedKey = "lastModified"
|
||||
|
||||
let record: CKRecord
|
||||
|
||||
init(record: CKRecord) {
|
||||
self.record = record
|
||||
}
|
||||
|
||||
/// Convert to CanonicalSport for local storage
|
||||
func toCanonical() -> CanonicalSport? {
|
||||
guard let id = record[CKSport.idKey] as? String,
|
||||
let abbreviation = record[CKSport.abbreviationKey] as? String,
|
||||
let displayName = record[CKSport.displayNameKey] as? String,
|
||||
let iconName = record[CKSport.iconNameKey] as? String,
|
||||
let colorHex = record[CKSport.colorHexKey] as? String,
|
||||
let seasonStartMonth = record[CKSport.seasonStartMonthKey] as? Int,
|
||||
let seasonEndMonth = record[CKSport.seasonEndMonthKey] as? Int
|
||||
else { return nil }
|
||||
|
||||
let isActive = (record[CKSport.isActiveKey] as? Int ?? 1) == 1
|
||||
let schemaVersion = record[CKSport.schemaVersionKey] as? Int ?? SchemaVersion.current
|
||||
let lastModified = record[CKSport.lastModifiedKey] as? Date ?? record.modificationDate ?? Date()
|
||||
|
||||
return CanonicalSport(
|
||||
id: id,
|
||||
abbreviation: abbreviation,
|
||||
displayName: displayName,
|
||||
iconName: iconName,
|
||||
colorHex: colorHex,
|
||||
seasonStartMonth: seasonStartMonth,
|
||||
seasonEndMonth: seasonEndMonth,
|
||||
isActive: isActive,
|
||||
lastModified: lastModified,
|
||||
schemaVersion: schemaVersion,
|
||||
source: .cloudKit
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CKStadiumAlias
|
||||
|
||||
struct CKStadiumAlias {
|
||||
|
||||
@@ -481,6 +481,66 @@ final class CanonicalGame {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Canonical Sport
|
||||
|
||||
@Model
|
||||
final class CanonicalSport {
|
||||
@Attribute(.unique) var id: String
|
||||
var abbreviation: String
|
||||
var displayName: String
|
||||
var iconName: String
|
||||
var colorHex: String
|
||||
var seasonStartMonth: Int
|
||||
var seasonEndMonth: Int
|
||||
var isActive: Bool
|
||||
var lastModified: Date
|
||||
var schemaVersion: Int
|
||||
var sourceRaw: String
|
||||
|
||||
init(
|
||||
id: String,
|
||||
abbreviation: String,
|
||||
displayName: String,
|
||||
iconName: String,
|
||||
colorHex: String,
|
||||
seasonStartMonth: Int,
|
||||
seasonEndMonth: Int,
|
||||
isActive: Bool = true,
|
||||
lastModified: Date = Date(),
|
||||
schemaVersion: Int = SchemaVersion.current,
|
||||
source: DataSource = .cloudKit
|
||||
) {
|
||||
self.id = id
|
||||
self.abbreviation = abbreviation
|
||||
self.displayName = displayName
|
||||
self.iconName = iconName
|
||||
self.colorHex = colorHex
|
||||
self.seasonStartMonth = seasonStartMonth
|
||||
self.seasonEndMonth = seasonEndMonth
|
||||
self.isActive = isActive
|
||||
self.lastModified = lastModified
|
||||
self.schemaVersion = schemaVersion
|
||||
self.sourceRaw = source.rawValue
|
||||
}
|
||||
|
||||
var source: DataSource {
|
||||
get { DataSource(rawValue: sourceRaw) ?? .cloudKit }
|
||||
set { sourceRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
func toDomain() -> DynamicSport {
|
||||
DynamicSport(
|
||||
id: id,
|
||||
abbreviation: abbreviation,
|
||||
displayName: displayName,
|
||||
iconName: iconName,
|
||||
colorHex: colorHex,
|
||||
seasonStartMonth: seasonStartMonth,
|
||||
seasonEndMonth: seasonEndMonth
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bundled Data Timestamps
|
||||
|
||||
/// Timestamps for bundled data files.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,6 +418,27 @@ actor CloudKitService {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sport Sync
|
||||
|
||||
/// Fetch sports for sync operations
|
||||
/// - Parameter lastSync: If nil, fetches all sports. If provided, fetches only sports modified since that date.
|
||||
func fetchSportsForSync(since lastSync: Date?) async throws -> [CanonicalSport] {
|
||||
let predicate: NSPredicate
|
||||
if let lastSync = lastSync {
|
||||
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
|
||||
} else {
|
||||
predicate = NSPredicate(value: true)
|
||||
}
|
||||
let query = CKQuery(recordType: CKRecordType.sport, predicate: predicate)
|
||||
|
||||
let (results, _) = try await publicDatabase.records(matching: query)
|
||||
|
||||
return results.compactMap { result -> CanonicalSport? in
|
||||
guard case .success(let record) = result.1 else { return nil }
|
||||
return CKSport(record: record).toCanonical()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sync Status
|
||||
|
||||
func checkAccountStatus() async -> CKAccountStatus {
|
||||
|
||||
@@ -17,6 +17,7 @@ final class AppDataProvider: ObservableObject {
|
||||
|
||||
@Published private(set) var teams: [Team] = []
|
||||
@Published private(set) var stadiums: [Stadium] = []
|
||||
@Published private(set) var dynamicSports: [DynamicSport] = []
|
||||
@Published private(set) var isLoading = false
|
||||
@Published private(set) var error: Error?
|
||||
@Published private(set) var errorMessage: String?
|
||||
@@ -24,6 +25,7 @@ final class AppDataProvider: ObservableObject {
|
||||
// Lookup dictionaries - keyed by canonical ID (String)
|
||||
private var teamsById: [String: Team] = [:]
|
||||
private var stadiumsById: [String: Stadium] = [:]
|
||||
private var dynamicSportsById: [String: DynamicSport] = [:]
|
||||
|
||||
private var modelContext: ModelContext?
|
||||
|
||||
@@ -80,10 +82,27 @@ final class AppDataProvider: ObservableObject {
|
||||
teamLookup[team.id] = team
|
||||
}
|
||||
|
||||
// Fetch canonical sports from SwiftData
|
||||
let sportDescriptor = FetchDescriptor<CanonicalSport>(
|
||||
predicate: #Predicate { $0.isActive == true }
|
||||
)
|
||||
let canonicalSports = try context.fetch(sportDescriptor)
|
||||
|
||||
// Convert to domain models
|
||||
var loadedSports: [DynamicSport] = []
|
||||
var sportLookup: [String: DynamicSport] = [:]
|
||||
for canonical in canonicalSports {
|
||||
let sport = canonical.toDomain()
|
||||
loadedSports.append(sport)
|
||||
sportLookup[sport.id] = sport
|
||||
}
|
||||
|
||||
self.teams = loadedTeams
|
||||
self.stadiums = loadedStadiums
|
||||
self.dynamicSports = loadedSports
|
||||
self.teamsById = teamLookup
|
||||
self.stadiumsById = stadiumLookup
|
||||
self.dynamicSportsById = sportLookup
|
||||
|
||||
} catch {
|
||||
self.error = error
|
||||
@@ -116,6 +135,17 @@ final class AppDataProvider: ObservableObject {
|
||||
teams.filter { $0.sport == sport }
|
||||
}
|
||||
|
||||
func dynamicSport(for id: String) -> DynamicSport? {
|
||||
dynamicSportsById[id]
|
||||
}
|
||||
|
||||
/// All sports: built-in Sport enum cases + CloudKit-defined DynamicSports
|
||||
var allSports: [any AnySport] {
|
||||
let builtIn: [any AnySport] = Sport.allCases
|
||||
let dynamic: [any AnySport] = dynamicSports
|
||||
return builtIn + dynamic
|
||||
}
|
||||
|
||||
// MARK: - Game Fetching
|
||||
|
||||
/// Filter games from SwiftData within date range
|
||||
|
||||
Reference in New Issue
Block a user