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:
Trey t
2026-01-13 18:27:56 -06:00
parent dc278085de
commit f180e5bfed
10 changed files with 2080 additions and 161 deletions

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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
}
}
}

View File

@@ -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 {

View File

@@ -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