Files
Sportstime/docs/plans/2026-01-17-itinerary-reorder-implementation.md

42 KiB

Itinerary Reorder Refactor Implementation Plan

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

Goal: Refactor TripDetailView itinerary with constrained drag-and-drop where games are fixed, travel has hard constraints, and custom items are freely movable.

Architecture: Unified ItineraryItem model replaces separate CustomItineraryItem and TravelDayOverride. A testable ItineraryConstraints type validates all drag positions. Full itinerary structure stored in CloudKit with debounced sync.

Tech Stack: Swift, SwiftUI, UIKit (UITableView for drag), CloudKit, SwiftData


Phase 1: Data Model

Task 1: Create ItineraryItem Model

Files:

  • Create: SportsTime/Core/Models/Domain/ItineraryItem.swift

Step 1: Create the ItineraryItem struct and ItemKind enum

import Foundation
import CoreLocation

/// Unified model for all itinerary items (games, travel, custom)
struct ItineraryItem: Identifiable, Codable, Hashable {
    let id: UUID
    let tripId: UUID
    var day: Int                    // 1-indexed day number
    var sortOrder: Double           // Position within day (fractional)
    var kind: ItemKind
    var modifiedAt: Date

    init(
        id: UUID = UUID(),
        tripId: UUID,
        day: Int,
        sortOrder: Double,
        kind: ItemKind,
        modifiedAt: Date = Date()
    ) {
        self.id = id
        self.tripId = tripId
        self.day = day
        self.sortOrder = sortOrder
        self.kind = kind
        self.modifiedAt = modifiedAt
    }
}

/// The type of itinerary item
enum ItemKind: Codable, Hashable {
    case game(gameId: String)
    case travel(TravelInfo)
    case custom(CustomInfo)
}

/// Travel-specific information
struct TravelInfo: Codable, Hashable {
    let fromCity: String
    let toCity: String
    var distanceMeters: Double?
    var durationSeconds: Double?

    var formattedDistance: String {
        guard let meters = distanceMeters else { return "" }
        let miles = meters / 1609.34
        return String(format: "%.0f mi", miles)
    }

    var formattedDuration: String {
        guard let seconds = durationSeconds else { return "" }
        let hours = Int(seconds) / 3600
        let minutes = (Int(seconds) % 3600) / 60
        if hours > 0 {
            return "\(hours)h \(minutes)m"
        }
        return "\(minutes)m"
    }
}

/// Custom item information
struct CustomInfo: Codable, Hashable {
    var title: String
    var icon: String
    var time: Date?
    var latitude: Double?
    var longitude: Double?
    var address: String?

    var coordinate: CLLocationCoordinate2D? {
        guard let lat = latitude, let lon = longitude else { return nil }
        return CLLocationCoordinate2D(latitude: lat, longitude: lon)
    }

    var isMappable: Bool {
        latitude != nil && longitude != nil
    }
}

// MARK: - Convenience Properties

extension ItineraryItem {
    var isGame: Bool {
        if case .game = kind { return true }
        return false
    }

    var isTravel: Bool {
        if case .travel = kind { return true }
        return false
    }

    var isCustom: Bool {
        if case .custom = kind { return true }
        return false
    }

    var travelInfo: TravelInfo? {
        if case .travel(let info) = kind { return info }
        return nil
    }

    var customInfo: CustomInfo? {
        if case .custom(let info) = kind { return info }
        return nil
    }

    var gameId: String? {
        if case .game(let id) = kind { return id }
        return nil
    }

    /// Display title for the item
    var displayTitle: String {
        switch kind {
        case .game(let gameId):
            return "Game: \(gameId)"
        case .travel(let info):
            return "\(info.fromCity)\(info.toCity)"
        case .custom(let info):
            return info.title
        }
    }
}

Step 2: Verify file compiles

Run: cd /Users/treyt/Desktop/code/SportsTime/.worktrees/itinerary-reorder && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | grep -E "(error:|BUILD SUCCEEDED|BUILD FAILED)" Expected: BUILD SUCCEEDED

Step 3: Commit

git add SportsTime/Core/Models/Domain/ItineraryItem.swift
git commit -m "feat: add unified ItineraryItem model

Replaces CustomItineraryItem and TravelDayOverride with single model.
Supports game, travel, and custom item kinds.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 2: Create ItineraryConstraints Type

Files:

  • Create: SportsTime/Core/Models/Domain/ItineraryConstraints.swift
  • Create: SportsTimeTests/ItineraryConstraintsTests.swift

Step 1: Write failing tests for basic constraint validation

import XCTest
@testable import SportsTime

final class ItineraryConstraintsTests: XCTestCase {

    // MARK: - Custom Item Tests (No Constraints)

