refactor(itinerary): replace anchor-based positioning with day/sortOrder

Replace complex anchor system (anchorType, anchorId, anchorDay) with
simple (day: Int, sortOrder: Double) positioning for custom items.

Changes:
- CustomItineraryItem: Remove anchor fields, add day and sortOrder
- CKModels: Add migration fallback from old CloudKit fields
- ItineraryTableViewController: Add calculateSortOrder() for midpoint insertion
- TripDetailView: Simplify callbacks, itinerarySections, and routeWaypoints
- AddItemSheet: Take simple day parameter instead of anchor
- SavedTrip: Update LocalCustomItem SwiftData model

Benefits:
- Items freely movable via drag-and-drop
- Route waypoints follow exact visual order
- Simpler mental model: position = (day, sortOrder)
- Midpoint insertion allows unlimited reordering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-17 09:47:11 -06:00
parent 59ba2c6965
commit 2a8bfeeff8
10 changed files with 238 additions and 385 deletions

View File

@@ -632,16 +632,19 @@ struct CKCustomItineraryItem {
static let tripIdKey = "tripId"
static let categoryKey = "category"
static let titleKey = "title"
static let anchorTypeKey = "anchorType"
static let anchorIdKey = "anchorId"
static let anchorDayKey = "anchorDay"
static let sortOrderKey = "sortOrder"
static let dayKey = "day" // NEW: replaces anchorDay
static let sortOrderDoubleKey = "sortOrderDouble" // NEW: Double instead of Int
static let createdAtKey = "createdAt"
static let modifiedAtKey = "modifiedAt"
// Location fields for mappable items
static let latitudeKey = "latitude"
static let longitudeKey = "longitude"
static let addressKey = "address"
// DEPRECATED - kept for migration reads only
static let anchorTypeKey = "anchorType"
static let anchorIdKey = "anchorId"
static let anchorDayKey = "anchorDay"
static let sortOrderKey = "sortOrder"
let record: CKRecord
@@ -658,10 +661,8 @@ struct CKCustomItineraryItem {
record[CKCustomItineraryItem.tripIdKey] = item.tripId.uuidString
record[CKCustomItineraryItem.categoryKey] = item.category.rawValue
record[CKCustomItineraryItem.titleKey] = item.title
record[CKCustomItineraryItem.anchorTypeKey] = item.anchorType.rawValue
record[CKCustomItineraryItem.anchorIdKey] = item.anchorId
record[CKCustomItineraryItem.anchorDayKey] = item.anchorDay
record[CKCustomItineraryItem.sortOrderKey] = item.sortOrder
record[CKCustomItineraryItem.dayKey] = item.day
record[CKCustomItineraryItem.sortOrderDoubleKey] = item.sortOrder
record[CKCustomItineraryItem.createdAtKey] = item.createdAt
record[CKCustomItineraryItem.modifiedAtKey] = item.modifiedAt
// Location fields (nil values are not stored in CloudKit)
@@ -679,15 +680,30 @@ struct CKCustomItineraryItem {
let categoryString = record[CKCustomItineraryItem.categoryKey] as? String,
let category = CustomItineraryItem.ItemCategory(rawValue: categoryString),
let title = record[CKCustomItineraryItem.titleKey] as? String,
let anchorTypeString = record[CKCustomItineraryItem.anchorTypeKey] as? String,
let anchorType = CustomItineraryItem.AnchorType(rawValue: anchorTypeString),
let anchorDay = record[CKCustomItineraryItem.anchorDayKey] as? Int,
let createdAt = record[CKCustomItineraryItem.createdAtKey] as? Date,
let modifiedAt = record[CKCustomItineraryItem.modifiedAtKey] as? Date
else { return nil }
let anchorId = record[CKCustomItineraryItem.anchorIdKey] as? String
let sortOrder = record[CKCustomItineraryItem.sortOrderKey] as? Int ?? 0
// Read new fields, with migration fallback from old fields
let day: Int
if let newDay = record[CKCustomItineraryItem.dayKey] as? Int {
day = newDay
} else if let oldDay = record[CKCustomItineraryItem.anchorDayKey] as? Int {
// Migration: use old anchorDay
day = oldDay
} else {
return nil
}
let sortOrder: Double
if let newSortOrder = record[CKCustomItineraryItem.sortOrderDoubleKey] as? Double {
sortOrder = newSortOrder
} else if let oldSortOrder = record[CKCustomItineraryItem.sortOrderKey] as? Int {
// Migration: convert old Int sortOrder to Double
sortOrder = Double(oldSortOrder)
} else {
sortOrder = 0.0
}
// Location fields (optional - nil if not stored)
let latitude = record[CKCustomItineraryItem.latitudeKey] as? Double
@@ -699,9 +715,7 @@ struct CKCustomItineraryItem {
tripId: tripId,
category: category,
title: title,
anchorType: anchorType,
anchorId: anchorId,
anchorDay: anchorDay,
day: day,
sortOrder: sortOrder,
createdAt: createdAt,
modifiedAt: modifiedAt,

View File

@@ -11,10 +11,8 @@ struct CustomItineraryItem: Identifiable, Codable, Hashable {
let tripId: UUID
var category: ItemCategory
var title: String
var anchorType: AnchorType
var anchorId: String?
var anchorDay: Int
var sortOrder: Int // For ordering within same anchor position
var day: Int // Day number (1-indexed)
var sortOrder: Double // Position within day (allows insertion between items)
let createdAt: Date
var modifiedAt: Date
@@ -39,10 +37,8 @@ struct CustomItineraryItem: Identifiable, Codable, Hashable {
tripId: UUID,
category: ItemCategory,
title: String,
anchorType: AnchorType = .startOfDay,
anchorId: String? = nil,
anchorDay: Int,
sortOrder: Int = 0,
day: Int,
sortOrder: Double = 0.0,
createdAt: Date = Date(),
modifiedAt: Date = Date(),
latitude: Double? = nil,
@@ -53,9 +49,7 @@ struct CustomItineraryItem: Identifiable, Codable, Hashable {
self.tripId = tripId
self.category = category
self.title = title
self.anchorType = anchorType
self.anchorId = anchorId
self.anchorDay = anchorDay
self.day = day
self.sortOrder = sortOrder
self.createdAt = createdAt
self.modifiedAt = modifiedAt
@@ -97,10 +91,4 @@ struct CustomItineraryItem: Identifiable, Codable, Hashable {
}
}
}
enum AnchorType: String, Codable {
case startOfDay
case afterGame
case afterTravel
}
}

View File

@@ -109,9 +109,8 @@ final class LocalCustomItem {
var tripId: UUID
var category: String
var title: String
var anchorType: String
var anchorId: String?
var anchorDay: Int
var day: Int
var sortOrder: Double
var createdAt: Date
var modifiedAt: Date
var pendingSync: Bool // True if needs to sync to CloudKit
@@ -121,9 +120,8 @@ final class LocalCustomItem {
tripId: UUID,
category: CustomItineraryItem.ItemCategory,
title: String,
anchorType: CustomItineraryItem.AnchorType = .startOfDay,
anchorId: String? = nil,
anchorDay: Int,
day: Int,
sortOrder: Double = 0.0,
createdAt: Date = Date(),
modifiedAt: Date = Date(),
pendingSync: Bool = false
@@ -132,17 +130,15 @@ final class LocalCustomItem {
self.tripId = tripId
self.category = category.rawValue
self.title = title
self.anchorType = anchorType.rawValue
self.anchorId = anchorId
self.anchorDay = anchorDay
self.day = day
self.sortOrder = sortOrder
self.createdAt = createdAt
self.modifiedAt = modifiedAt
self.pendingSync = pendingSync
}
var toItem: CustomItineraryItem? {
guard let category = CustomItineraryItem.ItemCategory(rawValue: category),
let anchorType = CustomItineraryItem.AnchorType(rawValue: anchorType)
guard let category = CustomItineraryItem.ItemCategory(rawValue: category)
else { return nil }
return CustomItineraryItem(
@@ -150,9 +146,8 @@ final class LocalCustomItem {
tripId: tripId,
category: category,
title: title,
anchorType: anchorType,
anchorId: anchorId,
anchorDay: anchorDay,
day: day,
sortOrder: sortOrder,
createdAt: createdAt,
modifiedAt: modifiedAt
)
@@ -164,9 +159,8 @@ final class LocalCustomItem {
tripId: item.tripId,
category: item.category,
title: item.title,
anchorType: item.anchorType,
anchorId: item.anchorId,
anchorDay: item.anchorDay,
day: item.day,
sortOrder: item.sortOrder,
createdAt: item.createdAt,
modifiedAt: item.modifiedAt,
pendingSync: pendingSync

View File

@@ -55,10 +55,8 @@ actor CustomItemService {
let now = Date()
existingRecord[CKCustomItineraryItem.categoryKey] = item.category.rawValue
existingRecord[CKCustomItineraryItem.titleKey] = item.title
existingRecord[CKCustomItineraryItem.anchorTypeKey] = item.anchorType.rawValue
existingRecord[CKCustomItineraryItem.anchorIdKey] = item.anchorId
existingRecord[CKCustomItineraryItem.anchorDayKey] = item.anchorDay
existingRecord[CKCustomItineraryItem.sortOrderKey] = item.sortOrder
existingRecord[CKCustomItineraryItem.dayKey] = item.day
existingRecord[CKCustomItineraryItem.sortOrderDoubleKey] = item.sortOrder
existingRecord[CKCustomItineraryItem.modifiedAtKey] = now
// Location fields (nil values clear the field in CloudKit)
existingRecord[CKCustomItineraryItem.latitudeKey] = item.latitude
@@ -77,16 +75,16 @@ actor CustomItemService {
}
}
/// Batch update sortOrder for multiple items (for reordering)
/// Batch update day+sortOrder for multiple items (for reordering)
func updateSortOrders(_ items: [CustomItineraryItem]) async throws {
guard !items.isEmpty else { return }
print("☁️ [CloudKit] Batch updating sortOrder for \(items.count) items")
print("☁️ [CloudKit] Batch updating day+sortOrder for \(items.count) items")
// Fetch all records
let recordIDs = items.map { CKRecord.ID(recordName: $0.id.uuidString) }
let fetchResults = try await publicDatabase.records(for: recordIDs)
// Update each record's sortOrder
// Update each record's day and sortOrder
var recordsToSave: [CKRecord] = []
let now = Date()
@@ -94,7 +92,8 @@ actor CustomItemService {
let recordID = CKRecord.ID(recordName: item.id.uuidString)
guard case .success(let record) = fetchResults[recordID] else { continue }
record[CKCustomItineraryItem.sortOrderKey] = item.sortOrder
record[CKCustomItineraryItem.dayKey] = item.day
record[CKCustomItineraryItem.sortOrderDoubleKey] = item.sortOrder
record[CKCustomItineraryItem.modifiedAtKey] = now
recordsToSave.append(record)
}
@@ -107,7 +106,7 @@ actor CustomItemService {
modifyOp.modifyRecordsResultBlock = { result in
switch result {
case .success:
print("☁️ [CloudKit] Batch sortOrder update complete")
print("☁️ [CloudKit] Batch day+sortOrder update complete")
continuation.resume()
case .failure(let error):
print("☁️ [CloudKit] Batch update failed: \(error)")
@@ -156,7 +155,7 @@ actor CustomItemService {
print("☁️ [CloudKit] Failed to parse record: \(record.recordID.recordName)")
}
return item
}.sorted { ($0.anchorDay, $0.sortOrder) < ($1.anchorDay, $1.sortOrder) }
}.sorted { ($0.day, $0.sortOrder) < ($1.day, $1.sortOrder) }
print("☁️ [CloudKit] Parsed \(items.count) valid items")
return items