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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -217,14 +217,40 @@ final class AppDataProvider: ObservableObject {
|
||||
func filterRichGames(sports: Set<Sport>, 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)
|
||||
|
||||
79
SportsTime/Core/Services/SyncLogger.swift
Normal file
79
SportsTime/Core/Services/SyncLogger.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user