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.
|
/// - lastSync: If nil, fetches all games. If provided, fetches only games modified since that date.
|
||||||
/// - cancellationToken: Optional token to check for cancellation between pages
|
/// - cancellationToken: Optional token to check for cancellation between pages
|
||||||
func fetchGamesForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncGame] {
|
func fetchGamesForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncGame] {
|
||||||
|
let log = SyncLogger.shared
|
||||||
let predicate: NSPredicate
|
let predicate: NSPredicate
|
||||||
if let lastSync = lastSync {
|
if let lastSync = lastSync {
|
||||||
|
log.log("☁️ [CK] Fetching games modified since \(lastSync.formatted())")
|
||||||
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
|
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
|
||||||
} else {
|
} else {
|
||||||
|
log.log("☁️ [CK] Fetching ALL games (full sync)")
|
||||||
predicate = NSPredicate(value: true)
|
predicate = NSPredicate(value: true)
|
||||||
}
|
}
|
||||||
let query = CKQuery(recordType: CKRecordType.game, predicate: predicate)
|
let query = CKQuery(recordType: CKRecordType.game, predicate: predicate)
|
||||||
|
|
||||||
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
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)
|
let ckGame = CKGame(record: record)
|
||||||
|
|
||||||
guard let canonicalId = ckGame.canonicalId,
|
guard let canonicalId = ckGame.canonicalId,
|
||||||
let homeTeamCanonicalId = ckGame.homeTeamCanonicalId,
|
let homeTeamCanonicalId = ckGame.homeTeamCanonicalId,
|
||||||
let awayTeamCanonicalId = ckGame.awayTeamCanonicalId,
|
let awayTeamCanonicalId = ckGame.awayTeamCanonicalId,
|
||||||
let stadiumCanonicalId = ckGame.stadiumCanonicalId
|
let stadiumCanonicalId = ckGame.stadiumCanonicalId
|
||||||
else { return nil }
|
else {
|
||||||
|
skippedMissingIds += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
guard let game = ckGame.game(
|
guard let game = ckGame.game(
|
||||||
homeTeamId: homeTeamCanonicalId,
|
homeTeamId: homeTeamCanonicalId,
|
||||||
awayTeamId: awayTeamCanonicalId,
|
awayTeamId: awayTeamCanonicalId,
|
||||||
stadiumId: stadiumCanonicalId
|
stadiumId: stadiumCanonicalId
|
||||||
) else { return nil }
|
) else {
|
||||||
|
skippedInvalidGame += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
return SyncGame(
|
validGames.append(SyncGame(
|
||||||
game: game,
|
game: game,
|
||||||
canonicalId: canonicalId,
|
canonicalId: canonicalId,
|
||||||
homeTeamCanonicalId: homeTeamCanonicalId,
|
homeTeamCanonicalId: homeTeamCanonicalId,
|
||||||
awayTeamCanonicalId: awayTeamCanonicalId,
|
awayTeamCanonicalId: awayTeamCanonicalId,
|
||||||
stadiumCanonicalId: stadiumCanonicalId
|
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
|
// 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] {
|
func filterRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
|
||||||
let games = try await filterGames(sports: sports, startDate: startDate, endDate: endDate)
|
let games = try await filterGames(sports: sports, startDate: startDate, endDate: endDate)
|
||||||
|
|
||||||
return games.compactMap { game in
|
print("🎮 [DATA] filterRichGames: \(games.count) games from SwiftData for \(sports.map(\.rawValue).joined(separator: ", "))")
|
||||||
guard let homeTeam = teamsById[game.homeTeamId],
|
|
||||||
let awayTeam = teamsById[game.awayTeamId],
|
var richGames: [RichGame] = []
|
||||||
let stadium = stadiumsById[game.stadiumId] else {
|
var droppedGames: [(game: Game, reason: String)] = []
|
||||||
return nil
|
|
||||||
|
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)
|
/// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import os.log
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.sportstime.app", category: "ScheduleViewModel")
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
@@ -13,8 +16,8 @@ final class ScheduleViewModel {
|
|||||||
// MARK: - Filter State
|
// MARK: - Filter State
|
||||||
|
|
||||||
var selectedSports: Set<Sport> = Set(Sport.supported)
|
var selectedSports: Set<Sport> = Set(Sport.supported)
|
||||||
var startDate: Date = Date()
|
var startDate: Date = Calendar.current.startOfDay(for: Date())
|
||||||
var endDate: Date = Calendar.current.date(byAdding: .day, value: 14, to: Date()) ?? Date()
|
var endDate: Date = Calendar.current.date(byAdding: .day, value: 14, to: Calendar.current.startOfDay(for: Date())) ?? Date()
|
||||||
var searchText: String = ""
|
var searchText: String = ""
|
||||||
|
|
||||||
// MARK: - Data State
|
// MARK: - Data State
|
||||||
@@ -34,6 +37,11 @@ final class ScheduleViewModel {
|
|||||||
/// All games matching current filters (before any display limiting)
|
/// All games matching current filters (before any display limiting)
|
||||||
private var filteredGames: [RichGame] = []
|
private var filteredGames: [RichGame] = []
|
||||||
|
|
||||||
|
// MARK: - Diagnostics
|
||||||
|
|
||||||
|
/// Debug info for troubleshooting missing games
|
||||||
|
private(set) var diagnostics: ScheduleDiagnostics = ScheduleDiagnostics()
|
||||||
|
|
||||||
var hasFilters: Bool {
|
var hasFilters: Bool {
|
||||||
selectedSports.count < Sport.supported.count || !searchText.isEmpty
|
selectedSports.count < Sport.supported.count || !searchText.isEmpty
|
||||||
}
|
}
|
||||||
@@ -51,9 +59,18 @@ final class ScheduleViewModel {
|
|||||||
error = nil
|
error = nil
|
||||||
errorMessage = 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 {
|
do {
|
||||||
// Load initial data if needed
|
// Load initial data if needed
|
||||||
if dataProvider.teams.isEmpty {
|
if dataProvider.teams.isEmpty {
|
||||||
|
logger.info("📅 Teams empty, loading initial data...")
|
||||||
await dataProvider.loadInitialData()
|
await dataProvider.loadInitialData()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,20 +80,58 @@ final class ScheduleViewModel {
|
|||||||
self.error = dataProvider.error
|
self.error = dataProvider.error
|
||||||
isLoading = false
|
isLoading = false
|
||||||
updateFilteredGames()
|
updateFilteredGames()
|
||||||
|
logger.error("📅 DataProvider error: \(providerError)")
|
||||||
return
|
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(
|
games = try await dataProvider.filterRichGames(
|
||||||
sports: selectedSports,
|
sports: selectedSports,
|
||||||
startDate: startDate,
|
startDate: startDate,
|
||||||
endDate: endDate
|
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 {
|
} catch let cloudKitError as CloudKitError {
|
||||||
self.error = cloudKitError
|
self.error = cloudKitError
|
||||||
self.errorMessage = cloudKitError.errorDescription
|
self.errorMessage = cloudKitError.errorDescription
|
||||||
|
logger.error("📅 CloudKit error: \(cloudKitError.errorDescription ?? "unknown")")
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error
|
self.error = error
|
||||||
self.errorMessage = error.localizedDescription
|
self.errorMessage = error.localizedDescription
|
||||||
|
logger.error("📅 Error loading games: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
@@ -147,3 +202,42 @@ final class ScheduleViewModel {
|
|||||||
.map { (sport: $0.key, games: $0.value.sorted { $0.game.dateTime < $1.game.dateTime }) }
|
.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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ struct SettingsView: View {
|
|||||||
@State private var showOnboardingPaywall = false
|
@State private var showOnboardingPaywall = false
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@State private var selectedSyncStatus: EntitySyncStatus?
|
@State private var selectedSyncStatus: EntitySyncStatus?
|
||||||
|
@State private var showSyncLogs = false
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -433,6 +434,13 @@ struct SettingsView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Trigger Sync Now", systemImage: "arrow.triangle.2.circlepath")
|
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: {
|
} header: {
|
||||||
Text("Sync Status")
|
Text("Sync Status")
|
||||||
} footer: {
|
} footer: {
|
||||||
@@ -442,6 +450,9 @@ struct SettingsView: View {
|
|||||||
.sheet(item: $selectedSyncStatus) { status in
|
.sheet(item: $selectedSyncStatus) { status in
|
||||||
SyncStatusDetailSheet(status: status)
|
SyncStatusDetailSheet(status: status)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showSyncLogs) {
|
||||||
|
SyncLogViewerSheet()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#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
|
#endif
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ struct BootstrappedContentView: View {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func performBootstrap() async {
|
private func performBootstrap() async {
|
||||||
|
print("🚀 [BOOT] Starting app bootstrap...")
|
||||||
isBootstrapping = true
|
isBootstrapping = true
|
||||||
bootstrapError = nil
|
bootstrapError = nil
|
||||||
|
|
||||||
@@ -159,34 +160,44 @@ struct BootstrappedContentView: View {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
// 1. Bootstrap from bundled JSON if first launch (no data exists)
|
// 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)
|
try await bootstrapService.bootstrapIfNeeded(context: context)
|
||||||
|
|
||||||
// 2. Configure DataProvider with SwiftData context
|
// 2. Configure DataProvider with SwiftData context
|
||||||
|
print("🚀 [BOOT] Step 2: Configuring DataProvider...")
|
||||||
AppDataProvider.shared.configure(with: context)
|
AppDataProvider.shared.configure(with: context)
|
||||||
|
|
||||||
// 3. Configure BackgroundSyncManager with model container
|
// 3. Configure BackgroundSyncManager with model container
|
||||||
|
print("🚀 [BOOT] Step 3: Configuring BackgroundSyncManager...")
|
||||||
BackgroundSyncManager.shared.configure(with: modelContainer)
|
BackgroundSyncManager.shared.configure(with: modelContainer)
|
||||||
|
|
||||||
// 4. Load data from SwiftData into memory
|
// 4. Load data from SwiftData into memory
|
||||||
|
print("🚀 [BOOT] Step 4: Loading initial data from SwiftData...")
|
||||||
await AppDataProvider.shared.loadInitialData()
|
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
|
// 5. Load store products and entitlements
|
||||||
|
print("🚀 [BOOT] Step 5: Loading store products...")
|
||||||
await StoreManager.shared.loadProducts()
|
await StoreManager.shared.loadProducts()
|
||||||
await StoreManager.shared.updateEntitlements()
|
await StoreManager.shared.updateEntitlements()
|
||||||
|
|
||||||
// 6. Start network monitoring and wire up sync callback
|
// 6. Start network monitoring and wire up sync callback
|
||||||
|
print("🚀 [BOOT] Step 6: Starting network monitoring...")
|
||||||
NetworkMonitor.shared.onSyncNeeded = {
|
NetworkMonitor.shared.onSyncNeeded = {
|
||||||
await BackgroundSyncManager.shared.triggerSyncFromNetworkRestoration()
|
await BackgroundSyncManager.shared.triggerSyncFromNetworkRestoration()
|
||||||
}
|
}
|
||||||
NetworkMonitor.shared.startMonitoring()
|
NetworkMonitor.shared.startMonitoring()
|
||||||
|
|
||||||
// 7. App is now usable
|
// 7. App is now usable
|
||||||
|
print("🚀 [BOOT] Step 7: Bootstrap complete - app ready")
|
||||||
isBootstrapping = false
|
isBootstrapping = false
|
||||||
|
|
||||||
// 8. Schedule background tasks for future syncs
|
// 8. Schedule background tasks for future syncs
|
||||||
BackgroundSyncManager.shared.scheduleAllTasks()
|
BackgroundSyncManager.shared.scheduleAllTasks()
|
||||||
|
|
||||||
// 9. Background: Try to refresh from CloudKit (non-blocking)
|
// 9. Background: Try to refresh from CloudKit (non-blocking)
|
||||||
|
print("🚀 [BOOT] Step 9: Starting background CloudKit sync...")
|
||||||
Task.detached(priority: .background) {
|
Task.detached(priority: .background) {
|
||||||
await self.performBackgroundSync(context: context)
|
await self.performBackgroundSync(context: context)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
@@ -194,6 +205,7 @@ struct BootstrappedContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
print("❌ [BOOT] Bootstrap failed: \(error.localizedDescription)")
|
||||||
bootstrapError = error
|
bootstrapError = error
|
||||||
isBootstrapping = false
|
isBootstrapping = false
|
||||||
}
|
}
|
||||||
@@ -201,22 +213,46 @@ struct BootstrappedContentView: View {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func performBackgroundSync(context: ModelContext) async {
|
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()
|
let syncService = CanonicalSyncService()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let result = try await syncService.syncAll(context: context)
|
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 any data was updated, reload the DataProvider
|
||||||
if !result.isEmpty {
|
if !result.isEmpty {
|
||||||
|
log.log("🔄 [SYNC] Reloading DataProvider...")
|
||||||
await AppDataProvider.shared.loadInitialData()
|
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 {
|
} catch CanonicalSyncService.SyncError.cloudKitUnavailable {
|
||||||
// Offline or CloudKit not available - silently continue with local data
|
log.log("❌ [SYNC] CloudKit unavailable - using local data only")
|
||||||
print("CloudKit unavailable, using local data")
|
|
||||||
} catch {
|
} catch {
|
||||||
// Other sync errors - log but don't interrupt user
|
log.log("❌ [SYNC] Error: \(error.localizedDescription)")
|
||||||
print("Background sync error: \(error.localizedDescription)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user