    func test_customItem_canGoOnAnyDay() {
        // Given: A 5-day trip with games on days 1 and 5
        let constraints = makeConstraints(tripDays: 5, gameDays: [1, 5])
        let customItem = makeCustomItem(day: 1, sortOrder: 50)

        // When/Then: Custom item can go on any day
        XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 1, sortOrder: 50))
        XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 2, sortOrder: 50))
        XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 3, sortOrder: 50))
        XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 4, sortOrder: 50))
        XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 5, sortOrder: 50))
    }

    func test_customItem_canGoBeforeOrAfterGames() {
        // Given: A day with a game at sortOrder 100
        let constraints = makeConstraints(tripDays: 3, gameDays: [2])
        let customItem = makeCustomItem(day: 2, sortOrder: 50)

        // When/Then: Custom item can go before or after game
        XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 2, sortOrder: 50))  // Before
        XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 2, sortOrder: 150)) // After
    }

    // MARK: - Travel Constraint Tests

    func test_travel_validDayRange_simpleCase() {
        // Given: Chicago game Day 1, Detroit game Day 3
        let constraints = makeConstraints(
            tripDays: 5,
            games: [
                makeGameItem(city: "Chicago", day: 1),
                makeGameItem(city: "Detroit", day: 3)
            ]
        )
        let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: 200)

        // When
        let range = constraints.validDayRange(for: travel)

        // Then: Travel can be on days 1 (after game), 2, or 3 (before game)
        XCTAssertEqual(range, 1...3)
    }

    func test_travel_mustBeAfterDepartureGames() {
        // Given: Chicago game on Day 1 at sortOrder 100
        let constraints = makeConstraints(
            tripDays: 3,
            games: [makeGameItem(city: "Chicago", day: 1, sortOrder: 100)]
        )
        let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: 50)

        // When/Then: Travel before game is invalid
        XCTAssertFalse(constraints.isValidPosition(for: travel, day: 1, sortOrder: 50))

        // Travel after game is valid
        XCTAssertTrue(constraints.isValidPosition(for: travel, day: 1, sortOrder: 150))
    }

    func test_travel_mustBeBeforeArrivalGames() {
        // Given: Detroit game on Day 3 at sortOrder 100
        let constraints = makeConstraints(
            tripDays: 3,
            games: [makeGameItem(city: "Detroit", day: 3, sortOrder: 100)]
        )
        let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 3, sortOrder: 150)

        // When/Then: Travel after arrival game is invalid
        XCTAssertFalse(constraints.isValidPosition(for: travel, day: 3, sortOrder: 150))

        // Travel before game is valid
        XCTAssertTrue(constraints.isValidPosition(for: travel, day: 3, sortOrder: 50))
    }

    func test_travel_canBeAnywhereOnRestDays() {
        // Given: Chicago game Day 1, Detroit game Day 4, rest days 2-3
        let constraints = makeConstraints(
            tripDays: 4,
            games: [
                makeGameItem(city: "Chicago", day: 1),
                makeGameItem(city: "Detroit", day: 4)
            ]
        )
        let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 2, sortOrder: 50)

        // When/Then: Any position on rest days is valid
        XCTAssertTrue(constraints.isValidPosition(for: travel, day: 2, sortOrder: 1))
        XCTAssertTrue(constraints.isValidPosition(for: travel, day: 2, sortOrder: 100))
        XCTAssertTrue(constraints.isValidPosition(for: travel, day: 2, sortOrder: 500))
        XCTAssertTrue(constraints.isValidPosition(for: travel, day: 3, sortOrder: 50))
    }

    func test_travel_mustBeAfterAllDepartureGamesOnSameDay() {
        // Given: Two games in Chicago on Day 1 (1pm and 7pm)
        let constraints = makeConstraints(
            tripDays: 3,
            games: [
                makeGameItem(city: "Chicago", day: 1, sortOrder: 100), // 1pm
                makeGameItem(city: "Chicago", day: 1, sortOrder: 101)  // 7pm
            ]
        )
        let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: 100.5)

        // When/Then: Between games is invalid
        XCTAssertFalse(constraints.isValidPosition(for: travel, day: 1, sortOrder: 100.5))

        // After all games is valid
        XCTAssertTrue(constraints.isValidPosition(for: travel, day: 1, sortOrder: 150))
    }

    func test_travel_mustBeBeforeAllArrivalGamesOnSameDay() {
        // Given: Two games in Detroit on Day 3 (1pm and 7pm)
        let constraints = makeConstraints(
            tripDays: 3,
            games: [
                makeGameItem(city: "Detroit", day: 3, sortOrder: 100), // 1pm
                makeGameItem(city: "Detroit", day: 3, sortOrder: 101)  // 7pm
            ]
        )
        let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 3, sortOrder: 100.5)

        // When/Then: Between games is invalid
        XCTAssertFalse(constraints.isValidPosition(for: travel, day: 3, sortOrder: 100.5))

        // Before all games is valid
        XCTAssertTrue(constraints.isValidPosition(for: travel, day: 3, sortOrder: 50))
    }

    func test_travel_cannotGoOutsideValidDayRange() {
        // Given: Chicago game Day 2, Detroit game Day 4
        let constraints = makeConstraints(
            tripDays: 5,
            games: [
                makeGameItem(city: "Chicago", day: 2),
                makeGameItem(city: "Detroit", day: 4)
            ]
        )
        let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: 50)

        // When/Then: Day 1 (before departure game) is invalid
        XCTAssertFalse(constraints.isValidPosition(for: travel, day: 1, sortOrder: 50))

        // Day 5 (after arrival game) is invalid
        XCTAssertFalse(constraints.isValidPosition(for: travel, day: 5, sortOrder: 50))
    }

    // MARK: - Barrier Games Tests

    func test_barrierGames_returnsDepartureAndArrivalGames() {
        // Given: Chicago games Days 1-2, Detroit games Days 4-5
        let chicagoGame1 = makeGameItem(city: "Chicago", day: 1, sortOrder: 100)
        let chicagoGame2 = makeGameItem(city: "Chicago", day: 2, sortOrder: 100)
        let detroitGame1 = makeGameItem(city: "Detroit", day: 4, sortOrder: 100)
        let detroitGame2 = makeGameItem(city: "Detroit", day: 5, sortOrder: 100)

        let constraints = makeConstraints(
            tripDays: 5,
            games: [chicagoGame1, chicagoGame2, detroitGame1, detroitGame2]
        )
        let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 3, sortOrder: 50)

        // When
        let barriers = constraints.barrierGames(for: travel)

        // Then: Returns the last Chicago game and first Detroit game
        XCTAssertEqual(barriers.count, 2)
        XCTAssertTrue(barriers.contains { $0.id == chicagoGame2.id })
        XCTAssertTrue(barriers.contains { $0.id == detroitGame1.id })
    }

    // MARK: - Helpers

    private let testTripId = UUID()

    private func makeConstraints(tripDays: Int, gameDays: [Int] = []) -> ItineraryConstraints {
        let games = gameDays.map { makeGameItem(city: "TestCity", day: $0) }
        return makeConstraints(tripDays: tripDays, games: games)
    }

    private func makeConstraints(tripDays: Int, games: [ItineraryItem]) -> ItineraryConstraints {
        return ItineraryConstraints(tripDayCount: tripDays, items: games)
    }

    private func makeGameItem(city: String, day: Int, sortOrder: Double = 100) -> ItineraryItem {
        // For tests, we use gameId to encode the city
        return ItineraryItem(
            tripId: testTripId,
            day: day,
            sortOrder: sortOrder,
            kind: .game(gameId: "game-\(city)-\(UUID().uuidString.prefix(4))")
        )
    }

    private func makeTravelItem(from: String, to: String, day: Int, sortOrder: Double) -> ItineraryItem {
        return ItineraryItem(
            tripId: testTripId,
            day: day,
            sortOrder: sortOrder,
            kind: .travel(TravelInfo(fromCity: from, toCity: to))
        )
    }

    private func makeCustomItem(day: Int, sortOrder: Double) -> ItineraryItem {
        return ItineraryItem(
            tripId: testTripId,
            day: day,
            sortOrder: sortOrder,
            kind: .custom(CustomInfo(title: "Test Item", icon: "star"))
        )
    }
}

