- Remove all print statements from planning engine, data providers, and PDF generation - Fix deprecated CLGeocoder usage with MKLocalSearch for iOS 26 - Fix Swift 6 actor isolation by converting PDFGenerator/ExportService to @MainActor - Add @retroactive to CLLocationCoordinate2D protocol conformances - Fix unused variable warnings in GameDAGRouter and scenario planners - Remove unreachable catch blocks in SettingsViewModel 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
373 lines
13 KiB
Swift
373 lines
13 KiB
Swift
//
|
|
// StubDataProvider.swift
|
|
// SportsTime
|
|
//
|
|
// Provides real data from bundled JSON files for Simulator testing
|
|
//
|
|
|
|
import Foundation
|
|
import CryptoKit
|
|
|
|
actor StubDataProvider: DataProvider {
|
|
|
|
// MARK: - JSON Models
|
|
|
|
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 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?
|
|
}
|
|
|
|
// MARK: - Cached Data
|
|
|
|
private var cachedGames: [Game]?
|
|
private var cachedTeams: [Team]?
|
|
private var cachedStadiums: [Stadium]?
|
|
private var teamsByAbbrev: [String: Team] = [:]
|
|
private var stadiumsByVenue: [String: Stadium] = [:]
|
|
|
|
// MARK: - DataProvider Protocol
|
|
|
|
func fetchTeams(for sport: Sport) async throws -> [Team] {
|
|
try await loadAllDataIfNeeded()
|
|
return cachedTeams?.filter { $0.sport == sport } ?? []
|
|
}
|
|
|
|
func fetchAllTeams() async throws -> [Team] {
|
|
try await loadAllDataIfNeeded()
|
|
return cachedTeams ?? []
|
|
}
|
|
|
|
func fetchStadiums() async throws -> [Stadium] {
|
|
try await loadAllDataIfNeeded()
|
|
return cachedStadiums ?? []
|
|
}
|
|
|
|
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game] {
|
|
try await loadAllDataIfNeeded()
|
|
|
|
return (cachedGames ?? []).filter { game in
|
|
sports.contains(game.sport) &&
|
|
game.dateTime >= startDate &&
|
|
game.dateTime <= endDate
|
|
}
|
|
}
|
|
|
|
func fetchGame(by id: UUID) async throws -> Game? {
|
|
try await loadAllDataIfNeeded()
|
|
return cachedGames?.first { $0.id == id }
|
|
}
|
|
|
|
func fetchRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
|
|
try await loadAllDataIfNeeded()
|
|
|
|
let games = try await fetchGames(sports: sports, startDate: startDate, endDate: endDate)
|
|
let teamsById = Dictionary(uniqueKeysWithValues: (cachedTeams ?? []).map { ($0.id, $0) })
|
|
let stadiumsById = Dictionary(uniqueKeysWithValues: (cachedStadiums ?? []).map { ($0.id, $0) })
|
|
|
|
return games.compactMap { game in
|
|
guard let homeTeam = teamsById[game.homeTeamId],
|
|
let awayTeam = teamsById[game.awayTeamId],
|
|
let stadium = stadiumsById[game.stadiumId] else {
|
|
return nil
|
|
}
|
|
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
|
}
|
|
}
|
|
|
|
// MARK: - Data Loading
|
|
|
|
private func loadAllDataIfNeeded() async throws {
|
|
guard cachedGames == nil else { return }
|
|
|
|
// Load stadiums first
|
|
let jsonStadiums = try loadStadiumsJSON()
|
|
cachedStadiums = jsonStadiums.map { convertStadium($0) }
|
|
|
|
// Build stadium lookup by venue name
|
|
for stadium in cachedStadiums ?? [] {
|
|
stadiumsByVenue[stadium.name.lowercased()] = stadium
|
|
}
|
|
|
|
// Load games and extract teams
|
|
let jsonGames = try loadGamesJSON()
|
|
|
|
// Build teams from games data
|
|
var teamsDict: [String: Team] = [:]
|
|
for jsonGame in jsonGames {
|
|
let sport = parseSport(jsonGame.sport)
|
|
|
|
// Home team
|
|
let homeKey = "\(sport.rawValue)_\(jsonGame.home_team_abbrev)"
|
|
if teamsDict[homeKey] == nil {
|
|
let stadiumId = findStadiumId(venue: jsonGame.venue, sport: sport)
|
|
let team = Team(
|
|
id: deterministicUUID(from: homeKey),
|
|
name: extractTeamName(from: jsonGame.home_team),
|
|
abbreviation: jsonGame.home_team_abbrev,
|
|
sport: sport,
|
|
city: extractCity(from: jsonGame.home_team),
|
|
stadiumId: stadiumId
|
|
)
|
|
teamsDict[homeKey] = team
|
|
teamsByAbbrev[homeKey] = team
|
|
}
|
|
|
|
// Away team
|
|
let awayKey = "\(sport.rawValue)_\(jsonGame.away_team_abbrev)"
|
|
if teamsDict[awayKey] == nil {
|
|
// Away teams might not have a stadium in our data yet
|
|
let team = Team(
|
|
id: deterministicUUID(from: awayKey),
|
|
name: extractTeamName(from: jsonGame.away_team),
|
|
abbreviation: jsonGame.away_team_abbrev,
|
|
sport: sport,
|
|
city: extractCity(from: jsonGame.away_team),
|
|
stadiumId: UUID() // Placeholder, will be updated when they're home team
|
|
)
|
|
teamsDict[awayKey] = team
|
|
teamsByAbbrev[awayKey] = team
|
|
}
|
|
}
|
|
cachedTeams = Array(teamsDict.values)
|
|
|
|
// Convert games (deduplicate by ID - JSON may have duplicate entries)
|
|
var seenGameIds = Set<String>()
|
|
let uniqueJsonGames = jsonGames.filter { game in
|
|
if seenGameIds.contains(game.id) {
|
|
return false
|
|
}
|
|
seenGameIds.insert(game.id)
|
|
return true
|
|
}
|
|
cachedGames = uniqueJsonGames.compactMap { convertGame($0) }
|
|
}
|
|
|
|
private func loadGamesJSON() throws -> [JSONGame] {
|
|
guard let url = Bundle.main.url(forResource: "games", withExtension: "json") else {
|
|
return []
|
|
}
|
|
let data = try Data(contentsOf: url)
|
|
return try JSONDecoder().decode([JSONGame].self, from: data)
|
|
}
|
|
|
|
private func loadStadiumsJSON() throws -> [JSONStadium] {
|
|
guard let url = Bundle.main.url(forResource: "stadiums", withExtension: "json") else {
|
|
return []
|
|
}
|
|
let data = try Data(contentsOf: url)
|
|
return try JSONDecoder().decode([JSONStadium].self, from: data)
|
|
}
|
|
|
|
// MARK: - Conversion Helpers
|
|
|
|
private func convertStadium(_ json: JSONStadium) -> Stadium {
|
|
Stadium(
|
|
id: deterministicUUID(from: json.id),
|
|
name: json.name,
|
|
city: json.city,
|
|
state: json.state.isEmpty ? stateFromCity(json.city) : json.state,
|
|
latitude: json.latitude,
|
|
longitude: json.longitude,
|
|
capacity: json.capacity,
|
|
yearOpened: json.year_opened
|
|
)
|
|
}
|
|
|
|
private func convertGame(_ json: JSONGame) -> Game? {
|
|
let sport = parseSport(json.sport)
|
|
|
|
let homeKey = "\(sport.rawValue)_\(json.home_team_abbrev)"
|
|
let awayKey = "\(sport.rawValue)_\(json.away_team_abbrev)"
|
|
|
|
guard let homeTeam = teamsByAbbrev[homeKey],
|
|
let awayTeam = teamsByAbbrev[awayKey] else {
|
|
return nil
|
|
}
|
|
|
|
let stadiumId = findStadiumId(venue: json.venue, sport: sport)
|
|
|
|
guard let dateTime = parseDateTime(date: json.date, time: json.time ?? "7:00p") else {
|
|
return nil
|
|
}
|
|
|
|
return Game(
|
|
id: deterministicUUID(from: json.id),
|
|
homeTeamId: homeTeam.id,
|
|
awayTeamId: awayTeam.id,
|
|
stadiumId: stadiumId,
|
|
dateTime: dateTime,
|
|
sport: sport,
|
|
season: json.season,
|
|
isPlayoff: json.is_playoff,
|
|
broadcastInfo: json.broadcast
|
|
)
|
|
}
|
|
|
|
private func parseSport(_ sport: String) -> Sport {
|
|
switch sport.uppercased() {
|
|
case "MLB": return .mlb
|
|
case "NBA": return .nba
|
|
case "NHL": return .nhl
|
|
case "NFL": return .nfl
|
|
case "MLS": return .mls
|
|
default: return .mlb
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Venue name aliases for stadiums that changed names
|
|
private static let venueAliases: [String: String] = [
|
|
"daikin park": "minute maid park", // Houston Astros (renamed 2024)
|
|
"rate field": "guaranteed rate field", // Chicago White Sox
|
|
"george m. steinbrenner field": "tropicana field", // Tampa Bay spring training → main stadium
|
|
"loandepot park": "loandepot park", // Miami - ensure case match
|
|
]
|
|
|
|
private func findStadiumId(venue: String, sport: Sport) -> UUID {
|
|
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.id
|
|
}
|
|
|
|
// Try partial match
|
|
for (name, stadium) in stadiumsByVenue {
|
|
if name.contains(venueLower) || venueLower.contains(name) {
|
|
return stadium.id
|
|
}
|
|
}
|
|
|
|
// Generate deterministic ID for unknown venues
|
|
return deterministicUUID(from: "venue_\(venue)")
|
|
}
|
|
|
|
private func deterministicUUID(from string: String) -> UUID {
|
|
// Create a deterministic UUID using SHA256 (truly deterministic across launches)
|
|
let data = Data(string.utf8)
|
|
let hash = SHA256.hash(data: data)
|
|
let hashBytes = Array(hash)
|
|
|
|
// Use first 16 bytes of SHA256 hash
|
|
var bytes = Array(hashBytes.prefix(16))
|
|
|
|
// Set UUID version (4) and variant bits
|
|
bytes[6] = (bytes[6] & 0x0F) | 0x40
|
|
bytes[8] = (bytes[8] & 0x3F) | 0x80
|
|
|
|
return UUID(uuid: (
|
|
bytes[0], bytes[1], bytes[2], bytes[3],
|
|
bytes[4], bytes[5], bytes[6], bytes[7],
|
|
bytes[8], bytes[9], bytes[10], bytes[11],
|
|
bytes[12], bytes[13], bytes[14], bytes[15]
|
|
))
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
private func extractCity(from fullName: String) -> String {
|
|
// "Boston Celtics" -> "Boston"
|
|
// "New York Knicks" -> "New York"
|
|
// "Los Angeles Lakers" -> "Los Angeles"
|
|
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))
|
|
}
|
|
|
|
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] ?? ""
|
|
}
|
|
}
|