Replace anchor-based positioning with simple sort-order system: - Custom items use (day, sortOrder: Double) instead of anchors - Travel segments have hard guardrails based on city game schedules - Route waypoints follow exact visual display order Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
8.3 KiB
8.3 KiB
Itinerary Refactor Design
Goal
Refactor the trip itinerary system to use simple sort-order positioning instead of anchor-based positioning, enabling movable travel segments with hard guardrails and custom items that can be moved anywhere.
Problem Statement
The current anchor-based system (anchorType, anchorId, anchorDay) is overly complex and fragile:
- Anchor inference during drag/drop frequently fails
- Custom items "disappear" when anchors become invalid
- Route calculation doesn't match visual order
- Code is hard to reason about and debug
Design Decisions
- Travel guardrails: City-bound - travel can only be positioned between the last game in the departure city and the first game in the destination city
- Custom item routing: Full position ordering - route follows exact visual position of items
- Data model: Simple sort order - replace anchors with
day: IntandsortOrder: Double
Section 1: Data Model
Current Model (Being Replaced)
struct CustomItineraryItem {
var anchorType: AnchorType // .startOfDay, .afterGame, .afterTravel
var anchorId: String? // game ID or travel ID
var anchorDay: Int
var sortOrder: Int
}
New Model
struct CustomItineraryItem {
var day: Int // Which day (1-indexed)
var sortOrder: Double // Position within day (0.0, 1.0, 2.0... or 0.5 for between)
// Remove: anchorType, anchorId
}
Why Double for sortOrder?
- Insert between items without rewriting all sort orders
- Example: items at 1.0 and 2.0, insert at 1.5
- Periodically normalize (1, 2, 3...) during sync
Travel Day Storage
// In Trip or separate storage
var travelDayOverrides: [String: Int] // "milwaukee->salt lake": 4
Migration
- Existing items:
day = anchorDay,sortOrder = Double(sortOrder) - One-time migration on app update
- CloudKit schema: add
day,sortOrderDouble, deprecate anchor fields
Section 2: Itinerary Building Logic
Single Build Function
func buildItinerary() -> [ItineraryRow] {
var rows: [ItineraryRow] = []
for dayNum in 1...tripDays.count {
let dayDate = tripDays[dayNum - 1]
// 1. Travel arriving this day (if any)
if let travel = travelArrivingOn(day: dayNum) {
rows.append(.travel(travel, canMove: travelCanMove(travel)))
}
// 2. Day header
let games = gamesOn(date: dayDate)
rows.append(.dayHeader(dayNum, dayDate, games))
// 3. Custom items for this day (sorted)
let items = customItems
.filter { $0.day == dayNum }
.sorted { $0.sortOrder < $1.sortOrder }
for item in items {
rows.append(.customItem(item))
}
// 4. Add button
rows.append(.addButton(dayNum))
}
return rows
}
Travel Constraints
func travelValidRange(_ segment: TravelSegment) -> ClosedRange<Int> {
let lastGameInFromCity = findLastGameDay(in: segment.fromCity)
let firstGameInToCity = findFirstGameDay(in: segment.toCity)
// Travel must be AFTER last game in departure city
// Travel must be ON or BEFORE first game in destination city
let minDay = lastGameInFromCity + 1
let maxDay = firstGameInToCity
return minDay...maxDay
}
Route Waypoints
var routeWaypoints: [CLLocationCoordinate2D] {
var waypoints: [CLLocationCoordinate2D] = []
for row in buildItinerary() {
switch row {
case .dayHeader(_, _, let games):
// Add game locations in order
for game in games {
waypoints.append(game.stadium.coordinate)
}
case .customItem(let item):
// Add custom item if it has location
if let coord = item.coordinate {
waypoints.append(coord)
}
default:
break
}
}
return waypoints
}
Section 3: Drag & Drop Handling
Move Custom Item
func moveCustomItem(_ item: CustomItineraryItem, to targetDay: Int, afterRow: Int) {
// 1. Determine new sortOrder based on position
let itemsInTargetDay = customItems.filter { $0.day == targetDay }
let newSortOrder = calculateSortOrder(afterRow: afterRow, existingItems: itemsInTargetDay)
// 2. Update item directly - no anchor inference!
var updated = item
updated.day = targetDay
updated.sortOrder = newSortOrder
// 3. Save to CloudKit
Task { try await CustomItemService.shared.updateItem(updated) }
}
Move Travel
func moveTravel(_ segment: TravelSegment, to targetDay: Int) {
let validRange = travelValidRange(segment)
// Enforce guardrails
guard validRange.contains(targetDay) else {
showAlert("Travel must be between Day \(validRange.lowerBound) and Day \(validRange.upperBound)")
return
}
// Store override
let travelId = "\(segment.fromCity.lowercased())->\(segment.toCity.lowercased())"
travelDayOverrides[travelId] = targetDay
// Persist (CloudKit or local)
saveTravelOverrides()
}
Calculate Sort Order
func calculateSortOrder(afterRow: Int, existingItems: [CustomItineraryItem]) -> Double {
// If inserting at start, use 0.5 before first item
if afterRow == 0 {
let firstOrder = existingItems.first?.sortOrder ?? 1.0
return firstOrder - 1.0
}
// If inserting at end, use last + 1
if afterRow >= existingItems.count {
let lastOrder = existingItems.last?.sortOrder ?? 0.0
return lastOrder + 1.0
}
// Insert between two items
let before = existingItems[afterRow - 1].sortOrder
let after = existingItems[afterRow].sortOrder
return (before + after) / 2.0
}
Section 4: UI Implementation
Keep UITableView
The existing ItineraryTableViewController works well for drag/drop. Changes:
- Simplified data source: Just iterate
buildItinerary()result - Move handling: Direct day/sortOrder updates, no anchor inference
- Travel rows: Show drag handle only when
canMoveis true
Visual Feedback for Travel Constraints
// In tableView(_:targetIndexPathForMove...)
func tableView(_ tableView: UITableView,
targetIndexPathForMoveFromRowAt source: IndexPath,
toProposedIndexPath proposed: IndexPath) -> IndexPath {
guard case .travel(let segment, _) = flatItems[source.row] else {
return proposed // Custom items can go anywhere
}
// For travel, clamp to valid day range
let targetDay = dayNumber(for: proposed)
let validRange = travelValidRange(segment)
if targetDay < validRange.lowerBound {
return indexPath(forDay: validRange.lowerBound, position: .start)
}
if targetDay > validRange.upperBound {
return indexPath(forDay: validRange.upperBound, position: .start)
}
return proposed
}
Wrapper Updates
ItineraryTableViewWrapper passes:
customItems: [CustomItineraryItem]travelDayOverrides: [String: Int]onCustomItemMoved: (UUID, Int, Double) -> Void// id, day, sortOrderonTravelMoved: (String, Int) -> Void// travelId, day
Files to Modify
| File | Changes |
|---|---|
CustomItineraryItem.swift |
Replace anchors with day + sortOrder: Double |
CKCustomItineraryItem.swift |
Update CloudKit field mappings |
CustomItemService.swift |
Update CRUD for new fields |
ItineraryTableViewController.swift |
Simplify move logic, remove anchor inference |
ItineraryTableViewWrapper.swift |
Update callbacks, simplify data building |
TripDetailView.swift |
Update routeWaypoints to use build order |
Files Unchanged
TravelSegment.swift- No model changes neededTrip.swift- Travel overrides stored separatelyCustomItemRow.swift- Display unchanged
Migration Plan
- Add new fields to CloudKit schema (non-breaking)
- App update reads old anchors, writes new fields
- Backfill:
day = anchorDay,sortOrder = Double(existing sortOrder) - After transition period, remove anchor fields from code
Benefits
- Simpler mental model: Position = (day, sortOrder), that's it
- No inference bugs: What you drop is what you get
- Route matches visual: Waypoints follow exact display order
- Easier debugging: Just check day and sortOrder values
- Travel guardrails: Clear constraints, enforced at drag time