# 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** ```swift 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** ```bash 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 " ``` --- ### 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** ```swift 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** ```swift 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? { 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** ```bash 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 " ``` --- ## Phase 2: Service Layer ### Task 3: Create ItineraryItemService **Files:** - Create: `SportsTime/Core/Services/ItineraryItemService.swift` **Step 1: Create the service with CloudKit CRUD operations** ```swift 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? 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** ```bash 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 " ``` --- ## 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** ```bash 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** ```bash 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 " ``` --- ## 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** ```swift 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)** ```swift 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)** ```swift 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)** ```swift 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** ```bash 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 " ``` --- ### 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: ```swift 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** ```bash 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 " ``` --- ## 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: ```swift private var constraints: ItineraryConstraints? private var draggingItem: ItineraryItem? private var invalidRowIndices: Set = [] private var barrierGameIds: Set = [] ``` **Step 2: Calculate invalid zones on drag start** In `tableView(_:dragSessionWillBegin:)`: ```swift // 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:)`: ```swift // 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:)`: ```swift // 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** ```swift 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** ```bash 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 " ``` --- ## 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** ```swift 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** ```bash 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 " ``` --- ### 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** ```bash 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 " ``` --- ### 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** ```bash git add -A git commit -m "test: verify all tests pass after itinerary refactor Co-Authored-By: Claude Opus 4.5 " ``` --- ## 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