From 87079b434def16e5808e0494887722a827629d79 Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 20 Jan 2026 22:25:44 -0600 Subject: [PATCH] fix(schedule): use start of day for date range queries - Fix startDate to use Calendar.startOfDay instead of Date() to include games earlier in the current day - Add SyncLogger for file-based sync logging viewable in Settings - Add "View Sync Logs" button in Settings debug section - Add diagnostics and NBA game logging to ScheduleViewModel - Add dropped game logging to DataProvider.filterRichGames - Use SyncLogger in SportsTimeApp and CloudKitService for sync operations Co-Authored-By: Claude Opus 4.5 --- .../Core/Services/CloudKitService.swift | 39 ++++++-- SportsTime/Core/Services/DataProvider.swift | 38 +++++-- SportsTime/Core/Services/SyncLogger.swift | 79 +++++++++++++++ .../ViewModels/ScheduleViewModel.swift | 98 ++++++++++++++++++- .../Settings/Views/SettingsView.swift | 74 ++++++++++++++ SportsTime/SportsTimeApp.swift | 46 ++++++++- 6 files changed, 355 insertions(+), 19 deletions(-) create mode 100644 SportsTime/Core/Services/SyncLogger.swift diff --git a/SportsTime/Core/Services/CloudKitService.swift b/SportsTime/Core/Services/CloudKitService.swift index df23847..5c9a523 100644 --- a/SportsTime/Core/Services/CloudKitService.swift +++ b/SportsTime/Core/Services/CloudKitService.swift @@ -317,39 +317,66 @@ actor CloudKitService { /// - lastSync: If nil, fetches all games. If provided, fetches only games modified since that date. /// - cancellationToken: Optional token to check for cancellation between pages func fetchGamesForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncGame] { + let log = SyncLogger.shared let predicate: NSPredicate if let lastSync = lastSync { + log.log("☁️ [CK] Fetching games modified since \(lastSync.formatted())") predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate) } else { + log.log("☁️ [CK] Fetching ALL games (full sync)") predicate = NSPredicate(value: true) } let query = CKQuery(recordType: CKRecordType.game, predicate: predicate) let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken) + log.log("☁️ [CK] Received \(records.count) game records from CloudKit") - return records.compactMap { record -> SyncGame? in + var validGames: [SyncGame] = [] + var skippedMissingIds = 0 + var skippedInvalidGame = 0 + + for record in records { let ckGame = CKGame(record: record) guard let canonicalId = ckGame.canonicalId, let homeTeamCanonicalId = ckGame.homeTeamCanonicalId, let awayTeamCanonicalId = ckGame.awayTeamCanonicalId, let stadiumCanonicalId = ckGame.stadiumCanonicalId - else { return nil } + else { + skippedMissingIds += 1 + continue + } guard let game = ckGame.game( homeTeamId: homeTeamCanonicalId, awayTeamId: awayTeamCanonicalId, stadiumId: stadiumCanonicalId - ) else { return nil } + ) else { + skippedInvalidGame += 1 + continue + } - return SyncGame( + validGames.append(SyncGame( game: game, canonicalId: canonicalId, homeTeamCanonicalId: homeTeamCanonicalId, awayTeamCanonicalId: awayTeamCanonicalId, stadiumCanonicalId: stadiumCanonicalId - ) - }.sorted { $0.game.dateTime < $1.game.dateTime } + )) + } + + log.log("☁️ [CK] Parsed \(validGames.count) valid games (skipped: \(skippedMissingIds) missing IDs, \(skippedInvalidGame) invalid)") + + // Log sport breakdown + var bySport: [String: Int] = [:] + for g in validGames { + bySport[g.game.sport.rawValue, default: 0] += 1 + } + for (sport, count) in bySport.sorted(by: { $0.key < $1.key }) { + log.log("☁️ [CK] \(sport): \(count) games") + } + + return validGames.sorted { $0.game.dateTime < $1.game.dateTime } } // MARK: - League Structure & Team Aliases diff --git a/SportsTime/Core/Services/DataProvider.swift b/SportsTime/Core/Services/DataProvider.swift index 6b5dcec..556f2eb 100644 --- a/SportsTime/Core/Services/DataProvider.swift +++ b/SportsTime/Core/Services/DataProvider.swift @@ -217,14 +217,40 @@ final class AppDataProvider: ObservableObject { func filterRichGames(sports: Set, startDate: Date, endDate: Date) async throws -> [RichGame] { let games = try await filterGames(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 + print("🎮 [DATA] filterRichGames: \(games.count) games from SwiftData for \(sports.map(\.rawValue).joined(separator: ", "))") + + var richGames: [RichGame] = [] + var droppedGames: [(game: Game, reason: String)] = [] + + for game in games { + let homeTeam = teamsById[game.homeTeamId] + let awayTeam = teamsById[game.awayTeamId] + let stadium = stadiumsById[game.stadiumId] + + if homeTeam == nil || awayTeam == nil || stadium == nil { + var reasons: [String] = [] + if homeTeam == nil { reasons.append("homeTeam(\(game.homeTeamId))") } + if awayTeam == nil { reasons.append("awayTeam(\(game.awayTeamId))") } + if stadium == nil { reasons.append("stadium(\(game.stadiumId))") } + droppedGames.append((game, "missing: \(reasons.joined(separator: ", "))")) + continue } - return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium) + + richGames.append(RichGame(game: game, homeTeam: homeTeam!, awayTeam: awayTeam!, stadium: stadium!)) } + + if !droppedGames.isEmpty { + print("⚠️ [DATA] Dropped \(droppedGames.count) games due to missing lookups:") + for (game, reason) in droppedGames.prefix(10) { + print("⚠️ [DATA] \(game.sport.rawValue) game \(game.id): \(reason)") + } + if droppedGames.count > 10 { + print("⚠️ [DATA] ... and \(droppedGames.count - 10) more") + } + } + + print("🎮 [DATA] Returning \(richGames.count) rich games") + return richGames } /// Get all games with full team and stadium data (no date filtering) diff --git a/SportsTime/Core/Services/SyncLogger.swift b/SportsTime/Core/Services/SyncLogger.swift new file mode 100644 index 0000000..118801f --- /dev/null +++ b/SportsTime/Core/Services/SyncLogger.swift @@ -0,0 +1,79 @@ +// +// SyncLogger.swift +// SportsTime +// +// File-based logger for sync operations. +// Writes to Documents/sync_log.txt for viewing in Settings. +// + +import Foundation + +final class SyncLogger { + static let shared = SyncLogger() + + private let fileURL: URL + private let maxLines = 500 + private let queue = DispatchQueue(label: "com.sportstime.synclogger") + + private init() { + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + fileURL = docs.appendingPathComponent("sync_log.txt") + } + + // MARK: - Public API + + func log(_ message: String) { + let timestamp = ISO8601DateFormatter().string(from: Date()) + let line = "[\(timestamp)] \(message)\n" + + // Also print to console + print(message) + + queue.async { [weak self] in + self?.appendToFile(line) + } + } + + func readLog() -> String { + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return "No sync logs yet." + } + return (try? String(contentsOf: fileURL, encoding: .utf8)) ?? "Failed to read log." + } + + func clearLog() { + queue.async { [weak self] in + guard let self = self else { return } + try? FileManager.default.removeItem(at: self.fileURL) + } + } + + // MARK: - Private + + private func appendToFile(_ line: String) { + if !FileManager.default.fileExists(atPath: fileURL.path) { + FileManager.default.createFile(atPath: fileURL.path, contents: nil) + } + + guard let handle = try? FileHandle(forWritingTo: fileURL) else { return } + defer { try? handle.close() } + + handle.seekToEndOfFile() + if let data = line.data(using: .utf8) { + handle.write(data) + } + + // Trim if too large + trimIfNeeded() + } + + private func trimIfNeeded() { + guard let content = try? String(contentsOf: fileURL, encoding: .utf8) else { return } + let lines = content.components(separatedBy: "\n") + + if lines.count > maxLines { + let trimmed = lines.suffix(maxLines).joined(separator: "\n") + try? trimmed.write(to: fileURL, atomically: true, encoding: .utf8) + } + } +} diff --git a/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift b/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift index e9e325f..a55fe9b 100644 --- a/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift +++ b/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift @@ -5,6 +5,9 @@ import Foundation import SwiftUI +import os.log + +private let logger = Logger(subsystem: "com.sportstime.app", category: "ScheduleViewModel") @MainActor @Observable @@ -13,8 +16,8 @@ final class ScheduleViewModel { // MARK: - Filter State var selectedSports: Set = Set(Sport.supported) - var startDate: Date = Date() - var endDate: Date = Calendar.current.date(byAdding: .day, value: 14, to: Date()) ?? Date() + 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 @@ -34,6 +37,11 @@ final class ScheduleViewModel { /// 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 } @@ -51,9 +59,18 @@ final class ScheduleViewModel { 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() } @@ -63,20 +80,58 @@ final class ScheduleViewModel { 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 + + 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.formatted(date: .abbreviated, time: .shortened) + 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 @@ -147,3 +202,42 @@ final class ScheduleViewModel { .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") + } +} diff --git a/SportsTime/Features/Settings/Views/SettingsView.swift b/SportsTime/Features/Settings/Views/SettingsView.swift index 31e06d5..0f8ffed 100644 --- a/SportsTime/Features/Settings/Views/SettingsView.swift +++ b/SportsTime/Features/Settings/Views/SettingsView.swift @@ -14,6 +14,7 @@ struct SettingsView: View { @State private var showOnboardingPaywall = false #if DEBUG @State private var selectedSyncStatus: EntitySyncStatus? + @State private var showSyncLogs = false #endif var body: some View { @@ -433,6 +434,13 @@ struct SettingsView: View { } label: { Label("Trigger Sync Now", systemImage: "arrow.triangle.2.circlepath") } + + // View sync logs + Button { + showSyncLogs = true + } label: { + Label("View Sync Logs", systemImage: "doc.text.magnifyingglass") + } } header: { Text("Sync Status") } footer: { @@ -442,6 +450,9 @@ struct SettingsView: View { .sheet(item: $selectedSyncStatus) { status in SyncStatusDetailSheet(status: status) } + .sheet(isPresented: $showSyncLogs) { + SyncLogViewerSheet() + } } #endif @@ -666,4 +677,67 @@ private struct DetailRow: View { } } +/// Sheet to view sync logs +struct SyncLogViewerSheet: View { + @Environment(\.dismiss) private var dismiss + @State private var logContent = "" + @State private var autoScroll = true + + var body: some View { + NavigationStack { + ScrollViewReader { proxy in + ScrollView { + Text(logContent) + .font(.system(.caption, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .id("logBottom") + } + .onChange(of: logContent) { + if autoScroll { + withAnimation { + proxy.scrollTo("logBottom", anchor: .bottom) + } + } + } + } + .navigationTitle("Sync Logs") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Clear") { + SyncLogger.shared.clearLog() + logContent = "Log cleared." + } + } + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + dismiss() + } + } + ToolbarItem(placement: .bottomBar) { + HStack { + Button { + logContent = SyncLogger.shared.readLog() + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + + Spacer() + + Toggle("Auto-scroll", isOn: $autoScroll) + .toggleStyle(.switch) + .labelsHidden() + Text("Auto-scroll") + .font(.caption) + } + } + } + .onAppear { + logContent = SyncLogger.shared.readLog() + } + } + } +} + #endif diff --git a/SportsTime/SportsTimeApp.swift b/SportsTime/SportsTimeApp.swift index 6cf60b1..2a3cb9d 100644 --- a/SportsTime/SportsTimeApp.swift +++ b/SportsTime/SportsTimeApp.swift @@ -151,6 +151,7 @@ struct BootstrappedContentView: View { @MainActor private func performBootstrap() async { + print("🚀 [BOOT] Starting app bootstrap...") isBootstrapping = true bootstrapError = nil @@ -159,34 +160,44 @@ struct BootstrappedContentView: View { do { // 1. Bootstrap from bundled JSON if first launch (no data exists) + print("🚀 [BOOT] Step 1: Checking if bootstrap needed...") try await bootstrapService.bootstrapIfNeeded(context: context) // 2. Configure DataProvider with SwiftData context + print("🚀 [BOOT] Step 2: Configuring DataProvider...") AppDataProvider.shared.configure(with: context) // 3. Configure BackgroundSyncManager with model container + print("🚀 [BOOT] Step 3: Configuring BackgroundSyncManager...") BackgroundSyncManager.shared.configure(with: modelContainer) // 4. Load data from SwiftData into memory + print("🚀 [BOOT] Step 4: Loading initial data from SwiftData...") await AppDataProvider.shared.loadInitialData() + print("🚀 [BOOT] Loaded \(AppDataProvider.shared.teams.count) teams") + print("🚀 [BOOT] Loaded \(AppDataProvider.shared.stadiums.count) stadiums") // 5. Load store products and entitlements + print("🚀 [BOOT] Step 5: Loading store products...") await StoreManager.shared.loadProducts() await StoreManager.shared.updateEntitlements() // 6. Start network monitoring and wire up sync callback + print("🚀 [BOOT] Step 6: Starting network monitoring...") NetworkMonitor.shared.onSyncNeeded = { await BackgroundSyncManager.shared.triggerSyncFromNetworkRestoration() } NetworkMonitor.shared.startMonitoring() // 7. App is now usable + print("🚀 [BOOT] Step 7: Bootstrap complete - app ready") isBootstrapping = false // 8. Schedule background tasks for future syncs BackgroundSyncManager.shared.scheduleAllTasks() // 9. Background: Try to refresh from CloudKit (non-blocking) + print("🚀 [BOOT] Step 9: Starting background CloudKit sync...") Task.detached(priority: .background) { await self.performBackgroundSync(context: context) await MainActor.run { @@ -194,6 +205,7 @@ struct BootstrappedContentView: View { } } } catch { + print("❌ [BOOT] Bootstrap failed: \(error.localizedDescription)") bootstrapError = error isBootstrapping = false } @@ -201,22 +213,46 @@ struct BootstrappedContentView: View { @MainActor private func performBackgroundSync(context: ModelContext) async { + let log = SyncLogger.shared + log.log("🔄 [SYNC] Starting background sync...") + + // Reset stale syncInProgress flag (in case app was killed mid-sync) + let syncState = SyncState.current(in: context) + if syncState.syncInProgress { + log.log("⚠️ [SYNC] Resetting stale syncInProgress flag") + syncState.syncInProgress = false + try? context.save() + } + let syncService = CanonicalSyncService() do { let result = try await syncService.syncAll(context: context) + log.log("🔄 [SYNC] Sync completed in \(String(format: "%.2f", result.duration))s") + log.log("🔄 [SYNC] Stadiums: \(result.stadiumsUpdated)") + log.log("🔄 [SYNC] Teams: \(result.teamsUpdated)") + log.log("🔄 [SYNC] Games: \(result.gamesUpdated)") + log.log("🔄 [SYNC] League Structures: \(result.leagueStructuresUpdated)") + log.log("🔄 [SYNC] Team Aliases: \(result.teamAliasesUpdated)") + log.log("🔄 [SYNC] Stadium Aliases: \(result.stadiumAliasesUpdated)") + log.log("🔄 [SYNC] Sports: \(result.sportsUpdated)") + log.log("🔄 [SYNC] Skipped (incompatible): \(result.skippedIncompatible)") + log.log("🔄 [SYNC] Skipped (older): \(result.skippedOlder)") + log.log("🔄 [SYNC] Total updated: \(result.totalUpdated)") + // If any data was updated, reload the DataProvider if !result.isEmpty { + log.log("🔄 [SYNC] Reloading DataProvider...") await AppDataProvider.shared.loadInitialData() - print("CloudKit sync completed: \(result.totalUpdated) items updated") + log.log("🔄 [SYNC] DataProvider reloaded. Teams: \(AppDataProvider.shared.teams.count), Stadiums: \(AppDataProvider.shared.stadiums.count)") + } else { + log.log("🔄 [SYNC] No updates - skipping DataProvider reload") } } catch CanonicalSyncService.SyncError.cloudKitUnavailable { - // Offline or CloudKit not available - silently continue with local data - print("CloudKit unavailable, using local data") + log.log("❌ [SYNC] CloudKit unavailable - using local data only") } catch { - // Other sync errors - log but don't interrupt user - print("Background sync error: \(error.localizedDescription)") + log.log("❌ [SYNC] Error: \(error.localizedDescription)") } }