Add StadiumAlias CloudKit sync and offline-first data architecture

- Add CKStadiumAlias model for CloudKit record mapping
- Add fetchStadiumAliases/fetchStadiumAliasChanges to CloudKitService
- Add syncStadiumAliases to CanonicalSyncService for delta sync
- Add subscribeToStadiumAliasUpdates for push notifications
- Update cloudkit_import.py with --stadium-aliases-only option

Data Architecture Updates:
- Remove obsolete provider files (CanonicalDataProvider, CloudKitDataProvider, StubDataProvider)
- AppDataProvider now reads exclusively from SwiftData
- Add background CloudKit sync on app startup (non-blocking)
- Document data architecture in CLAUDE.md

🤖 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-08 22:20:07 -06:00
parent 588938d2a1
commit 1ee47df53e
12 changed files with 482 additions and 780 deletions

View File

@@ -17,6 +17,7 @@ enum CKRecordType {
static let sport = "Sport"
static let leagueStructure = "LeagueStructure"
static let teamAlias = "TeamAlias"
static let stadiumAlias = "StadiumAlias"
}
// MARK: - CKTeam
@@ -273,6 +274,55 @@ struct CKLeagueStructure {
}
}
// MARK: - CKStadiumAlias
struct CKStadiumAlias {
static let aliasNameKey = "aliasName"
static let stadiumCanonicalIdKey = "stadiumCanonicalId"
static let validFromKey = "validFrom"
static let validUntilKey = "validUntil"
static let schemaVersionKey = "schemaVersion"
static let lastModifiedKey = "lastModified"
let record: CKRecord
init(record: CKRecord) {
self.record = record
}
init(model: StadiumAlias) {
let record = CKRecord(recordType: CKRecordType.stadiumAlias, recordID: CKRecord.ID(recordName: model.aliasName))
record[CKStadiumAlias.aliasNameKey] = model.aliasName
record[CKStadiumAlias.stadiumCanonicalIdKey] = model.stadiumCanonicalId
record[CKStadiumAlias.validFromKey] = model.validFrom
record[CKStadiumAlias.validUntilKey] = model.validUntil
record[CKStadiumAlias.schemaVersionKey] = model.schemaVersion
record[CKStadiumAlias.lastModifiedKey] = model.lastModified
self.record = record
}
/// Convert to SwiftData model for local storage
func toModel() -> StadiumAlias? {
guard let aliasName = record[CKStadiumAlias.aliasNameKey] as? String,
let stadiumCanonicalId = record[CKStadiumAlias.stadiumCanonicalIdKey] as? String
else { return nil }
let validFrom = record[CKStadiumAlias.validFromKey] as? Date
let validUntil = record[CKStadiumAlias.validUntilKey] as? Date
let schemaVersion = record[CKStadiumAlias.schemaVersionKey] as? Int ?? SchemaVersion.current
let lastModified = record[CKStadiumAlias.lastModifiedKey] as? Date ?? record.modificationDate ?? Date()
return StadiumAlias(
aliasName: aliasName,
stadiumCanonicalId: stadiumCanonicalId,
validFrom: validFrom,
validUntil: validUntil,
schemaVersion: schemaVersion,
lastModified: lastModified
)
}
}
// MARK: - CKTeamAlias
struct CKTeamAlias {

View File

@@ -1,234 +0,0 @@
//
// CanonicalDataProvider.swift
// SportsTime
//
// DataProvider implementation that reads from SwiftData canonical models.
// This is the primary data source after bootstrap completes.
//
import Foundation
import SwiftData
actor CanonicalDataProvider: DataProvider {
// MARK: - Properties
private let modelContainer: ModelContainer
// Caches for converted domain objects (rebuilt on first access)
private var cachedTeams: [Team]?
private var cachedStadiums: [Stadium]?
private var teamsByCanonicalId: [String: Team] = [:]
private var stadiumsByCanonicalId: [String: Stadium] = [:]
private var teamUUIDByCanonicalId: [String: UUID] = [:]
private var stadiumUUIDByCanonicalId: [String: UUID] = [:]
// MARK: - Initialization
init(modelContainer: ModelContainer) {
self.modelContainer = modelContainer
}
// MARK: - DataProvider Protocol
func fetchTeams(for sport: Sport) async throws -> [Team] {
try await loadCachesIfNeeded()
return cachedTeams?.filter { $0.sport == sport } ?? []
}
func fetchAllTeams() async throws -> [Team] {
try await loadCachesIfNeeded()
return cachedTeams ?? []
}
func fetchStadiums() async throws -> [Stadium] {
try await loadCachesIfNeeded()
return cachedStadiums ?? []
}
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game] {
try await loadCachesIfNeeded()
let context = ModelContext(modelContainer)
// Fetch canonical games within date range
let sportStrings = sports.map { $0.rawValue }
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { game in
sportStrings.contains(game.sport) &&
game.dateTime >= startDate &&
game.dateTime <= endDate &&
game.deprecatedAt == nil
},
sortBy: [SortDescriptor(\.dateTime)]
)
let canonicalGames = try context.fetch(descriptor)
// Convert to domain models
return canonicalGames.compactMap { canonical -> Game? in
guard let homeTeamUUID = teamUUIDByCanonicalId[canonical.homeTeamCanonicalId],
let awayTeamUUID = teamUUIDByCanonicalId[canonical.awayTeamCanonicalId],
let stadiumUUID = stadiumUUIDByCanonicalId[canonical.stadiumCanonicalId] else {
return nil
}
return Game(
id: canonical.uuid,
homeTeamId: homeTeamUUID,
awayTeamId: awayTeamUUID,
stadiumId: stadiumUUID,
dateTime: canonical.dateTime,
sport: canonical.sportEnum ?? .mlb,
season: canonical.season,
isPlayoff: canonical.isPlayoff,
broadcastInfo: canonical.broadcastInfo
)
}
}
func fetchGame(by id: UUID) async throws -> Game? {
try await loadCachesIfNeeded()
let context = ModelContext(modelContainer)
// Search by UUID
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { game in
game.uuid == id && game.deprecatedAt == nil
}
)
guard let canonical = try context.fetch(descriptor).first else {
return nil
}
guard let homeTeamUUID = teamUUIDByCanonicalId[canonical.homeTeamCanonicalId],
let awayTeamUUID = teamUUIDByCanonicalId[canonical.awayTeamCanonicalId],
let stadiumUUID = stadiumUUIDByCanonicalId[canonical.stadiumCanonicalId] else {
return nil
}
return Game(
id: canonical.uuid,
homeTeamId: homeTeamUUID,
awayTeamId: awayTeamUUID,
stadiumId: stadiumUUID,
dateTime: canonical.dateTime,
sport: canonical.sportEnum ?? .mlb,
season: canonical.season,
isPlayoff: canonical.isPlayoff,
broadcastInfo: canonical.broadcastInfo
)
}
func fetchRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
try await loadCachesIfNeeded()
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: - Additional Queries
/// Fetch stadium by canonical ID (useful for visit tracking)
func fetchStadium(byCanonicalId canonicalId: String) async throws -> Stadium? {
try await loadCachesIfNeeded()
return stadiumsByCanonicalId[canonicalId]
}
/// Fetch team by canonical ID
func fetchTeam(byCanonicalId canonicalId: String) async throws -> Team? {
try await loadCachesIfNeeded()
return teamsByCanonicalId[canonicalId]
}
/// Find stadium by name (matches aliases)
func findStadium(byName name: String) async throws -> Stadium? {
let context = ModelContext(modelContainer)
// Precompute lowercased name outside the predicate
let lowercasedName = name.lowercased()
// First try exact alias match
let aliasDescriptor = FetchDescriptor<StadiumAlias>(
predicate: #Predicate<StadiumAlias> { alias in
alias.aliasName == lowercasedName
}
)
if let alias = try context.fetch(aliasDescriptor).first,
let stadiumCanonicalId = Optional(alias.stadiumCanonicalId) {
return try await fetchStadium(byCanonicalId: stadiumCanonicalId)
}
return nil
}
/// Invalidate caches (call after sync completes)
func invalidateCaches() {
cachedTeams = nil
cachedStadiums = nil
teamsByCanonicalId.removeAll()
stadiumsByCanonicalId.removeAll()
teamUUIDByCanonicalId.removeAll()
stadiumUUIDByCanonicalId.removeAll()
}
// MARK: - Private Helpers
private func loadCachesIfNeeded() async throws {
guard cachedTeams == nil else { return }
let context = ModelContext(modelContainer)
// Load stadiums
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate<CanonicalStadium> { stadium in
stadium.deprecatedAt == nil
}
)
let canonicalStadiums = try context.fetch(stadiumDescriptor)
cachedStadiums = canonicalStadiums.map { canonical in
let stadium = canonical.toDomain()
stadiumsByCanonicalId[canonical.canonicalId] = stadium
stadiumUUIDByCanonicalId[canonical.canonicalId] = stadium.id
return stadium
}
// Load teams
let teamDescriptor = FetchDescriptor<CanonicalTeam>(
predicate: #Predicate<CanonicalTeam> { team in
team.deprecatedAt == nil
}
)
let canonicalTeams = try context.fetch(teamDescriptor)
cachedTeams = canonicalTeams.compactMap { canonical -> Team? in
guard let stadiumUUID = stadiumUUIDByCanonicalId[canonical.stadiumCanonicalId] else {
// Generate a placeholder UUID for teams without known stadiums
let placeholderUUID = CanonicalStadium.deterministicUUID(from: canonical.stadiumCanonicalId)
let team = canonical.toDomain(stadiumUUID: placeholderUUID)
teamsByCanonicalId[canonical.canonicalId] = team
teamUUIDByCanonicalId[canonical.canonicalId] = team.id
return team
}
let team = canonical.toDomain(stadiumUUID: stadiumUUID)
teamsByCanonicalId[canonical.canonicalId] = team
teamUUIDByCanonicalId[canonical.canonicalId] = team.id
return team
}
}
}

