# 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 { 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