Files
Sportstime/docs/plans/2026-01-17-flexible-itinerary-ordering-plan.md
Trey t 828059a12a docs: add flexible itinerary ordering implementation plan
13-task plan covering:
- TravelDayOverride model update with sortOrder
- CloudKit schema update
- Flattening logic to sort games/travel/custom together
- Drag constraint updates for flexible custom items
- Regression tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 11:07:58 -06:00

24 KiB

Flexible Itinerary Ordering Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Enable fully flexible ordering of itinerary items - custom items can go anywhere, travel is reorderable within day constraints, games have sortOrder but are immovable.

Architecture: Extend existing TravelDayOverride model with sortOrder field. Update flattening logic to sort all orderable items (games, travel, custom) together by sortOrder within each day. Games get auto-assigned sortOrder (100, 101...) but no drag handle.

Tech Stack: Swift, SwiftUI, UIKit (UITableViewController), CloudKit


Task 1: Update TravelDayOverride Domain Model

Files:

  • Modify: SportsTime/Core/Models/Domain/TravelDayOverride.swift

Step 1: Add sortOrder field to TravelDayOverride

struct TravelDayOverride: Identifiable, Codable, Hashable {
    let id: UUID
    let tripId: UUID
    let travelAnchorId: String
    var displayDay: Int
    var sortOrder: Double  // NEW: Position within day (allows interleaving with custom items)
    let createdAt: Date
    var modifiedAt: Date

    init(
        id: UUID = UUID(),
        tripId: UUID,
        travelAnchorId: String,
        displayDay: Int,
        sortOrder: Double = 50.0,  // Default before games (games start at 100)
        createdAt: Date = Date(),
        modifiedAt: Date = Date()
    ) {
        self.id = id
        self.tripId = tripId
        self.travelAnchorId = travelAnchorId
        self.displayDay = displayDay
        self.sortOrder = sortOrder
        self.createdAt = createdAt
        self.modifiedAt = modifiedAt
    }
}

Step 2: Build to verify compilation

Run: cd /Users/treyt/Desktop/code/SportsTime && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20

Expected: Build succeeds (existing code still works due to default parameter)

Step 3: Commit

git add SportsTime/Core/Models/Domain/TravelDayOverride.swift
git commit -m "feat(model): add sortOrder to TravelDayOverride"

Task 2: Update CloudKit Model for TravelDayOverride

Files:

  • Modify: SportsTime/Core/Models/CloudKit/CKModels.swift (CKTravelDayOverride section)

Step 1: Add sortOrder field to CKTravelDayOverride

Find CKTravelDayOverride struct and add:

struct CKTravelDayOverride {
    static let overrideIdKey = "overrideId"
    static let tripIdKey = "tripId"
    static let travelAnchorIdKey = "travelAnchorId"
    static let displayDayKey = "displayDay"
    static let sortOrderKey = "sortOrder"  // NEW
    static let createdAtKey = "createdAt"
    static let modifiedAtKey = "modifiedAt"

    let record: CKRecord

    init(record: CKRecord) {
        self.record = record
    }

    init(override: TravelDayOverride) {
        let record = CKRecord(
            recordType: CKRecordType.travelDayOverride,
            recordID: CKRecord.ID(recordName: override.id.uuidString)
        )
        record[CKTravelDayOverride.overrideIdKey] = override.id.uuidString
        record[CKTravelDayOverride.tripIdKey] = override.tripId.uuidString
        record[CKTravelDayOverride.travelAnchorIdKey] = override.travelAnchorId
        record[CKTravelDayOverride.displayDayKey] = override.displayDay
        record[CKTravelDayOverride.sortOrderKey] = override.sortOrder  // NEW
        record[CKTravelDayOverride.createdAtKey] = override.createdAt
        record[CKTravelDayOverride.modifiedAtKey] = override.modifiedAt
        self.record = record
    }

    func toOverride() -> TravelDayOverride? {
        guard let overrideIdString = record[CKTravelDayOverride.overrideIdKey] as? String,
              let overrideId = UUID(uuidString: overrideIdString),
              let tripIdString = record[CKTravelDayOverride.tripIdKey] as? String,
              let tripId = UUID(uuidString: tripIdString),
              let travelAnchorId = record[CKTravelDayOverride.travelAnchorIdKey] as? String,
              let displayDay = record[CKTravelDayOverride.displayDayKey] as? Int,
              let createdAt = record[CKTravelDayOverride.createdAtKey] as? Date,
              let modifiedAt = record[CKTravelDayOverride.modifiedAtKey] as? Date
        else { return nil }

        // Migration: default sortOrder for existing records without it
        let sortOrder = record[CKTravelDayOverride.sortOrderKey] as? Double ?? 50.0

        return TravelDayOverride(
            id: overrideId,
            tripId: tripId,
            travelAnchorId: travelAnchorId,
            displayDay: displayDay,
            sortOrder: sortOrder,
            createdAt: createdAt,
            modifiedAt: modifiedAt
        )
    }
}

