// // 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 dynamicSports: [DynamicSport] = [] @Published private(set) var isLoading = false @Published private(set) var error: Error? @Published private(set) var errorMessage: String? // 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? 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( predicate: #Predicate { $0.deprecatedAt == nil } ) let canonicalStadiums = try context.fetch(stadiumDescriptor) // Convert to domain models and build lookups var loadedStadiums: [Stadium] = [] var stadiumLookup: [String: Stadium] = [:] for canonical in canonicalStadiums { let stadium = canonical.toDomain() loadedStadiums.append(stadium) stadiumLookup[stadium.id] = stadium } // Fetch canonical teams from SwiftData let teamDescriptor = FetchDescriptor( predicate: #Predicate { $0.deprecatedAt == nil } ) let canonicalTeams = try context.fetch(teamDescriptor) // Convert to domain models var loadedTeams: [Team] = [] var teamLookup: [String: Team] = [:] for canonical in canonicalTeams { let team = canonical.toDomain() loadedTeams.append(team) teamLookup[team.id] = team } // Fetch canonical sports from SwiftData let sportDescriptor = FetchDescriptor( 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 self.errorMessage = error.localizedDescription } isLoading = false } func clearError() { error = nil errorMessage = nil } func retry() async { await loadInitialData() } // MARK: - Data Access func team(for id: String) -> Team? { teamsById[id] } func stadium(for id: String) -> Stadium? { stadiumsById[id] } func teams(for sport: Sport) -> [Team] { 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 func filterGames(sports: Set, startDate: Date, endDate: Date) async throws -> [Game] { guard let context = modelContext else { throw DataProviderError.contextNotConfigured } let sportStrings = Set(sports.map(\.rawValue)) let descriptor = FetchDescriptor( predicate: #Predicate { 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 } return canonical.toDomain() } } /// Get all games for specified sports (no date filtering) func allGames(for sports: Set) async throws -> [Game] { guard let context = modelContext else { throw DataProviderError.contextNotConfigured } let sportStrings = Set(sports.map(\.rawValue)) let descriptor = FetchDescriptor( predicate: #Predicate { game in game.deprecatedAt == nil }, sortBy: [SortDescriptor(\.dateTime)] ) let canonicalGames = try context.fetch(descriptor) return canonicalGames.compactMap { canonical -> Game? in guard sportStrings.contains(canonical.sport) else { return nil } return canonical.toDomain() } } /// Fetch a single game by canonical ID func fetchGame(by id: String) async throws -> Game? { guard let context = modelContext else { throw DataProviderError.contextNotConfigured } let descriptor = FetchDescriptor( predicate: #Predicate { $0.canonicalId == id } ) guard let canonical = try context.fetch(descriptor).first else { return nil } return canonical.toDomain() } /// Filter games with full team and stadium data within date range func filterRichGames(sports: Set, startDate: Date, endDate: Date) async throws -> [RichGame] { let games = try await filterGames(sports: sports, startDate: startDate, endDate: endDate) #if DEBUG print("🎮 [DATA] filterRichGames: \(games.count) games from SwiftData for \(sports.map(\.rawValue).joined(separator: ", "))") #endif var richGames: [RichGame] = [] var droppedGames: [(game: Game, reason: String)] = [] var stadiumFallbacksApplied = 0 for game in games { let homeTeam = teamsById[game.homeTeamId] let awayTeam = teamsById[game.awayTeamId] let resolvedStadium = resolveStadium(for: game, homeTeam: homeTeam, awayTeam: awayTeam) if resolvedStadium?.id != game.stadiumId { stadiumFallbacksApplied += 1 } if homeTeam == nil || awayTeam == nil || resolvedStadium == nil { var reasons: [String] = [] if homeTeam == nil { reasons.append("homeTeam(\(game.homeTeamId))") } if awayTeam == nil { reasons.append("awayTeam(\(game.awayTeamId))") } if resolvedStadium == nil { reasons.append("stadium(\(game.stadiumId))") } droppedGames.append((game, "missing: \(reasons.joined(separator: ", "))")) continue } richGames.append(RichGame(game: game, homeTeam: homeTeam!, awayTeam: awayTeam!, stadium: resolvedStadium!)) } #if DEBUG if !droppedGames.isEmpty { print("⚠️ [DATA] Dropped \(droppedGames.count) games due to missing lookups:") for (game, reason) in droppedGames.prefix(10) { print("⚠️ [DATA] \(game.sport.rawValue) game \(game.id): \(reason)") } if droppedGames.count > 10 { print("⚠️ [DATA] ... and \(droppedGames.count - 10) more") } } if stadiumFallbacksApplied > 0 { print("⚠️ [DATA] Applied stadium fallback for \(stadiumFallbacksApplied) games") } print("🎮 [DATA] Returning \(richGames.count) rich games") #endif return richGames } /// Get all games with full team and stadium data (no date filtering) func allRichGames(for sports: Set) async throws -> [RichGame] { let games = try await allGames(for: sports) return games.compactMap { game in guard let homeTeam = teamsById[game.homeTeamId], let awayTeam = teamsById[game.awayTeamId], let stadium = resolveStadium(for: game, homeTeam: homeTeam, awayTeam: awayTeam) 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 = resolveStadium(for: game, homeTeam: homeTeam, awayTeam: awayTeam) else { return nil } return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium) } /// Get all games for a specific team (home or away) - for lazy loading in game picker func gamesForTeam(teamId: String) async throws -> [RichGame] { guard let context = modelContext else { throw DataProviderError.contextNotConfigured } // Use two separate predicates to avoid the type-check timeout from combining // captured vars with OR in a single #Predicate expression. let homeDescriptor = FetchDescriptor( predicate: #Predicate { $0.deprecatedAt == nil && $0.homeTeamCanonicalId == teamId }, sortBy: [SortDescriptor(\.dateTime)] ) let awayDescriptor = FetchDescriptor( predicate: #Predicate { $0.deprecatedAt == nil && $0.awayTeamCanonicalId == teamId }, sortBy: [SortDescriptor(\.dateTime)] ) let homeGames = try context.fetch(homeDescriptor) let awayGames = try context.fetch(awayDescriptor) // Merge and deduplicate var seenIds = Set() var teamGames: [RichGame] = [] for canonical in homeGames + awayGames { guard seenIds.insert(canonical.canonicalId).inserted else { continue } let game = canonical.toDomain() guard let homeTeam = teamsById[game.homeTeamId], let awayTeam = teamsById[game.awayTeamId], let stadium = resolveStadium(for: game, homeTeam: homeTeam, awayTeam: awayTeam) else { continue } teamGames.append(RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)) } return teamGames.sorted { $0.game.dateTime < $1.game.dateTime } } // Resolve stadium defensively: direct game reference first, then team home-venue fallbacks. private func resolveStadium(for game: Game, homeTeam: Team?, awayTeam: Team?) -> Stadium? { if let stadium = stadiumsById[game.stadiumId] { return stadium } if let homeTeam, let fallback = stadiumsById[homeTeam.stadiumId] { return fallback } if let awayTeam, let fallback = stadiumsById[awayTeam.stadiumId] { return fallback } return nil } } // MARK: - Errors enum DataProviderError: Error, LocalizedError { case contextNotConfigured var errorDescription: String? { switch self { case .contextNotConfigured: return "Data provider not configured with model context" } } }