757 lines
28 KiB
Swift
757 lines
28 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)
|
|
|
|
// MARK: - Canonical JSON Models (from canonicalization pipeline)
|
|
|
|
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?
|
|
}
|
|
|
|
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 date: String
|
|
let time: String?
|
|
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?
|
|
}
|
|
|
|
// MARK: - Legacy JSON Models (for backward compatibility)
|
|
|
|
private struct JSONStadium: Codable {
|
|
let id: String
|
|
let name: String
|
|
let city: String
|
|
let state: String
|
|
let latitude: Double
|
|
let longitude: Double
|
|
let capacity: Int
|
|
let sport: String
|
|
let team_abbrevs: [String]
|
|
let source: String
|
|
let year_opened: Int?
|
|
}
|
|
|
|
private struct JSONGame: Codable {
|
|
let id: String
|
|
let sport: String
|
|
let season: String
|
|
let date: String
|
|
let time: String?
|
|
let home_team: String
|
|
let away_team: String
|
|
let home_team_abbrev: String
|
|
let away_team_abbrev: String
|
|
let venue: String
|
|
let source: String
|
|
let is_playoff: Bool
|
|
let broadcast: 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?
|
|
}
|
|
|
|
// MARK: - Public Methods
|
|
|
|
/// Bootstrap canonical data from bundled JSON if not already done.
|
|
/// This is the main entry point called at app launch.
|
|
///
|
|
/// Prefers new canonical format files (*_canonical.json) from the pipeline,
|
|
/// falls back to legacy format for backward compatibility.
|
|
@MainActor
|
|
func bootstrapIfNeeded(context: ModelContext) async throws {
|
|
let syncState = SyncState.current(in: context)
|
|
|
|
// Skip if already bootstrapped
|
|
guard !syncState.bootstrapCompleted else {
|
|
return
|
|
}
|
|
|
|
// 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)
|
|
|
|
// Mark bootstrap complete
|
|
syncState.bootstrapCompleted = true
|
|
syncState.bundledSchemaVersion = SchemaVersion.current
|
|
syncState.lastBootstrap = Date()
|
|
|
|
do {
|
|
try context.save()
|
|
} catch {
|
|
throw BootstrapError.saveFailed(error)
|
|
}
|
|
}
|
|
|
|
// MARK: - Bootstrap Steps
|
|
|
|
@MainActor
|
|
private func bootstrapStadiums(context: ModelContext) async throws {
|
|
// Try canonical format first, fall back to legacy
|
|
if let url = Bundle.main.url(forResource: "stadiums_canonical", withExtension: "json") {
|
|
try await bootstrapStadiumsCanonical(url: url, context: context)
|
|
} else if let url = Bundle.main.url(forResource: "stadiums", withExtension: "json") {
|
|
try await bootstrapStadiumsLegacy(url: url, context: context)
|
|
} else {
|
|
throw BootstrapError.bundledResourceNotFound("stadiums_canonical.json or stadiums.json")
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func bootstrapStadiumsCanonical(url: URL, context: ModelContext) async throws {
|
|
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 ? stateFromCity(jsonStadium.city) : jsonStadium.state,
|
|
latitude: jsonStadium.latitude,
|
|
longitude: jsonStadium.longitude,
|
|
capacity: jsonStadium.capacity,
|
|
yearOpened: jsonStadium.year_opened,
|
|
sport: jsonStadium.sport,
|
|
timezoneIdentifier: jsonStadium.timezone_identifier
|
|
)
|
|
context.insert(canonical)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func bootstrapStadiumsLegacy(url: URL, context: ModelContext) async throws {
|
|
let data: Data
|
|
let stadiums: [JSONStadium]
|
|
|
|
do {
|
|
data = try Data(contentsOf: url)
|
|
stadiums = try JSONDecoder().decode([JSONStadium].self, from: data)
|
|
} catch {
|
|
throw BootstrapError.jsonDecodingFailed("stadiums.json", error)
|
|
}
|
|
|
|
for jsonStadium in stadiums {
|
|
let canonical = CanonicalStadium(
|
|
canonicalId: jsonStadium.id,
|
|
schemaVersion: SchemaVersion.current,
|
|
lastModified: BundledDataTimestamp.stadiums,
|
|
source: .bundled,
|
|
name: jsonStadium.name,
|
|
city: jsonStadium.city,
|
|
state: jsonStadium.state.isEmpty ? stateFromCity(jsonStadium.city) : jsonStadium.state,
|
|
latitude: jsonStadium.latitude,
|
|
longitude: jsonStadium.longitude,
|
|
capacity: jsonStadium.capacity,
|
|
yearOpened: jsonStadium.year_opened,
|
|
sport: jsonStadium.sport
|
|
)
|
|
context.insert(canonical)
|
|
|
|
// Legacy format: create stadium alias for the current name
|
|
let alias = StadiumAlias(
|
|
aliasName: jsonStadium.name,
|
|
stadiumCanonicalId: jsonStadium.id,
|
|
schemaVersion: SchemaVersion.current,
|
|
lastModified: BundledDataTimestamp.stadiums
|
|
)
|
|
alias.stadium = canonical
|
|
context.insert(alias)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func bootstrapStadiumAliases(context: ModelContext) async throws {
|
|
// Stadium aliases are loaded from stadium_aliases.json (from canonical pipeline)
|
|
guard let url = Bundle.main.url(forResource: "stadium_aliases", withExtension: "json") else {
|
|
// Aliases are optional - legacy format creates them inline
|
|
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 {
|
|
// Load league structure if file exists
|
|
guard let url = Bundle.main.url(forResource: "league_structure", withExtension: "json") else {
|
|
// League structure is optional for MVP - create basic structure from known sports
|
|
createDefaultLeagueStructure(context: context)
|
|
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,
|
|
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 {
|
|
// Try canonical format first, fall back to legacy extraction from games
|
|
if let url = Bundle.main.url(forResource: "teams_canonical", withExtension: "json") {
|
|
try await bootstrapTeamsCanonical(url: url, context: context)
|
|
} else {
|
|
// Legacy: Teams will be extracted from games during bootstrapGames
|
|
// This path is deprecated but maintained for backward compatibility
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func bootstrapTeamsCanonical(url: URL, context: ModelContext) async throws {
|
|
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,
|
|
city: jsonTeam.city,
|
|
stadiumCanonicalId: jsonTeam.stadium_canonical_id,
|
|
conferenceId: jsonTeam.conference_id,
|
|
divisionId: jsonTeam.division_id
|
|
)
|
|
context.insert(team)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func bootstrapGames(context: ModelContext) async throws {
|
|
// Try canonical format first, fall back to legacy
|
|
if let url = Bundle.main.url(forResource: "games_canonical", withExtension: "json") {
|
|
try await bootstrapGamesCanonical(url: url, context: context)
|
|
} else if let url = Bundle.main.url(forResource: "games", withExtension: "json") {
|
|
try await bootstrapGamesLegacy(url: url, context: context)
|
|
} else {
|
|
throw BootstrapError.bundledResourceNotFound("games_canonical.json or games.json")
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func bootstrapGamesCanonical(url: URL, context: ModelContext) async throws {
|
|
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>()
|
|
|
|
for jsonGame in games {
|
|
// Deduplicate
|
|
guard !seenGameIds.contains(jsonGame.canonical_id) else { continue }
|
|
seenGameIds.insert(jsonGame.canonical_id)
|
|
|
|
guard let dateTime = parseDateTime(date: jsonGame.date, time: jsonGame.time ?? "7:00p") else {
|
|
continue
|
|
}
|
|
|
|
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: jsonGame.stadium_canonical_id,
|
|
dateTime: dateTime,
|
|
sport: jsonGame.sport,
|
|
season: jsonGame.season,
|
|
isPlayoff: jsonGame.is_playoff,
|
|
broadcastInfo: jsonGame.broadcast_info
|
|
)
|
|
context.insert(game)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func bootstrapGamesLegacy(url: URL, context: ModelContext) async throws {
|
|
let data: Data
|
|
let games: [JSONGame]
|
|
|
|
do {
|
|
data = try Data(contentsOf: url)
|
|
games = try JSONDecoder().decode([JSONGame].self, from: data)
|
|
} catch {
|
|
throw BootstrapError.jsonDecodingFailed("games.json", error)
|
|
}
|
|
|
|
// Build stadium lookup for legacy venue matching
|
|
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>()
|
|
let canonicalStadiums = (try? context.fetch(stadiumDescriptor)) ?? []
|
|
var stadiumsByVenue: [String: CanonicalStadium] = [:]
|
|
for stadium in canonicalStadiums {
|
|
stadiumsByVenue[stadium.name.lowercased()] = stadium
|
|
}
|
|
|
|
// Check if teams already exist (from teams_canonical.json)
|
|
let teamDescriptor = FetchDescriptor<CanonicalTeam>()
|
|
let existingTeams = (try? context.fetch(teamDescriptor)) ?? []
|
|
var teamsCreated: [String: CanonicalTeam] = Dictionary(
|
|
uniqueKeysWithValues: existingTeams.map { ($0.canonicalId, $0) }
|
|
)
|
|
let teamsAlreadyLoaded = !existingTeams.isEmpty
|
|
|
|
var seenGameIds = Set<String>()
|
|
|
|
for jsonGame in games {
|
|
let sport = jsonGame.sport.uppercased()
|
|
|
|
// Legacy team extraction (only if teams not already loaded)
|
|
if !teamsAlreadyLoaded {
|
|
let homeTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.home_team_abbrev.lowercased())"
|
|
if teamsCreated[homeTeamCanonicalId] == nil {
|
|
let stadiumCanonicalId = findStadiumCanonicalId(
|
|
venue: jsonGame.venue,
|
|
sport: sport,
|
|
stadiumsByVenue: stadiumsByVenue
|
|
)
|
|
|
|
let team = CanonicalTeam(
|
|
canonicalId: homeTeamCanonicalId,
|
|
schemaVersion: SchemaVersion.current,
|
|
lastModified: BundledDataTimestamp.games,
|
|
source: .bundled,
|
|
name: extractTeamName(from: jsonGame.home_team),
|
|
abbreviation: jsonGame.home_team_abbrev,
|
|
sport: sport,
|
|
city: extractCity(from: jsonGame.home_team),
|
|
stadiumCanonicalId: stadiumCanonicalId
|
|
)
|
|
context.insert(team)
|
|
teamsCreated[homeTeamCanonicalId] = team
|
|
}
|
|
|
|
let awayTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.away_team_abbrev.lowercased())"
|
|
if teamsCreated[awayTeamCanonicalId] == nil {
|
|
let team = CanonicalTeam(
|
|
canonicalId: awayTeamCanonicalId,
|
|
schemaVersion: SchemaVersion.current,
|
|
lastModified: BundledDataTimestamp.games,
|
|
source: .bundled,
|
|
name: extractTeamName(from: jsonGame.away_team),
|
|
abbreviation: jsonGame.away_team_abbrev,
|
|
sport: sport,
|
|
city: extractCity(from: jsonGame.away_team),
|
|
stadiumCanonicalId: "unknown"
|
|
)
|
|
context.insert(team)
|
|
teamsCreated[awayTeamCanonicalId] = team
|
|
}
|
|
}
|
|
|
|
// Deduplicate games
|
|
guard !seenGameIds.contains(jsonGame.id) else { continue }
|
|
seenGameIds.insert(jsonGame.id)
|
|
|
|
guard let dateTime = parseDateTime(date: jsonGame.date, time: jsonGame.time ?? "7:00p") else {
|
|
continue
|
|
}
|
|
|
|
let homeTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.home_team_abbrev.lowercased())"
|
|
let awayTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.away_team_abbrev.lowercased())"
|
|
let stadiumCanonicalId = findStadiumCanonicalId(
|
|
venue: jsonGame.venue,
|
|
sport: sport,
|
|
stadiumsByVenue: stadiumsByVenue
|
|
)
|
|
|
|
let game = CanonicalGame(
|
|
canonicalId: jsonGame.id,
|
|
schemaVersion: SchemaVersion.current,
|
|
lastModified: BundledDataTimestamp.games,
|
|
source: .bundled,
|
|
homeTeamCanonicalId: homeTeamCanonicalId,
|
|
awayTeamCanonicalId: awayTeamCanonicalId,
|
|
stadiumCanonicalId: stadiumCanonicalId,
|
|
dateTime: dateTime,
|
|
sport: sport,
|
|
season: jsonGame.season,
|
|
isPlayoff: jsonGame.is_playoff,
|
|
broadcastInfo: jsonGame.broadcast
|
|
)
|
|
context.insert(game)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func bootstrapTeamAliases(context: ModelContext) async throws {
|
|
// Team aliases are optional - load if file exists
|
|
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 = ISO8601DateFormatter()
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
@MainActor
|
|
private func createDefaultLeagueStructure(context: ModelContext) {
|
|
// Create minimal league structure for supported sports
|
|
let timestamp = BundledDataTimestamp.leagueStructure
|
|
|
|
// MLB
|
|
context.insert(LeagueStructureModel(
|
|
id: "mlb_league",
|
|
sport: "MLB",
|
|
structureType: .league,
|
|
name: "Major League Baseball",
|
|
abbreviation: "MLB",
|
|
displayOrder: 0,
|
|
schemaVersion: SchemaVersion.current,
|
|
lastModified: timestamp
|
|
))
|
|
|
|
// NBA
|
|
context.insert(LeagueStructureModel(
|
|
id: "nba_league",
|
|
sport: "NBA",
|
|
structureType: .league,
|
|
name: "National Basketball Association",
|
|
abbreviation: "NBA",
|
|
displayOrder: 0,
|
|
schemaVersion: SchemaVersion.current,
|
|
lastModified: timestamp
|
|
))
|
|
|
|
// NHL
|
|
context.insert(LeagueStructureModel(
|
|
id: "nhl_league",
|
|
sport: "NHL",
|
|
structureType: .league,
|
|
name: "National Hockey League",
|
|
abbreviation: "NHL",
|
|
displayOrder: 0,
|
|
schemaVersion: SchemaVersion.current,
|
|
lastModified: timestamp
|
|
))
|
|
}
|
|
|
|
// Venue name aliases for stadiums that changed names
|
|
private static let venueAliases: [String: String] = [
|
|
"daikin park": "minute maid park",
|
|
"rate field": "guaranteed rate field",
|
|
"george m. steinbrenner field": "tropicana field",
|
|
"loandepot park": "loandepot park",
|
|
]
|
|
|
|
nonisolated private func findStadiumCanonicalId(
|
|
venue: String,
|
|
sport: String,
|
|
stadiumsByVenue: [String: CanonicalStadium]
|
|
) -> String {
|
|
var venueLower = venue.lowercased()
|
|
|
|
// Check for known aliases
|
|
if let aliasedName = Self.venueAliases[venueLower] {
|
|
venueLower = aliasedName
|
|
}
|
|
|
|
// Try exact match
|
|
if let stadium = stadiumsByVenue[venueLower] {
|
|
return stadium.canonicalId
|
|
}
|
|
|
|
// Try partial match
|
|
for (name, stadium) in stadiumsByVenue {
|
|
if name.contains(venueLower) || venueLower.contains(name) {
|
|
return stadium.canonicalId
|
|
}
|
|
}
|
|
|
|
// Generate deterministic ID for unknown venues
|
|
return "venue_unknown_\(venue.lowercased().replacingOccurrences(of: " ", with: "_"))"
|
|
}
|
|
|
|
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 extractTeamName(from fullName: String) -> String {
|
|
// "Boston Celtics" -> "Celtics"
|
|
let parts = fullName.split(separator: " ")
|
|
if parts.count > 1 {
|
|
return parts.dropFirst().joined(separator: " ")
|
|
}
|
|
return fullName
|
|
}
|
|
|
|
nonisolated private func extractCity(from fullName: String) -> String {
|
|
// "Boston Celtics" -> "Boston"
|
|
// "New York Knicks" -> "New York"
|
|
let knownCities = [
|
|
"New York", "Los Angeles", "San Francisco", "San Diego", "San Antonio",
|
|
"New Orleans", "Oklahoma City", "Salt Lake City", "Kansas City",
|
|
"St. Louis", "St Louis"
|
|
]
|
|
|
|
for city in knownCities {
|
|
if fullName.hasPrefix(city) {
|
|
return city
|
|
}
|
|
}
|
|
|
|
// Default: first word
|
|
return String(fullName.split(separator: " ").first ?? Substring(fullName))
|
|
}
|
|
|
|
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] ?? ""
|
|
}
|
|
}
|