feat(itinerary): add draggable travel day positioning with CloudKit persistence
- Add TravelDayOverride model for storing user-customized travel day positions - Add TravelOverrideService for CloudKit CRUD operations on travel overrides - Add CKTravelDayOverride CloudKit model wrapper - Refactor itinerarySections to validate travel day bounds (must be after last game in departure city) - Travel segments can now be dragged to different days within valid range - Persist travel day overrides to CloudKit for cross-device sync Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ enum CKRecordType {
|
||||
static let tripPoll = "TripPoll"
|
||||
static let pollVote = "PollVote"
|
||||
static let customItineraryItem = "CustomItineraryItem"
|
||||
static let travelDayOverride = "TravelDayOverride"
|
||||
}
|
||||
|
||||
// MARK: - CKTeam
|
||||
@@ -694,3 +695,55 @@ struct CKCustomItineraryItem {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CKTravelDayOverride
|
||||
|
||||
struct CKTravelDayOverride {
|
||||
static let overrideIdKey = "overrideId"
|
||||
static let tripIdKey = "tripId"
|
||||
static let travelAnchorIdKey = "travelAnchorId"
|
||||
static let displayDayKey = "displayDay"
|
||||
static let createdAtKey = "createdAt"
|
||||
static let modifiedAtKey = "modifiedAt"
|
||||
|
||||
let record: CKRecord
|
||||
|
||||
init(record: CKRecord) {
|
||||
self.record = record
|
||||
}
|
||||
|
||||
init(override: TravelDayOverride) {
|
||||
let record = CKRecord(
|
||||
recordType: CKRecordType.travelDayOverride,
|
||||
recordID: CKRecord.ID(recordName: override.id.uuidString)
|
||||
)
|
||||
record[CKTravelDayOverride.overrideIdKey] = override.id.uuidString
|
||||
record[CKTravelDayOverride.tripIdKey] = override.tripId.uuidString
|
||||
record[CKTravelDayOverride.travelAnchorIdKey] = override.travelAnchorId
|
||||
record[CKTravelDayOverride.displayDayKey] = override.displayDay
|
||||
record[CKTravelDayOverride.createdAtKey] = override.createdAt
|
||||
record[CKTravelDayOverride.modifiedAtKey] = override.modifiedAt
|
||||
self.record = record
|
||||
}
|
||||
|
||||
func toOverride() -> TravelDayOverride? {
|
||||
guard let overrideIdString = record[CKTravelDayOverride.overrideIdKey] as? String,
|
||||
let overrideId = UUID(uuidString: overrideIdString),
|
||||
let tripIdString = record[CKTravelDayOverride.tripIdKey] as? String,
|
||||
let tripId = UUID(uuidString: tripIdString),
|
||||
let travelAnchorId = record[CKTravelDayOverride.travelAnchorIdKey] as? String,
|
||||
let displayDay = record[CKTravelDayOverride.displayDayKey] as? Int,
|
||||
let createdAt = record[CKTravelDayOverride.createdAtKey] as? Date,
|
||||
let modifiedAt = record[CKTravelDayOverride.modifiedAtKey] as? Date
|
||||
else { return nil }
|
||||
|
||||
return TravelDayOverride(
|
||||
id: overrideId,
|
||||
tripId: tripId,
|
||||
travelAnchorId: travelAnchorId,
|
||||
displayDay: displayDay,
|
||||
createdAt: createdAt,
|
||||
modifiedAt: modifiedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
38
SportsTime/Core/Models/Domain/TravelDayOverride.swift
Normal file
38
SportsTime/Core/Models/Domain/TravelDayOverride.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// TravelDayOverride.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Stores user-customized travel day positions
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Represents a user override of the default travel day placement.
|
||||
/// Travel segments normally appear on the day after the last game in the departure city.
|
||||
/// This model allows users to drag travel to a different day within valid bounds.
|
||||
struct TravelDayOverride: Identifiable, Codable, Hashable {
|
||||
let id: UUID
|
||||
let tripId: UUID
|
||||
/// Stable identifier for the travel segment (e.g., "travel:houston->arlington")
|
||||
let travelAnchorId: String
|
||||
/// The day number (1-indexed) where the travel should display
|
||||
var displayDay: Int
|
||||
let createdAt: Date
|
||||
var modifiedAt: Date
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
tripId: UUID,
|
||||
travelAnchorId: String,
|
||||
displayDay: Int,
|
||||
createdAt: Date = Date(),
|
||||
modifiedAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.tripId = tripId
|
||||
self.travelAnchorId = travelAnchorId
|
||||
self.displayDay = displayDay
|
||||
self.createdAt = createdAt
|
||||
self.modifiedAt = modifiedAt
|
||||
}
|
||||
}
|
||||
180
SportsTime/Core/Services/TravelOverrideService.swift
Normal file
180
SportsTime/Core/Services/TravelOverrideService.swift
Normal file
@@ -0,0 +1,180 @@
|
||||
//
|
||||
// TravelOverrideService.swift
|
||||
// SportsTime
|
||||
//
|
||||
// CloudKit service for travel day position overrides
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CloudKit
|
||||
|
||||
actor TravelOverrideService {
|
||||
static let shared = TravelOverrideService()
|
||||
|
||||
private let container: CKContainer
|
||||
private let publicDatabase: CKDatabase
|
||||
|
||||
private init() {
|
||||
self.container = CKContainer(identifier: "iCloud.com.sportstime.app")
|
||||
self.publicDatabase = container.publicCloudDatabase
|
||||
}
|
||||
|
||||
// MARK: - CRUD Operations
|
||||
|
||||
/// Create or update a travel day override (upserts by tripId + travelAnchorId)
|
||||
func saveOverride(_ override: TravelDayOverride) async throws -> TravelDayOverride {
|
||||
print("☁️ [CloudKit] Saving travel override for: \(override.travelAnchorId)")
|
||||
|
||||
// Look up existing override by travelAnchorId (not by UUID)
|
||||
let existingOverrides = try await fetchOverrides(forTripId: override.tripId)
|
||||
let existingOverride = existingOverrides.first { $0.travelAnchorId == override.travelAnchorId }
|
||||
|
||||
let record: CKRecord
|
||||
let now = Date()
|
||||
|
||||
if let existing = existingOverride {
|
||||
// Fetch the actual CKRecord to update it
|
||||
let recordID = CKRecord.ID(recordName: existing.id.uuidString)
|
||||
do {
|
||||
let existingRecord = try await publicDatabase.record(for: recordID)
|
||||
existingRecord[CKTravelDayOverride.displayDayKey] = override.displayDay
|
||||
existingRecord[CKTravelDayOverride.modifiedAtKey] = now
|
||||
record = existingRecord
|
||||
print("☁️ [CloudKit] Updating existing override record: \(existing.id)")
|
||||
} catch {
|
||||
// Record doesn't exist anymore, create new
|
||||
let ckOverride = CKTravelDayOverride(override: override)
|
||||
record = ckOverride.record
|
||||
}
|
||||
} else {
|
||||
// Create new record
|
||||
let ckOverride = CKTravelDayOverride(override: override)
|
||||
record = ckOverride.record
|
||||
print("☁️ [CloudKit] Creating new override record")
|
||||
}
|
||||
|
||||
do {
|
||||
try await publicDatabase.save(record)
|
||||
print("☁️ [CloudKit] Travel override saved: \(record.recordID.recordName)")
|
||||
var savedOverride = override
|
||||
savedOverride.modifiedAt = now
|
||||
return savedOverride
|
||||
} catch let error as CKError {
|
||||
print("☁️ [CloudKit] CKError on save: \(error.code.rawValue) - \(error.localizedDescription)")
|
||||
throw mapCloudKitError(error)
|
||||
} catch {
|
||||
print("☁️ [CloudKit] Unknown error on save: \(error)")
|
||||
throw TravelOverrideError.unknown(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a travel day override
|
||||
func deleteOverride(_ overrideId: UUID) async throws {
|
||||
let recordID = CKRecord.ID(recordName: overrideId.uuidString)
|
||||
|
||||
do {
|
||||
try await publicDatabase.deleteRecord(withID: recordID)
|
||||
print("☁️ [CloudKit] Travel override deleted: \(overrideId)")
|
||||
} catch let error as CKError {
|
||||
if error.code != .unknownItem {
|
||||
throw mapCloudKitError(error)
|
||||
}
|
||||
// Already deleted - ignore
|
||||
} catch {
|
||||
throw TravelOverrideError.unknown(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete override by travel anchor ID (useful when removing by travel segment)
|
||||
func deleteOverride(forTripId tripId: UUID, travelAnchorId: String) async throws {
|
||||
// First fetch to find the record
|
||||
let overrides = try await fetchOverrides(forTripId: tripId)
|
||||
if let existing = overrides.first(where: { $0.travelAnchorId == travelAnchorId }) {
|
||||
try await deleteOverride(existing.id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch all travel day overrides for a trip
|
||||
func fetchOverrides(forTripId tripId: UUID) async throws -> [TravelDayOverride] {
|
||||
print("☁️ [CloudKit] Fetching travel overrides for tripId: \(tripId.uuidString)")
|
||||
let predicate = NSPredicate(
|
||||
format: "%K == %@",
|
||||
CKTravelDayOverride.tripIdKey,
|
||||
tripId.uuidString
|
||||
)
|
||||
let query = CKQuery(recordType: CKRecordType.travelDayOverride, predicate: predicate)
|
||||
|
||||
do {
|
||||
let (results, _) = try await publicDatabase.records(matching: query)
|
||||
print("☁️ [CloudKit] Query returned \(results.count) travel override records")
|
||||
|
||||
let overrides = results.compactMap { result -> TravelDayOverride? in
|
||||
guard case .success(let record) = result.1 else {
|
||||
print("☁️ [CloudKit] Record fetch failed for: \(result.0.recordName)")
|
||||
return nil
|
||||
}
|
||||
let override = CKTravelDayOverride(record: record).toOverride()
|
||||
if override == nil {
|
||||
print("☁️ [CloudKit] Failed to parse travel override record: \(record.recordID.recordName)")
|
||||
}
|
||||
return override
|
||||
}
|
||||
|
||||
print("☁️ [CloudKit] Parsed \(overrides.count) valid travel overrides")
|
||||
return overrides
|
||||
} catch let error as CKError {
|
||||
print("☁️ [CloudKit] CKError on fetch: \(error.code.rawValue) - \(error.localizedDescription)")
|
||||
throw mapCloudKitError(error)
|
||||
} catch {
|
||||
print("☁️ [CloudKit] Unknown error on fetch: \(error)")
|
||||
throw TravelOverrideError.unknown(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert array of overrides to dictionary keyed by travelAnchorId
|
||||
/// If duplicate travelAnchorIds exist (legacy data), takes the most recently modified one
|
||||
func fetchOverridesAsDictionary(forTripId tripId: UUID) async throws -> [String: Int] {
|
||||
let overrides = try await fetchOverrides(forTripId: tripId)
|
||||
// Sort by modifiedAt descending so most recent comes first
|
||||
let sorted = overrides.sorted { $0.modifiedAt > $1.modifiedAt }
|
||||
// Use uniquingKeysWith to keep the first (most recent) value for duplicates
|
||||
return Dictionary(sorted.map { ($0.travelAnchorId, $0.displayDay) }) { first, _ in first }
|
||||
}
|
||||
|
||||
// MARK: - Error Mapping
|
||||
|
||||
private func mapCloudKitError(_ error: CKError) -> TravelOverrideError {
|
||||
switch error.code {
|
||||
case .notAuthenticated:
|
||||
return .notSignedIn
|
||||
case .networkUnavailable, .networkFailure:
|
||||
return .networkUnavailable
|
||||
case .unknownItem:
|
||||
return .overrideNotFound
|
||||
default:
|
||||
return .unknown(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum TravelOverrideError: Error, LocalizedError {
|
||||
case notSignedIn
|
||||
case overrideNotFound
|
||||
case networkUnavailable
|
||||
case unknown(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notSignedIn:
|
||||
return "Please sign in to iCloud to customize travel days."
|
||||
case .overrideNotFound:
|
||||
return "Travel day setting not found."
|
||||
case .networkUnavailable:
|
||||
return "Unable to connect. Please check your internet connection."
|
||||
case .unknown(let error):
|
||||
return "An error occurred: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user