import Foundation import CloudKit import os /// Service for persisting and syncing ItineraryItems to CloudKit actor ItineraryItemService { static let shared = ItineraryItemService() private let container = CloudKitContainerConfig.makeContainer() private var database: CKDatabase { container.privateCloudDatabase } private let recordType = "ItineraryItem" // Debounce tracking private var pendingUpdates: [UUID: ItineraryItem] = [:] private var retryCount: [UUID: Int] = [:] private var flushTask: Task? private let maxRetries = 3 private let debounceInterval: Duration = .seconds(1.5) private init() {} /// Cancel any pending debounce task (for cleanup) func cancelPendingSync() { flushTask?.cancel() flushTask = 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 scheduleFlush(after: debounceInterval) } /// 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 { // Keep failed updates queued and retry with backoff, but cap retries. var highestRetry = 0 for item in updates.values { let currentRetries = retryCount[item.id] ?? 0 if currentRetries >= maxRetries { pendingUpdates.removeValue(forKey: item.id) retryCount.removeValue(forKey: item.id) os_log(.error, "Max retries reached for itinerary item %@", item.id.uuidString) continue } let nextRetry = currentRetries + 1 retryCount[item.id] = nextRetry highestRetry = max(highestRetry, nextRetry) pendingUpdates[item.id] = item } guard !pendingUpdates.isEmpty else { return } let retryDelay = retryDelay(for: highestRetry) scheduleFlush(after: retryDelay) } } // MARK: - Flush Scheduling private func scheduleFlush(after delay: Duration) { flushTask?.cancel() flushTask = Task { guard !Task.isCancelled else { return } do { try await Task.sleep(for: delay) } catch { return } guard !Task.isCancelled else { return } await flushPendingUpdates() } } private func retryDelay(for retryLevel: Int) -> Duration { switch retryLevel { case 0, 1: return .seconds(5) case 2: return .seconds(15) default: return .seconds(30) } } /// 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, segmentIndex: record["travelSegmentIndex"] as? Int, 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["travelSegmentIndex"] = info.segmentIndex 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 } }