Step 2: Build to verify compilation

Run: cd /Users/treyt/Desktop/code/SportsTime && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20

Expected: Build succeeds

Step 3: Commit

git add SportsTime/Core/Models/CloudKit/CKModels.swift
git commit -m "feat(cloudkit): add sortOrder to CKTravelDayOverride"

Task 3: Update TravelOverrideService

Files:

  • Modify: SportsTime/Core/Services/TravelOverrideService.swift

Step 1: Update saveOverride to persist sortOrder

In saveOverride(_:), add the sortOrder field when updating existing record:

if let existing = existingOverride {
    let recordID = CKRecord.ID(recordName: existing.id.uuidString)
    do {
        let existingRecord = try await publicDatabase.record(for: recordID)
        existingRecord[CKTravelDayOverride.displayDayKey] = override.displayDay
        existingRecord[CKTravelDayOverride.sortOrderKey] = override.sortOrder  // NEW
        existingRecord[CKTravelDayOverride.modifiedAtKey] = now
        record = existingRecord
        // ...
    }
}

Step 2: Update fetchOverridesAsDictionary return type

Change from [String: Int] to return full TravelDayOverride objects:

/// Fetch all travel overrides for a trip, keyed by travelAnchorId
/// If duplicate travelAnchorIds exist (legacy data), takes the most recently modified one
func fetchOverridesAsDictionary(forTripId tripId: UUID) async throws -> [String: TravelDayOverride] {
    let overrides = try await fetchOverrides(forTripId: tripId)
    let sorted = overrides.sorted { $0.modifiedAt > $1.modifiedAt }
    return Dictionary(sorted.map { ($0.travelAnchorId, $0) }) { first, _ in first }
}

Step 3: Build to verify compilation

Run: cd /Users/treyt/Desktop/code/SportsTime && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20

Expected: Build fails (TripDetailView uses old return type) - this is expected

Step 4: Commit

git add SportsTime/Core/Services/TravelOverrideService.swift
git commit -m "feat(service): update TravelOverrideService for sortOrder"

Task 4: Update ItineraryTableViewWrapper Interface

Files:

  • Modify: SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift

Step 1: Change travelDayOverrides type

Update the property and init:

let travelDayOverrides: [String: TravelDayOverride]  // Changed from [String: Int]

Update init parameter:

init(
    trip: Trip,
    games: [RichGame],
    customItems: [CustomItineraryItem],
    travelDayOverrides: [String: TravelDayOverride],  // Changed
    // ...
)

Step 2: Update buildItineraryData to use sortOrder

In buildItineraryData(), update the override lookup:

// Use override if valid, otherwise use default
if let override = travelDayOverrides[travelId], validRange.contains(override.displayDay) {
    travelByDay[override.displayDay] = (segment, override.sortOrder)
} else {
    let clampedDefault = max(validRange.lowerBound, min(defaultDay, validRange.upperBound))
    travelByDay[clampedDefault] = (segment, 50.0)  // Default sortOrder before games
}

Step 3: Update ItineraryDayData to store travel sortOrder

Change travelBefore to include sortOrder:

struct ItineraryDayData: Identifiable {
    let id: Int
    let dayNumber: Int
    let date: Date
    let games: [RichGame]
    var items: [ItineraryRowItem]
    var travelBefore: (segment: TravelSegment, sortOrder: Double)?  // Changed
    // ...
}

Step 4: Build (expect failures - controller needs update)

Run: cd /Users/treyt/Desktop/code/SportsTime && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | grep -E "error:|warning:" | head -20

Step 5: Commit

git add SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift
git commit -m "feat(wrapper): update interface for travel sortOrder"

Task 5: Update ItineraryRowItem to Include sortOrder

Files:

  • Modify: SportsTime/Features/Trip/Views/ItineraryTableViewController.swift

Step 1: Add sortOrder to ItineraryRowItem enum

Update cases to include sortOrder where needed:

