Files
Sportstime/SportsTime/Core/Services/DataProvider.swift
Trey t f5e509a9ae Add region-based filtering and route length diversity
- 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>
2026-01-09 15:18:37 -06:00

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"
}
}
}