Step 2: Run tests to verify they fail

Run: cd /Users/treyt/Desktop/code/SportsTime/.worktrees/itinerary-reorder && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/ItineraryConstraintsTests test 2>&1 | grep -E "(error:|Test Case|passed|failed)" Expected: Compilation errors (ItineraryConstraints type doesn't exist)

Step 3: Implement ItineraryConstraints

import Foundation

/// Validates itinerary item positions and calculates valid drop zones
struct ItineraryConstraints {
    let tripDayCount: Int
    private let items: [ItineraryItem]

    /// City extracted from game ID (format: "game-CityName-xxxx")
    private func city(forGameId gameId: String) -> String? {
        let components = gameId.components(separatedBy: "-")
        guard components.count >= 2 else { return nil }
        return components[1]
    }

    init(tripDayCount: Int, items: [ItineraryItem]) {
        self.tripDayCount = tripDayCount
        self.items = items
    }

    // MARK: - Public API

    /// Check if a position is valid for an item
    func isValidPosition(for item: ItineraryItem, day: Int, sortOrder: Double) -> Bool {
        // Day must be within trip range
        guard day >= 1 && day <= tripDayCount else { return false }

        switch item.kind {
        case .game:
            // Games are fixed, should never be moved
            return false

        case .travel(let info):
            return isValidTravelPosition(
                fromCity: info.fromCity,
                toCity: info.toCity,
                day: day,
                sortOrder: sortOrder
            )

        case .custom:
            // Custom items can go anywhere
            return true
        }
    }

    /// Get the valid day range for a travel item
    func validDayRange(for item: ItineraryItem) -> ClosedRange<Int>? {
        guard case .travel(let info) = item.kind else { return nil }

        let departureGameDays = gameDays(in: info.fromCity)
        let arrivalGameDays = gameDays(in: info.toCity)

        // Can leave on or after the day of last departure game
        let minDay = departureGameDays.max() ?? 1
        // Must arrive on or before the day of first arrival game
        let maxDay = arrivalGameDays.min() ?? tripDayCount

        guard minDay <= maxDay else { return nil }
        return minDay...maxDay
    }

    /// Get the games that act as barriers for a travel item (for visual highlighting)
    func barrierGames(for item: ItineraryItem) -> [ItineraryItem] {
        guard case .travel(let info) = item.kind else { return [] }

        var barriers: [ItineraryItem] = []

        // Last game in departure city
        let departureGames = games(in: info.fromCity).sorted { $0.day < $1.day || ($0.day == $1.day && $0.sortOrder < $1.sortOrder) }
        if let lastDeparture = departureGames.last {
            barriers.append(lastDeparture)
        }

        // First game in arrival city
        let arrivalGames = games(in: info.toCity).sorted { $0.day < $1.day || ($0.day == $1.day && $0.sortOrder < $1.sortOrder) }
        if let firstArrival = arrivalGames.first {
            barriers.append(firstArrival)
        }

        return barriers
    }

    // MARK: - Private Helpers

    private func isValidTravelPosition(fromCity: String, toCity: String, day: Int, sortOrder: Double) -> Bool {
        let departureGameDays = gameDays(in: fromCity)
        let arrivalGameDays = gameDays(in: toCity)

        let minDay = departureGameDays.max() ?? 1
        let maxDay = arrivalGameDays.min() ?? tripDayCount

        // Check day is in valid range
        guard day >= minDay && day <= maxDay else { return false }

        // Check sortOrder constraints on edge days
        if departureGameDays.contains(day) {
            // On a departure game day: must be after ALL games in that city on that day
            let maxGameSortOrder = games(in: fromCity)
                .filter { $0.day == day }
                .map { $0.sortOrder }
                .max() ?? 0

            if sortOrder <= maxGameSortOrder {
                return false
            }
        }

        if arrivalGameDays.contains(day) {
            // On an arrival game day: must be before ALL games in that city on that day
            let minGameSortOrder = games(in: toCity)
                .filter { $0.day == day }
                .map { $0.sortOrder }
                .min() ?? Double.greatestFiniteMagnitude

            if sortOrder >= minGameSortOrder {
                return false
            }
        }

        return true
    }

    private func gameDays(in city: String) -> [Int] {
        return games(in: city).map { $0.day }
    }

    private func games(in city: String) -> [ItineraryItem] {
        return items.filter { item in
            guard case .game(let gameId) = item.kind else { return false }
            return self.city(forGameId: gameId) == city
        }
    }
}

Step 4: Run tests to verify they pass

Run: cd /Users/treyt/Desktop/code/SportsTime/.worktrees/itinerary-reorder && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/ItineraryConstraintsTests test 2>&1 | grep -E "(Test Case|passed|failed)" Expected: All tests pass

Step 5: Commit

git add SportsTime/Core/Models/Domain/ItineraryConstraints.swift SportsTimeTests/ItineraryConstraintsTests.swift
git commit -m "feat: add ItineraryConstraints with full test coverage

Validates travel positions based on game locations:
- Travel must be after ALL departure city games
- Travel must be before ALL arrival city games
- Custom items have no constraints

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Phase 2: Service Layer

Task 3: Create ItineraryItemService

Files:

  • Create: SportsTime/Core/Services/ItineraryItemService.swift

Step 1: Create the service with CloudKit CRUD operations

import Foundation
import CloudKit
import Combine

/// Service for persisting and syncing ItineraryItems to CloudKit
actor ItineraryItemService {
    static let shared = ItineraryItemService()

    private let container = CKContainer(identifier: "iCloud.com.sportstime.app")
    private var database: CKDatabase { container.privateCloudDatabase }

    private let recordType = "ItineraryItem"

    // Debounce tracking
    private var pendingUpdates: [UUID: ItineraryItem] = [:]
    private var debounceTask: Task<Void, Never>?

    private init() {}

    // MARK: - CRUD Operations

    /// Fetch all items for a trip
    func fetchItems(forTripId tripId: UUID) async throws -> [ItineraryItem] {
        let predicate = NSPredicate(format: "tripId == %@", tripId.uuidString)
        let query = CKQuery(recordType: recordType, predicate: predicate)

        let (results, _) = try await database.records(matching: query)

        return results.compactMap { _, result in
            guard case .success(let record) = result else { return nil }
            return ItineraryItem(from: record)
        }
    }

    /// Create a new item
    func createItem(_ item: ItineraryItem) async throws -> ItineraryItem {
        let record = item.toCKRecord()
        let savedRecord = try await database.save(record)
        return ItineraryItem(from: savedRecord) ?? item
    }

    /// Update an existing item (debounced)
    func updateItem(_ item: ItineraryItem) async {
        pendingUpdates[item.id] = item

        // Cancel existing debounce
        debounceTask?.cancel()

        // Start new debounce
        debounceTask = Task {
            try? await Task.sleep(for: .seconds(1.5))

            guard !Task.isCancelled else { return }

            await flushPendingUpdates()
        }
    }

    /// Force immediate sync of pending updates
    func flushPendingUpdates() async {
        let updates = pendingUpdates
        pendingUpdates.removeAll()

        for (_, item) in updates {
            do {
                let record = item.toCKRecord()
                _ = try await database.save(record)
            } catch {
                // Silent retry - add back to pending
                pendingUpdates[item.id] = item
            }
        }
    }

    /// Delete an item
    func deleteItem(_ itemId: UUID) async throws {
        let recordId = CKRecord.ID(recordName: itemId.uuidString)
        try await database.deleteRecord(withID: recordId)
    }

    /// Delete all items for a trip
    func deleteItems(forTripId tripId: UUID) async throws {
        let items = try await fetchItems(forTripId: tripId)

        for item in items {
            let recordId = CKRecord.ID(recordName: item.id.uuidString)
            try? await database.deleteRecord(withID: recordId)
        }
    }
}

// MARK: - CloudKit Conversion

extension ItineraryItem {
    init?(from record: CKRecord) {
        guard let idString = record["itemId"] as? String,
              let id = UUID(uuidString: idString),
              let tripIdString = record["tripId"] as? String,
              let tripId = UUID(uuidString: tripIdString),
              let day = record["day"] as? Int,
              let sortOrder = record["sortOrder"] as? Double,
              let kindString = record["kind"] as? String,
              let modifiedAt = record["modifiedAt"] as? Date else {
            return nil
        }

        self.id = id
        self.tripId = tripId
        self.day = day
        self.sortOrder = sortOrder
        self.modifiedAt = modifiedAt

        // Parse kind
        switch kindString {
        case "game":
            guard let gameId = record["gameId"] as? String else { return nil }
            self.kind = .game(gameId: gameId)

        case "travel":
            guard let fromCity = record["travelFromCity"] as? String,
                  let toCity = record["travelToCity"] as? String else { return nil }
            let info = TravelInfo(
                fromCity: fromCity,
                toCity: toCity,
                distanceMeters: record["travelDistanceMeters"] as? Double,
                durationSeconds: record["travelDurationSeconds"] as? Double
            )
            self.kind = .travel(info)

        case "custom":
            guard let title = record["customTitle"] as? String,
                  let icon = record["customIcon"] as? String else { return nil }
            let info = CustomInfo(
                title: title,
                icon: icon,
                time: record["customTime"] as? Date,
                latitude: record["latitude"] as? Double,
                longitude: record["longitude"] as? Double,
                address: record["address"] as? String
            )
            self.kind = .custom(info)

        default:
            return nil
        }
    }

    func toCKRecord() -> CKRecord {
        let recordId = CKRecord.ID(recordName: id.uuidString)
        let record = CKRecord(recordType: "ItineraryItem", recordID: recordId)

        record["itemId"] = id.uuidString
        record["tripId"] = tripId.uuidString
        record["day"] = day
        record["sortOrder"] = sortOrder
        record["modifiedAt"] = modifiedAt

        switch kind {
        case .game(let gameId):
            record["kind"] = "game"
            record["gameId"] = gameId

        case .travel(let info):
            record["kind"] = "travel"
            record["travelFromCity"] = info.fromCity
            record["travelToCity"] = info.toCity
            record["travelDistanceMeters"] = info.distanceMeters
            record["travelDurationSeconds"] = info.durationSeconds

        case .custom(let info):
            record["kind"] = "custom"
            record["customTitle"] = info.title
            record["customIcon"] = info.icon
            record["customTime"] = info.time
            record["latitude"] = info.latitude
            record["longitude"] = info.longitude
            record["address"] = info.address
        }

        return record
    }
}

Step 2: Verify it compiles

Run: cd /Users/treyt/Desktop/code/SportsTime/.worktrees/itinerary-reorder && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | grep -E "(error:|BUILD SUCCEEDED|BUILD FAILED)" Expected: BUILD SUCCEEDED

Step 3: Commit

git add SportsTime/Core/Services/ItineraryItemService.swift
git commit -m "feat: add ItineraryItemService with CloudKit sync

Debounced updates (1.5s), local-first with silent retry.
Supports game, travel, and custom item kinds.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Phase 3: Delete Old Files

Task 4: Remove Legacy Models and Services

Files:

  • Delete: SportsTime/Core/Models/Domain/CustomItineraryItem.swift
  • Delete: SportsTime/Core/Models/Domain/TravelDayOverride.swift
  • Delete: SportsTime/Core/Services/CustomItemService.swift
  • Delete: SportsTime/Core/Services/CustomItemSubscriptionService.swift
  • Delete: SportsTime/Core/Services/TravelOverrideService.swift
  • Delete: SportsTime/Features/Trip/Views/CustomItemRow.swift

Step 1: Delete files and remove from Xcode project

cd /Users/treyt/Desktop/code/SportsTime/.worktrees/itinerary-reorder

# Delete the files
rm -f SportsTime/Core/Models/Domain/CustomItineraryItem.swift
rm -f SportsTime/Core/Models/Domain/TravelDayOverride.swift
rm -f SportsTime/Core/Services/CustomItemService.swift
rm -f SportsTime/Core/Services/CustomItemSubscriptionService.swift
rm -f SportsTime/Core/Services/TravelOverrideService.swift
rm -f SportsTime/Features/Trip/Views/CustomItemRow.swift

Step 2: Update TripDetailView to use new models (stub - will be completed in Phase 4)

This step will cause compilation errors. We'll fix them in the next phase.

Step 3: Commit deletions

git add -A
git commit -m "chore: remove legacy itinerary models and services

Removed:
- CustomItineraryItem
- TravelDayOverride
- CustomItemService
- CustomItemSubscriptionService
- TravelOverrideService
- CustomItemRow

These are replaced by unified ItineraryItem model and service.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Phase 4: UI Implementation

Task 5: Create Itinerary Row Views

Files:

  • Create: SportsTime/Features/Trip/Views/ItineraryRows/DayHeaderRow.swift
  • Create: SportsTime/Features/Trip/Views/ItineraryRows/GameItemRow.swift
  • Create: SportsTime/Features/Trip/Views/ItineraryRows/TravelItemRow.swift
  • Create: SportsTime/Features/Trip/Views/ItineraryRows/CustomItemRow.swift

Step 1: Create DayHeaderRow

import SwiftUI

struct DayHeaderRow: View {
    let dayNumber: Int
    let date: Date
    let onAddTapped: () -> Void

    @Environment(\.colorScheme) private var colorScheme

    private var formattedDate: String {
        date.formatted(.dateTime.weekday(.wide).month().day())
    }

    var body: some View {
        HStack {
            VStack(alignment: .leading, spacing: 2) {
                Text("Day \(dayNumber)")
                    .font(.title3)
                    .fontWeight(.semibold)
                    .foregroundStyle(Theme.textPrimary(colorScheme))

                Text(formattedDate)
                    .font(.subheadline)
                    .foregroundStyle(Theme.textSecondary(colorScheme))
            }

            Spacer()

            Button(action: onAddTapped) {
                Image(systemName: "plus.circle.fill")
                    .font(.title2)
                    .foregroundStyle(Theme.warmOrange)
            }
        }
        .padding(.vertical, Theme.Spacing.sm)
        .padding(.horizontal, Theme.Spacing.md)
    }
}

Step 2: Create GameItemRow (prominent card)

import SwiftUI

struct GameItemRow: View {
    let game: RichGame

    @Environment(\.colorScheme) private var colorScheme

    var body: some View {
        HStack(spacing: Theme.Spacing.md) {
            // Sport color bar
            SportColorBar(sport: game.game.sport)

            VStack(alignment: .leading, spacing: 4) {
                // Sport badge + Matchup
                HStack(spacing: 6) {
                    HStack(spacing: 3) {
                        Image(systemName: game.game.sport.iconName)
                            .font(.caption2)
                        Text(game.game.sport.rawValue)
                            .font(.caption2)
                    }
                    .foregroundStyle(game.game.sport.themeColor)

                    HStack(spacing: 4) {
                        Text(game.awayTeam.abbreviation)
                            .font(.body)
                        Text("@")
                            .foregroundStyle(Theme.textMuted(colorScheme))
                        Text(game.homeTeam.abbreviation)
                            .font(.body)
                    }
                    .foregroundStyle(Theme.textPrimary(colorScheme))
                }

                // Stadium
                HStack(spacing: 4) {
                    Image(systemName: "building.2")
                        .font(.caption2)
                    Text(game.stadium.name)
                        .font(.subheadline)
                }
                .foregroundStyle(Theme.textSecondary(colorScheme))
            }

            Spacer()

            // Time
            Text(game.localGameTimeShort)
                .font(.subheadline)
                .foregroundStyle(Theme.warmOrange)
        }
        .padding(Theme.Spacing.md)
        .background(Theme.cardBackgroundElevated(colorScheme))
        .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
        .overlay {
            RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
                .strokeBorder(game.game.sport.themeColor.opacity(0.3), lineWidth: 1)
        }
    }
}

Step 3: Create TravelItemRow (gold styling with drag handle)

import SwiftUI

struct TravelItemRow: View {
    let item: ItineraryItem
    let isHighlighted: Bool

    @Environment(\.colorScheme) private var colorScheme

    private var travelInfo: TravelInfo? {
        item.travelInfo
    }

    var body: some View {
        HStack(spacing: Theme.Spacing.md) {
            // Drag handle
            Image(systemName: "line.3.horizontal")
                .font(.title3)
                .foregroundStyle(Theme.textMuted(colorScheme))

            // Car icon
            ZStack {
                Circle()
                    .fill(Theme.routeGold.opacity(0.2))
                    .frame(width: 36, height: 36)

                Image(systemName: "car.fill")
                    .font(.body)
                    .foregroundStyle(Theme.routeGold)
            }

            VStack(alignment: .leading, spacing: 2) {
                if let info = travelInfo {
                    Text("\(info.fromCity)\(info.toCity)")
                        .font(.body)
                        .foregroundStyle(Theme.textPrimary(colorScheme))

                    HStack(spacing: Theme.Spacing.xs) {
                        if !info.formattedDistance.isEmpty {
                            Text(info.formattedDistance)
                                .font(.caption)
                        }
                        if !info.formattedDistance.isEmpty && !info.formattedDuration.isEmpty {
                            Text("•")
                        }
                        if !info.formattedDuration.isEmpty {
                            Text(info.formattedDuration)
                                .font(.caption)
                        }
                    }
                    .foregroundStyle(Theme.textSecondary(colorScheme))
                }
            }

            Spacer()
        }
        .padding(Theme.Spacing.md)
        .background(Theme.cardBackground(colorScheme))
        .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
        .overlay {
            RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
                .strokeBorder(isHighlighted ? Theme.routeGold : Theme.routeGold.opacity(0.3), lineWidth: isHighlighted ? 2 : 1)
        }
    }
}

Step 4: Create CustomItemRow (minimal with drag handle)

import SwiftUI

struct CustomItemRow: View {
    let item: ItineraryItem
    let onTap: () -> Void

    @Environment(\.colorScheme) private var colorScheme

    private var customInfo: CustomInfo? {
        item.customInfo
    }

    var body: some View {
        Button(action: onTap) {
            HStack(spacing: Theme.Spacing.md) {
                // Drag handle
                Image(systemName: "line.3.horizontal")
                    .font(.title3)
                    .foregroundStyle(Theme.textMuted(colorScheme))

                // Icon
                if let info = customInfo {
                    Text(info.icon)
                        .font(.title3)
                }

                // Title
                if let info = customInfo {
                    Text(info.title)
                        .font(.body)
                        .foregroundStyle(Theme.textPrimary(colorScheme))
                }

                Spacer()

                // Time (if set)
                if let time = customInfo?.time {
                    Text(time.formatted(.dateTime.hour().minute()))
                        .font(.caption)
                        .foregroundStyle(Theme.textSecondary(colorScheme))
                }
            }
            .padding(.vertical, Theme.Spacing.sm)
            .padding(.horizontal, Theme.Spacing.md)
        }
        .buttonStyle(.plain)
    }
}

Step 5: Verify compilation

Run: cd /Users/treyt/Desktop/code/SportsTime/.worktrees/itinerary-reorder && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | grep -E "(error:|BUILD SUCCEEDED|BUILD FAILED)" Expected: BUILD SUCCEEDED (or errors that we'll fix in next task)

Step 6: Commit

git add SportsTime/Features/Trip/Views/ItineraryRows/
git commit -m "feat: add itinerary row components

- DayHeaderRow with add button
- GameItemRow (prominent card, no drag handle)
- TravelItemRow (gold styling, drag handle)
- CustomItemRow (minimal, drag handle)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 6: Refactor TripDetailView Itinerary Section

Files:

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

This is a large refactor. The key changes:

  1. Replace customItems: [CustomItineraryItem] with itineraryItems: [ItineraryItem]
  2. Replace travelDayOverrides with items stored in itineraryItems
  3. Remove legacy service imports
  4. Update itinerarySections to use new model
  5. Update all row rendering to use new components

Step 1: Update state and imports

In TripDetailView, replace:

  • @State private var customItems: [CustomItineraryItem] = []
  • @State private var travelDayOverrides: [String: Int] = [:]

With:

  • @State private var itineraryItems: [ItineraryItem] = []

Step 2: Update loading logic

Replace loadCustomItems() and travel override loading with:

private func loadItineraryItems() async {
    do {
        let items = try await ItineraryItemService.shared.fetchItems(forTripId: trip.id)
        itineraryItems = items
    } catch {
        print("Failed to load itinerary items: \(error)")
    }
}

Step 3: Update sections building

The itinerarySections computed property needs to be updated to:

  1. Build games from trip data (as ItineraryItem with .game kind)
  2. Include travel and custom items from itineraryItems
  3. Sort by day then sortOrder

Step 4: Update row rendering

Update itineraryRow(for:at:) to use new row components.

Step 5: Update drag/drop handlers

Update all drag handlers to work with the new ItineraryItem model.

Note: This is a large refactor. See the design document for full details.

Step 6: Verify compilation and test

Run: cd /Users/treyt/Desktop/code/SportsTime/.worktrees/itinerary-reorder && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | grep -E "(error:|BUILD SUCCEEDED|BUILD FAILED)"

Step 7: Commit

git add SportsTime/Features/Trip/Views/TripDetailView.swift
git commit -m "refactor: update TripDetailView to use unified ItineraryItem

- Replace CustomItineraryItem and TravelDayOverride with ItineraryItem
- Use ItineraryItemService for persistence
- Update itinerary sections building
- Update row rendering with new components

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Phase 5: Drag & Drop with Constraints

Task 7: Implement Constraint-Aware Drag & Drop

Files:

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

Step 1: Add constraint validation during drag

Add properties:

private var constraints: ItineraryConstraints?
private var draggingItem: ItineraryItem?
private var invalidRowIndices: Set<Int> = []
private var barrierGameIds: Set<UUID> = []

Step 2: Calculate invalid zones on drag start

In tableView(_:dragSessionWillBegin:):

// Pre-calculate invalid rows and barrier games
if let item = draggingItem {
    if item.isTravel {
        barrierGameIds = Set(constraints?.barrierGames(for: item).map(\.id) ?? [])
    }
    invalidRowIndices = calculateInvalidRows(for: item)
}

Step 3: Apply visual feedback

In tableView(_:cellForRowAt:):

// Dim invalid zones
if draggingItem != nil && invalidRowIndices.contains(indexPath.row) {
    cell.contentView.alpha = 0.3
}

// Highlight barrier games
if let gameItem = rowItem.gameItem, barrierGameIds.contains(gameItem.id) {
    // Apply gold border highlight
}

Step 4: Block invalid drops

In tableView(_:targetIndexPathForMoveFromRowAt:toProposedIndexPath:):

// Validate proposed position
let targetDay = dayNumber(forRow: proposed.row)
let targetSortOrder = calculateSortOrder(at: proposed.row)

if let item = draggingItem,
   let constraints = constraints,
   !constraints.isValidPosition(for: item, day: targetDay, sortOrder: targetSortOrder) {
    // Find nearest valid position or return source
    return findNearestValidPosition(for: item, from: proposed.row) ?? source
}

Step 5: Add haptic feedback

private let feedbackGenerator = UIImpactFeedbackGenerator(style: .medium)

// On drag start
feedbackGenerator.prepare()
feedbackGenerator.impactOccurred()

// On valid zone hover
feedbackGenerator.impactOccurred(intensity: 0.5)

// On drop
feedbackGenerator.impactOccurred()

Step 6: Commit

git add SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
git commit -m "feat: add constraint-aware drag & drop

- Invalid zones dimmed during drag
- Barrier games highlighted for travel
- Drops blocked at invalid positions
- Haptic feedback on pickup, hover, drop

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Phase 6: Final Integration

Task 8: Update AddItemSheet

Files:

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

Update to create ItineraryItem with .custom kind instead of CustomItineraryItem.

Step 1: Update the item creation

let newItem = ItineraryItem(
    tripId: tripId,
    day: day,
    sortOrder: calculateNextSortOrder(),
    kind: .custom(CustomInfo(
        title: title,
        icon: selectedCategory.icon,
        time: selectedTime,
        latitude: selectedLocation?.latitude,
        longitude: selectedLocation?.longitude,
        address: selectedAddress
    ))
)

Step 2: Commit

git add SportsTime/Features/Trip/Views/AddItemSheet.swift
git commit -m "refactor: update AddItemSheet for ItineraryItem

Create ItineraryItem with .custom kind instead of CustomItineraryItem.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 9: Update PDF Export

Files:

  • Modify: SportsTime/Export/PDFGenerator.swift

Update to respect itinerary order from stored items.

Step 1: Update the itinerary rendering

Read items in sortOrder within each day instead of deriving order.

Step 2: Commit

git add SportsTime/Export/PDFGenerator.swift
git commit -m "refactor: update PDFGenerator for itinerary order

Respects user's custom itinerary ordering in PDF export.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 10: Run Full Test Suite

Step 1: Run all tests

Run: cd /Users/treyt/Desktop/code/SportsTime/.worktrees/itinerary-reorder && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test 2>&1 | grep -E "(Test Case|passed|failed|error:)"

Step 2: Fix any failures

Address any test failures before proceeding.

Step 3: Final commit

git add -A
git commit -m "test: verify all tests pass after itinerary refactor

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Summary

Phase Tasks Key Deliverables
1. Data Model 1-2 ItineraryItem, ItineraryConstraints with tests
2. Service 3 ItineraryItemService with CloudKit sync
3. Cleanup 4 Remove legacy files
4. UI 5-6 Row components, TripDetailView refactor
5. Drag/Drop 7 Constraint-aware drag with visual feedback
6. Integration 8-10 AddItemSheet, PDFGenerator, test suite

Total estimated tasks: 10 tasks across 6 phases