enum ItineraryRowItem: Identifiable, Equatable {
    case dayHeader(dayNumber: Int, date: Date)
    case games([RichGame], dayNumber: Int, sortOrder: Double)  // Added sortOrder
    case travel(TravelSegment, dayNumber: Int, sortOrder: Double)  // Added sortOrder
    case customItem(CustomItineraryItem)  // Already has sortOrder in the model

    /// Get sortOrder for sorting within a day (nil for headers)
    var sortOrder: Double? {
        switch self {
        case .dayHeader:
            return nil
        case .games(_, _, let order):
            return order
        case .travel(_, _, let order):
            return order
        case .customItem(let item):
            return item.sortOrder
        }
    }

    // Update id and isReorderable as needed...
}

Step 2: Update id property

var id: String {
    switch self {
    case .dayHeader(let dayNumber, _):
        return "day:\(dayNumber)"
    case .games(_, let dayNumber, _):
        return "games:\(dayNumber)"
    case .travel(let segment, _, _):
        return "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
    case .customItem(let item):
        return "item:\(item.id.uuidString)"
    }
}

Step 3: Commit

git add SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
git commit -m "feat(controller): add sortOrder to ItineraryRowItem"

Task 6: Update Flattening Logic to Sort All Items Together

Files:

  • Modify: SportsTime/Features/Trip/Views/ItineraryTableViewController.swift

Step 1: Rewrite reloadData flattening

Replace the flattening loop in reloadData(days:travelValidRanges:):

func reloadData(days: [ItineraryDayData], travelValidRanges: [String: ClosedRange<Int>]) {
    self.travelValidRanges = travelValidRanges

    flatItems = []

    for day in days {
        // 1. Day header (fixed, always first)
        flatItems.append(.dayHeader(dayNumber: day.dayNumber, date: day.date))

        // 2. Collect all orderable items for this day
        var orderableItems: [ItineraryRowItem] = []

        // Games get sortOrder starting at 100
        for (index, game) in day.games.enumerated() {
            let gameSortOrder = 100.0 + Double(index)
            orderableItems.append(.games([game], dayNumber: day.dayNumber, sortOrder: gameSortOrder))
        }

        // Travel (if arriving this day)
        if let (travel, sortOrder) = day.travelBefore {
            orderableItems.append(.travel(travel, dayNumber: day.dayNumber, sortOrder: sortOrder))
        }

        // Custom items
        for item in day.items {
            if case .customItem = item {
                orderableItems.append(item)
            }
        }

        // 3. Sort all orderable items by sortOrder
        orderableItems.sort { ($0.sortOrder ?? 0) < ($1.sortOrder ?? 0) }

        // 4. Append sorted items
        flatItems.append(contentsOf: orderableItems)
    }

    tableView.reloadData()
}

Step 2: Build to verify

Run: cd /Users/treyt/Desktop/code/SportsTime && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20

Step 3: Commit

git add SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
git commit -m "feat(controller): sort all orderable items together by sortOrder"

Task 7: Update Drag Constraints for Custom Items

Files:

  • Modify: SportsTime/Features/Trip/Views/ItineraryTableViewController.swift

Step 1: Update targetIndexPathForMoveFromRowAt for custom items

Remove the constraints that prevent custom items from going before games/travel. In the .customItem case:

case .customItem:
    // Custom items can go anywhere except:
    // 1. Before position 0
    // 2. Directly ON a day header (redirect to after header)

    // Don't drop ON a day header - go after it instead
    if proposedRow < flatItems.count, case .dayHeader = flatItems[proposedRow] {
        return IndexPath(row: proposedRow + 1, section: 0)
    }

    // Allow dropping anywhere else (before games, before travel, etc.)
    return IndexPath(row: proposedRow, section: 0)

Step 2: Update calculateSortOrder to consider all orderable items

Update calculateSortOrder(at:) to scan for games and travel, not just custom items:

