// // BootstrapService.swift // SportsTime // // Bootstraps canonical data from bundled JSON files into SwiftData. // Runs once on first launch, then relies on CloudKit for updates. // import Foundation import SwiftData import CryptoKit actor BootstrapService { // MARK: - Errors enum BootstrapError: Error, LocalizedError { case bundledResourceNotFound(String) case jsonDecodingFailed(String, Error) case saveFailed(Error) var errorDescription: String? { switch self { case .bundledResourceNotFound(let resource): return "Bundled resource not found: \(resource)" case .jsonDecodingFailed(let resource, let error): return "Failed to decode \(resource): \(error.localizedDescription)" case .saveFailed(let error): return "Failed to save bootstrap data: \(error.localizedDescription)" } } } // MARK: - JSON Models (match bundled JSON structure) private struct JSONCanonicalStadium: Codable { let canonical_id: String let name: String let city: String let state: String? let latitude: Double let longitude: Double let capacity: Int let sport: String let primary_team_abbrevs: [String] let year_opened: Int? let timezone_identifier: String? let image_url: String? } private struct JSONCanonicalTeam: Codable { let canonical_id: String let name: String let abbreviation: String let sport: String let city: String let stadium_canonical_id: String let conference_id: String? let division_id: String? let primary_color: String? let secondary_color: String? } private struct JSONCanonicalGame: Codable { let canonical_id: String let sport: String let season: String let game_datetime_utc: String? // ISO 8601 format let date: String? // Fallback date+time format let time: String? // Fallback date+time format let home_team_canonical_id: String let away_team_canonical_id: String let stadium_canonical_id: String? let is_playoff: Bool let broadcast_info: String? } private struct JSONStadiumAlias: Codable { let alias_name: String let stadium_canonical_id: String let valid_from: String? let valid_until: String? } private struct JSONLeagueStructure: Codable { let id: String let sport: String let type: String // "conference", "division", "league" let name: String let abbreviation: String? let parent_id: String? let display_order: Int } private struct JSONTeamAlias: Codable { let id: String let team_canonical_id: String let alias_type: String // "abbreviation", "name", "city" let alias_value: String let valid_from: String? let valid_until: String? } private struct JSONCanonicalSport: Codable { let sport_id: String let abbreviation: String let display_name: String let icon_name: String let color_hex: String let season_start_month: Int let season_end_month: Int let is_active: Bool } // MARK: - Public Methods /// Bootstrap canonical data from bundled JSON if not already done, /// or re-bootstrap if the bundled data schema version has been bumped. /// This is the main entry point called at app launch. @MainActor func bootstrapIfNeeded(context: ModelContext) async throws { let syncState = SyncState.current(in: context) let hasCoreCanonicalData = hasRequiredCanonicalData(context: context) // Re-bootstrap if bundled data version is newer (e.g., updated game schedules) let needsRebootstrap = syncState.bootstrapCompleted && syncState.bundledSchemaVersion < SchemaVersion.current if needsRebootstrap { syncState.bootstrapCompleted = false } // Recover from corrupted/partial local stores where bootstrap flag is true but core tables are empty. if syncState.bootstrapCompleted && !hasCoreCanonicalData { syncState.bootstrapCompleted = false } // Skip if already bootstrapped with current schema guard !syncState.bootstrapCompleted else { return } // Fresh bootstrap should always force a full CloudKit sync baseline. resetSyncProgress(syncState) // Clear any partial bootstrap data from a previous failed attempt try clearCanonicalData(context: context) // Bootstrap in dependency order: // 1. Stadiums (no dependencies) // 2. Stadium aliases (depends on stadiums) // 3. League structure (no dependencies) // 4. Teams (depends on stadiums) // 5. Team aliases (depends on teams) // 6. Games (depends on teams + stadiums) try await bootstrapStadiums(context: context) try await bootstrapStadiumAliases(context: context) try await bootstrapLeagueStructure(context: context) try await bootstrapTeams(context: context) try await bootstrapTeamAliases(context: context) try await bootstrapGames(context: context) try await bootstrapSports(context: context) // Mark bootstrap complete syncState.bootstrapCompleted = true syncState.bundledSchemaVersion = SchemaVersion.current syncState.lastBootstrap = Date() do { try context.save() } catch { throw BootstrapError.saveFailed(error) } } @MainActor private func resetSyncProgress(_ syncState: SyncState) { syncState.lastSuccessfulSync = nil syncState.lastSyncAttempt = nil syncState.lastSyncError = nil syncState.syncInProgress = false syncState.syncEnabled = true syncState.syncPausedReason = nil syncState.consecutiveFailures = 0 syncState.stadiumChangeToken = nil syncState.teamChangeToken = nil syncState.gameChangeToken = nil syncState.leagueChangeToken = nil syncState.lastStadiumSync = nil syncState.lastTeamSync = nil syncState.lastGameSync = nil syncState.lastLeagueStructureSync = nil syncState.lastTeamAliasSync = nil syncState.lastStadiumAliasSync = nil syncState.lastSportSync = nil } // MARK: - Bootstrap Steps @MainActor private func bootstrapStadiums(context: ModelContext) async throws { guard let url = Bundle.main.url(forResource: "stadiums_canonical", withExtension: "json") else { throw BootstrapError.bundledResourceNotFound("stadiums_canonical.json") } let data: Data let stadiums: [JSONCanonicalStadium] do { data = try Data(contentsOf: url) stadiums = try JSONDecoder().decode([JSONCanonicalStadium].self, from: data) } catch { throw BootstrapError.jsonDecodingFailed("stadiums_canonical.json", error) } for jsonStadium in stadiums { let canonical = CanonicalStadium( canonicalId: jsonStadium.canonical_id, schemaVersion: SchemaVersion.current, lastModified: BundledDataTimestamp.stadiums, source: .bundled, name: jsonStadium.name, city: jsonStadium.city, state: (jsonStadium.state?.isEmpty ?? true) ? stateFromCity(jsonStadium.city) : jsonStadium.state!, latitude: jsonStadium.latitude, longitude: jsonStadium.longitude, capacity: jsonStadium.capacity, yearOpened: jsonStadium.year_opened, imageURL: jsonStadium.image_url, sport: jsonStadium.sport.uppercased(), timezoneIdentifier: jsonStadium.timezone_identifier ) context.insert(canonical) } } @MainActor private func bootstrapStadiumAliases(context: ModelContext) async throws { guard let url = Bundle.main.url(forResource: "stadium_aliases", withExtension: "json") else { return } let data: Data let aliases: [JSONStadiumAlias] do { data = try Data(contentsOf: url) aliases = try JSONDecoder().decode([JSONStadiumAlias].self, from: data) } catch { throw BootstrapError.jsonDecodingFailed("stadium_aliases.json", error) } // Build stadium lookup let stadiumDescriptor = FetchDescriptor() let stadiums = (try? context.fetch(stadiumDescriptor)) ?? [] let stadiumsByCanonicalId = Dictionary(uniqueKeysWithValues: stadiums.map { ($0.canonicalId, $0) }) let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" for jsonAlias in aliases { let alias = StadiumAlias( aliasName: jsonAlias.alias_name, stadiumCanonicalId: jsonAlias.stadium_canonical_id, validFrom: jsonAlias.valid_from.flatMap { dateFormatter.date(from: $0) }, validUntil: jsonAlias.valid_until.flatMap { dateFormatter.date(from: $0) }, schemaVersion: SchemaVersion.current, lastModified: BundledDataTimestamp.stadiums ) // Link to stadium if found if let stadium = stadiumsByCanonicalId[jsonAlias.stadium_canonical_id] { alias.stadium = stadium } context.insert(alias) } } @MainActor private func bootstrapLeagueStructure(context: ModelContext) async throws { guard let url = Bundle.main.url(forResource: "league_structure", withExtension: "json") else { return } let data: Data let structures: [JSONLeagueStructure] do { data = try Data(contentsOf: url) structures = try JSONDecoder().decode([JSONLeagueStructure].self, from: data) } catch { throw BootstrapError.jsonDecodingFailed("league_structure.json", error) } for structure in structures { let structureType: LeagueStructureType switch structure.type.lowercased() { case "conference": structureType = .conference case "division": structureType = .division case "league": structureType = .league default: structureType = .division } let model = LeagueStructureModel( id: structure.id, sport: structure.sport.uppercased(), structureType: structureType, name: structure.name, abbreviation: structure.abbreviation, parentId: structure.parent_id, displayOrder: structure.display_order, schemaVersion: SchemaVersion.current, lastModified: BundledDataTimestamp.leagueStructure ) context.insert(model) } } @MainActor private func bootstrapTeams(context: ModelContext) async throws { guard let url = Bundle.main.url(forResource: "teams_canonical", withExtension: "json") else { throw BootstrapError.bundledResourceNotFound("teams_canonical.json") } let data: Data let teams: [JSONCanonicalTeam] do { data = try Data(contentsOf: url) teams = try JSONDecoder().decode([JSONCanonicalTeam].self, from: data) } catch { throw BootstrapError.jsonDecodingFailed("teams_canonical.json", error) } for jsonTeam in teams { let team = CanonicalTeam( canonicalId: jsonTeam.canonical_id, schemaVersion: SchemaVersion.current, lastModified: BundledDataTimestamp.games, source: .bundled, name: jsonTeam.name, abbreviation: jsonTeam.abbreviation, sport: jsonTeam.sport.uppercased(), city: jsonTeam.city, stadiumCanonicalId: jsonTeam.stadium_canonical_id, conferenceId: jsonTeam.conference_id, divisionId: jsonTeam.division_id ) context.insert(team) } } @MainActor private func bootstrapTeamAliases(context: ModelContext) async throws { guard let url = Bundle.main.url(forResource: "team_aliases", withExtension: "json") else { return } let data: Data let aliases: [JSONTeamAlias] do { data = try Data(contentsOf: url) aliases = try JSONDecoder().decode([JSONTeamAlias].self, from: data) } catch { throw BootstrapError.jsonDecodingFailed("team_aliases.json", error) } let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" for jsonAlias in aliases { let aliasType: TeamAliasType switch jsonAlias.alias_type.lowercased() { case "abbreviation": aliasType = .abbreviation case "name": aliasType = .name case "city": aliasType = .city default: aliasType = .name } let alias = TeamAlias( id: jsonAlias.id, teamCanonicalId: jsonAlias.team_canonical_id, aliasType: aliasType, aliasValue: jsonAlias.alias_value, validFrom: jsonAlias.valid_from.flatMap { dateFormatter.date(from: $0) }, validUntil: jsonAlias.valid_until.flatMap { dateFormatter.date(from: $0) }, schemaVersion: SchemaVersion.current, lastModified: BundledDataTimestamp.games ) context.insert(alias) } } @MainActor private func bootstrapGames(context: ModelContext) async throws { guard let url = Bundle.main.url(forResource: "games_canonical", withExtension: "json") else { throw BootstrapError.bundledResourceNotFound("games_canonical.json") } let data: Data let games: [JSONCanonicalGame] do { data = try Data(contentsOf: url) games = try JSONDecoder().decode([JSONCanonicalGame].self, from: data) } catch { throw BootstrapError.jsonDecodingFailed("games_canonical.json", error) } var seenGameIds = Set() let teams = (try? context.fetch(FetchDescriptor())) ?? [] let stadiumByTeamId = Dictionary(uniqueKeysWithValues: teams.map { ($0.canonicalId, $0.stadiumCanonicalId) }) for jsonGame in games { // Deduplicate guard !seenGameIds.contains(jsonGame.canonical_id) else { continue } seenGameIds.insert(jsonGame.canonical_id) // Parse datetime: prefer ISO 8601 format, fall back to date+time let dateTime: Date? if let iso8601String = jsonGame.game_datetime_utc { dateTime = parseISO8601(iso8601String) } else if let date = jsonGame.date { dateTime = parseDateTime(date: date, time: jsonGame.time ?? "7:00p") } else { dateTime = nil } guard let dateTime else { continue } let explicitStadium = jsonGame.stadium_canonical_id? .trimmingCharacters(in: .whitespacesAndNewlines) let resolvedStadiumCanonicalId: String if let explicitStadium, !explicitStadium.isEmpty { resolvedStadiumCanonicalId = explicitStadium } else if let homeStadium = stadiumByTeamId[jsonGame.home_team_canonical_id], !homeStadium.isEmpty { resolvedStadiumCanonicalId = homeStadium } else if let awayStadium = stadiumByTeamId[jsonGame.away_team_canonical_id], !awayStadium.isEmpty { resolvedStadiumCanonicalId = awayStadium } else { resolvedStadiumCanonicalId = "stadium_placeholder_\(jsonGame.canonical_id)" } let game = CanonicalGame( canonicalId: jsonGame.canonical_id, schemaVersion: SchemaVersion.current, lastModified: BundledDataTimestamp.games, source: .bundled, homeTeamCanonicalId: jsonGame.home_team_canonical_id, awayTeamCanonicalId: jsonGame.away_team_canonical_id, stadiumCanonicalId: resolvedStadiumCanonicalId, dateTime: dateTime, sport: jsonGame.sport.uppercased(), season: jsonGame.season, isPlayoff: jsonGame.is_playoff, broadcastInfo: jsonGame.broadcast_info ) context.insert(game) } } @MainActor private func bootstrapSports(context: ModelContext) async throws { guard let url = Bundle.main.url(forResource: "sports_canonical", withExtension: "json") else { return } let data: Data let sports: [JSONCanonicalSport] do { data = try Data(contentsOf: url) sports = try JSONDecoder().decode([JSONCanonicalSport].self, from: data) } catch { throw BootstrapError.jsonDecodingFailed("sports_canonical.json", error) } for jsonSport in sports { let sport = CanonicalSport( id: jsonSport.sport_id, abbreviation: jsonSport.abbreviation, displayName: jsonSport.display_name, iconName: jsonSport.icon_name, colorHex: jsonSport.color_hex, seasonStartMonth: jsonSport.season_start_month, seasonEndMonth: jsonSport.season_end_month, isActive: jsonSport.is_active, lastModified: BundledDataTimestamp.sports, schemaVersion: SchemaVersion.current, source: .bundled ) context.insert(sport) } } // MARK: - Helpers @MainActor private func clearCanonicalData(context: ModelContext) throws { try context.delete(model: CanonicalStadium.self) try context.delete(model: StadiumAlias.self) try context.delete(model: LeagueStructureModel.self) try context.delete(model: CanonicalTeam.self) try context.delete(model: TeamAlias.self) try context.delete(model: CanonicalGame.self) try context.delete(model: CanonicalSport.self) } @MainActor private func hasRequiredCanonicalData(context: ModelContext) -> Bool { let stadiumCount = (try? context.fetchCount( FetchDescriptor( predicate: #Predicate { $0.deprecatedAt == nil } ) )) ?? 0 let teamCount = (try? context.fetchCount( FetchDescriptor( predicate: #Predicate { $0.deprecatedAt == nil } ) )) ?? 0 let gameCount = (try? context.fetchCount( FetchDescriptor( predicate: #Predicate { $0.deprecatedAt == nil } ) )) ?? 0 return stadiumCount > 0 && teamCount > 0 && gameCount > 0 } nonisolated private func parseISO8601(_ string: String) -> Date? { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime] return formatter.date(from: string) } nonisolated private func parseDateTime(date: String, time: String) -> Date? { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") // Parse date formatter.dateFormat = "yyyy-MM-dd" guard let dateOnly = formatter.date(from: date) else { return nil } // Parse time (e.g., "7:30p", "10:00p", "1:05p") var hour = 12 var minute = 0 let cleanTime = time.lowercased().replacingOccurrences(of: " ", with: "") let isPM = cleanTime.contains("p") let timeWithoutAMPM = cleanTime.replacingOccurrences(of: "p", with: "").replacingOccurrences(of: "a", with: "") let components = timeWithoutAMPM.split(separator: ":") if !components.isEmpty, let h = Int(components[0]) { hour = h if isPM && hour != 12 { hour += 12 } else if !isPM && hour == 12 { hour = 0 } } if components.count > 1, let m = Int(components[1]) { minute = m } return Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: dateOnly) } nonisolated private func stateFromCity(_ city: String) -> String { let cityToState: [String: String] = [ "Atlanta": "GA", "Boston": "MA", "Brooklyn": "NY", "Charlotte": "NC", "Chicago": "IL", "Cleveland": "OH", "Dallas": "TX", "Denver": "CO", "Detroit": "MI", "Houston": "TX", "Indianapolis": "IN", "Los Angeles": "CA", "Memphis": "TN", "Miami": "FL", "Milwaukee": "WI", "Minneapolis": "MN", "New Orleans": "LA", "New York": "NY", "Oklahoma City": "OK", "Orlando": "FL", "Philadelphia": "PA", "Phoenix": "AZ", "Portland": "OR", "Sacramento": "CA", "San Antonio": "TX", "San Francisco": "CA", "Seattle": "WA", "Toronto": "ON", "Washington": "DC", "Las Vegas": "NV", "Tampa": "FL", "Pittsburgh": "PA", "Baltimore": "MD", "Cincinnati": "OH", "St. Louis": "MO", "Kansas City": "MO", "Arlington": "TX", "Anaheim": "CA", "Oakland": "CA", "San Diego": "CA", "Tampa Bay": "FL", "St Petersburg": "FL", "Salt Lake City": "UT" ] return cityToState[city] ?? "" } }