Files
Sportstime/SportsTime/Core/Services/BootstrapService.swift
2026-02-10 18:15:36 -06:00

589 lines
22 KiB
Swift

//
// 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<CanonicalStadium>()
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<String>()
let teams = (try? context.fetch(FetchDescriptor<CanonicalTeam>())) ?? []
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<CanonicalStadium>(
predicate: #Predicate { $0.deprecatedAt == nil }
)
)) ?? 0
let teamCount = (try? context.fetchCount(
FetchDescriptor<CanonicalTeam>(
predicate: #Predicate { $0.deprecatedAt == nil }
)
)) ?? 0
let gameCount = (try? context.fetchCount(
FetchDescriptor<CanonicalGame>(
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] ?? ""
}
}