From cae24efa908cdd8f01e2e3ddb0d84529b5fac034 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 17 Jan 2026 21:16:19 -0600 Subject: [PATCH] feat: add ItineraryItemService with CloudKit sync Debounced updates (1.5s), local-first with silent retry. Supports game, travel, and custom item kinds. Co-Authored-By: Claude Opus 4.5 --- .../Core/Services/ItineraryItemService.swift | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 SportsTime/Core/Services/ItineraryItemService.swift diff --git a/SportsTime/Core/Services/ItineraryItemService.swift b/SportsTime/Core/Services/ItineraryItemService.swift new file mode 100644 index 0000000..09e36a1 --- /dev/null +++ b/SportsTime/Core/Services/ItineraryItemService.swift @@ -0,0 +1,198 @@ +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.sportstime.app") + 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 init() {} + + // 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 + debounceTask?.cancel() + + // Start new debounce + debounceTask = Task { + try? await Task.sleep(for: .seconds(1.5)) + + guard !Task.isCancelled else { return } + + await 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 else { return nil } + self.kind = .game(gameId: gameId) + + 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): + record["kind"] = "game" + record["gameId"] = gameId + + 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 + } +}