View File

@@ -42,12 +42,13 @@ actor CanonicalSyncService {
let gamesUpdated: Int
let leagueStructuresUpdated: Int
let teamAliasesUpdated: Int
let stadiumAliasesUpdated: Int
let skippedIncompatible: Int
let skippedOlder: Int
let duration: TimeInterval
var totalUpdated: Int {
stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated
stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated + stadiumAliasesUpdated
}
var isEmpty: Bool { totalUpdated == 0 }
@@ -81,7 +82,7 @@ actor CanonicalSyncService {
guard syncState.syncEnabled else {
return SyncResult(
stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0,
leagueStructuresUpdated: 0, teamAliasesUpdated: 0,
leagueStructuresUpdated: 0, teamAliasesUpdated: 0, stadiumAliasesUpdated: 0,
skippedIncompatible: 0, skippedOlder: 0,
duration: 0
)
@@ -101,6 +102,7 @@ actor CanonicalSyncService {
var totalGames = 0
var totalLeagueStructures = 0
var totalTeamAliases = 0
var totalStadiumAliases = 0
var totalSkippedIncompatible = 0
var totalSkippedOlder = 0
@@ -138,13 +140,21 @@ actor CanonicalSyncService {
totalSkippedIncompatible += skipIncompat4
totalSkippedOlder += skipOlder4
let (games, skipIncompat5, skipOlder5) = try await syncGames(
let (stadiumAliases, skipIncompat5, skipOlder5) = try await syncStadiumAliases(
context: context,
since: syncState.lastSuccessfulSync
)
totalStadiumAliases = stadiumAliases
totalSkippedIncompatible += skipIncompat5
totalSkippedOlder += skipOlder5
let (games, skipIncompat6, skipOlder6) = try await syncGames(
context: context,
since: syncState.lastSuccessfulSync
)
totalGames = games
totalSkippedIncompatible += skipIncompat5
totalSkippedOlder += skipOlder5
totalSkippedIncompatible += skipIncompat6
totalSkippedOlder += skipOlder6
// Mark sync successful
syncState.syncInProgress = false
@@ -176,6 +186,7 @@ actor CanonicalSyncService {
gamesUpdated: totalGames,
leagueStructuresUpdated: totalLeagueStructures,
teamAliasesUpdated: totalTeamAliases,
stadiumAliasesUpdated: totalStadiumAliases,
skippedIncompatible: totalSkippedIncompatible,
skippedOlder: totalSkippedOlder,
duration: Date().timeIntervalSince(startTime)
@@ -346,6 +357,30 @@ actor CanonicalSyncService {
return (updated, skippedIncompatible, skippedOlder)
}
@MainActor
private func syncStadiumAliases(
context: ModelContext,
since lastSync: Date?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
let remoteAliases = try await cloudKitService.fetchStadiumAliasChanges(since: lastSync)
var updated = 0
var skippedIncompatible = 0
var skippedOlder = 0
for remoteAlias in remoteAliases {
let result = try mergeStadiumAlias(remoteAlias, context: context)
switch result {
case .applied: updated += 1
case .skippedIncompatible: skippedIncompatible += 1
case .skippedOlder: skippedOlder += 1
}
}
return (updated, skippedIncompatible, skippedOlder)
}
// MARK: - Merge Logic
private enum MergeResult {
@@ -631,4 +666,41 @@ actor CanonicalSyncService {
return .applied
}
}
@MainActor
private func mergeStadiumAlias(
_ remote: StadiumAlias,
context: ModelContext
) throws -> MergeResult {
// Schema version check
guard remote.schemaVersion <= SchemaVersion.current else {
return .skippedIncompatible
}
let remoteAliasName = remote.aliasName
let descriptor = FetchDescriptor<StadiumAlias>(
predicate: #Predicate { $0.aliasName == remoteAliasName }
)
let existing = try context.fetch(descriptor).first
if let existing = existing {
// lastModified check
guard remote.lastModified > existing.lastModified else {
return .skippedOlder
}
// Update all fields (no user fields on StadiumAlias)
existing.stadiumCanonicalId = remote.stadiumCanonicalId
existing.validFrom = remote.validFrom
existing.validUntil = remote.validUntil
existing.schemaVersion = remote.schemaVersion
existing.lastModified = remote.lastModified
return .applied
} else {
// Insert new
context.insert(remote)
return .applied
}
}
}

View File

@@ -1,108 +0,0 @@
//
// CloudKitDataProvider.swift
// SportsTime
//
// Wraps CloudKitService to conform to DataProvider protocol
//
import Foundation
actor CloudKitDataProvider: DataProvider {
private let cloudKit = CloudKitService.shared
// MARK: - Availability
func checkAvailability() async throws {
try await cloudKit.checkAvailabilityWithError()
}
// MARK: - DataProvider Protocol
func fetchTeams(for sport: Sport) async throws -> [Team] {
do {
try await checkAvailability()
return try await cloudKit.fetchTeams(for: sport)
} catch {
throw CloudKitError.from(error)
}
}
func fetchAllTeams() async throws -> [Team] {
do {
try await checkAvailability()
var allTeams: [Team] = []
for sport in Sport.supported {
let teams = try await cloudKit.fetchTeams(for: sport)
allTeams.append(contentsOf: teams)
}
return allTeams
} catch {
throw CloudKitError.from(error)
}
}
func fetchStadiums() async throws -> [Stadium] {
do {
try await checkAvailability()
return try await cloudKit.fetchStadiums()
} catch {
throw CloudKitError.from(error)
}
}
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game] {
do {
try await checkAvailability()
return try await cloudKit.fetchGames(sports: sports, startDate: startDate, endDate: endDate)
} catch {
throw CloudKitError.from(error)
}
}
func fetchGame(by id: UUID) async throws -> Game? {
do {
try await checkAvailability()
return try await cloudKit.fetchGame(by: id)
} catch {
throw CloudKitError.from(error)
}
}
func fetchRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
do {
try await checkAvailability()
// Fetch all required data
async let gamesTask = cloudKit.fetchGames(sports: sports, startDate: startDate, endDate: endDate)
async let teamsTask = fetchAllTeamsInternal()
async let stadiumsTask = cloudKit.fetchStadiums()
let (games, teams, stadiums) = try await (gamesTask, teamsTask, stadiumsTask)
let teamsById = Dictionary(uniqueKeysWithValues: teams.map { ($0.id, $0) })
let stadiumsById = Dictionary(uniqueKeysWithValues: stadiums.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)
}
} catch {
throw CloudKitError.from(error)
}
}
// Internal helper to avoid duplicate availability checks
private func fetchAllTeamsInternal() async throws -> [Team] {
var allTeams: [Team] = []
for sport in Sport.supported {
let teams = try await cloudKit.fetchTeams(for: sport)
allTeams.append(contentsOf: teams)
}
return allTeams
}
}

View File

@@ -228,6 +228,24 @@ actor CloudKitService {
}
}
func fetchStadiumAliases(for stadiumCanonicalId: String? = nil) async throws -> [StadiumAlias] {
let predicate: NSPredicate
if let stadiumId = stadiumCanonicalId {
predicate = NSPredicate(format: "stadiumCanonicalId == %@", stadiumId)
} else {
predicate = NSPredicate(value: true)
}
let query = CKQuery(recordType: CKRecordType.stadiumAlias, predicate: predicate)
let (results, _) = try await publicDatabase.records(matching: query)
return results.compactMap { result in
guard case .success(let record) = result.1 else { return nil }
return CKStadiumAlias(record: record).toModel()
}
}
// MARK: - Delta Sync (Date-Based for Public Database)
/// Fetch league structure records modified after the given date
@@ -270,6 +288,26 @@ actor CloudKitService {
}
}
/// Fetch stadium alias records modified after the given date
func fetchStadiumAliasChanges(since lastSync: Date?) async throws -> [StadiumAlias] {
let predicate: NSPredicate
if let lastSync = lastSync {
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
} else {
predicate = NSPredicate(value: true)
}
let query = CKQuery(recordType: CKRecordType.stadiumAlias, predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: CKStadiumAlias.lastModifiedKey, ascending: true)]
let (results, _) = try await publicDatabase.records(matching: query)
return results.compactMap { result in
guard case .success(let record) = result.1 else { return nil }
return CKStadiumAlias(record: record).toModel()
}
}
// MARK: - Sync Status
func checkAccountStatus() async -> CKAccountStatus {
@@ -327,10 +365,26 @@ actor CloudKitService {
try await publicDatabase.save(subscription)
}
func subscribeToStadiumAliasUpdates() async throws {
let subscription = CKQuerySubscription(
recordType: CKRecordType.stadiumAlias,
predicate: NSPredicate(value: true),
subscriptionID: "stadium-alias-updates",
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
)
let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification
try await publicDatabase.save(subscription)
}
/// Subscribe to all canonical data updates
func subscribeToAllUpdates() async throws {
try await subscribeToScheduleUpdates()
try await subscribeToLeagueStructureUpdates()
try await subscribeToTeamAliasUpdates()
try await subscribeToStadiumAliasUpdates()
}
}

