import Foundation import CloudKit /// Service for persisting and syncing ItineraryItems to CloudKit actor ItineraryItemService { static let shared = ItineraryItemService() private let container = CKContainer(identifier: "iCloud.com.88oakapps.SportsTime") private var database: CKDatabase { container.privateCloudDatabase } private let recordType = "ItineraryItem" // Debounce tracking private var pendingUpdates: [UUID: ItineraryItem] = [:] private var retryCount: [UUID: Int] = [:] private var debounceTask: Task? private let maxRetries = 3 private let debounceInterval: Duration = .seconds(1.5) private init() {} /// Cancel any pending debounce task (for cleanup) func cancelPendingSync() { debounceTask?.cancel() debounceTask = nil } // MARK: - CRUD Operations /// Fetch all items for a trip func fetchItems(forTripId tripId: UUID) async throws -> [ItineraryItem] { let predicate = NSPredicate(format: "tripId == %@", tripId.uuidString) let query = CKQuery(recordType: recordType, predicate: predicate) let (results, _) = try await database.records(matching: query) return results.compactMap { _, result in guard case .success(let record) = result else { return nil } return ItineraryItem(from: record) } } /// Create a new item func createItem(_ item: ItineraryItem) async throws -> ItineraryItem { let record = item.toCKRecord() let savedRecord = try await database.save(record) return ItineraryItem(from: savedRecord) ?? item } /// Update an existing item (debounced) func updateItem(_ item: ItineraryItem) async { pendingUpdates[item.id] = item // Cancel existing debounce task debounceTask?.cancel() // Start new debounce task with proper cancellation handling debounceTask = Task { [weak self] in // Check cancellation before sleeping guard !Task.isCancelled else { return } do { try await Task.sleep(for: self?.debounceInterval ?? .seconds(1.5)) } catch { // Task was cancelled during sleep return } // Check cancellation after sleeping (belt and suspenders) guard !Task.isCancelled else { return } await self?.flushPendingUpdates() } } /// Force immediate sync of pending updates func flushPendingUpdates() async { let updates = pendingUpdates pendingUpdates.removeAll() guard !updates.isEmpty else { return } let records = updates.values.map { $0.toCKRecord() } do { // Use .changedKeys save policy - CloudKit handles conflicts automatically _ = try await database.modifyRecords(saving: records, deleting: [], savePolicy: .changedKeys) // Success - clear retry counts for saved items for item in updates.values { retryCount[item.id] = nil } } catch { // Add back to pending for retry, respecting max retry limit for item in updates.values { let currentRetries = retryCount[item.id] ?? 0 if currentRetries < maxRetries { pendingUpdates[item.id] = item retryCount[item.id] = currentRetries + 1 } // If max retries exceeded, silently drop the update to prevent infinite loop } } } /// Delete an item func deleteItem(_ itemId: UUID) async throws { let recordId = CKRecord.ID(recordName: itemId.uuidString) try await database.deleteRecord(withID: recordId) } /// Delete all items for a trip func deleteItems(forTripId tripId: UUID) async throws { let items = try await fetchItems(forTripId: tripId) for item in items { let recordId = CKRecord.ID(recordName: item.id.uuidString) try? await database.deleteRecord(withID: recordId) } } } // MARK: - CloudKit Conversion extension ItineraryItem { init?(from record: CKRecord) { guard let idString = record["itemId"] as? String, let id = UUID(uuidString: idString), let tripIdString = record["tripId"] as? String, let tripId = UUID(uuidString: tripIdString), let day = record["day"] as? Int, let sortOrder = record["sortOrder"] as? Double, let kindString = record["kind"] as? String, let modifiedAt = record["modifiedAt"] as? Date else { return nil } self.id = id self.tripId = tripId self.day = day self.sortOrder = sortOrder self.modifiedAt = modifiedAt // Parse kind switch kindString { case "game": guard let gameId = record["gameId"] as? String, let gameCity = record["gameCity"] as? String else { return nil } self.kind = .game(gameId: gameId, city: gameCity) case "travel": guard let fromCity = record["travelFromCity"] as? String, let toCity = record["travelToCity"] as? String else { return nil } let info = TravelInfo( fromCity: fromCity, toCity: toCity, distanceMeters: record["travelDistanceMeters"] as? Double, durationSeconds: record["travelDurationSeconds"] as? Double ) self.kind = .travel(info) case "custom": guard let title = record["customTitle"] as? String, let icon = record["customIcon"] as? String else { return nil } let info = CustomInfo( title: title, icon: icon, time: record["customTime"] as? Date, latitude: record["latitude"] as? Double, longitude: record["longitude"] as? Double, address: record["address"] as? String ) self.kind = .custom(info) default: return nil } } func toCKRecord() -> CKRecord { let recordId = CKRecord.ID(recordName: id.uuidString) let record = CKRecord(recordType: "ItineraryItem", recordID: recordId) record["itemId"] = id.uuidString record["tripId"] = tripId.uuidString record["day"] = day record["sortOrder"] = sortOrder record["modifiedAt"] = modifiedAt switch kind { case .game(let gameId, let city): record["kind"] = "game" record["gameId"] = gameId record["gameCity"] = city case .travel(let info): record["kind"] = "travel" record["travelFromCity"] = info.fromCity record["travelToCity"] = info.toCity record["travelDistanceMeters"] = info.distanceMeters record["travelDurationSeconds"] = info.durationSeconds case .custom(let info): record["kind"] = "custom" record["customTitle"] = info.title record["customIcon"] = info.icon record["customTime"] = info.time record["latitude"] = info.latitude record["longitude"] = info.longitude record["address"] = info.address } return record } }