- Add RegionMapSelector UI for geographic trip filtering (East/Central/West) - Add RouteFilters module for allowRepeatCities preference - Improve GameDAGRouter to preserve route length diversity - Routes now grouped by city count before scoring - Ensures 2-city trips appear alongside longer trips - Increased beam width and max options for better coverage - Add TripOptionsView filters (max cities slider, pace filter) - Remove TravelStyle section from trip creation (replaced by region selector) - Clean up debug logging from DataProvider and ScenarioAPlanner 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
238 lines
7.9 KiB
Swift
238 lines
7.9 KiB
Swift
//
|
|
// 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
|
|
|
|
/// Environment-aware data provider that reads from SwiftData (offline-first)
|
|
@MainActor
|
|
final class AppDataProvider: ObservableObject {
|
|
static let shared = AppDataProvider()
|
|
|
|
@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?
|
|
|
|
private var teamsById: [UUID: Team] = [:]
|
|
private var stadiumsById: [UUID: Stadium] = [:]
|
|
private var stadiumsByCanonicalId: [String: Stadium] = [:]
|
|
private var teamsByCanonicalId: [String: Team] = [:]
|
|
|
|
// 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 {
|
|
// Fetch canonical stadiums from SwiftData
|
|
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
|
|
predicate: #Predicate { $0.deprecatedAt == nil }
|
|
)
|
|
let canonicalStadiums = try context.fetch(stadiumDescriptor)
|
|
|
|
// 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
|
|
|
|
// 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
|
|
self.errorMessage = error.localizedDescription
|
|
}
|
|
|
|
isLoading = false
|
|
}
|
|
|
|
func clearError() {
|
|
error = nil
|
|
errorMessage = nil
|
|
}
|
|
|
|
func retry() async {
|
|
await loadInitialData()
|
|
}
|
|
|
|
// MARK: - Data Access
|
|
|
|
func team(for id: UUID) -> Team? {
|
|
teamsById[id]
|
|
}
|
|
|
|
func stadium(for id: UUID) -> Stadium? {
|
|
stadiumsById[id]
|
|
}
|
|
|
|
func teams(for sport: Sport) -> [Team] {
|
|
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] {
|
|
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
|
|
let result = 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
|
|
)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/// 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 fetchGames(sports: sports, startDate: startDate, endDate: endDate)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
func richGame(from game: Game) -> RichGame? {
|
|
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: - Errors
|
|
|
|
enum DataProviderError: Error, LocalizedError {
|
|
case contextNotConfigured
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .contextNotConfigured:
|
|
return "Data provider not configured with model context"
|
|
}
|
|
}
|
|
}
|