private func calculateSortOrder(at row: Int) -> Double {
    var prevSortOrder: Double?
    var nextSortOrder: Double?

    // SCAN BACKWARDS to find previous orderable item in this day
    for i in stride(from: row - 1, through: 0, by: -1) {
        switch flatItems[i] {
        case .dayHeader:
            // Hit day boundary - no previous item in this day
            break
        case .games(_, _, let order):
            prevSortOrder = order
        case .travel(_, _, let order):
            prevSortOrder = order
        case .customItem(let item):
            prevSortOrder = item.sortOrder
        }
        if prevSortOrder != nil { break }
        if case .dayHeader = flatItems[i] { break }
    }

    // SCAN FORWARDS to find next orderable item in this day
    for i in row..<flatItems.count {
        switch flatItems[i] {
        case .dayHeader:
            // Hit next day boundary
            break
        case .games(_, _, let order):
            nextSortOrder = order
        case .travel(_, _, let order):
            nextSortOrder = order
        case .customItem(let item):
            nextSortOrder = item.sortOrder
        }
        if nextSortOrder != nil { break }
        if case .dayHeader = flatItems[i] { break }
    }

    // CALCULATE sortOrder based on what we found
    switch (prevSortOrder, nextSortOrder) {
    case (nil, nil):
        return 1.0
    case (let prev?, nil):
        return prev + 1.0
    case (nil, let next?):
        return next / 2.0
    case (let prev?, let next?):
        return (prev + next) / 2.0
    }
}

Step 3: Commit

git add SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
git commit -m "feat(controller): allow custom items anywhere, update sortOrder calc"

Task 8: Update Travel Move Callback

Files:

  • Modify: SportsTime/Features/Trip/Views/ItineraryTableViewController.swift

Step 1: Update onTravelMoved callback signature

Change callback to include sortOrder:

var onTravelMoved: ((String, Int, Double) -> Void)?  // travelId, newDay, newSortOrder

Step 2: Update moveRowAt to pass sortOrder

In tableView(_:moveRowAt:to:):

case .travel(let segment, _):
    let newDay = dayForTravelAt(row: destinationIndexPath.row)
    let sortOrder = calculateSortOrder(at: destinationIndexPath.row)
    let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
    onTravelMoved?(travelId, newDay, sortOrder)

Step 3: Commit

git add SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
git commit -m "feat(controller): include sortOrder in travel move callback"

Task 9: Update ItineraryTableViewWrapper Callback

Files:

  • Modify: SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift

Step 1: Update onTravelMoved callback type

var onTravelMoved: ((String, Int, Double) -> Void)?  // travelId, newDay, newSortOrder

Step 2: Update makeUIViewController

controller.onTravelMoved = onTravelMoved

Step 3: Commit

git add SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift
git commit -m "feat(wrapper): update travel move callback for sortOrder"

Task 10: Update TripDetailView State and Handlers

Files:

  • Modify: SportsTime/Features/Trip/Views/TripDetailView.swift

Step 1: Update state type

@State private var travelDayOverrides: [String: TravelDayOverride] = [:]

Step 2: Update onTravelMoved handler

onTravelMoved: { travelId, newDay, newSortOrder in
    Task { @MainActor in
        // Create or update the override
        let override = TravelDayOverride(
            id: travelDayOverrides[travelId]?.id ?? UUID(),
            tripId: trip.id,
            travelAnchorId: travelId,
            displayDay: newDay,
            sortOrder: newSortOrder,
            createdAt: travelDayOverrides[travelId]?.createdAt ?? Date(),
            modifiedAt: Date()
        )

        withAnimation {
            travelDayOverrides[travelId] = override
        }

        await saveTravelDayOverride(override)
    }
}

Step 3: Update saveTravelDayOverride function

Change signature and implementation:

private func saveTravelDayOverride(_ override: TravelDayOverride) async {
    do {
        _ = try await TravelOverrideService.shared.saveOverride(override)
        print("✅ [TravelOverride] Saved: \(override.travelAnchorId) -> day \(override.displayDay), sortOrder \(override.sortOrder)")
    } catch {
        print("❌ [TravelOverride] Failed to save: \(error)")
    }
}

Step 4: Update loadCustomItems to use new return type

let overrides = try await TravelOverrideService.shared.fetchOverridesAsDictionary(forTripId: trip.id)
print("✅ [TravelOverrides] Loaded \(overrides.count) travel day overrides")
travelDayOverrides = overrides

Step 5: Build and fix any remaining compilation errors

Run: cd /Users/treyt/Desktop/code/SportsTime && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -30

Step 6: Commit

git add SportsTime/Features/Trip/Views/TripDetailView.swift
git commit -m "feat(tripdetail): update state and handlers for travel sortOrder"

Task 11: Update Cell Configuration for Games

Files:

  • Modify: SportsTime/Features/Trip/Views/ItineraryTableViewController.swift

Step 1: Update cellForRowAt for games case

case .games(let games, _, _):
    let cell = tableView.dequeueReusableCell(withIdentifier: gamesCellId, for: indexPath)
    configureGamesCell(cell, games: games)
    return cell

