- Add local canonicalization pipeline (stadiums, teams, games) that generates deterministic canonical IDs before CloudKit upload - Fix CanonicalSyncService to use deterministic UUIDs from canonical IDs instead of random UUIDs from CloudKit records - Add SyncStadium/SyncTeam/SyncGame types to CloudKitService that preserve canonical ID relationships during sync - Add canonical ID field keys to CKModels for reading from CloudKit records - Bundle canonical JSON files (stadiums_canonical, teams_canonical, games_canonical, stadium_aliases) for consistent bootstrap data - Update BootstrapService to prefer canonical format files over legacy format This ensures all entities use consistent deterministic UUIDs derived from their canonical IDs, preventing duplicate records when syncing CloudKit data with bootstrapped local data. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
236 lines
7.8 KiB
Swift
236 lines
7.8 KiB
Swift
//
|
|
// DataProvider.swift
|
|
// SportsTime
|
|
//
|
|
// Unified data provider that reads from SwiftData for offline support.
|
|
// Data is bootstrapped from bundled JSON and can be synced via CloudKit.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftData
|
|
import Combine
|
|
|
|
/// Environment-aware data provider that reads from SwiftData (offline-first)
|
|
@MainActor
|
|
final class AppDataProvider: ObservableObject {
|
|
static let shared = AppDataProvider()
|
|
|
|
@Published private(set) var teams: [Team] = []
|
|
@Published private(set) var stadiums: [Stadium] = []
|
|
@Published private(set) var isLoading = false
|
|
@Published private(set) var error: Error?
|
|
@Published private(set) var errorMessage: String?
|
|
|
|
private var teamsById: [UUID: Team] = [:]
|
|
private var stadiumsById: [UUID: Stadium] = [:]
|
|
private var stadiumsByCanonicalId: [String: Stadium] = [:]
|
|
private var teamsByCanonicalId: [String: Team] = [:]
|
|
|
|
// Canonical ID lookups for game conversion
|
|
private var canonicalTeamUUIDs: [String: UUID] = [:]
|
|
private var canonicalStadiumUUIDs: [String: UUID] = [:]
|
|
|
|
private var modelContext: ModelContext?
|
|
|
|
private init() {}
|
|
|
|
// MARK: - Configuration
|
|
|
|
/// Set the model context for SwiftData access
|
|
func configure(with context: ModelContext) {
|
|
self.modelContext = context
|
|
}
|
|
|
|
// MARK: - Data Loading
|
|
|
|
/// Load all data from SwiftData into memory for fast access
|
|
func loadInitialData() async {
|
|
guard let context = modelContext else {
|
|
errorMessage = "Model context not configured"
|
|
return
|
|
}
|
|
|
|
isLoading = true
|
|
error = nil
|
|
errorMessage = nil
|
|
|
|
do {
|
|
// Fetch canonical stadiums from SwiftData
|
|
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
|
|
predicate: #Predicate { $0.deprecatedAt == nil }
|
|
)
|
|
let canonicalStadiums = try context.fetch(stadiumDescriptor)
|
|
|
|
// Convert to domain models and build lookups
|
|
var loadedStadiums: [Stadium] = []
|
|
for canonical in canonicalStadiums {
|
|
let stadium = canonical.toDomain()
|
|
loadedStadiums.append(stadium)
|
|
stadiumsByCanonicalId[canonical.canonicalId] = stadium
|
|
canonicalStadiumUUIDs[canonical.canonicalId] = stadium.id
|
|
}
|
|
|
|
// Fetch canonical teams from SwiftData
|
|
let teamDescriptor = FetchDescriptor<CanonicalTeam>(
|
|
predicate: #Predicate { $0.deprecatedAt == nil }
|
|
)
|
|
let canonicalTeams = try context.fetch(teamDescriptor)
|
|
|
|
// Convert to domain models
|
|
var loadedTeams: [Team] = []
|
|
for canonical in canonicalTeams {
|
|
// Get stadium UUID for this team
|
|
let stadiumUUID = canonicalStadiumUUIDs[canonical.stadiumCanonicalId] ?? UUID()
|
|
let team = canonical.toDomain(stadiumUUID: stadiumUUID)
|
|
loadedTeams.append(team)
|
|
teamsByCanonicalId[canonical.canonicalId] = team
|
|
canonicalTeamUUIDs[canonical.canonicalId] = team.id
|
|
}
|
|
|
|
self.teams = loadedTeams
|
|
self.stadiums = loadedStadiums
|
|
|
|
// Build lookup dictionaries (use reduce to handle potential duplicates gracefully)
|
|
self.teamsById = loadedTeams.reduce(into: [:]) { dict, team in
|
|
if dict[team.id] != nil {
|
|
print("⚠️ Duplicate team UUID: \(team.id) - \(team.name)")
|
|
}
|
|
dict[team.id] = team
|
|
}
|
|
self.stadiumsById = loadedStadiums.reduce(into: [:]) { dict, stadium in
|
|
if dict[stadium.id] != nil {
|
|
print("⚠️ Duplicate stadium UUID: \(stadium.id) - \(stadium.name)")
|
|
}
|
|
dict[stadium.id] = stadium
|
|
}
|
|
|
|
} catch {
|
|
self.error = error
|
|
self.errorMessage = error.localizedDescription
|
|
}
|
|
|
|
isLoading = false
|
|
}
|
|
|
|
func clearError() {
|
|
error = nil
|
|
errorMessage = nil
|
|
}
|
|
|
|
func retry() async {
|
|
await loadInitialData()
|
|
}
|
|
|
|
// MARK: - Data Access
|
|
|
|
func team(for id: UUID) -> Team? {
|
|
teamsById[id]
|
|
}
|
|
|
|
func stadium(for id: UUID) -> Stadium? {
|
|
stadiumsById[id]
|
|
}
|
|
|
|
func teams(for sport: Sport) -> [Team] {
|
|
teams.filter { $0.sport == sport }
|
|
}
|
|
|
|
// MARK: - Game Fetching
|
|
|
|
/// Fetch games from SwiftData within date range
|
|
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game] {
|
|
guard let context = modelContext else {
|
|
throw DataProviderError.contextNotConfigured
|
|
}
|
|
|
|
let sportStrings = sports.map { $0.rawValue }
|
|
|
|
let descriptor = FetchDescriptor<CanonicalGame>(
|
|
predicate: #Predicate<CanonicalGame> { game in
|
|
game.deprecatedAt == nil &&
|
|
game.dateTime >= startDate &&
|
|
game.dateTime <= endDate
|
|
}
|
|
)
|
|
|
|
let canonicalGames = try context.fetch(descriptor)
|
|
|
|
// Filter by sport and convert to domain models
|
|
return canonicalGames.compactMap { canonical -> Game? in
|
|
guard sportStrings.contains(canonical.sport) else { return nil }
|
|
|
|
let homeTeamUUID = canonicalTeamUUIDs[canonical.homeTeamCanonicalId] ?? UUID()
|
|
let awayTeamUUID = canonicalTeamUUIDs[canonical.awayTeamCanonicalId] ?? UUID()
|
|
let stadiumUUID = canonicalStadiumUUIDs[canonical.stadiumCanonicalId] ?? UUID()
|
|
|
|
return canonical.toDomain(
|
|
homeTeamUUID: homeTeamUUID,
|
|
awayTeamUUID: awayTeamUUID,
|
|
stadiumUUID: stadiumUUID
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Fetch a single game by ID
|
|
func fetchGame(by id: UUID) async throws -> Game? {
|
|
guard let context = modelContext else {
|
|
throw DataProviderError.contextNotConfigured
|
|
}
|
|
|
|
let idString = id.uuidString
|
|
let descriptor = FetchDescriptor<CanonicalGame>(
|
|
predicate: #Predicate<CanonicalGame> { $0.canonicalId == idString }
|
|
)
|
|
|
|
guard let canonical = try context.fetch(descriptor).first else {
|
|
return nil
|
|
}
|
|
|
|
let homeTeamUUID = canonicalTeamUUIDs[canonical.homeTeamCanonicalId] ?? UUID()
|
|
let awayTeamUUID = canonicalTeamUUIDs[canonical.awayTeamCanonicalId] ?? UUID()
|
|
let stadiumUUID = canonicalStadiumUUIDs[canonical.stadiumCanonicalId] ?? UUID()
|
|
|
|
return canonical.toDomain(
|
|
homeTeamUUID: homeTeamUUID,
|
|
awayTeamUUID: awayTeamUUID,
|
|
stadiumUUID: stadiumUUID
|
|
)
|
|
}
|
|
|
|
/// Fetch games with full team and stadium data
|
|
func fetchRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
|
|
let games = try await fetchGames(sports: sports, startDate: startDate, endDate: endDate)
|
|
|
|
return games.compactMap { game in
|
|
guard let homeTeam = teamsById[game.homeTeamId],
|
|
let awayTeam = teamsById[game.awayTeamId],
|
|
let stadium = stadiumsById[game.stadiumId] else {
|
|
return nil
|
|
}
|
|
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
|
}
|
|
}
|
|
|
|
func richGame(from game: Game) -> RichGame? {
|
|
guard let homeTeam = teamsById[game.homeTeamId],
|
|
let awayTeam = teamsById[game.awayTeamId],
|
|
let stadium = stadiumsById[game.stadiumId] else {
|
|
return nil
|
|
}
|
|
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
|
}
|
|
}
|
|
|
|
// MARK: - Errors
|
|
|
|
enum DataProviderError: Error, LocalizedError {
|
|
case contextNotConfigured
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .contextNotConfigured:
|
|
return "Data provider not configured with model context"
|
|
}
|
|
}
|
|
}
|