View File

@@ -2,61 +2,90 @@
// DataProvider.swift
// SportsTime
//
// Unified data provider that reads from SwiftData for offline support.
// Data is bootstrapped from bundled JSON and can be synced via CloudKit.
//
import Foundation
import SwiftData
import Combine
/// Protocol defining data operations for teams, stadiums, and games
protocol DataProvider: Sendable {
func fetchTeams(for sport: Sport) async throws -> [Team]
func fetchAllTeams() async throws -> [Team]
func fetchStadiums() async throws -> [Stadium]
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game]
func fetchGame(by id: UUID) async throws -> Game?
// Resolved data (with team/stadium references)
func fetchRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame]
}
/// Environment-aware data provider that switches between stub and CloudKit
/// Environment-aware data provider that reads from SwiftData (offline-first)
@MainActor
final class AppDataProvider: ObservableObject {
static let shared = AppDataProvider()
private let provider: any DataProvider
@Published private(set) var teams: [Team] = []
@Published private(set) var stadiums: [Stadium] = []
@Published private(set) var isLoading = false
@Published private(set) var error: Error?
@Published private(set) var errorMessage: String?
@Published private(set) var isUsingStubData: Bool
private var teamsById: [UUID: Team] = [:]
private var stadiumsById: [UUID: Stadium] = [:]
private var stadiumsByCanonicalId: [String: Stadium] = [:]
private var teamsByCanonicalId: [String: Team] = [:]
private init() {
#if targetEnvironment(simulator)
self.provider = StubDataProvider()
self.isUsingStubData = true
#else
self.provider = CloudKitDataProvider()
self.isUsingStubData = false
#endif
// Canonical ID lookups for game conversion
private var canonicalTeamUUIDs: [String: UUID] = [:]
private var canonicalStadiumUUIDs: [String: UUID] = [:]
private var modelContext: ModelContext?
private init() {}
// MARK: - Configuration
/// Set the model context for SwiftData access
func configure(with context: ModelContext) {
self.modelContext = context
}
// MARK: - Data Loading
/// Load all data from SwiftData into memory for fast access
func loadInitialData() async {
guard let context = modelContext else {
errorMessage = "Model context not configured"
return
}
isLoading = true
error = nil
errorMessage = nil
do {
async let teamsTask = provider.fetchAllTeams()
async let stadiumsTask = provider.fetchStadiums()
// Fetch canonical stadiums from SwiftData
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate { $0.deprecatedAt == nil }
)
let canonicalStadiums = try context.fetch(stadiumDescriptor)
let (loadedTeams, loadedStadiums) = try await (teamsTask, stadiumsTask)
// Convert to domain models and build lookups
var loadedStadiums: [Stadium] = []
for canonical in canonicalStadiums {
let stadium = canonical.toDomain()
loadedStadiums.append(stadium)
stadiumsByCanonicalId[canonical.canonicalId] = stadium
canonicalStadiumUUIDs[canonical.canonicalId] = stadium.id
}
// Fetch canonical teams from SwiftData
let teamDescriptor = FetchDescriptor<CanonicalTeam>(
predicate: #Predicate { $0.deprecatedAt == nil }
)
let canonicalTeams = try context.fetch(teamDescriptor)
// Convert to domain models
var loadedTeams: [Team] = []
for canonical in canonicalTeams {
// Get stadium UUID for this team
let stadiumUUID = canonicalStadiumUUIDs[canonical.stadiumCanonicalId] ?? UUID()
let team = canonical.toDomain(stadiumUUID: stadiumUUID)
loadedTeams.append(team)
teamsByCanonicalId[canonical.canonicalId] = team
canonicalTeamUUIDs[canonical.canonicalId] = team.id
}
self.teams = loadedTeams
self.stadiums = loadedStadiums
@@ -64,9 +93,7 @@ final class AppDataProvider: ObservableObject {
// Build lookup dictionaries
self.teamsById = Dictionary(uniqueKeysWithValues: loadedTeams.map { ($0.id, $0) })
self.stadiumsById = Dictionary(uniqueKeysWithValues: loadedStadiums.map { ($0.id, $0) })
} catch let cloudKitError as CloudKitError {
self.error = cloudKitError
self.errorMessage = cloudKitError.errorDescription
} catch {
self.error = error
self.errorMessage = error.localizedDescription
@@ -98,12 +125,71 @@ final class AppDataProvider: ObservableObject {
teams.filter { $0.sport == sport }
}
// MARK: - Game Fetching
/// Fetch games from SwiftData within date range
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game] {
try await provider.fetchGames(sports: sports, startDate: startDate, endDate: endDate)
guard let context = modelContext else {
throw DataProviderError.contextNotConfigured
}
let sportStrings = sports.map { $0.rawValue }
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { game in
game.deprecatedAt == nil &&
game.dateTime >= startDate &&
game.dateTime <= endDate
}
)
let canonicalGames = try context.fetch(descriptor)
// Filter by sport and convert to domain models
return canonicalGames.compactMap { canonical -> Game? in
guard sportStrings.contains(canonical.sport) else { return nil }
let homeTeamUUID = canonicalTeamUUIDs[canonical.homeTeamCanonicalId] ?? UUID()
let awayTeamUUID = canonicalTeamUUIDs[canonical.awayTeamCanonicalId] ?? UUID()
let stadiumUUID = canonicalStadiumUUIDs[canonical.stadiumCanonicalId] ?? UUID()
return canonical.toDomain(
homeTeamUUID: homeTeamUUID,
awayTeamUUID: awayTeamUUID,
stadiumUUID: stadiumUUID
)
}
}
/// Fetch a single game by ID
func fetchGame(by id: UUID) async throws -> Game? {
guard let context = modelContext else {
throw DataProviderError.contextNotConfigured
}
let idString = id.uuidString
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { $0.canonicalId == idString }
)
guard let canonical = try context.fetch(descriptor).first else {
return nil
}
let homeTeamUUID = canonicalTeamUUIDs[canonical.homeTeamCanonicalId] ?? UUID()
let awayTeamUUID = canonicalTeamUUIDs[canonical.awayTeamCanonicalId] ?? UUID()
let stadiumUUID = canonicalStadiumUUIDs[canonical.stadiumCanonicalId] ?? UUID()
return canonical.toDomain(
homeTeamUUID: homeTeamUUID,
awayTeamUUID: awayTeamUUID,
stadiumUUID: stadiumUUID
)
}
/// Fetch games with full team and stadium data
func fetchRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
let games = try await provider.fetchGames(sports: sports, startDate: startDate, endDate: endDate)
let games = try await fetchGames(sports: sports, startDate: startDate, endDate: endDate)
return games.compactMap { game in
guard let homeTeam = teamsById[game.homeTeamId],
@@ -124,3 +210,16 @@ final class AppDataProvider: ObservableObject {
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
}
}
// MARK: - Errors
enum DataProviderError: Error, LocalizedError {
case contextNotConfigured
var errorDescription: String? {
switch self {
case .contextNotConfigured:
return "Data provider not configured with model context"
}
}
}

View File

@@ -1,373 +0,0 @@
//
// 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,
sport: parseSport(json.sport),
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] ?? ""
}
}