Step 2: Update travel case

case .travel(let segment, _, _):
    let cell = tableView.dequeueReusableCell(withIdentifier: travelCellId, for: indexPath)
    configureTravelCell(cell, segment: segment)
    return cell

Step 3: Build to verify

Run: cd /Users/treyt/Desktop/code/SportsTime && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20

Step 4: Commit

git add SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
git commit -m "fix(controller): update cell configuration for new enum cases"

Task 12: Full Build and Manual Test

Step 1: Full clean build

Run: cd /Users/treyt/Desktop/code/SportsTime && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' clean build 2>&1 | tail -30

Expected: BUILD SUCCEEDED

Step 2: Run tests

Run: cd /Users/treyt/Desktop/code/SportsTime && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test 2>&1 | tail -50

Expected: All tests pass

Step 3: Manual verification checklist

  • Open app, navigate to a trip with games and travel
  • Verify games appear with no drag handle
  • Verify custom items have drag handle
  • Verify travel has drag handle
  • Drag custom item BEFORE a game - should work
  • Drag custom item AFTER a game - should work
  • Drag travel to different position within same day - should work
  • Drag travel to different valid day - should work
  • Drag travel to invalid day - should snap to nearest valid

Step 4: Commit

git add -A
git commit -m "feat: complete flexible itinerary ordering implementation"

Task 13: Write Regression Tests

Files:

  • Create: SportsTimeTests/Features/ItineraryOrderingTests.swift

Step 1: Create test file with ordering tests

import XCTest
@testable import SportsTime

final class ItineraryOrderingTests: XCTestCase {

    // MARK: - sortOrder Calculation Tests

    func test_calculateSortOrder_betweenTwoItems_returnsMidpoint() {
        // Given items at sortOrder 1.0 and 3.0
        // When calculating sortOrder for position between them
        // Then should return 2.0
    }

    func test_calculateSortOrder_beforeFirstItem_returnsHalf() {
        // Given first item at sortOrder 2.0
        // When calculating sortOrder for position before it
        // Then should return 1.0 (half of 2.0)
    }

    func test_calculateSortOrder_afterLastItem_returnsLastPlusOne() {
        // Given last item at sortOrder 5.0
        // When calculating sortOrder for position after it
        // Then should return 6.0
    }

    func test_calculateSortOrder_emptyDay_returnsOne() {
        // Given day with no orderable items
        // When calculating sortOrder
        // Then should return 1.0
    }

    // MARK: - Travel Constraint Tests

    func test_travelConstraint_cannotMoveBefore DepartureGame() {
        // Given travel from City A to City B
        // And City A has game on Day 2
        // When attempting to move travel to Day 1
        // Then travel should snap to Day 3 (minimum valid)
    }

    func test_travelConstraint_canMoveToArrivalGameDay() {
        // Given travel from City A to City B
        // And City B has game on Day 5
        // When moving travel to Day 5
        // Then should be allowed (arrive morning of game)
    }

    // MARK: - Custom Item Flexibility Tests

    func test_customItem_canBePositionedBeforeGame() {
        // Given a day with a game at sortOrder 100
        // When custom item dropped before game
        // Then custom item should get sortOrder < 100
    }

    func test_customItem_canBePositionedBetweenTravelAndGame() {
        // Given travel at sortOrder 50, game at sortOrder 100
        // When custom item dropped between them
        // Then custom item should get sortOrder ~75
    }
}

Step 2: Run tests

Run: cd /Users/treyt/Desktop/code/SportsTime && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/ItineraryOrderingTests test 2>&1 | tail -30

Step 3: Commit

git add SportsTimeTests/Features/ItineraryOrderingTests.swift
git commit -m "test: add regression tests for flexible itinerary ordering"

Summary

This plan implements flexible itinerary ordering in 13 tasks:

  1. Tasks 1-3: Update data models (domain, CloudKit, service) to support sortOrder on travel
  2. Tasks 4-6: Update wrapper and controller interfaces for new data structure
  3. Tasks 7-9: Update drag constraints and callbacks
  4. Task 10: Update TripDetailView state management
  5. Tasks 11-12: Fix compilation, full build, manual test
  6. Task 13: Write regression tests

Key changes:

  • TravelDayOverride gains sortOrder: Double
  • Games get auto-assigned sortOrder (100, 101, 102...)
  • All orderable items (games, travel, custom) sort together within each day
  • Custom items can now go anywhere (before/after games)
  • Travel remains constrained to valid day range but can be positioned within a day