docs: add itinerary refactor design

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>
This commit is contained in:
Trey t
2026-01-17 09:08:42 -06:00
parent 8df33a5614
commit bd1e24181f

View File

@@ -0,0 +1,280 @@
# 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
1. **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
2. **Custom item routing**: Full position ordering - route follows exact visual position of items
3. **Data model**: Simple sort order - replace anchors with `day: Int` and `sortOrder: Double`
---
## Section 1: Data Model
### Current Model (Being Replaced)
```swift
struct CustomItineraryItem {
var anchorType: AnchorType // .startOfDay, .afterGame, .afterTravel
var anchorId: String? // game ID or travel ID
var anchorDay: Int
var sortOrder: Int
}
```
### New Model
```swift
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
```swift
// 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
```swift
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
```swift
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
```swift
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
```swift
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
```swift
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
```swift
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:
1. **Simplified data source**: Just iterate `buildItinerary()` result
2. **Move handling**: Direct day/sortOrder updates, no anchor inference
3. **Travel rows**: Show drag handle only when `canMove` is true
### Visual Feedback for Travel Constraints
```swift
// 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, sortOrder
- `onTravelMoved: (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 needed
- `Trip.swift` - Travel overrides stored separately
- `CustomItemRow.swift` - Display unchanged
---
## Migration Plan
1. Add new fields to CloudKit schema (non-breaking)
2. App update reads old anchors, writes new fields
3. Backfill: `day = anchorDay`, `sortOrder = Double(existing sortOrder)`
4. After transition period, remove anchor fields from code
---
## Benefits
1. **Simpler mental model**: Position = (day, sortOrder), that's it
2. **No inference bugs**: What you drop is what you get
3. **Route matches visual**: Waypoints follow exact display order
4. **Easier debugging**: Just check day and sortOrder values
5. **Travel guardrails**: Clear constraints, enforced at drag time