Files
Sportstime/docs/plans/2026-01-17-itinerary-refactor-design.md
Trey t bd1e24181f 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>
2026-01-17 09:08:42 -06:00

8.3 KiB

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)

struct CustomItineraryItem {
    var anchorType: AnchorType  // .startOfDay, .afterGame, .afterTravel
    var anchorId: String?       // game ID or travel ID
    var anchorDay: Int
    var sortOrder: Int
}

New Model

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

// 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

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

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

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

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

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

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

// 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