Files
Sportstime/SportsTime/Core/Services/TravelOverrideService.swift
Trey t bf9619a207 feat(itinerary): add draggable travel day positioning with CloudKit persistence
- Add TravelDayOverride model for storing user-customized travel day positions
- Add TravelOverrideService for CloudKit CRUD operations on travel overrides
- Add CKTravelDayOverride CloudKit model wrapper
- Refactor itinerarySections to validate travel day bounds (must be after last game in departure city)
- Travel segments can now be dragged to different days within valid range
- Persist travel day overrides to CloudKit for cross-device sync

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 19:00:52 -06:00

181 lines
7.0 KiB
Swift

//
// TravelOverrideService.swift
// SportsTime
//
// CloudKit service for travel day position overrides
//
import Foundation
import CloudKit
actor TravelOverrideService {
static let shared = TravelOverrideService()
private let container: CKContainer
private let publicDatabase: CKDatabase
private init() {
self.container = CKContainer(identifier: "iCloud.com.sportstime.app")
self.publicDatabase = container.publicCloudDatabase
}
// MARK: - CRUD Operations
/// Create or update a travel day override (upserts by tripId + travelAnchorId)
func saveOverride(_ override: TravelDayOverride) async throws -> TravelDayOverride {
print("☁️ [CloudKit] Saving travel override for: \(override.travelAnchorId)")
// Look up existing override by travelAnchorId (not by UUID)
let existingOverrides = try await fetchOverrides(forTripId: override.tripId)
let existingOverride = existingOverrides.first { $0.travelAnchorId == override.travelAnchorId }
let record: CKRecord
let now = Date()
if let existing = existingOverride {
// Fetch the actual CKRecord to update it
let recordID = CKRecord.ID(recordName: existing.id.uuidString)
do {
let existingRecord = try await publicDatabase.record(for: recordID)
existingRecord[CKTravelDayOverride.displayDayKey] = override.displayDay
existingRecord[CKTravelDayOverride.modifiedAtKey] = now
record = existingRecord
print("☁️ [CloudKit] Updating existing override record: \(existing.id)")
} catch {
// Record doesn't exist anymore, create new
let ckOverride = CKTravelDayOverride(override: override)
record = ckOverride.record
}
} else {
// Create new record
let ckOverride = CKTravelDayOverride(override: override)
record = ckOverride.record
print("☁️ [CloudKit] Creating new override record")
}
do {
try await publicDatabase.save(record)
print("☁️ [CloudKit] Travel override saved: \(record.recordID.recordName)")
var savedOverride = override
savedOverride.modifiedAt = now
return savedOverride
} catch let error as CKError {
print("☁️ [CloudKit] CKError on save: \(error.code.rawValue) - \(error.localizedDescription)")
throw mapCloudKitError(error)
} catch {
print("☁️ [CloudKit] Unknown error on save: \(error)")
throw TravelOverrideError.unknown(error)
}
}
/// Delete a travel day override
func deleteOverride(_ overrideId: UUID) async throws {
let recordID = CKRecord.ID(recordName: overrideId.uuidString)
do {
try await publicDatabase.deleteRecord(withID: recordID)
print("☁️ [CloudKit] Travel override deleted: \(overrideId)")
} catch let error as CKError {
if error.code != .unknownItem {
throw mapCloudKitError(error)
}
// Already deleted - ignore
} catch {
throw TravelOverrideError.unknown(error)
}
}
/// Delete override by travel anchor ID (useful when removing by travel segment)
func deleteOverride(forTripId tripId: UUID, travelAnchorId: String) async throws {
// First fetch to find the record
let overrides = try await fetchOverrides(forTripId: tripId)
if let existing = overrides.first(where: { $0.travelAnchorId == travelAnchorId }) {
try await deleteOverride(existing.id)
}
}
/// Fetch all travel day overrides for a trip
func fetchOverrides(forTripId tripId: UUID) async throws -> [TravelDayOverride] {
print("☁️ [CloudKit] Fetching travel overrides for tripId: \(tripId.uuidString)")
let predicate = NSPredicate(
format: "%K == %@",
CKTravelDayOverride.tripIdKey,
tripId.uuidString
)
let query = CKQuery(recordType: CKRecordType.travelDayOverride, predicate: predicate)
do {
let (results, _) = try await publicDatabase.records(matching: query)
print("☁️ [CloudKit] Query returned \(results.count) travel override records")
let overrides = results.compactMap { result -> TravelDayOverride? in
guard case .success(let record) = result.1 else {
print("☁️ [CloudKit] Record fetch failed for: \(result.0.recordName)")
return nil
}
let override = CKTravelDayOverride(record: record).toOverride()
if override == nil {
print("☁️ [CloudKit] Failed to parse travel override record: \(record.recordID.recordName)")
}
return override
}
print("☁️ [CloudKit] Parsed \(overrides.count) valid travel overrides")
return overrides
} catch let error as CKError {
print("☁️ [CloudKit] CKError on fetch: \(error.code.rawValue) - \(error.localizedDescription)")
throw mapCloudKitError(error)
} catch {
print("☁️ [CloudKit] Unknown error on fetch: \(error)")
throw TravelOverrideError.unknown(error)
}
}
/// Convert array of overrides to dictionary keyed by travelAnchorId
/// If duplicate travelAnchorIds exist (legacy data), takes the most recently modified one
func fetchOverridesAsDictionary(forTripId tripId: UUID) async throws -> [String: Int] {
let overrides = try await fetchOverrides(forTripId: tripId)
// Sort by modifiedAt descending so most recent comes first
let sorted = overrides.sorted { $0.modifiedAt > $1.modifiedAt }
// Use uniquingKeysWith to keep the first (most recent) value for duplicates
return Dictionary(sorted.map { ($0.travelAnchorId, $0.displayDay) }) { first, _ in first }
}
// MARK: - Error Mapping
private func mapCloudKitError(_ error: CKError) -> TravelOverrideError {
switch error.code {
case .notAuthenticated:
return .notSignedIn
case .networkUnavailable, .networkFailure:
return .networkUnavailable
case .unknownItem:
return .overrideNotFound
default:
return .unknown(error)
}
}
}
// MARK: - Errors
enum TravelOverrideError: Error, LocalizedError {
case notSignedIn
case overrideNotFound
case networkUnavailable
case unknown(Error)
var errorDescription: String? {
switch self {
case .notSignedIn:
return "Please sign in to iCloud to customize travel days."
case .overrideNotFound:
return "Travel day setting not found."
case .networkUnavailable:
return "Unable to connect. Please check your internet connection."
case .unknown(let error):
return "An error occurred: \(error.localizedDescription)"
}
}
}