Fixes ~95 issues from deep audit across 12 categories in 82 files: - Crash prevention: double-resume in PhotoMetadataExtractor, force unwraps in DateRangePicker, array bounds checks in polls/achievements, ProGate hit-test bypass, Dictionary(uniqueKeysWithValues:) → uniquingKeysWith in 4 files - Silent failure elimination: all 34 try? sites replaced with do/try/catch + logging (SavedTrip, TripDetailView, CanonicalSyncService, BootstrapService, CanonicalModels, CKModels, SportsTimeApp, and more) - Performance: cached DateFormatters (7 files), O(1) team lookups via AppDataProvider, achievement definition dictionary, AnimatedBackground consolidated from 19 Tasks to 1, task cancellation in SharePreviewView - Concurrency: UIKit drawing → MainActor.run, background fetch timeout guard, @MainActor on ThemeManager/AppearanceManager, SyncLogger read/write race fix - Planning engine: game end time in travel feasibility, state-aware city normalization, exact city matching, DrivingConstraints parameter propagation - IAP: unknown subscription states → expired, unverified transaction logging, entitlements updated before paywall dismiss, restore visible to all users - Security: API key to Info.plist lookup, filename sanitization in PDF export, honest User-Agent, removed stale "Feels" analytics super properties - Navigation: consolidated competing navigationDestination, boolean → value-based - Testing: 8 sleep() → waitForExistence, duplicates extracted, Swift 6 compat - Service bugs: infinite retry cap, duplicate achievement prevention, TOCTOU vote fix, PollVote.odg → voterId rename, deterministic placeholder IDs, parallel MKDirections, Sendable-safe POI struct Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
642 lines
24 KiB
Swift
642 lines
24 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
|
|
import os
|
|
|
|
private let logger = Logger(subsystem: "com.88oakapps.SportsTime", category: "BootstrapService")
|
|
|
|
@MainActor
|
|
final class BootstrapService {
|
|
|
|
// MARK: - Errors
|
|
|
|
enum BootstrapError: Error, LocalizedError {
|
|
case bundledResourceNotFound(String)
|
|
case jsonDecodingFailed(String, Error)
|
|
case saveFailed(Error)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .bundledResourceNotFound(let resource):
|
|
return "Bundled resource not found: \(resource)"
|
|
case .jsonDecodingFailed(let resource, let error):
|
|
return "Failed to decode \(resource): \(error.localizedDescription)"
|
|
case .saveFailed(let error):
|
|
return "Failed to save bootstrap data: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - JSON Models (match bundled JSON structure)
|
|
|
|
private struct JSONCanonicalStadium: Codable {
|
|
let canonical_id: String
|
|
let name: String
|
|
let city: String
|
|
let state: String?
|
|
let latitude: Double
|
|
let longitude: Double
|
|
let capacity: Int
|
|
let sport: String
|
|
let primary_team_abbrevs: [String]
|
|
let year_opened: Int?
|
|
let timezone_identifier: String?
|
|
let image_url: String?
|
|
}
|
|
|
|
private struct JSONCanonicalTeam: Codable {
|
|
let canonical_id: String
|
|
let name: String
|
|
let abbreviation: String
|
|
let sport: String
|
|
let city: String
|
|
let stadium_canonical_id: String
|
|
let conference_id: String?
|
|
let division_id: String?
|
|
let primary_color: String?
|
|
let secondary_color: String?
|
|
}
|
|
|
|
private struct JSONCanonicalGame: Codable {
|
|
let canonical_id: String
|
|
let sport: String
|
|
let season: String
|
|
let game_datetime_utc: String? // ISO 8601 format
|
|
let date: String? // Fallback date+time format
|
|
let time: String? // Fallback date+time format
|
|
let home_team_canonical_id: String
|
|
let away_team_canonical_id: String
|
|
let stadium_canonical_id: String?
|
|
let is_playoff: Bool
|
|
let broadcast_info: String?
|
|
}
|
|
|
|
private struct JSONStadiumAlias: Codable {
|
|
let alias_name: String
|
|
let stadium_canonical_id: String
|
|
let valid_from: String?
|
|
let valid_until: String?
|
|
}
|
|
|
|
private struct JSONLeagueStructure: Codable {
|
|
let id: String
|
|
let sport: String
|
|
let type: String // "conference", "division", "league"
|
|
let name: String
|
|
let abbreviation: String?
|
|
let parent_id: String?
|
|
let display_order: Int
|
|
}
|
|
|
|
private struct JSONTeamAlias: Codable {
|
|
let id: String
|
|
let team_canonical_id: String
|
|
let alias_type: String // "abbreviation", "name", "city"
|
|
let alias_value: String
|
|
let valid_from: String?
|
|
let valid_until: String?
|
|
}
|
|
|
|
private struct JSONCanonicalSport: Codable {
|
|
let sport_id: String
|
|
let abbreviation: String
|
|
let display_name: String
|
|
let icon_name: String
|
|
let color_hex: String
|
|
let season_start_month: Int
|
|
let season_end_month: Int
|
|
let is_active: Bool
|
|
}
|
|
|
|
// MARK: - Public Methods
|
|
|
|
/// Bootstrap canonical data from bundled JSON if not already done,
|
|
/// or re-bootstrap if the bundled data schema version has been bumped.
|
|
/// This is the main entry point called at app launch.
|
|
func bootstrapIfNeeded(context: ModelContext) async throws {
|
|
let syncState = SyncState.current(in: context)
|
|
let hasCoreCanonicalData = hasRequiredCanonicalData(context: context)
|
|
|
|
// Re-bootstrap if bundled data version is newer (e.g., updated game schedules)
|
|
let needsRebootstrap = syncState.bootstrapCompleted && syncState.bundledSchemaVersion < SchemaVersion.current
|
|
if needsRebootstrap {
|
|
syncState.bootstrapCompleted = false
|
|
}
|
|
|
|
// Recover from corrupted/partial local stores where bootstrap flag is true but core tables are empty.
|
|
if syncState.bootstrapCompleted && !hasCoreCanonicalData {
|
|
syncState.bootstrapCompleted = false
|
|
}
|
|
|
|
// Skip if already bootstrapped with current schema
|
|
guard !syncState.bootstrapCompleted else {
|
|
return
|
|
}
|
|
|
|
// Fresh bootstrap should always force a full CloudKit sync baseline.
|
|
resetSyncProgress(syncState)
|
|
|
|
// Clear any partial bootstrap data from a previous failed attempt
|
|
try clearCanonicalData(context: context)
|
|
|
|
// Bootstrap in dependency order:
|
|
// 1. Stadiums (no dependencies)
|
|
// 2. Stadium aliases (depends on stadiums)
|
|
// 3. League structure (no dependencies)
|
|
// 4. Teams (depends on stadiums)
|
|
// 5. Team aliases (depends on teams)
|
|
// 6. Games (depends on teams + stadiums)
|
|
|
|
try await bootstrapStadiums(context: context)
|
|
try await bootstrapStadiumAliases(context: context)
|
|
try await bootstrapLeagueStructure(context: context)
|
|
try await bootstrapTeams(context: context)
|
|
try await bootstrapTeamAliases(context: context)
|
|
try await bootstrapGames(context: context)
|
|
try await bootstrapSports(context: context)
|
|
|
|
// Mark bootstrap complete
|
|
syncState.bootstrapCompleted = true
|
|
syncState.bundledSchemaVersion = SchemaVersion.current
|
|
syncState.lastBootstrap = Date()
|
|
|
|
do {
|
|
try context.save()
|
|
} catch {
|
|
throw BootstrapError.saveFailed(error)
|
|
}
|
|
}
|
|
|
|
private func resetSyncProgress(_ syncState: SyncState) {
|
|
syncState.lastSuccessfulSync = nil
|
|
syncState.lastSyncAttempt = nil
|
|
syncState.lastSyncError = nil
|
|
syncState.syncInProgress = false
|
|
syncState.syncEnabled = true
|
|
syncState.syncPausedReason = nil
|
|
syncState.consecutiveFailures = 0
|
|
|
|
syncState.stadiumChangeToken = nil
|
|
syncState.teamChangeToken = nil
|
|
syncState.gameChangeToken = nil
|
|
syncState.leagueChangeToken = nil
|
|
|
|
syncState.lastStadiumSync = nil
|
|
syncState.lastTeamSync = nil
|
|
syncState.lastGameSync = nil
|
|
syncState.lastLeagueStructureSync = nil
|
|
syncState.lastTeamAliasSync = nil
|
|
syncState.lastStadiumAliasSync = nil
|
|
syncState.lastSportSync = nil
|
|
}
|
|
|
|
// MARK: - Bootstrap Steps
|
|
|
|
private func bootstrapStadiums(context: ModelContext) async throws {
|
|
guard let url = Bundle.main.url(forResource: "stadiums_canonical", withExtension: "json") else {
|
|
throw BootstrapError.bundledResourceNotFound("stadiums_canonical.json")
|
|
}
|
|
|
|
let stadiums: [JSONCanonicalStadium]
|
|
|
|
do {
|
|
stadiums = try await Task.detached {
|
|
let data = try Data(contentsOf: url)
|
|
return try JSONDecoder().decode([JSONCanonicalStadium].self, from: data)
|
|
}.value
|
|
} catch {
|
|
throw BootstrapError.jsonDecodingFailed("stadiums_canonical.json", error)
|
|
}
|
|
|
|
for jsonStadium in stadiums {
|
|
let canonical = CanonicalStadium(
|
|
canonicalId: jsonStadium.canonical_id,
|
|
schemaVersion: SchemaVersion.current,
|
|
lastModified: BundledDataTimestamp.stadiums,
|
|
source: .bundled,
|
|
name: jsonStadium.name,
|
|
city: jsonStadium.city,
|
|
state: (jsonStadium.state?.isEmpty ?? true) ? stateFromCity(jsonStadium.city) : jsonStadium.state!,
|
|
latitude: jsonStadium.latitude,
|
|
longitude: jsonStadium.longitude,
|
|
capacity: jsonStadium.capacity,
|
|
yearOpened: jsonStadium.year_opened,
|
|
imageURL: jsonStadium.image_url,
|
|
sport: jsonStadium.sport.uppercased(),
|
|
timezoneIdentifier: jsonStadium.timezone_identifier
|
|
)
|
|
context.insert(canonical)
|
|
}
|
|
}
|
|
|
|
private func bootstrapStadiumAliases(context: ModelContext) async throws {
|
|
guard let url = Bundle.main.url(forResource: "stadium_aliases", withExtension: "json") else {
|
|
return
|
|
}
|
|
|
|
let aliases: [JSONStadiumAlias]
|
|
|
|
do {
|
|
aliases = try await Task.detached {
|
|
let data = try Data(contentsOf: url)
|
|
return try JSONDecoder().decode([JSONStadiumAlias].self, from: data)
|
|
}.value
|
|
} catch {
|
|
throw BootstrapError.jsonDecodingFailed("stadium_aliases.json", error)
|
|
}
|
|
|
|
// Build stadium lookup
|
|
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>()
|
|
let stadiums: [CanonicalStadium]
|
|
do {
|
|
stadiums = try context.fetch(stadiumDescriptor)
|
|
} catch {
|
|
logger.error("Failed to fetch stadiums for alias linking: \(error.localizedDescription)")
|
|
stadiums = []
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
private func bootstrapLeagueStructure(context: ModelContext) async throws {
|
|
guard let url = Bundle.main.url(forResource: "league_structure", withExtension: "json") else {
|
|
return
|
|
}
|
|
|
|
let structures: [JSONLeagueStructure]
|
|
|
|
do {
|
|
structures = try await Task.detached {
|
|
let data = try Data(contentsOf: url)
|
|
return try JSONDecoder().decode([JSONLeagueStructure].self, from: data)
|
|
}.value
|
|
} catch {
|
|
throw BootstrapError.jsonDecodingFailed("league_structure.json", error)
|
|
}
|
|
|
|
for structure in structures {
|
|
let structureType: LeagueStructureType
|
|
switch structure.type.lowercased() {
|
|
case "conference": structureType = .conference
|
|
case "division": structureType = .division
|
|
case "league": structureType = .league
|
|
default: structureType = .division
|
|
}
|
|
|
|
let model = LeagueStructureModel(
|
|
id: structure.id,
|
|
sport: structure.sport.uppercased(),
|
|
structureType: structureType,
|
|
name: structure.name,
|
|
abbreviation: structure.abbreviation,
|
|
parentId: structure.parent_id,
|
|
displayOrder: structure.display_order,
|
|
schemaVersion: SchemaVersion.current,
|
|
lastModified: BundledDataTimestamp.leagueStructure
|
|
)
|
|
context.insert(model)
|
|
}
|
|
}
|
|
|
|
private func bootstrapTeams(context: ModelContext) async throws {
|
|
guard let url = Bundle.main.url(forResource: "teams_canonical", withExtension: "json") else {
|
|
throw BootstrapError.bundledResourceNotFound("teams_canonical.json")
|
|
}
|
|
|
|
let teams: [JSONCanonicalTeam]
|
|
|
|
do {
|
|
teams = try await Task.detached {
|
|
let data = try Data(contentsOf: url)
|
|
return try JSONDecoder().decode([JSONCanonicalTeam].self, from: data)
|
|
}.value
|
|
} catch {
|
|
throw BootstrapError.jsonDecodingFailed("teams_canonical.json", error)
|
|
}
|
|
|
|
for jsonTeam in teams {
|
|
let team = CanonicalTeam(
|
|
canonicalId: jsonTeam.canonical_id,
|
|
schemaVersion: SchemaVersion.current,
|
|
lastModified: BundledDataTimestamp.games,
|
|
source: .bundled,
|
|
name: jsonTeam.name,
|
|
abbreviation: jsonTeam.abbreviation,
|
|
sport: jsonTeam.sport.uppercased(),
|
|
city: jsonTeam.city,
|
|
stadiumCanonicalId: jsonTeam.stadium_canonical_id,
|
|
conferenceId: jsonTeam.conference_id,
|
|
divisionId: jsonTeam.division_id
|
|
)
|
|
context.insert(team)
|
|
}
|
|
}
|
|
|
|
private func bootstrapTeamAliases(context: ModelContext) async throws {
|
|
guard let url = Bundle.main.url(forResource: "team_aliases", withExtension: "json") else {
|
|
return
|
|
}
|
|
|
|
let aliases: [JSONTeamAlias]
|
|
|
|
do {
|
|
aliases = try await Task.detached {
|
|
let data = try Data(contentsOf: url)
|
|
return try JSONDecoder().decode([JSONTeamAlias].self, from: data)
|
|
}.value
|
|
} catch {
|
|
throw BootstrapError.jsonDecodingFailed("team_aliases.json", error)
|
|
}
|
|
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.dateFormat = "yyyy-MM-dd"
|
|
|
|
for jsonAlias in aliases {
|
|
let aliasType: TeamAliasType
|
|
switch jsonAlias.alias_type.lowercased() {
|
|
case "abbreviation": aliasType = .abbreviation
|
|
case "name": aliasType = .name
|
|
case "city": aliasType = .city
|
|
default: aliasType = .name
|
|
}
|
|
|
|
let alias = TeamAlias(
|
|
id: jsonAlias.id,
|
|
teamCanonicalId: jsonAlias.team_canonical_id,
|
|
aliasType: aliasType,
|
|
aliasValue: jsonAlias.alias_value,
|
|
validFrom: jsonAlias.valid_from.flatMap { dateFormatter.date(from: $0) },
|
|
validUntil: jsonAlias.valid_until.flatMap { dateFormatter.date(from: $0) },
|
|
schemaVersion: SchemaVersion.current,
|
|
lastModified: BundledDataTimestamp.games
|
|
)
|
|
context.insert(alias)
|
|
}
|
|
}
|
|
|
|
private func bootstrapGames(context: ModelContext) async throws {
|
|
guard let url = Bundle.main.url(forResource: "games_canonical", withExtension: "json") else {
|
|
throw BootstrapError.bundledResourceNotFound("games_canonical.json")
|
|
}
|
|
|
|
let games: [JSONCanonicalGame]
|
|
|
|
do {
|
|
games = try await Task.detached {
|
|
let data = try Data(contentsOf: url)
|
|
return try JSONDecoder().decode([JSONCanonicalGame].self, from: data)
|
|
}.value
|
|
} catch {
|
|
throw BootstrapError.jsonDecodingFailed("games_canonical.json", error)
|
|
}
|
|
|
|
var seenGameIds = Set<String>()
|
|
let teams: [CanonicalTeam]
|
|
do {
|
|
teams = try context.fetch(FetchDescriptor<CanonicalTeam>())
|
|
} catch {
|
|
logger.error("Failed to fetch teams for game bootstrap: \(error.localizedDescription)")
|
|
teams = []
|
|
}
|
|
let stadiumByTeamId = Dictionary(uniqueKeysWithValues: teams.map { ($0.canonicalId, $0.stadiumCanonicalId) })
|
|
|
|
// Build stadium timezone lookup for correct local time parsing
|
|
let stadiums: [CanonicalStadium]
|
|
do {
|
|
stadiums = try context.fetch(FetchDescriptor<CanonicalStadium>())
|
|
} catch {
|
|
logger.error("Failed to fetch stadiums for game bootstrap: \(error.localizedDescription)")
|
|
stadiums = []
|
|
}
|
|
let timezoneByStadiumId: [String: TimeZone] = stadiums.reduce(into: [:]) { dict, stadium in
|
|
if let tzId = stadium.timezoneIdentifier, let tz = TimeZone(identifier: tzId) {
|
|
dict[stadium.canonicalId] = tz
|
|
}
|
|
}
|
|
|
|
for jsonGame in games {
|
|
// Deduplicate
|
|
guard !seenGameIds.contains(jsonGame.canonical_id) else { continue }
|
|
seenGameIds.insert(jsonGame.canonical_id)
|
|
|
|
// Resolve stadium ID first (needed for timezone lookup)
|
|
let explicitStadium = jsonGame.stadium_canonical_id?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let resolvedStadiumCanonicalId: String
|
|
if let explicitStadium, !explicitStadium.isEmpty {
|
|
resolvedStadiumCanonicalId = explicitStadium
|
|
} else if let homeStadium = stadiumByTeamId[jsonGame.home_team_canonical_id],
|
|
!homeStadium.isEmpty {
|
|
resolvedStadiumCanonicalId = homeStadium
|
|
} else if let awayStadium = stadiumByTeamId[jsonGame.away_team_canonical_id],
|
|
!awayStadium.isEmpty {
|
|
resolvedStadiumCanonicalId = awayStadium
|
|
} else {
|
|
resolvedStadiumCanonicalId = "stadium_placeholder_\(jsonGame.canonical_id)"
|
|
}
|
|
|
|
// Parse datetime: prefer ISO 8601 format, fall back to date+time
|
|
// Times in JSON are venue-local, so parse in the stadium's timezone
|
|
let venueTimeZone = timezoneByStadiumId[resolvedStadiumCanonicalId]
|
|
let dateTime: Date?
|
|
if let iso8601String = jsonGame.game_datetime_utc {
|
|
dateTime = parseISO8601(iso8601String)
|
|
} else if let date = jsonGame.date {
|
|
dateTime = parseDateTime(date: date, time: jsonGame.time ?? "7:00p", timeZone: venueTimeZone)
|
|
} else {
|
|
dateTime = nil
|
|
}
|
|
|
|
guard let dateTime 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: resolvedStadiumCanonicalId,
|
|
dateTime: dateTime,
|
|
sport: jsonGame.sport.uppercased(),
|
|
season: jsonGame.season,
|
|
isPlayoff: jsonGame.is_playoff,
|
|
broadcastInfo: jsonGame.broadcast_info
|
|
)
|
|
context.insert(game)
|
|
}
|
|
}
|
|
|
|
private func bootstrapSports(context: ModelContext) async throws {
|
|
guard let url = Bundle.main.url(forResource: "sports_canonical", withExtension: "json") else {
|
|
return
|
|
}
|
|
|
|
let sports: [JSONCanonicalSport]
|
|
|
|
do {
|
|
sports = try await Task.detached {
|
|
let data = try Data(contentsOf: url)
|
|
return try JSONDecoder().decode([JSONCanonicalSport].self, from: data)
|
|
}.value
|
|
} catch {
|
|
throw BootstrapError.jsonDecodingFailed("sports_canonical.json", error)
|
|
}
|
|
|
|
for jsonSport in sports {
|
|
let sport = CanonicalSport(
|
|
id: jsonSport.sport_id,
|
|
abbreviation: jsonSport.abbreviation,
|
|
displayName: jsonSport.display_name,
|
|
iconName: jsonSport.icon_name,
|
|
colorHex: jsonSport.color_hex,
|
|
seasonStartMonth: jsonSport.season_start_month,
|
|
seasonEndMonth: jsonSport.season_end_month,
|
|
isActive: jsonSport.is_active,
|
|
lastModified: BundledDataTimestamp.sports,
|
|
schemaVersion: SchemaVersion.current,
|
|
source: .bundled
|
|
)
|
|
context.insert(sport)
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func clearCanonicalData(context: ModelContext) throws {
|
|
try context.delete(model: CanonicalStadium.self)
|
|
try context.delete(model: StadiumAlias.self)
|
|
try context.delete(model: LeagueStructureModel.self)
|
|
try context.delete(model: CanonicalTeam.self)
|
|
try context.delete(model: TeamAlias.self)
|
|
try context.delete(model: CanonicalGame.self)
|
|
try context.delete(model: CanonicalSport.self)
|
|
}
|
|
|
|
private func hasRequiredCanonicalData(context: ModelContext) -> Bool {
|
|
let stadiumCount: Int
|
|
do {
|
|
stadiumCount = try context.fetchCount(
|
|
FetchDescriptor<CanonicalStadium>(
|
|
predicate: #Predicate { $0.deprecatedAt == nil }
|
|
)
|
|
)
|
|
} catch {
|
|
logger.error("Failed to count stadiums: \(error.localizedDescription)")
|
|
stadiumCount = 0
|
|
}
|
|
let teamCount: Int
|
|
do {
|
|
teamCount = try context.fetchCount(
|
|
FetchDescriptor<CanonicalTeam>(
|
|
predicate: #Predicate { $0.deprecatedAt == nil }
|
|
)
|
|
)
|
|
} catch {
|
|
logger.error("Failed to count teams: \(error.localizedDescription)")
|
|
teamCount = 0
|
|
}
|
|
let gameCount: Int
|
|
do {
|
|
gameCount = try context.fetchCount(
|
|
FetchDescriptor<CanonicalGame>(
|
|
predicate: #Predicate { $0.deprecatedAt == nil }
|
|
)
|
|
)
|
|
} catch {
|
|
logger.error("Failed to count games: \(error.localizedDescription)")
|
|
gameCount = 0
|
|
}
|
|
return stadiumCount > 0 && teamCount > 0 && gameCount > 0
|
|
}
|
|
|
|
nonisolated private func parseISO8601(_ string: String) -> Date? {
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime]
|
|
return formatter.date(from: string)
|
|
}
|
|
|
|
nonisolated private func parseDateTime(date: String, time: String, timeZone: TimeZone? = nil) -> Date? {
|
|
let formatter = DateFormatter()
|
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
|
|
// Use the venue's timezone so "1:05p" is interpreted as 1:05 PM at the stadium
|
|
let tz = timeZone ?? .current
|
|
formatter.timeZone = tz
|
|
|
|
// 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
|
|
}
|
|
|
|
var calendar = Calendar.current
|
|
calendar.timeZone = tz
|
|
return calendar.date(bySettingHour: hour, minute: minute, second: 0, of: dateOnly)
|
|
}
|
|
|
|
nonisolated private func stateFromCity(_ city: String) -> String {
|
|
let cityToState: [String: String] = [
|
|
"Atlanta": "GA", "Boston": "MA", "Brooklyn": "NY", "Charlotte": "NC",
|
|
"Chicago": "IL", "Cleveland": "OH", "Dallas": "TX", "Denver": "CO",
|
|
"Detroit": "MI", "Houston": "TX", "Indianapolis": "IN", "Los Angeles": "CA",
|
|
"Memphis": "TN", "Miami": "FL", "Milwaukee": "WI", "Minneapolis": "MN",
|
|
"New Orleans": "LA", "New York": "NY", "Oklahoma City": "OK", "Orlando": "FL",
|
|
"Philadelphia": "PA", "Phoenix": "AZ", "Portland": "OR", "Sacramento": "CA",
|
|
"San Antonio": "TX", "San Francisco": "CA", "Seattle": "WA", "Toronto": "ON",
|
|
"Washington": "DC", "Las Vegas": "NV", "Tampa": "FL", "Pittsburgh": "PA",
|
|
"Baltimore": "MD", "Cincinnati": "OH", "St. Louis": "MO", "Kansas City": "MO",
|
|
"Arlington": "TX", "Anaheim": "CA", "Oakland": "CA", "San Diego": "CA",
|
|
"Tampa Bay": "FL", "St Petersburg": "FL", "Salt Lake City": "UT"
|
|
]
|
|
return cityToState[city] ?? ""
|
|
}
|
|
}
|