- 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>
755 lines
28 KiB
Swift
755 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?
|
|
}
|
|
|
|
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: 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
|
|
)
|
|
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
|
|
)
|
|
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] ?? ""
|
|
}
|
|
}
|