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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user