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:
- Replace
customItems: [CustomItineraryItem]withitineraryItems: [ItineraryItem] - Replace
travelDayOverrideswith items stored initineraryItems - Remove legacy service imports
- Update
itinerarySectionsto use new model - 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:
- Build games from trip data (as ItineraryItem with .game kind)
- Include travel and custom items from
itineraryItems - 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