Integrate self-hosted PostHog (SPM) with AnalyticsManager singleton wrapping all SDK calls. Adds ~40 type-safe events covering trip planning, schedule, progress, IAP, settings, polls, export, and share flows. Includes session replay, autocapture, network telemetry, privacy opt-out toggle in Settings, and super properties (app version, device, pro status, selected sports). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
246 lines
8.7 KiB
Swift
246 lines
8.7 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 = Calendar.current.startOfDay(for: Date())
|
|
var endDate: Date = Calendar.current.date(byAdding: .day, value: 14, to: Calendar.current.startOfDay(for: Date())) ?? Date()
|
|
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
|
|
|
|
// MARK: - Pre-computed Groupings (avoid computed property overhead)
|
|
|
|
/// Games grouped by sport - pre-computed to avoid re-grouping on every render
|
|
private(set) var gamesBySport: [(sport: Sport, 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()
|
|
|
|
var hasFilters: Bool {
|
|
selectedSports.count < Sport.supported.count || !searchText.isEmpty
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
func loadGames() async {
|
|
guard !selectedSports.isEmpty else {
|
|
games = []
|
|
updateFilteredGames()
|
|
return
|
|
}
|
|
|
|
isLoading = true
|
|
error = nil
|
|
errorMessage = nil
|
|
|
|
// Start diagnostics
|
|
let queryStartDate = startDate
|
|
let queryEndDate = endDate
|
|
let querySports = selectedSports
|
|
|
|
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()
|
|
}
|
|
|
|
// Check if data provider had an error
|
|
if let providerError = dataProvider.errorMessage {
|
|
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")
|
|
|
|
games = try await dataProvider.filterRichGames(
|
|
sports: selectedSports,
|
|
startDate: startDate,
|
|
endDate: endDate
|
|
)
|
|
|
|
// 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(selectedSports).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")
|
|
}
|
|
|
|
// 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))")
|
|
}
|
|
|
|
} catch let cloudKitError as CloudKitError {
|
|
self.error = cloudKitError
|
|
self.errorMessage = cloudKitError.errorDescription
|
|
logger.error("📅 CloudKit error: \(cloudKitError.errorDescription ?? "unknown")")
|
|
} catch {
|
|
self.error = error
|
|
self.errorMessage = error.localizedDescription
|
|
logger.error("📅 Error loading games: \(error.localizedDescription)")
|
|
}
|
|
|
|
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()))"))
|
|
Task {
|
|
await loadGames()
|
|
}
|
|
}
|
|
|
|
func resetFilters() {
|
|
selectedSports = Set(Sport.supported)
|
|
searchText = ""
|
|
startDate = Date()
|
|
endDate = Calendar.current.date(byAdding: .day, value: 14, to: Date()) ?? Date()
|
|
Task {
|
|
await loadGames()
|
|
}
|
|
}
|
|
|
|
func updateDateRange(start: Date, end: Date) {
|
|
startDate = start
|
|
endDate = end
|
|
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 sport (done once, not per-render)
|
|
let grouped = Dictionary(grouping: filteredGames) { $0.game.sport }
|
|
gamesBySport = grouped
|
|
.sorted { lhs, rhs in
|
|
let lhsIndex = Sport.allCases.firstIndex(of: lhs.key) ?? 0
|
|
let rhsIndex = Sport.allCases.firstIndex(of: rhs.key) ?? 0
|
|
return lhsIndex < rhsIndex
|
|
}
|
|
.map { (sport: $0.key, games: $0.value.sorted { $0.game.dateTime < $1.game.dateTime }) }
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|