Stadium Progress & Achievements: - Add StadiumVisit and Achievement SwiftData models - Create Progress tab with interactive map view - Implement photo-based visit import with GPS/date matching - Add achievement badges (count-based, regional, journey) - Create shareable progress cards for social media - Add canonical data infrastructure (stadium identities, team aliases) - Implement score resolution from free APIs (MLB, NBA, NHL stats) UI Improvements: - Add ThemedSpinner and ThemedSpinnerCompact components - Replace all ProgressView() with themed spinners throughout app - Fix sport selection state not persisting when navigating away Bug Fixes: - Fix Coast to Coast trips showing only 1 city (validation issue) - Fix stadium progress showing 0/0 (filtering issue) - Remove "Stadium Quest" title from progress view 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
635 lines
21 KiB
Swift
635 lines
21 KiB
Swift
//
|
|
// CanonicalSyncService.swift
|
|
// SportsTime
|
|
//
|
|
// Orchestrates syncing canonical data from CloudKit into SwiftData.
|
|
// Uses date-based delta sync for public database efficiency.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftData
|
|
import CloudKit
|
|
|
|
actor CanonicalSyncService {
|
|
|
|
// MARK: - Errors
|
|
|
|
enum SyncError: Error, LocalizedError {
|
|
case cloudKitUnavailable
|
|
case syncAlreadyInProgress
|
|
case saveFailed(Error)
|
|
case schemaVersionTooNew(Int)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .cloudKitUnavailable:
|
|
return "CloudKit is not available. Check your internet connection and iCloud settings."
|
|
case .syncAlreadyInProgress:
|
|
return "A sync operation is already in progress."
|
|
case .saveFailed(let error):
|
|
return "Failed to save synced data: \(error.localizedDescription)"
|
|
case .schemaVersionTooNew(let version):
|
|
return "Data requires app version supporting schema \(version). Please update the app."
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Sync Result
|
|
|
|
struct SyncResult {
|
|
let stadiumsUpdated: Int
|
|
let teamsUpdated: Int
|
|
let gamesUpdated: Int
|
|
let leagueStructuresUpdated: Int
|
|
let teamAliasesUpdated: Int
|
|
let skippedIncompatible: Int
|
|
let skippedOlder: Int
|
|
let duration: TimeInterval
|
|
|
|
var totalUpdated: Int {
|
|
stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated
|
|
}
|
|
|
|
var isEmpty: Bool { totalUpdated == 0 }
|
|
}
|
|
|
|
// MARK: - Properties
|
|
|
|
private let cloudKitService: CloudKitService
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(cloudKitService: CloudKitService = .shared) {
|
|
self.cloudKitService = cloudKitService
|
|
}
|
|
|
|
// MARK: - Public Sync Methods
|
|
|
|
/// Perform a full sync of all canonical data types.
|
|
/// This is the main entry point for background sync.
|
|
@MainActor
|
|
func syncAll(context: ModelContext) async throws -> SyncResult {
|
|
let startTime = Date()
|
|
let syncState = SyncState.current(in: context)
|
|
|
|
// Prevent concurrent syncs
|
|
guard !syncState.syncInProgress else {
|
|
throw SyncError.syncAlreadyInProgress
|
|
}
|
|
|
|
// Check if sync is enabled
|
|
guard syncState.syncEnabled else {
|
|
return SyncResult(
|
|
stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0,
|
|
leagueStructuresUpdated: 0, teamAliasesUpdated: 0,
|
|
skippedIncompatible: 0, skippedOlder: 0,
|
|
duration: 0
|
|
)
|
|
}
|
|
|
|
// Check CloudKit availability
|
|
guard await cloudKitService.isAvailable() else {
|
|
throw SyncError.cloudKitUnavailable
|
|
}
|
|
|
|
// Mark sync in progress
|
|
syncState.syncInProgress = true
|
|
syncState.lastSyncAttempt = Date()
|
|
|
|
var totalStadiums = 0
|
|
var totalTeams = 0
|
|
var totalGames = 0
|
|
var totalLeagueStructures = 0
|
|
var totalTeamAliases = 0
|
|
var totalSkippedIncompatible = 0
|
|
var totalSkippedOlder = 0
|
|
|
|
do {
|
|
// Sync in dependency order
|
|
let (stadiums, skipIncompat1, skipOlder1) = try await syncStadiums(
|
|
context: context,
|
|
since: syncState.lastSuccessfulSync
|
|
)
|
|
totalStadiums = stadiums
|
|
totalSkippedIncompatible += skipIncompat1
|
|
totalSkippedOlder += skipOlder1
|
|
|
|
let (leagueStructures, skipIncompat2, skipOlder2) = try await syncLeagueStructure(
|
|
context: context,
|
|
since: syncState.lastSuccessfulSync
|
|
)
|
|
totalLeagueStructures = leagueStructures
|
|
totalSkippedIncompatible += skipIncompat2
|
|
totalSkippedOlder += skipOlder2
|
|
|
|
let (teams, skipIncompat3, skipOlder3) = try await syncTeams(
|
|
context: context,
|
|
since: syncState.lastSuccessfulSync
|
|
)
|
|
totalTeams = teams
|
|
totalSkippedIncompatible += skipIncompat3
|
|
totalSkippedOlder += skipOlder3
|
|
|
|
let (teamAliases, skipIncompat4, skipOlder4) = try await syncTeamAliases(
|
|
context: context,
|
|
since: syncState.lastSuccessfulSync
|
|
)
|
|
totalTeamAliases = teamAliases
|
|
totalSkippedIncompatible += skipIncompat4
|
|
totalSkippedOlder += skipOlder4
|
|
|
|
let (games, skipIncompat5, skipOlder5) = try await syncGames(
|
|
context: context,
|
|
since: syncState.lastSuccessfulSync
|
|
)
|
|
totalGames = games
|
|
totalSkippedIncompatible += skipIncompat5
|
|
totalSkippedOlder += skipOlder5
|
|
|
|
// Mark sync successful
|
|
syncState.syncInProgress = false
|
|
syncState.lastSuccessfulSync = Date()
|
|
syncState.lastSyncError = nil
|
|
syncState.consecutiveFailures = 0
|
|
|
|
try context.save()
|
|
|
|
} catch {
|
|
// Mark sync failed
|
|
syncState.syncInProgress = false
|
|
syncState.lastSyncError = error.localizedDescription
|
|
syncState.consecutiveFailures += 1
|
|
|
|
// Pause sync after too many failures
|
|
if syncState.consecutiveFailures >= 5 {
|
|
syncState.syncEnabled = false
|
|
syncState.syncPausedReason = "Too many consecutive failures. Sync paused."
|
|
}
|
|
|
|
try? context.save()
|
|
throw error
|
|
}
|
|
|
|
return SyncResult(
|
|
stadiumsUpdated: totalStadiums,
|
|
teamsUpdated: totalTeams,
|
|
gamesUpdated: totalGames,
|
|
leagueStructuresUpdated: totalLeagueStructures,
|
|
teamAliasesUpdated: totalTeamAliases,
|
|
skippedIncompatible: totalSkippedIncompatible,
|
|
skippedOlder: totalSkippedOlder,
|
|
duration: Date().timeIntervalSince(startTime)
|
|
)
|
|
}
|
|
|
|
/// Re-enable sync after it was paused due to failures.
|
|
@MainActor
|
|
func resumeSync(context: ModelContext) {
|
|
let syncState = SyncState.current(in: context)
|
|
syncState.syncEnabled = true
|
|
syncState.syncPausedReason = nil
|
|
syncState.consecutiveFailures = 0
|
|
try? context.save()
|
|
}
|
|
|
|
// MARK: - Individual Sync Methods
|
|
|
|
@MainActor
|
|
private func syncStadiums(
|
|
context: ModelContext,
|
|
since lastSync: Date?
|
|
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
|
let remoteStadiums = try await cloudKitService.fetchStadiums()
|
|
|
|
var updated = 0
|
|
var skippedIncompatible = 0
|
|
var skippedOlder = 0
|
|
|
|
for remoteStadium in remoteStadiums {
|
|
// For now, fetch full list and merge - CloudKit public DB doesn't have delta sync
|
|
// In future, could add lastModified filtering on CloudKit query
|
|
|
|
let canonicalId = "stadium_\(remoteStadium.sport.rawValue.lowercased())_\(remoteStadium.id.uuidString.prefix(8))"
|
|
|
|
let result = try mergeStadium(
|
|
remoteStadium,
|
|
canonicalId: canonicalId,
|
|
context: context
|
|
)
|
|
|
|
switch result {
|
|
case .applied: updated += 1
|
|
case .skippedIncompatible: skippedIncompatible += 1
|
|
case .skippedOlder: skippedOlder += 1
|
|
}
|
|
}
|
|
|
|
return (updated, skippedIncompatible, skippedOlder)
|
|
}
|
|
|
|
@MainActor
|
|
private func syncTeams(
|
|
context: ModelContext,
|
|
since lastSync: Date?
|
|
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
|
// Fetch teams for all sports
|
|
var allTeams: [Team] = []
|
|
for sport in Sport.allCases {
|
|
let teams = try await cloudKitService.fetchTeams(for: sport)
|
|
allTeams.append(contentsOf: teams)
|
|
}
|
|
|
|
var updated = 0
|
|
var skippedIncompatible = 0
|
|
var skippedOlder = 0
|
|
|
|
for remoteTeam in allTeams {
|
|
let canonicalId = "team_\(remoteTeam.sport.rawValue.lowercased())_\(remoteTeam.abbreviation.lowercased())"
|
|
|
|
let result = try mergeTeam(
|
|
remoteTeam,
|
|
canonicalId: canonicalId,
|
|
context: context
|
|
)
|
|
|
|
switch result {
|
|
case .applied: updated += 1
|
|
case .skippedIncompatible: skippedIncompatible += 1
|
|
case .skippedOlder: skippedOlder += 1
|
|
}
|
|
}
|
|
|
|
return (updated, skippedIncompatible, skippedOlder)
|
|
}
|
|
|
|
@MainActor
|
|
private func syncGames(
|
|
context: ModelContext,
|
|
since lastSync: Date?
|
|
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
|
// Fetch games for the next 6 months from all sports
|
|
let startDate = lastSync ?? Date()
|
|
let endDate = Calendar.current.date(byAdding: .month, value: 6, to: Date()) ?? Date()
|
|
|
|
let remoteGames = try await cloudKitService.fetchGames(
|
|
sports: Set(Sport.allCases),
|
|
startDate: startDate,
|
|
endDate: endDate
|
|
)
|
|
|
|
var updated = 0
|
|
var skippedIncompatible = 0
|
|
var skippedOlder = 0
|
|
|
|
for remoteGame in remoteGames {
|
|
let result = try mergeGame(
|
|
remoteGame,
|
|
canonicalId: remoteGame.id.uuidString,
|
|
context: context
|
|
)
|
|
|
|
switch result {
|
|
case .applied: updated += 1
|
|
case .skippedIncompatible: skippedIncompatible += 1
|
|
case .skippedOlder: skippedOlder += 1
|
|
}
|
|
}
|
|
|
|
return (updated, skippedIncompatible, skippedOlder)
|
|
}
|
|
|
|
@MainActor
|
|
private func syncLeagueStructure(
|
|
context: ModelContext,
|
|
since lastSync: Date?
|
|
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
|
let remoteStructures = try await cloudKitService.fetchLeagueStructureChanges(since: lastSync)
|
|
|
|
var updated = 0
|
|
var skippedIncompatible = 0
|
|
var skippedOlder = 0
|
|
|
|
for remoteStructure in remoteStructures {
|
|
let result = try mergeLeagueStructure(remoteStructure, context: context)
|
|
|
|
switch result {
|
|
case .applied: updated += 1
|
|
case .skippedIncompatible: skippedIncompatible += 1
|
|
case .skippedOlder: skippedOlder += 1
|
|
}
|
|
}
|
|
|
|
return (updated, skippedIncompatible, skippedOlder)
|
|
}
|
|
|
|
@MainActor
|
|
private func syncTeamAliases(
|
|
context: ModelContext,
|
|
since lastSync: Date?
|
|
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
|
let remoteAliases = try await cloudKitService.fetchTeamAliasChanges(since: lastSync)
|
|
|
|
var updated = 0
|
|
var skippedIncompatible = 0
|
|
var skippedOlder = 0
|
|
|
|
for remoteAlias in remoteAliases {
|
|
let result = try mergeTeamAlias(remoteAlias, 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 {
|
|
case applied
|
|
case skippedIncompatible
|
|
case skippedOlder
|
|
}
|
|
|
|
@MainActor
|
|
private func mergeStadium(
|
|
_ remote: Stadium,
|
|
canonicalId: String,
|
|
context: ModelContext
|
|
) throws -> MergeResult {
|
|
// Look up existing
|
|
let descriptor = FetchDescriptor<CanonicalStadium>(
|
|
predicate: #Predicate { $0.canonicalId == canonicalId }
|
|
)
|
|
let existing = try context.fetch(descriptor).first
|
|
|
|
if let existing = existing {
|
|
// Preserve user fields
|
|
let savedNickname = existing.userNickname
|
|
let savedNotes = existing.userNotes
|
|
let savedFavorite = existing.isFavorite
|
|
|
|
// Update system fields
|
|
existing.name = remote.name
|
|
existing.city = remote.city
|
|
existing.state = remote.state
|
|
existing.latitude = remote.latitude
|
|
existing.longitude = remote.longitude
|
|
existing.capacity = remote.capacity
|
|
existing.yearOpened = remote.yearOpened
|
|
existing.imageURL = remote.imageURL?.absoluteString
|
|
existing.sport = remote.sport.rawValue
|
|
existing.source = .cloudKit
|
|
existing.lastModified = Date()
|
|
|
|
// Restore user fields
|
|
existing.userNickname = savedNickname
|
|
existing.userNotes = savedNotes
|
|
existing.isFavorite = savedFavorite
|
|
|
|
return .applied
|
|
} else {
|
|
// Insert new
|
|
let canonical = CanonicalStadium(
|
|
canonicalId: canonicalId,
|
|
uuid: remote.id,
|
|
schemaVersion: SchemaVersion.current,
|
|
lastModified: Date(),
|
|
source: .cloudKit,
|
|
name: remote.name,
|
|
city: remote.city,
|
|
state: remote.state,
|
|
latitude: remote.latitude,
|
|
longitude: remote.longitude,
|
|
capacity: remote.capacity,
|
|
yearOpened: remote.yearOpened,
|
|
imageURL: remote.imageURL?.absoluteString,
|
|
sport: remote.sport.rawValue
|
|
)
|
|
context.insert(canonical)
|
|
return .applied
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func mergeTeam(
|
|
_ remote: Team,
|
|
canonicalId: String,
|
|
context: ModelContext
|
|
) throws -> MergeResult {
|
|
let descriptor = FetchDescriptor<CanonicalTeam>(
|
|
predicate: #Predicate { $0.canonicalId == canonicalId }
|
|
)
|
|
let existing = try context.fetch(descriptor).first
|
|
|
|
// Find stadium canonical ID
|
|
let remoteStadiumId = remote.stadiumId
|
|
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
|
|
predicate: #Predicate { $0.uuid == remoteStadiumId }
|
|
)
|
|
let stadium = try context.fetch(stadiumDescriptor).first
|
|
let stadiumCanonicalId = stadium?.canonicalId ?? "unknown"
|
|
|
|
if let existing = existing {
|
|
// Preserve user fields
|
|
let savedNickname = existing.userNickname
|
|
let savedFavorite = existing.isFavorite
|
|
|
|
// Update system fields
|
|
existing.name = remote.name
|
|
existing.abbreviation = remote.abbreviation
|
|
existing.sport = remote.sport.rawValue
|
|
existing.city = remote.city
|
|
existing.stadiumCanonicalId = stadiumCanonicalId
|
|
existing.logoURL = remote.logoURL?.absoluteString
|
|
existing.primaryColor = remote.primaryColor
|
|
existing.secondaryColor = remote.secondaryColor
|
|
existing.source = .cloudKit
|
|
existing.lastModified = Date()
|
|
|
|
// Restore user fields
|
|
existing.userNickname = savedNickname
|
|
existing.isFavorite = savedFavorite
|
|
|
|
return .applied
|
|
} else {
|
|
let canonical = CanonicalTeam(
|
|
canonicalId: canonicalId,
|
|
uuid: remote.id,
|
|
schemaVersion: SchemaVersion.current,
|
|
lastModified: Date(),
|
|
source: .cloudKit,
|
|
name: remote.name,
|
|
abbreviation: remote.abbreviation,
|
|
sport: remote.sport.rawValue,
|
|
city: remote.city,
|
|
stadiumCanonicalId: stadiumCanonicalId,
|
|
logoURL: remote.logoURL?.absoluteString,
|
|
primaryColor: remote.primaryColor,
|
|
secondaryColor: remote.secondaryColor
|
|
)
|
|
context.insert(canonical)
|
|
return .applied
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func mergeGame(
|
|
_ remote: Game,
|
|
canonicalId: String,
|
|
context: ModelContext
|
|
) throws -> MergeResult {
|
|
let descriptor = FetchDescriptor<CanonicalGame>(
|
|
predicate: #Predicate { $0.canonicalId == canonicalId }
|
|
)
|
|
let existing = try context.fetch(descriptor).first
|
|
|
|
// Look up canonical IDs for teams and stadium
|
|
let remoteHomeTeamId = remote.homeTeamId
|
|
let remoteAwayTeamId = remote.awayTeamId
|
|
let remoteStadiumId = remote.stadiumId
|
|
|
|
let homeTeamDescriptor = FetchDescriptor<CanonicalTeam>(
|
|
predicate: #Predicate { $0.uuid == remoteHomeTeamId }
|
|
)
|
|
let awayTeamDescriptor = FetchDescriptor<CanonicalTeam>(
|
|
predicate: #Predicate { $0.uuid == remoteAwayTeamId }
|
|
)
|
|
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
|
|
predicate: #Predicate { $0.uuid == remoteStadiumId }
|
|
)
|
|
|
|
let homeTeam = try context.fetch(homeTeamDescriptor).first
|
|
let awayTeam = try context.fetch(awayTeamDescriptor).first
|
|
let stadium = try context.fetch(stadiumDescriptor).first
|
|
|
|
let homeTeamCanonicalId = homeTeam?.canonicalId ?? "unknown"
|
|
let awayTeamCanonicalId = awayTeam?.canonicalId ?? "unknown"
|
|
let stadiumCanonicalId = stadium?.canonicalId ?? "unknown"
|
|
|
|
if let existing = existing {
|
|
// Preserve user fields
|
|
let savedAttending = existing.userAttending
|
|
let savedNotes = existing.userNotes
|
|
|
|
// Update system fields
|
|
existing.homeTeamCanonicalId = homeTeamCanonicalId
|
|
existing.awayTeamCanonicalId = awayTeamCanonicalId
|
|
existing.stadiumCanonicalId = stadiumCanonicalId
|
|
existing.dateTime = remote.dateTime
|
|
existing.sport = remote.sport.rawValue
|
|
existing.season = remote.season
|
|
existing.isPlayoff = remote.isPlayoff
|
|
existing.broadcastInfo = remote.broadcastInfo
|
|
existing.source = .cloudKit
|
|
existing.lastModified = Date()
|
|
|
|
// Restore user fields
|
|
existing.userAttending = savedAttending
|
|
existing.userNotes = savedNotes
|
|
|
|
return .applied
|
|
} else {
|
|
let canonical = CanonicalGame(
|
|
canonicalId: canonicalId,
|
|
uuid: remote.id,
|
|
schemaVersion: SchemaVersion.current,
|
|
lastModified: Date(),
|
|
source: .cloudKit,
|
|
homeTeamCanonicalId: homeTeamCanonicalId,
|
|
awayTeamCanonicalId: awayTeamCanonicalId,
|
|
stadiumCanonicalId: stadiumCanonicalId,
|
|
dateTime: remote.dateTime,
|
|
sport: remote.sport.rawValue,
|
|
season: remote.season,
|
|
isPlayoff: remote.isPlayoff,
|
|
broadcastInfo: remote.broadcastInfo
|
|
)
|
|
context.insert(canonical)
|
|
return .applied
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func mergeLeagueStructure(
|
|
_ remote: LeagueStructureModel,
|
|
context: ModelContext
|
|
) throws -> MergeResult {
|
|
// Schema version check
|
|
guard remote.schemaVersion <= SchemaVersion.current else {
|
|
return .skippedIncompatible
|
|
}
|
|
|
|
let remoteId = remote.id
|
|
let descriptor = FetchDescriptor<LeagueStructureModel>(
|
|
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 LeagueStructure)
|
|
existing.sport = remote.sport
|
|
existing.structureTypeRaw = remote.structureTypeRaw
|
|
existing.name = remote.name
|
|
existing.abbreviation = remote.abbreviation
|
|
existing.parentId = remote.parentId
|
|
existing.displayOrder = remote.displayOrder
|
|
existing.schemaVersion = remote.schemaVersion
|
|
existing.lastModified = remote.lastModified
|
|
|
|
return .applied
|
|
} else {
|
|
// Insert new
|
|
context.insert(remote)
|
|
return .applied
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func mergeTeamAlias(
|
|
_ remote: TeamAlias,
|
|
context: ModelContext
|
|
) throws -> MergeResult {
|
|
// Schema version check
|
|
guard remote.schemaVersion <= SchemaVersion.current else {
|
|
return .skippedIncompatible
|
|
}
|
|
|
|
let remoteId = remote.id
|
|
let descriptor = FetchDescriptor<TeamAlias>(
|
|
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 TeamAlias)
|
|
existing.teamCanonicalId = remote.teamCanonicalId
|
|
existing.aliasTypeRaw = remote.aliasTypeRaw
|
|
existing.aliasValue = remote.aliasValue
|
|
existing.validFrom = remote.validFrom
|
|
existing.validUntil = remote.validUntil
|
|
existing.schemaVersion = remote.schemaVersion
|
|
existing.lastModified = remote.lastModified
|
|
|
|
return .applied
|
|
} else {
|
|
// Insert new
|
|
context.insert(remote)
|
|
return .applied
|
|
}
|
|
}
|
|
}
|