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>
281 lines
8.3 KiB
Markdown
281 lines
8.3 KiB
Markdown
# 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
|