Files
Sportstime/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift

300 lines
11 KiB
Swift

//
// ScheduleViewModel.swift
// SportsTime
//
import Foundation
import SwiftUI
import os.log
private let logger = Logger(subsystem: "com.88oakapps.SportsTime", category: "ScheduleViewModel")
@MainActor
@Observable
final class ScheduleViewModel {
// MARK: - Filter State
var selectedSports: Set<Sport> = Set(Sport.supported)
var startDate: Date = ScheduleViewModel.defaultDateRange().start
var endDate: Date = ScheduleViewModel.defaultDateRange().end
var searchText: String = ""
// MARK: - Data State
private(set) var games: [RichGame] = []
private(set) var isLoading = false
private(set) var error: Error?
private(set) var errorMessage: String?
private let dataProvider = AppDataProvider.shared
@ObservationIgnored
nonisolated(unsafe) private var loadTask: Task<Void, Never>?
private var latestLoadRequestID = UUID()
// MARK: - Pre-computed Groupings (avoid computed property overhead)
/// Games grouped by date - pre-computed to avoid re-grouping on every render
private(set) var gamesByDate: [(date: Date, games: [RichGame])] = []
/// All games matching current filters (before any display limiting)
private var filteredGames: [RichGame] = []
// MARK: - Diagnostics
/// Debug info for troubleshooting missing games
private(set) var diagnostics: ScheduleDiagnostics = ScheduleDiagnostics()
deinit {
loadTask?.cancel()
}
var hasFilters: Bool {
let defaults = Self.defaultDateRange()
let calendar = Calendar.current
let hasDateFilter = !calendar.isDate(startDate, inSameDayAs: defaults.start) ||
!calendar.isDate(endDate, inSameDayAs: defaults.end)
return selectedSports.count < Sport.supported.count || !searchText.isEmpty || hasDateFilter
}
private static func defaultDateRange() -> (start: Date, end: Date) {
let calendar = Calendar.current
let start = calendar.startOfDay(for: Date())
let endDay = calendar.date(byAdding: .day, value: 14, to: start) ?? start
let end = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: endDay) ?? endDay
return (start, end)
}
private static func normalizedDateRange(start: Date, end: Date) -> (start: Date, end: Date) {
let calendar = Calendar.current
let normalizedStart = calendar.startOfDay(for: start)
let endDay = calendar.startOfDay(for: end)
let normalizedEnd = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: endDay) ?? endDay
return (normalizedStart, normalizedEnd)
}
// MARK: - Actions
func loadGames() async {
let requestID = beginLoadRequest()
let queryStartDate = startDate
let queryEndDate = endDate
let querySports = selectedSports
guard !querySports.isEmpty else {
guard !isStaleLoad(requestID) else { return }
games = []
isLoading = false
error = nil
errorMessage = nil
updateFilteredGames()
return
}
isLoading = true
error = nil
errorMessage = nil
logger.info("📅 Loading games: \(querySports.map(\.rawValue).joined(separator: ", "))")
logger.info("📅 Date range: \(queryStartDate.formatted()) to \(queryEndDate.formatted())")
do {
// Load initial data if needed
if dataProvider.teams.isEmpty {
logger.info("📅 Teams empty, loading initial data...")
await dataProvider.loadInitialData()
}
guard !isStaleLoad(requestID) else { return }
// Check if data provider had an error
if let providerError = dataProvider.errorMessage {
guard !isStaleLoad(requestID) else { return }
self.errorMessage = providerError
self.error = dataProvider.error
isLoading = false
updateFilteredGames()
logger.error("📅 DataProvider error: \(providerError)")
return
}
// Log team/stadium counts for diagnostics
logger.info("📅 Loaded \(self.dataProvider.teams.count) teams, \(self.dataProvider.stadiums.count) stadiums")
let loadedGames = try await dataProvider.filterRichGames(
sports: querySports,
startDate: queryStartDate,
endDate: queryEndDate
)
guard !isStaleLoad(requestID) else { return }
games = loadedGames
// Update diagnostics
var newDiagnostics = ScheduleDiagnostics()
newDiagnostics.lastQueryStartDate = queryStartDate
newDiagnostics.lastQueryEndDate = queryEndDate
newDiagnostics.lastQuerySports = Array(querySports)
newDiagnostics.totalGamesReturned = games.count
newDiagnostics.teamsLoaded = dataProvider.teams.count
newDiagnostics.stadiumsLoaded = dataProvider.stadiums.count
// Count games by sport
var sportCounts: [Sport: Int] = [:]
for game in games {
sportCounts[game.game.sport, default: 0] += 1
}
newDiagnostics.gamesBySport = sportCounts
self.diagnostics = newDiagnostics
AnalyticsManager.shared.track(.scheduleViewed(sports: Array(querySports).map(\.rawValue)))
logger.info("📅 Returned \(self.games.count) games")
for (sport, count) in sportCounts.sorted(by: { $0.key.rawValue < $1.key.rawValue }) {
logger.info("📅 \(sport.rawValue): \(count) games")
}
#if DEBUG
// Debug: Print all NBA games
let nbaGames = games.filter { $0.game.sport == .nba }
print("🏀 [DEBUG] All NBA games in schedule (\(nbaGames.count) total):")
for game in nbaGames.sorted(by: { $0.game.dateTime < $1.game.dateTime }) {
let dateStr = game.game.dateTime.gameDateTimeString(in: game.stadium.timeZone)
print("🏀 \(dateStr): \(game.awayTeam.name) @ \(game.homeTeam.name) (\(game.game.id))")
}
#endif
} catch let cloudKitError as CloudKitError {
guard !isStaleLoad(requestID) else { return }
self.error = cloudKitError
self.errorMessage = cloudKitError.errorDescription
logger.error("📅 CloudKit error: \(cloudKitError.errorDescription ?? "unknown")")
} catch {
guard !isStaleLoad(requestID) else { return }
self.error = error
self.errorMessage = error.localizedDescription
logger.error("📅 Error loading games: \(error.localizedDescription)")
}
guard !isStaleLoad(requestID) else { return }
isLoading = false
updateFilteredGames()
}
func clearError() {
error = nil
errorMessage = nil
}
func toggleSport(_ sport: Sport) {
if selectedSports.contains(sport) {
selectedSports.remove(sport)
} else {
selectedSports.insert(sport)
}
AnalyticsManager.shared.track(.scheduleFiltered(sport: sport.rawValue, dateRange: "\(startDate.formatted(.dateTime.month().day())) - \(endDate.formatted(.dateTime.month().day()))"))
loadTask?.cancel()
loadTask = Task { await loadGames() }
}
func resetFilters() {
selectedSports = Set(Sport.supported)
searchText = ""
let defaults = Self.defaultDateRange()
startDate = defaults.start
endDate = defaults.end
loadTask?.cancel()
loadTask = Task { await loadGames() }
}
func updateDateRange(start: Date, end: Date) {
let normalized = Self.normalizedDateRange(start: start, end: end)
startDate = normalized.start
endDate = normalized.end
loadTask?.cancel()
loadTask = Task { await loadGames() }
}
// MARK: - Filtering & Grouping (pre-computed, not computed properties)
/// Recomputes filtered games and groupings based on current search text
func updateFilteredGames() {
// Step 1: Filter by search text
if searchText.isEmpty {
filteredGames = games
} else {
let query = searchText.lowercased()
filteredGames = games.filter { game in
game.homeTeam.name.lowercased().contains(query) ||
game.homeTeam.city.lowercased().contains(query) ||
game.awayTeam.name.lowercased().contains(query) ||
game.awayTeam.city.lowercased().contains(query) ||
game.stadium.name.lowercased().contains(query) ||
game.stadium.city.lowercased().contains(query)
}
}
// Step 2: Pre-compute grouping by date (done once, not per-render)
let calendar = Calendar.current
let grouped = Dictionary(grouping: filteredGames) { calendar.startOfDay(for: $0.game.dateTime) }
gamesByDate = grouped
.sorted { $0.key < $1.key }
.map { (date: $0.key, games: $0.value.sorted { lhs, rhs in
if lhs.game.dateTime == rhs.game.dateTime {
// Same UTC time: sort by local display time (earlier local times first)
let lhsOffset = lhs.stadium.timeZone?.secondsFromGMT(for: lhs.game.dateTime) ?? 0
let rhsOffset = rhs.stadium.timeZone?.secondsFromGMT(for: rhs.game.dateTime) ?? 0
return lhsOffset < rhsOffset
}
return lhs.game.dateTime < rhs.game.dateTime
}) }
}
private func beginLoadRequest() -> UUID {
let requestID = UUID()
latestLoadRequestID = requestID
return requestID
}
private func isStaleLoad(_ requestID: UUID) -> Bool {
Task.isCancelled || latestLoadRequestID != requestID
}
}
// MARK: - Diagnostics Model
/// Diagnostic information for troubleshooting schedule display issues
struct ScheduleDiagnostics {
var lastQueryStartDate: Date?
var lastQueryEndDate: Date?
var lastQuerySports: [Sport] = []
var totalGamesReturned: Int = 0
var teamsLoaded: Int = 0
var stadiumsLoaded: Int = 0
var gamesBySport: [Sport: Int] = [:]
var summary: String {
guard let start = lastQueryStartDate, let end = lastQueryEndDate else {
return "No query executed"
}
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .short
dateFormatter.timeStyle = .short
var lines: [String] = []
lines.append("Date Range: \(dateFormatter.string(from: start)) - \(dateFormatter.string(from: end))")
lines.append("Sports: \(lastQuerySports.map(\.rawValue).joined(separator: ", "))")
lines.append("Teams Loaded: \(teamsLoaded)")
lines.append("Stadiums Loaded: \(stadiumsLoaded)")
lines.append("Total Games: \(totalGamesReturned)")
if !gamesBySport.isEmpty {
lines.append("By Sport:")
for (sport, count) in gamesBySport.sorted(by: { $0.key.rawValue < $1.key.rawValue }) {
lines.append(" \(sport.rawValue): \(count)")
}
}
return lines.joined(separator: "\n")
}
}