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:
280
docs/plans/2026-01-17-itinerary-refactor-design.md
Normal file
280
docs/plans/2026-01-17-itinerary-refactor-design.md
Normal 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
|
||||
Reference in New Issue
Block a user