From bd1e24181fc5505b9ee0d66c71770c7743909347 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 17 Jan 2026 09:08:42 -0600 Subject: [PATCH] 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 --- .../2026-01-17-itinerary-refactor-design.md | 280 ++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 docs/plans/2026-01-17-itinerary-refactor-design.md diff --git a/docs/plans/2026-01-17-itinerary-refactor-design.md b/docs/plans/2026-01-17-itinerary-refactor-design.md new file mode 100644 index 0000000..d833731 --- /dev/null +++ b/docs/plans/2026-01-17-itinerary-refactor-design.md @@ -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 { + 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