Add canonical ID pipeline and fix UUID consistency for CloudKit sync

- 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>
This commit is contained in:
Trey t
2026-01-09 10:30:09 -06:00
parent 1ee47df53e
commit 7efcea7bd4
31 changed files with 128868 additions and 282 deletions

View File

@@ -24,11 +24,13 @@ enum CKRecordType {
struct CKTeam {
static let idKey = "teamId"
static let canonicalIdKey = "canonicalId"
static let nameKey = "name"
static let abbreviationKey = "abbreviation"
static let sportKey = "sport"
static let cityKey = "city"
static let stadiumRefKey = "stadiumRef"
static let stadiumCanonicalIdKey = "stadiumCanonicalId"
static let logoURLKey = "logoURL"
static let primaryColorKey = "primaryColor"
static let secondaryColorKey = "secondaryColor"
@@ -53,6 +55,16 @@ struct CKTeam {
self.record = record
}
/// The canonical ID string from CloudKit (e.g., "team_nba_atl")
var canonicalId: String? {
record[CKTeam.canonicalIdKey] as? String
}
/// The stadium canonical ID string from CloudKit (e.g., "stadium_nba_state_farm_arena")
var stadiumCanonicalId: String? {
record[CKTeam.stadiumCanonicalIdKey] as? String
}
var team: Team? {
// Use teamId field, or fall back to record name
let idString = (record[CKTeam.idKey] as? String) ?? record.recordID.recordName
@@ -96,6 +108,7 @@ struct CKTeam {
struct CKStadium {
static let idKey = "stadiumId"
static let canonicalIdKey = "canonicalId"
static let nameKey = "name"
static let cityKey = "city"
static let stateKey = "state"
@@ -125,6 +138,11 @@ struct CKStadium {
self.record = record
}
/// The canonical ID string from CloudKit (e.g., "stadium_nba_state_farm_arena")
var canonicalId: String? {
record[CKStadium.canonicalIdKey] as? String
}
var stadium: Stadium? {
// Use stadiumId field, or fall back to record name
let idString = (record[CKStadium.idKey] as? String) ?? record.recordID.recordName
@@ -160,9 +178,13 @@ struct CKStadium {
struct CKGame {
static let idKey = "gameId"
static let canonicalIdKey = "canonicalId"
static let homeTeamRefKey = "homeTeamRef"
static let awayTeamRefKey = "awayTeamRef"
static let stadiumRefKey = "stadiumRef"
static let homeTeamCanonicalIdKey = "homeTeamCanonicalId"
static let awayTeamCanonicalIdKey = "awayTeamCanonicalId"
static let stadiumCanonicalIdKey = "stadiumCanonicalId"
static let dateTimeKey = "dateTime"
static let sportKey = "sport"
static let seasonKey = "season"
@@ -189,6 +211,26 @@ struct CKGame {
self.record = record
}
/// The canonical ID string from CloudKit (e.g., "game_nba_202526_20251021_hou_okc")
var canonicalId: String? {
record[CKGame.canonicalIdKey] as? String
}
/// The home team canonical ID string from CloudKit (e.g., "team_nba_okc")
var homeTeamCanonicalId: String? {
record[CKGame.homeTeamCanonicalIdKey] as? String
}
/// The away team canonical ID string from CloudKit (e.g., "team_nba_hou")
var awayTeamCanonicalId: String? {
record[CKGame.awayTeamCanonicalIdKey] as? String
}
/// The stadium canonical ID string from CloudKit (e.g., "stadium_nba_paycom_center")
var stadiumCanonicalId: String? {
record[CKGame.stadiumCanonicalIdKey] as? String
}
func game(homeTeamId: UUID, awayTeamId: UUID, stadiumId: UUID) -> Game? {
guard let idString = record[CKGame.idKey] as? String,
let id = UUID(uuidString: idString),

View File

@@ -17,7 +17,7 @@ struct Game: Identifiable, Codable, Hashable {
let broadcastInfo: String?
init(
id: UUID = UUID(),
id: UUID ,
homeTeamId: UUID,
awayTeamId: UUID,
stadiumId: UUID,

View File

@@ -19,7 +19,7 @@ struct Stadium: Identifiable, Codable, Hashable {
let imageURL: URL?
init(
id: UUID = UUID(),
id: UUID,
name: String,
city: String,
state: String,

View File

@@ -17,7 +17,7 @@ struct Team: Identifiable, Codable, Hashable {
let secondaryColor: String?
init(
id: UUID = UUID(),
id: UUID,
name: String,
abbreviation: String,
sport: Sport,

View File

@@ -81,7 +81,7 @@ struct LodgingSuggestion: Identifiable, Codable, Hashable {
let rating: Double?
init(
id: UUID = UUID(),
id: UUID,
name: String,
type: LodgingType,
address: String? = nil,

View File

@@ -33,6 +33,56 @@ actor BootstrapService {
// 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
@@ -86,6 +136,9 @@ actor BootstrapService {
/// 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)
@@ -95,11 +148,20 @@ actor BootstrapService {
return
}
// Bootstrap in dependency order
// 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 bootstrapTeamsAndGames(context: context)
try await bootstrapTeams(context: context)
try await bootstrapTeamAliases(context: context)
try await bootstrapGames(context: context)
// Mark bootstrap complete
syncState.bootstrapCompleted = true
@@ -117,10 +179,49 @@ actor BootstrapService {
@MainActor
private func bootstrapStadiums(context: ModelContext) async throws {
guard let url = Bundle.main.url(forResource: "stadiums", withExtension: "json") else {
throw BootstrapError.bundledResourceNotFound("stadiums.json")
// 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]
@@ -131,7 +232,6 @@ actor BootstrapService {
throw BootstrapError.jsonDecodingFailed("stadiums.json", error)
}
// Convert and insert
for jsonStadium in stadiums {
let canonical = CanonicalStadium(
canonicalId: jsonStadium.id,
@@ -149,7 +249,7 @@ actor BootstrapService {
)
context.insert(canonical)
// Create stadium alias for the current name (lowercase for matching)
// Legacy format: create stadium alias for the current name
let alias = StadiumAlias(
aliasName: jsonStadium.name,
stadiumCanonicalId: jsonStadium.id,
@@ -161,6 +261,51 @@ actor BootstrapService {
}
}
@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
@@ -205,11 +350,101 @@ actor BootstrapService {
}
@MainActor
private func bootstrapTeamsAndGames(context: ModelContext) async throws {
guard let url = Bundle.main.url(forResource: "games", withExtension: "json") else {
throw BootstrapError.bundledResourceNotFound("games.json")
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]
@@ -220,7 +455,7 @@ actor BootstrapService {
throw BootstrapError.jsonDecodingFailed("games.json", error)
}
// Build stadium lookup by venue name for game stadium matching
// Build stadium lookup for legacy venue matching
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>()
let canonicalStadiums = (try? context.fetch(stadiumDescriptor)) ?? []
var stadiumsByVenue: [String: CanonicalStadium] = [:]
@@ -228,65 +463,72 @@ actor BootstrapService {
stadiumsByVenue[stadium.name.lowercased()] = stadium
}
// Extract unique teams from games and create CanonicalTeam entries
var teamsCreated: [String: CanonicalTeam] = [:]
// 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()
// Process home team
let homeTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.home_team_abbrev.lowercased())"
if teamsCreated[homeTeamCanonicalId] == nil {
let stadiumCanonicalId = findStadiumCanonicalId(
venue: jsonGame.venue,
sport: sport,
stadiumsByVenue: stadiumsByVenue
)
// 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 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
}
}
// Process away team
let awayTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.away_team_abbrev.lowercased())"
if teamsCreated[awayTeamCanonicalId] == nil {
// Away teams might not have a known stadium yet
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" // Will be filled in when they're home team
)
context.insert(team)
teamsCreated[awayTeamCanonicalId] = team
}
// Deduplicate games by ID
// Deduplicate games
guard !seenGameIds.contains(jsonGame.id) else { continue }
seenGameIds.insert(jsonGame.id)
// Create game
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,

View File

@@ -210,21 +210,18 @@ actor CanonicalSyncService {
context: ModelContext,
since lastSync: Date?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
let remoteStadiums = try await cloudKitService.fetchStadiums()
// Use sync method that returns canonical IDs directly from CloudKit
let syncStadiums = try await cloudKitService.fetchStadiumsForSync()
var updated = 0
var skippedIncompatible = 0
var skippedOlder = 0
for remoteStadium in remoteStadiums {
// For now, fetch full list and merge - CloudKit public DB doesn't have delta sync
// In future, could add lastModified filtering on CloudKit query
let canonicalId = "stadium_\(remoteStadium.sport.rawValue.lowercased())_\(remoteStadium.id.uuidString.prefix(8))"
for syncStadium in syncStadiums {
// Use canonical ID directly from CloudKit - no UUID-based generation!
let result = try mergeStadium(
remoteStadium,
canonicalId: canonicalId,
syncStadium.stadium,
canonicalId: syncStadium.canonicalId,
context: context
)
@@ -243,23 +240,23 @@ actor CanonicalSyncService {
context: ModelContext,
since lastSync: Date?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
// Fetch teams for all sports
var allTeams: [Team] = []
// Use sync method that returns canonical IDs directly from CloudKit
var allSyncTeams: [CloudKitService.SyncTeam] = []
for sport in Sport.allCases {
let teams = try await cloudKitService.fetchTeams(for: sport)
allTeams.append(contentsOf: teams)
let syncTeams = try await cloudKitService.fetchTeamsForSync(for: sport)
allSyncTeams.append(contentsOf: syncTeams)
}
var updated = 0
var skippedIncompatible = 0
var skippedOlder = 0
for remoteTeam in allTeams {
let canonicalId = "team_\(remoteTeam.sport.rawValue.lowercased())_\(remoteTeam.abbreviation.lowercased())"
for syncTeam in allSyncTeams {
// Use canonical IDs directly from CloudKit - no UUID lookups!
let result = try mergeTeam(
remoteTeam,
canonicalId: canonicalId,
syncTeam.team,
canonicalId: syncTeam.canonicalId,
stadiumCanonicalId: syncTeam.stadiumCanonicalId,
context: context
)
@@ -278,11 +275,11 @@ actor CanonicalSyncService {
context: ModelContext,
since lastSync: Date?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
// Fetch games for the next 6 months from all sports
// Use sync method that returns canonical IDs directly from CloudKit
let startDate = lastSync ?? Date()
let endDate = Calendar.current.date(byAdding: .month, value: 6, to: Date()) ?? Date()
let remoteGames = try await cloudKitService.fetchGames(
let syncGames = try await cloudKitService.fetchGamesForSync(
sports: Set(Sport.allCases),
startDate: startDate,
endDate: endDate
@@ -292,10 +289,14 @@ actor CanonicalSyncService {
var skippedIncompatible = 0
var skippedOlder = 0
for remoteGame in remoteGames {
for syncGame in syncGames {
// Use canonical IDs directly from CloudKit - no UUID lookups!
let result = try mergeGame(
remoteGame,
canonicalId: remoteGame.id.uuidString,
syncGame.game,
canonicalId: syncGame.canonicalId,
homeTeamCanonicalId: syncGame.homeTeamCanonicalId,
awayTeamCanonicalId: syncGame.awayTeamCanonicalId,
stadiumCanonicalId: syncGame.stadiumCanonicalId,
context: context
)
@@ -427,10 +428,10 @@ actor CanonicalSyncService {
return .applied
} else {
// Insert new
// Insert new - let init() generate deterministic UUID from canonicalId
let canonical = CanonicalStadium(
canonicalId: canonicalId,
uuid: remote.id,
// uuid: omitted - will be generated deterministically from canonicalId
schemaVersion: SchemaVersion.current,
lastModified: Date(),
source: .cloudKit,
@@ -453,6 +454,7 @@ actor CanonicalSyncService {
private func mergeTeam(
_ remote: Team,
canonicalId: String,
stadiumCanonicalId: String,
context: ModelContext
) throws -> MergeResult {
let descriptor = FetchDescriptor<CanonicalTeam>(
@@ -460,13 +462,7 @@ actor CanonicalSyncService {
)
let existing = try context.fetch(descriptor).first
// Find stadium canonical ID
let remoteStadiumId = remote.stadiumId
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate { $0.uuid == remoteStadiumId }
)
let stadium = try context.fetch(stadiumDescriptor).first
let stadiumCanonicalId = stadium?.canonicalId ?? "unknown"
// Stadium canonical ID is passed directly from CloudKit - no UUID lookup needed!
if let existing = existing {
// Preserve user fields
@@ -491,9 +487,10 @@ actor CanonicalSyncService {
return .applied
} else {
// Insert new - let init() generate deterministic UUID from canonicalId
let canonical = CanonicalTeam(
canonicalId: canonicalId,
uuid: remote.id,
// uuid: omitted - will be generated deterministically from canonicalId
schemaVersion: SchemaVersion.current,
lastModified: Date(),
source: .cloudKit,
@@ -515,6 +512,9 @@ actor CanonicalSyncService {
private func mergeGame(
_ remote: Game,
canonicalId: String,
homeTeamCanonicalId: String,
awayTeamCanonicalId: String,
stadiumCanonicalId: String,
context: ModelContext
) throws -> MergeResult {
let descriptor = FetchDescriptor<CanonicalGame>(
@@ -522,28 +522,7 @@ actor CanonicalSyncService {
)
let existing = try context.fetch(descriptor).first
// Look up canonical IDs for teams and stadium
let remoteHomeTeamId = remote.homeTeamId
let remoteAwayTeamId = remote.awayTeamId
let remoteStadiumId = remote.stadiumId
let homeTeamDescriptor = FetchDescriptor<CanonicalTeam>(
predicate: #Predicate { $0.uuid == remoteHomeTeamId }
)
let awayTeamDescriptor = FetchDescriptor<CanonicalTeam>(
predicate: #Predicate { $0.uuid == remoteAwayTeamId }
)
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate { $0.uuid == remoteStadiumId }
)
let homeTeam = try context.fetch(homeTeamDescriptor).first
let awayTeam = try context.fetch(awayTeamDescriptor).first
let stadium = try context.fetch(stadiumDescriptor).first
let homeTeamCanonicalId = homeTeam?.canonicalId ?? "unknown"
let awayTeamCanonicalId = awayTeam?.canonicalId ?? "unknown"
let stadiumCanonicalId = stadium?.canonicalId ?? "unknown"
// All canonical IDs are passed directly from CloudKit - no UUID lookups needed!
if let existing = existing {
// Preserve user fields
@@ -568,9 +547,10 @@ actor CanonicalSyncService {
return .applied
} else {
// Insert new - let init() generate deterministic UUID from canonicalId
let canonical = CanonicalGame(
canonicalId: canonicalId,
uuid: remote.id,
// uuid: omitted - will be generated deterministically from canonicalId
schemaVersion: SchemaVersion.current,
lastModified: Date(),
source: .cloudKit,

View File

@@ -70,6 +70,27 @@ actor CloudKitService {
self.publicDatabase = container.publicCloudDatabase
}
// MARK: - Sync Types (include canonical IDs from CloudKit)
struct SyncStadium {
let stadium: Stadium
let canonicalId: String
}
struct SyncTeam {
let team: Team
let canonicalId: String
let stadiumCanonicalId: String
}
struct SyncGame {
let game: Game
let canonicalId: String
let homeTeamCanonicalId: String
let awayTeamCanonicalId: String
let stadiumCanonicalId: String
}
// MARK: - Availability Check
func isAvailable() async -> Bool {
@@ -189,6 +210,97 @@ actor CloudKitService {
return ckGame.game(homeTeamId: homeId, awayTeamId: awayId, stadiumId: stadiumId)
}
// MARK: - Sync Fetch Methods (return canonical IDs directly from CloudKit)
/// Fetch stadiums with canonical IDs for sync operations
func fetchStadiumsForSync() async throws -> [SyncStadium] {
let predicate = NSPredicate(value: true)
let query = CKQuery(recordType: CKRecordType.stadium, predicate: predicate)
let (results, _) = try await publicDatabase.records(matching: query)
return results.compactMap { result -> SyncStadium? in
guard case .success(let record) = result.1 else { return nil }
let ckStadium = CKStadium(record: record)
guard let stadium = ckStadium.stadium,
let canonicalId = ckStadium.canonicalId
else { return nil }
return SyncStadium(stadium: stadium, canonicalId: canonicalId)
}
}
/// Fetch teams with canonical IDs for sync operations
func fetchTeamsForSync(for sport: Sport) async throws -> [SyncTeam] {
let predicate = NSPredicate(format: "sport == %@", sport.rawValue)
let query = CKQuery(recordType: CKRecordType.team, predicate: predicate)
let (results, _) = try await publicDatabase.records(matching: query)
return results.compactMap { result -> SyncTeam? in
guard case .success(let record) = result.1 else { return nil }
let ckTeam = CKTeam(record: record)
guard let team = ckTeam.team,
let canonicalId = ckTeam.canonicalId,
let stadiumCanonicalId = ckTeam.stadiumCanonicalId
else { return nil }
return SyncTeam(team: team, canonicalId: canonicalId, stadiumCanonicalId: stadiumCanonicalId)
}
}
/// Fetch games with canonical IDs for sync operations
func fetchGamesForSync(
sports: Set<Sport>,
startDate: Date,
endDate: Date
) async throws -> [SyncGame] {
var allGames: [SyncGame] = []
for sport in sports {
let predicate = NSPredicate(
format: "sport == %@ AND dateTime >= %@ AND dateTime <= %@",
sport.rawValue,
startDate as NSDate,
endDate as NSDate
)
let query = CKQuery(recordType: CKRecordType.game, predicate: predicate)
let (results, _) = try await publicDatabase.records(matching: query)
let games = results.compactMap { result -> SyncGame? in
guard case .success(let record) = result.1 else { return nil }
let ckGame = CKGame(record: record)
// Extract canonical IDs directly from CloudKit
guard let canonicalId = ckGame.canonicalId,
let homeTeamCanonicalId = ckGame.homeTeamCanonicalId,
let awayTeamCanonicalId = ckGame.awayTeamCanonicalId,
let stadiumCanonicalId = ckGame.stadiumCanonicalId
else { return nil }
// For the Game domain object, we still need UUIDs - use placeholder
// The sync service will use canonical IDs for relationships
let placeholderUUID = UUID()
guard let game = ckGame.game(
homeTeamId: placeholderUUID,
awayTeamId: placeholderUUID,
stadiumId: placeholderUUID
) else { return nil }
return SyncGame(
game: game,
canonicalId: canonicalId,
homeTeamCanonicalId: homeTeamCanonicalId,
awayTeamCanonicalId: awayTeamCanonicalId,
stadiumCanonicalId: stadiumCanonicalId
)
}
allGames.append(contentsOf: games)
}
return allGames.sorted { $0.game.dateTime < $1.game.dateTime }
}
// MARK: - League Structure & Team Aliases
func fetchLeagueStructure(for sport: Sport? = nil) async throws -> [LeagueStructureModel] {

View File

@@ -90,9 +90,19 @@ final class AppDataProvider: ObservableObject {
self.teams = loadedTeams
self.stadiums = loadedStadiums
// Build lookup dictionaries
self.teamsById = Dictionary(uniqueKeysWithValues: loadedTeams.map { ($0.id, $0) })
self.stadiumsById = Dictionary(uniqueKeysWithValues: loadedStadiums.map { ($0.id, $0) })
// Build lookup dictionaries (use reduce to handle potential duplicates gracefully)
self.teamsById = loadedTeams.reduce(into: [:]) { dict, team in
if dict[team.id] != nil {
print("⚠️ Duplicate team UUID: \(team.id) - \(team.name)")
}
dict[team.id] = team
}
self.stadiumsById = loadedStadiums.reduce(into: [:]) { dict, stadium in
if dict[stadium.id] != nil {
print("⚠️ Duplicate stadium UUID: \(stadium.id) - \(stadium.name)")
}
dict[stadium.id] = stadium
}
} catch {
self.error = error

View File

@@ -108,9 +108,9 @@ final class SuggestedTripsGenerator {
return
}
// Build lookups
let stadiumsById = Dictionary(uniqueKeysWithValues: dataProvider.stadiums.map { ($0.id, $0) })
let teamsById = Dictionary(uniqueKeysWithValues: dataProvider.teams.map { ($0.id, $0) })
// Build lookups (use reduce to handle potential duplicate UUIDs gracefully)
let stadiumsById = dataProvider.stadiums.reduce(into: [UUID: Stadium]()) { $0[$1.id] = $1 }
let teamsById = dataProvider.teams.reduce(into: [UUID: Team]()) { $0[$1.id] = $1 }
var generatedTrips: [SuggestedTrip] = []

View File

@@ -126,9 +126,9 @@ struct SuggestedTripCard: View {
name: "Test Trip",
preferences: TripPreferences(),
stops: [
TripStop(stopNumber: 1, city: "New York", state: "NY", coordinate: nil, arrivalDate: Date(), departureDate: Date(), games: [], isRestDay: false),
TripStop(stopNumber: 2, city: "Boston", state: "MA", coordinate: nil, arrivalDate: Date(), departureDate: Date(), games: [], isRestDay: false),
TripStop(stopNumber: 3, city: "Philadelphia", state: "PA", coordinate: nil, arrivalDate: Date(), departureDate: Date(), games: [], isRestDay: false)
TripStop(id: UUID(), stopNumber: 1, city: "New York", state: "NY", coordinate: nil, arrivalDate: Date(), departureDate: Date(), games: [], isRestDay: false),
TripStop(id: UUID(),stopNumber: 2, city: "Boston", state: "MA", coordinate: nil, arrivalDate: Date(), departureDate: Date(), games: [], isRestDay: false),
TripStop(id: UUID(),stopNumber: 3, city: "Philadelphia", state: "PA", coordinate: nil, arrivalDate: Date(), departureDate: Date(), games: [], isRestDay: false)
],
totalGames: 5
)

View File

@@ -523,6 +523,7 @@ extension VisitSource {
#Preview {
let stadium = Stadium(
id: UUID(),
name: "Oracle Park",
city: "San Francisco",
state: "CA",

View File

@@ -423,48 +423,48 @@ struct HorizontalTimelineItemView: View {
// MARK: - Preview
#Preview {
let stop1 = ItineraryStop(
city: "Los Angeles",
state: "CA",
coordinate: nil,
games: [],
arrivalDate: Date(),
departureDate: Date(),
location: LocationInput(name: "Los Angeles"),
firstGameStart: nil
)
let stop2 = ItineraryStop(
city: "San Francisco",
state: "CA",
coordinate: nil,
games: [],
arrivalDate: Date().addingTimeInterval(86400),
departureDate: Date().addingTimeInterval(86400),
location: LocationInput(name: "San Francisco"),
firstGameStart: nil
)
let segment = TravelSegment(
fromLocation: LocationInput(name: "Los Angeles"),
toLocation: LocationInput(name: "San Francisco"),
travelMode: .drive,
distanceMeters: 600000,
durationSeconds: 21600
)
let option = ItineraryOption(
rank: 1,
stops: [stop1, stop2],
travelSegments: [segment],
totalDrivingHours: 6,
totalDistanceMiles: 380,
geographicRationale: "LA → SF"
)
return ScrollView {
TimelineView(option: option, games: [:])
.padding()
}
}
//#Preview {
// let stop1 = ItineraryStop(
// city: "Los Angeles",
// state: "CA",
// coordinate: nil,
// games: [],
// arrivalDate: Date(),
// departureDate: Date(),
// location: LocationInput(name: "Los Angeles"),
// firstGameStart: nil
// )
//
// let stop2 = ItineraryStop(
// city: "San Francisco",
// state: "CA",
// coordinate: nil,
// games: [],
// arrivalDate: Date().addingTimeInterval(86400),
// departureDate: Date().addingTimeInterval(86400),
// location: LocationInput(name: "San Francisco"),
// firstGameStart: nil
// )
//
// let segment = TravelSegment(
// fromLocation: LocationInput(name: "Los Angeles"),
// toLocation: LocationInput(name: "San Francisco"),
// travelMode: .drive,
// distanceMeters: 600000,
// durationSeconds: 21600
// )
//
// let option = ItineraryOption(
// rank: 1,
// stops: [stop1, stop2],
// travelSegments: [segment],
// totalDrivingHours: 6,
// totalDistanceMiles: 380,
// geographicRationale: "LA SF"
// )
//
// return ScrollView {
// TimelineView(option: option, games: [:])
// .padding()
// }
//}

View File

@@ -796,7 +796,7 @@ struct TripCreationView: View {
}
private func buildGamesDictionary() -> [UUID: RichGame] {
Dictionary(uniqueKeysWithValues: viewModel.availableGames.map { ($0.id, $0) })
viewModel.availableGames.reduce(into: [:]) { $0[$1.id] = $1 }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,782 @@
[
{
"alias_name": "state farm arena",
"stadium_canonical_id": "stadium_nba_state_farm_arena",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "td garden",
"stadium_canonical_id": "stadium_nba_td_garden",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "barclays center",
"stadium_canonical_id": "stadium_nba_barclays_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "spectrum center",
"stadium_canonical_id": "stadium_nba_spectrum_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "united center",
"stadium_canonical_id": "stadium_nba_united_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "rocket mortgage fieldhouse",
"stadium_canonical_id": "stadium_nba_rocket_mortgage_fieldhouse",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "american airlines center",
"stadium_canonical_id": "stadium_nba_american_airlines_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "ball arena",
"stadium_canonical_id": "stadium_nba_ball_arena",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "little caesars arena",
"stadium_canonical_id": "stadium_nba_little_caesars_arena",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "chase center",
"stadium_canonical_id": "stadium_nba_chase_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "toyota center",
"stadium_canonical_id": "stadium_nba_toyota_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "gainbridge fieldhouse",
"stadium_canonical_id": "stadium_nba_gainbridge_fieldhouse",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "intuit dome",
"stadium_canonical_id": "stadium_nba_intuit_dome",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "crypto.com arena",
"stadium_canonical_id": "stadium_nba_cryptocom_arena",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "cryptocom arena",
"stadium_canonical_id": "stadium_nba_cryptocom_arena",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "fedexforum",
"stadium_canonical_id": "stadium_nba_fedexforum",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "kaseya center",
"stadium_canonical_id": "stadium_nba_kaseya_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "fiserv forum",
"stadium_canonical_id": "stadium_nba_fiserv_forum",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "target center",
"stadium_canonical_id": "stadium_nba_target_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "smoothie king center",
"stadium_canonical_id": "stadium_nba_smoothie_king_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "madison square garden",
"stadium_canonical_id": "stadium_nba_madison_square_garden",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "paycom center",
"stadium_canonical_id": "stadium_nba_paycom_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "kia center",
"stadium_canonical_id": "stadium_nba_kia_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "wells fargo center",
"stadium_canonical_id": "stadium_nba_wells_fargo_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "footprint center",
"stadium_canonical_id": "stadium_nba_footprint_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "moda center",
"stadium_canonical_id": "stadium_nba_moda_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "golden 1 center",
"stadium_canonical_id": "stadium_nba_golden_1_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "frost bank center",
"stadium_canonical_id": "stadium_nba_frost_bank_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "scotiabank arena",
"stadium_canonical_id": "stadium_nba_scotiabank_arena",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "delta center",
"stadium_canonical_id": "stadium_nba_delta_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "capital one arena",
"stadium_canonical_id": "stadium_nba_capital_one_arena",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "chase field",
"stadium_canonical_id": "stadium_mlb_chase_field",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "truist park",
"stadium_canonical_id": "stadium_mlb_truist_park",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "oriole park at camden yards",
"stadium_canonical_id": "stadium_mlb_oriole_park_at_camden_yards",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "fenway park",
"stadium_canonical_id": "stadium_mlb_fenway_park",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "wrigley field",
"stadium_canonical_id": "stadium_mlb_wrigley_field",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "guaranteed rate field",
"stadium_canonical_id": "stadium_mlb_guaranteed_rate_field",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "great american ball park",
"stadium_canonical_id": "stadium_mlb_great_american_ball_park",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "progressive field",
"stadium_canonical_id": "stadium_mlb_progressive_field",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "coors field",
"stadium_canonical_id": "stadium_mlb_coors_field",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "comerica park",
"stadium_canonical_id": "stadium_mlb_comerica_park",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "minute maid park",
"stadium_canonical_id": "stadium_mlb_minute_maid_park",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "kauffman stadium",
"stadium_canonical_id": "stadium_mlb_kauffman_stadium",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "angel stadium",
"stadium_canonical_id": "stadium_mlb_angel_stadium",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "dodger stadium",
"stadium_canonical_id": "stadium_mlb_dodger_stadium",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "loandepot park",
"stadium_canonical_id": "stadium_mlb_loandepot_park",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "american family field",
"stadium_canonical_id": "stadium_mlb_american_family_field",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "target field",
"stadium_canonical_id": "stadium_mlb_target_field",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "citi field",
"stadium_canonical_id": "stadium_mlb_citi_field",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "yankee stadium",
"stadium_canonical_id": "stadium_mlb_yankee_stadium",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "sutter health park",
"stadium_canonical_id": "stadium_mlb_sutter_health_park",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "citizens bank park",
"stadium_canonical_id": "stadium_mlb_citizens_bank_park",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "pnc park",
"stadium_canonical_id": "stadium_mlb_pnc_park",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "petco park",
"stadium_canonical_id": "stadium_mlb_petco_park",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "oracle park",
"stadium_canonical_id": "stadium_mlb_oracle_park",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "t-mobile park",
"stadium_canonical_id": "stadium_mlb_tmobile_park",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "tmobile park",
"stadium_canonical_id": "stadium_mlb_tmobile_park",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "busch stadium",
"stadium_canonical_id": "stadium_mlb_busch_stadium",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "tropicana field",
"stadium_canonical_id": "stadium_mlb_tropicana_field",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "globe life field",
"stadium_canonical_id": "stadium_mlb_globe_life_field",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "rogers centre",
"stadium_canonical_id": "stadium_mlb_rogers_centre",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "nationals park",
"stadium_canonical_id": "stadium_mlb_nationals_park",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "honda center",
"stadium_canonical_id": "stadium_nhl_honda_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "delta center",
"stadium_canonical_id": "stadium_nhl_delta_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "td garden",
"stadium_canonical_id": "stadium_nhl_td_garden",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "keybank center",
"stadium_canonical_id": "stadium_nhl_keybank_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "scotiabank saddledome",
"stadium_canonical_id": "stadium_nhl_scotiabank_saddledome",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "pnc arena",
"stadium_canonical_id": "stadium_nhl_pnc_arena",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "united center",
"stadium_canonical_id": "stadium_nhl_united_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "ball arena",
"stadium_canonical_id": "stadium_nhl_ball_arena",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "nationwide arena",
"stadium_canonical_id": "stadium_nhl_nationwide_arena",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "american airlines center",
"stadium_canonical_id": "stadium_nhl_american_airlines_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "little caesars arena",
"stadium_canonical_id": "stadium_nhl_little_caesars_arena",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "rogers place",
"stadium_canonical_id": "stadium_nhl_rogers_place",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "amerant bank arena",
"stadium_canonical_id": "stadium_nhl_amerant_bank_arena",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "crypto.com arena",
"stadium_canonical_id": "stadium_nhl_cryptocom_arena",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "cryptocom arena",
"stadium_canonical_id": "stadium_nhl_cryptocom_arena",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "xcel energy center",
"stadium_canonical_id": "stadium_nhl_xcel_energy_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "bell centre",
"stadium_canonical_id": "stadium_nhl_bell_centre",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "bridgestone arena",
"stadium_canonical_id": "stadium_nhl_bridgestone_arena",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "prudential center",
"stadium_canonical_id": "stadium_nhl_prudential_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "ubs arena",
"stadium_canonical_id": "stadium_nhl_ubs_arena",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "madison square garden",
"stadium_canonical_id": "stadium_nhl_madison_square_garden",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "canadian tire centre",
"stadium_canonical_id": "stadium_nhl_canadian_tire_centre",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "wells fargo center",
"stadium_canonical_id": "stadium_nhl_wells_fargo_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "ppg paints arena",
"stadium_canonical_id": "stadium_nhl_ppg_paints_arena",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "sap center",
"stadium_canonical_id": "stadium_nhl_sap_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "climate pledge arena",
"stadium_canonical_id": "stadium_nhl_climate_pledge_arena",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "enterprise center",
"stadium_canonical_id": "stadium_nhl_enterprise_center",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "amalie arena",
"stadium_canonical_id": "stadium_nhl_amalie_arena",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "scotiabank arena",
"stadium_canonical_id": "stadium_nhl_scotiabank_arena",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "rogers arena",
"stadium_canonical_id": "stadium_nhl_rogers_arena",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "t-mobile arena",
"stadium_canonical_id": "stadium_nhl_tmobile_arena",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "tmobile arena",
"stadium_canonical_id": "stadium_nhl_tmobile_arena",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "capital one arena",
"stadium_canonical_id": "stadium_nhl_capital_one_arena",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "canada life centre",
"stadium_canonical_id": "stadium_nhl_canada_life_centre",
"valid_from": null,
"valid_until": null
},
{
"alias_name": "daikin park",
"stadium_canonical_id": "stadium_mlb_minute_maid_park",
"valid_from": "2025-01-01",
"valid_until": null
},
{
"alias_name": "enron field",
"stadium_canonical_id": "stadium_mlb_minute_maid_park",
"valid_from": "2000-04-01",
"valid_until": "2002-02-28"
},
{
"alias_name": "astros field",
"stadium_canonical_id": "stadium_mlb_minute_maid_park",
"valid_from": "2002-03-01",
"valid_until": "2002-06-04"
},
{
"alias_name": "rate field",
"stadium_canonical_id": "stadium_mlb_guaranteed_rate_field",
"valid_from": "2024-01-01",
"valid_until": null
},
{
"alias_name": "us cellular field",
"stadium_canonical_id": "stadium_mlb_guaranteed_rate_field",
"valid_from": "2003-01-01",
"valid_until": "2016-08-24"
},
{
"alias_name": "comiskey park ii",
"stadium_canonical_id": "stadium_mlb_guaranteed_rate_field",
"valid_from": "1991-04-01",
"valid_until": "2002-12-31"
},
{
"alias_name": "new comiskey park",
"stadium_canonical_id": "stadium_mlb_guaranteed_rate_field",
"valid_from": "1991-04-01",
"valid_until": "2002-12-31"
},
{
"alias_name": "suntrust park",
"stadium_canonical_id": "stadium_mlb_truist_park",
"valid_from": "2017-04-01",
"valid_until": "2020-01-13"
},
{
"alias_name": "jacobs field",
"stadium_canonical_id": "stadium_mlb_progressive_field",
"valid_from": "1994-04-01",
"valid_until": "2008-01-10"
},
{
"alias_name": "the jake",
"stadium_canonical_id": "stadium_mlb_progressive_field",
"valid_from": "1994-04-01",
"valid_until": "2008-01-10"
},
{
"alias_name": "miller park",
"stadium_canonical_id": "stadium_mlb_american_family_field",
"valid_from": "2001-04-01",
"valid_until": "2020-12-31"
},
{
"alias_name": "skydome",
"stadium_canonical_id": "stadium_mlb_rogers_centre",
"valid_from": "1989-06-01",
"valid_until": "2005-02-01"
},
{
"alias_name": "marlins park",
"stadium_canonical_id": "stadium_mlb_loandepot_park",
"valid_from": "2012-04-01",
"valid_until": "2021-03-31"
},
{
"alias_name": "att park",
"stadium_canonical_id": "stadium_mlb_oracle_park",
"valid_from": "2006-01-01",
"valid_until": "2019-01-08"
},
{
"alias_name": "sbc park",
"stadium_canonical_id": "stadium_mlb_oracle_park",
"valid_from": "2004-01-01",
"valid_until": "2005-12-31"
},
{
"alias_name": "pac bell park",
"stadium_canonical_id": "stadium_mlb_oracle_park",
"valid_from": "2000-04-01",
"valid_until": "2003-12-31"
},
{
"alias_name": "choctaw stadium",
"stadium_canonical_id": "stadium_mlb_globe_life_field",
"valid_from": "2020-01-01",
"valid_until": null
},
{
"alias_name": "philips arena",
"stadium_canonical_id": "stadium_nba_state_farm_arena",
"valid_from": "1999-09-01",
"valid_until": "2018-06-25"
},
{
"alias_name": "ftx arena",
"stadium_canonical_id": "stadium_nba_kaseya_center",
"valid_from": "2021-06-01",
"valid_until": "2023-03-31"
},
{
"alias_name": "american airlines arena",
"stadium_canonical_id": "stadium_nba_kaseya_center",
"valid_from": "1999-12-01",
"valid_until": "2021-05-31"
},
{
"alias_name": "bankers life fieldhouse",
"stadium_canonical_id": "stadium_nba_gainbridge_fieldhouse",
"valid_from": "2011-01-01",
"valid_until": "2021-12-31"
},
{
"alias_name": "conseco fieldhouse",
"stadium_canonical_id": "stadium_nba_gainbridge_fieldhouse",
"valid_from": "1999-11-01",
"valid_until": "2010-12-31"
},
{
"alias_name": "quicken loans arena",
"stadium_canonical_id": "stadium_nba_rocket_mortgage_fieldhouse",
"valid_from": "2005-08-01",
"valid_until": "2019-08-08"
},
{
"alias_name": "gund arena",
"stadium_canonical_id": "stadium_nba_rocket_mortgage_fieldhouse",
"valid_from": "1994-10-01",
"valid_until": "2005-07-31"
},
{
"alias_name": "amway center",
"stadium_canonical_id": "stadium_nba_kia_center",
"valid_from": "2010-10-01",
"valid_until": "2023-07-12"
},
{
"alias_name": "att center",
"stadium_canonical_id": "stadium_nba_frost_bank_center",
"valid_from": "2002-10-01",
"valid_until": "2023-10-01"
},
{
"alias_name": "vivint arena",
"stadium_canonical_id": "stadium_nba_delta_center",
"valid_from": "2020-12-01",
"valid_until": "2023-07-01"
},
{
"alias_name": "vivint smart home arena",
"stadium_canonical_id": "stadium_nba_delta_center",
"valid_from": "2015-11-01",
"valid_until": "2020-11-30"
},
{
"alias_name": "energysolutions arena",
"stadium_canonical_id": "stadium_nba_delta_center",
"valid_from": "2006-11-01",
"valid_until": "2015-10-31"
},
{
"alias_name": "fla live arena",
"stadium_canonical_id": "stadium_nhl_amerant_bank_arena",
"valid_from": "2021-10-01",
"valid_until": "2024-05-31"
},
{
"alias_name": "bb&t center",
"stadium_canonical_id": "stadium_nhl_amerant_bank_arena",
"valid_from": "2012-06-01",
"valid_until": "2021-09-30"
},
{
"alias_name": "bankatlantic center",
"stadium_canonical_id": "stadium_nhl_amerant_bank_arena",
"valid_from": "2005-10-01",
"valid_until": "2012-05-31"
},
{
"alias_name": "keyarena",
"stadium_canonical_id": "stadium_nhl_climate_pledge_arena",
"valid_from": "1995-01-01",
"valid_until": "2018-10-01"
},
{
"alias_name": "seattle center coliseum",
"stadium_canonical_id": "stadium_nhl_climate_pledge_arena",
"valid_from": "1962-01-01",
"valid_until": "1994-12-31"
}
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff