Files
Sportstime/SportsTime/Core/Services/ItineraryItemService.swift
Trey t e72da7c5a7 fix(itinerary): add city to game items for proper constraint validation
Travel constraint validation was not working because ItineraryConstraints
had no game items to validate against - games came from RichGame objects
but were never converted to ItineraryItem for constraint checking.

Changes:
- Add city parameter to ItemKind.game enum case
- Create game ItineraryItems from RichGame data in buildItineraryData()
- Update isValidTravelPosition to compare against actual game sortOrders
- Fix tests to use appropriate game sortOrder conventions

Now travel is properly constrained to appear before arrival city games
and after departure city games.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 22:46:40 -06:00

217 lines
7.3 KiB
Swift

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 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
}
}