Files
Sportstime/SportsTime/Core/Services/CloudKitService.swift
Trey t 87079b434d 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>
2026-01-20 22:25:44 -06:00

609 lines
23 KiB
Swift

//
// CloudKitService.swift
// SportsTime
//
import Foundation
import CloudKit
// MARK: - CloudKit Errors
enum CloudKitError: Error, LocalizedError {
case notSignedIn
case networkUnavailable
case serverError(String)
case quotaExceeded
case permissionDenied
case recordNotFound
case unknown(Error)
var errorDescription: String? {
switch self {
case .notSignedIn:
return "Please sign in to iCloud in Settings to sync data."
case .networkUnavailable:
return "Unable to connect to the server. Check your internet connection."
case .serverError(let message):
return "Server error: \(message)"
case .quotaExceeded:
return "iCloud storage quota exceeded."
case .permissionDenied:
return "Permission denied. Check your iCloud settings."
case .recordNotFound:
return "Data not found."
case .unknown(let error):
return "An unexpected error occurred: \(error.localizedDescription)"
}
}
static func from(_ error: Error) -> CloudKitError {
if let ckError = error as? CKError {
switch ckError.code {
case .notAuthenticated:
return .notSignedIn
case .networkUnavailable, .networkFailure:
return .networkUnavailable
case .serverResponseLost:
return .serverError("Connection lost")
case .quotaExceeded:
return .quotaExceeded
case .permissionFailure:
return .permissionDenied
case .unknownItem:
return .recordNotFound
default:
return .serverError(ckError.localizedDescription)
}
}
return .unknown(error)
}
}
actor CloudKitService {
static let shared = CloudKitService()
private let container: CKContainer
private let publicDatabase: CKDatabase
/// Maximum records per CloudKit query (400 is the default limit)
private let recordsPerPage = 400
private init() {
self.container = CKContainer(identifier: "iCloud.com.sportstime.app")
self.publicDatabase = container.publicCloudDatabase
}
// MARK: - Pagination Helper
/// Fetches all records matching a query using cursor-based pagination.
/// Checks cancellation token between pages to allow graceful interruption.
private func fetchAllRecords(
matching query: CKQuery,
cancellationToken: SyncCancellationToken?
) async throws -> [CKRecord] {
var allRecords: [CKRecord] = []
var cursor: CKQueryOperation.Cursor?
// First page
let (firstResults, firstCursor) = try await publicDatabase.records(
matching: query,
resultsLimit: recordsPerPage
)
for result in firstResults {
if case .success(let record) = result.1 {
allRecords.append(record)
}
}
cursor = firstCursor
// Continue fetching pages while cursor exists
while let currentCursor = cursor {
// Check cancellation between pages
if cancellationToken?.isCancelled == true {
throw CancellationError()
}
let (results, nextCursor) = try await publicDatabase.records(
continuingMatchFrom: currentCursor,
resultsLimit: recordsPerPage
)
for result in results {
if case .success(let record) = result.1 {
allRecords.append(record)
}
}
cursor = nextCursor
}
return allRecords
}
// MARK: - Sync Types (include canonical IDs from CloudKit)
struct SyncStadium {
let stadium: Stadium
let canonicalId: String
}
struct SyncTeam {
let team: Team
let canonicalId: String
let stadiumCanonicalId: String
}
struct SyncGame {
let game: Game
let canonicalId: String
let homeTeamCanonicalId: String
let awayTeamCanonicalId: String
let stadiumCanonicalId: String
}
// MARK: - Availability Check
func isAvailable() async -> Bool {
let status = await checkAccountStatus()
return status == .available
}
func checkAvailabilityWithError() async throws {
let status = await checkAccountStatus()
switch status {
case .available:
return
case .noAccount:
throw CloudKitError.notSignedIn
case .restricted:
throw CloudKitError.permissionDenied
case .couldNotDetermine:
throw CloudKitError.networkUnavailable
case .temporarilyUnavailable:
throw CloudKitError.networkUnavailable
@unknown default:
throw CloudKitError.networkUnavailable
}
}
// MARK: - Fetch Operations
func fetchTeams(for sport: Sport) async throws -> [Team] {
let predicate = NSPredicate(format: "sport == %@", sport.rawValue)
let query = CKQuery(recordType: CKRecordType.team, predicate: predicate)
let (results, _) = try await publicDatabase.records(matching: query)
return results.compactMap { result in
guard case .success(let record) = result.1 else { return nil }
return CKTeam(record: record).team
}
}
func fetchStadiums() async throws -> [Stadium] {
let predicate = NSPredicate(value: true)
let query = CKQuery(recordType: CKRecordType.stadium, predicate: predicate)
let (results, _) = try await publicDatabase.records(matching: query)
return results.compactMap { result in
guard case .success(let record) = result.1 else { return nil }
return CKStadium(record: record).stadium
}
}
func fetchGames(
sports: Set<Sport>,
startDate: Date,
endDate: Date
) async throws -> [Game] {
var allGames: [Game] = []
for sport in sports {
let predicate = NSPredicate(
format: "sport == %@ AND dateTime >= %@ AND dateTime <= %@",
sport.rawValue,
startDate as NSDate,
endDate as NSDate
)
let query = CKQuery(recordType: CKRecordType.game, predicate: predicate)
let (results, _) = try await publicDatabase.records(matching: query)
let games = results.compactMap { result -> Game? in
guard case .success(let record) = result.1 else { return nil }
let ckGame = CKGame(record: record)
guard let homeRef = record[CKGame.homeTeamRefKey] as? CKRecord.Reference,
let awayRef = record[CKGame.awayTeamRefKey] as? CKRecord.Reference
else { return nil }
let homeId = homeRef.recordID.recordName
let awayId = awayRef.recordID.recordName
// Stadium ref is optional - use placeholder if not present
let stadiumId: String
if let stadiumRef = record[CKGame.stadiumRefKey] as? CKRecord.Reference {
stadiumId = stadiumRef.recordID.recordName
} else {
stadiumId = "stadium_placeholder_\(UUID().uuidString)" // Placeholder - will be resolved via team lookup
}
return ckGame.game(homeTeamId: homeId, awayTeamId: awayId, stadiumId: stadiumId)
}
allGames.append(contentsOf: games)
}
return allGames.sorted { $0.dateTime < $1.dateTime }
}
func fetchGame(by id: String) async throws -> Game? {
let predicate = NSPredicate(format: "gameId == %@", id)
let query = CKQuery(recordType: CKRecordType.game, predicate: predicate)
let (results, _) = try await publicDatabase.records(matching: query)
guard let result = results.first,
case .success(let record) = result.1 else { return nil }
let ckGame = CKGame(record: record)
guard let homeRef = record[CKGame.homeTeamRefKey] as? CKRecord.Reference,
let awayRef = record[CKGame.awayTeamRefKey] as? CKRecord.Reference,
let stadiumRef = record[CKGame.stadiumRefKey] as? CKRecord.Reference
else { return nil }
let homeId = homeRef.recordID.recordName
let awayId = awayRef.recordID.recordName
let stadiumId = stadiumRef.recordID.recordName
return ckGame.game(homeTeamId: homeId, awayTeamId: awayId, stadiumId: stadiumId)
}
// MARK: - Sync Fetch Methods (return canonical IDs directly from CloudKit)
/// Fetch stadiums for sync operations
/// - Parameters:
/// - lastSync: If nil, fetches all stadiums. If provided, fetches only stadiums modified since that date.
/// - cancellationToken: Optional token to check for cancellation between pages
func fetchStadiumsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncStadium] {
let predicate: NSPredicate
if let lastSync = lastSync {
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
} else {
predicate = NSPredicate(value: true)
}
let query = CKQuery(recordType: CKRecordType.stadium, predicate: predicate)
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
return records.compactMap { record -> SyncStadium? in
let ckStadium = CKStadium(record: record)
guard let stadium = ckStadium.stadium,
let canonicalId = ckStadium.canonicalId
else { return nil }
return SyncStadium(stadium: stadium, canonicalId: canonicalId)
}
}
/// Fetch teams for sync operations
/// - Parameters:
/// - lastSync: If nil, fetches all teams. If provided, fetches only teams modified since that date.
/// - cancellationToken: Optional token to check for cancellation between pages
func fetchTeamsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncTeam] {
let predicate: NSPredicate
if let lastSync = lastSync {
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
} else {
predicate = NSPredicate(value: true)
}
let query = CKQuery(recordType: CKRecordType.team, predicate: predicate)
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
return records.compactMap { record -> SyncTeam? in
let ckTeam = CKTeam(record: record)
guard let team = ckTeam.team,
let canonicalId = ckTeam.canonicalId,
let stadiumCanonicalId = ckTeam.stadiumCanonicalId
else { return nil }
return SyncTeam(team: team, canonicalId: canonicalId, stadiumCanonicalId: stadiumCanonicalId)
}
}
/// Fetch games for sync operations
/// - Parameters:
/// - 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")
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 {
skippedMissingIds += 1
continue
}
guard let game = ckGame.game(
homeTeamId: homeTeamCanonicalId,
awayTeamId: awayTeamCanonicalId,
stadiumId: stadiumCanonicalId
) else {
skippedInvalidGame += 1
continue
}
validGames.append(SyncGame(
game: game,
canonicalId: canonicalId,
homeTeamCanonicalId: homeTeamCanonicalId,
awayTeamCanonicalId: awayTeamCanonicalId,
stadiumCanonicalId: stadiumCanonicalId
))
}
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
func fetchLeagueStructure(for sport: Sport? = nil) async throws -> [LeagueStructureModel] {
let predicate: NSPredicate
if let sport = sport {
predicate = NSPredicate(format: "sport == %@", sport.rawValue)
} else {
predicate = NSPredicate(value: true)
}
let query = CKQuery(recordType: CKRecordType.leagueStructure, predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: CKLeagueStructure.displayOrderKey, ascending: true)]
let (results, _) = try await publicDatabase.records(matching: query)
return results.compactMap { result in
guard case .success(let record) = result.1 else { return nil }
return CKLeagueStructure(record: record).toModel()
}
}
func fetchTeamAliases(for teamCanonicalId: String? = nil) async throws -> [TeamAlias] {
let predicate: NSPredicate
if let teamId = teamCanonicalId {
predicate = NSPredicate(format: "teamCanonicalId == %@", teamId)
} else {
predicate = NSPredicate(value: true)
}
let query = CKQuery(recordType: CKRecordType.teamAlias, predicate: predicate)
let (results, _) = try await publicDatabase.records(matching: query)
return results.compactMap { result in
guard case .success(let record) = result.1 else { return nil }
return CKTeamAlias(record: record).toModel()
}
}
func fetchStadiumAliases(for stadiumCanonicalId: String? = nil) async throws -> [StadiumAlias] {
let predicate: NSPredicate
if let stadiumId = stadiumCanonicalId {
predicate = NSPredicate(format: "stadiumCanonicalId == %@", stadiumId)
} else {
predicate = NSPredicate(value: true)
}
let query = CKQuery(recordType: CKRecordType.stadiumAlias, predicate: predicate)
let (results, _) = try await publicDatabase.records(matching: query)
return results.compactMap { result in
guard case .success(let record) = result.1 else { return nil }
return CKStadiumAlias(record: record).toModel()
}
}
// MARK: - Delta Sync (Date-Based for Public Database)
/// Fetch league structure records modified after the given date
/// - Parameters:
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
/// - cancellationToken: Optional token to check for cancellation between pages
func fetchLeagueStructureChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [LeagueStructureModel] {
let predicate: NSPredicate
if let lastSync = lastSync {
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
} else {
predicate = NSPredicate(value: true)
}
let query = CKQuery(recordType: CKRecordType.leagueStructure, predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: CKLeagueStructure.lastModifiedKey, ascending: true)]
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
return records.compactMap { record in
return CKLeagueStructure(record: record).toModel()
}
}
/// Fetch team alias records modified after the given date
/// - Parameters:
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
/// - cancellationToken: Optional token to check for cancellation between pages
func fetchTeamAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [TeamAlias] {
let predicate: NSPredicate
if let lastSync = lastSync {
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
} else {
predicate = NSPredicate(value: true)
}
let query = CKQuery(recordType: CKRecordType.teamAlias, predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: CKTeamAlias.lastModifiedKey, ascending: true)]
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
return records.compactMap { record in
return CKTeamAlias(record: record).toModel()
}
}
/// Fetch stadium alias records modified after the given date
/// - Parameters:
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
/// - cancellationToken: Optional token to check for cancellation between pages
func fetchStadiumAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [StadiumAlias] {
let predicate: NSPredicate
if let lastSync = lastSync {
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
} else {
predicate = NSPredicate(value: true)
}
let query = CKQuery(recordType: CKRecordType.stadiumAlias, predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: CKStadiumAlias.lastModifiedKey, ascending: true)]
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
return records.compactMap { record in
return CKStadiumAlias(record: record).toModel()
}
}
// MARK: - Sport Sync
/// Fetch sports for sync operations
/// - Parameters:
/// - lastSync: If nil, fetches all sports. If provided, fetches only sports modified since that date.
/// - cancellationToken: Optional token to check for cancellation between pages
func fetchSportsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [CanonicalSport] {
let predicate: NSPredicate
if let lastSync = lastSync {
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
} else {
predicate = NSPredicate(value: true)
}
let query = CKQuery(recordType: CKRecordType.sport, predicate: predicate)
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
return records.compactMap { record -> CanonicalSport? in
return CKSport(record: record).toCanonical()
}
}
// MARK: - Sync Status
func checkAccountStatus() async -> CKAccountStatus {
do {
return try await container.accountStatus()
} catch {
return .couldNotDetermine
}
}
// MARK: - Subscriptions
func subscribeToScheduleUpdates() async throws {
let subscription = CKQuerySubscription(
recordType: CKRecordType.game,
predicate: NSPredicate(value: true),
subscriptionID: "game-updates",
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
)
let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification
try await publicDatabase.save(subscription)
}
func subscribeToLeagueStructureUpdates() async throws {
let subscription = CKQuerySubscription(
recordType: CKRecordType.leagueStructure,
predicate: NSPredicate(value: true),
subscriptionID: "league-structure-updates",
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
)
let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification
try await publicDatabase.save(subscription)
}
func subscribeToTeamAliasUpdates() async throws {
let subscription = CKQuerySubscription(
recordType: CKRecordType.teamAlias,
predicate: NSPredicate(value: true),
subscriptionID: "team-alias-updates",
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
)
let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification
try await publicDatabase.save(subscription)
}
func subscribeToStadiumAliasUpdates() async throws {
let subscription = CKQuerySubscription(
recordType: CKRecordType.stadiumAlias,
predicate: NSPredicate(value: true),
subscriptionID: "stadium-alias-updates",
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
)
let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification
try await publicDatabase.save(subscription)
}
/// Subscribe to all canonical data updates
func subscribeToAllUpdates() async throws {
try await subscribeToScheduleUpdates()
try await subscribeToLeagueStructureUpdates()
try await subscribeToTeamAliasUpdates()
try await subscribeToStadiumAliasUpdates()
}
}