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 <noreply@anthropic.com>
This commit is contained in:
198
SportsTime/Core/Services/ItineraryItemService.swift
Normal file
198
SportsTime/Core/Services/ItineraryItemService.swift
Normal file
@@ -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<Void, Never>?
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user