Files
Sportstime/docs/plans/2026-01-17-itinerary-refactor-implementation.md
Trey t 59ba2c6965 docs: add detailed itinerary refactor implementation plan
11 bite-sized TDD tasks to replace anchor-based positioning with
simple (day, sortOrder) model. Includes migration path for CloudKit.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 09:12:16 -06:00

38 KiB

Itinerary Refactor Implementation Plan

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

Goal: Replace anchor-based positioning with simple sort-order positioning for custom itinerary items, enabling intuitive drag-and-drop without inference bugs.

Architecture: Remove anchorType and anchorId from CustomItineraryItem, replacing them with day: Int (already exists as anchorDay) and sortOrder: Double. Update all CloudKit, UI, and routing code to use the new model. Travel constraints remain enforced via valid ranges.

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


Task 1: Update CustomItineraryItem Model

Files:

  • Modify: SportsTime/Core/Models/Domain/CustomItineraryItem.swift
  • Modify: SportsTimeTests/Domain/CustomItineraryItemTests.swift

Step 1: Write failing test for new model structure

Add to CustomItineraryItemTests.swift:

@Test("Item initializes with day and sortOrder (no anchors)")
func item_InitializesWithDayAndSortOrder() {
    let tripId = UUID()
    let item = CustomItineraryItem(
        tripId: tripId,
        category: .restaurant,
        title: "Joe's BBQ",
        day: 1,
        sortOrder: 1.5
    )

    #expect(item.tripId == tripId)
    #expect(item.category == .restaurant)
    #expect(item.title == "Joe's BBQ")
    #expect(item.day == 1)
    #expect(item.sortOrder == 1.5)
}

@Test("SortOrder defaults to 0.0")
func sortOrder_DefaultsToZero() {
    let item = CustomItineraryItem(
        tripId: UUID(),
        category: .activity,
        title: "City Tour",
        day: 2
    )

    #expect(item.sortOrder == 0.0)
}

Step 2: Run test to verify it fails

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/CustomItineraryItemTests test

Expected: FAIL - initializer doesn't match

Step 3: Update CustomItineraryItem model

Replace SportsTime/Core/Models/Domain/CustomItineraryItem.swift:

//
//  CustomItineraryItem.swift
//  SportsTime
//

import Foundation
import CoreLocation

struct CustomItineraryItem: Identifiable, Codable, Hashable {
    let id: UUID
    let tripId: UUID
    var category: ItemCategory
    var title: String
    var day: Int           // Day number (1-indexed)
    var sortOrder: Double  // Position within day (allows insertion between items)
    let createdAt: Date
    var modifiedAt: Date

    // Optional location for mappable items (from MapKit search)
    var latitude: Double?
    var longitude: Double?
    var address: String?

    /// Whether this item has a location and can be shown on the map
    var isMappable: Bool {
        latitude != nil && longitude != nil
    }

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

    init(
        id: UUID = UUID(),
        tripId: UUID,
        category: ItemCategory,
        title: String,
        day: Int,
        sortOrder: Double = 0.0,
        createdAt: Date = Date(),
        modifiedAt: Date = Date(),
        latitude: Double? = nil,
        longitude: Double? = nil,
        address: String? = nil
    ) {
        self.id = id
        self.tripId = tripId
        self.category = category
        self.title = title
        self.day = day
        self.sortOrder = sortOrder
        self.createdAt = createdAt
        self.modifiedAt = modifiedAt
        self.latitude = latitude
        self.longitude = longitude
        self.address = address
    }

    enum ItemCategory: String, Codable, CaseIterable {
        case restaurant
        case hotel
        case activity
        case note

        var icon: String {
            switch self {
            case .restaurant: return "🍽️"
            case .hotel: return "🏨"
            case .activity: return "🎯"
            case .note: return "📝"
            }
        }

        var label: String {
            switch self {
            case .restaurant: return "Restaurant"
            case .hotel: return "Hotel"
            case .activity: return "Activity"
            case .note: return "Note"
            }
        }

        var systemImage: String {
            switch self {
            case .restaurant: return "fork.knife"
            case .hotel: return "bed.double.fill"
            case .activity: return "figure.run"
            case .note: return "note.text"
            }
        }
    }
}

Step 4: Update existing tests to use new model

Replace SportsTimeTests/Domain/CustomItineraryItemTests.swift:

//
//  CustomItineraryItemTests.swift
//  SportsTimeTests
//

import Testing
@testable import SportsTime
import Foundation

struct CustomItineraryItemTests {

    @Test("Item initializes with default values")
    func item_InitializesWithDefaults() {
        let tripId = UUID()
        let item = CustomItineraryItem(
            tripId: tripId,
            category: .restaurant,
            title: "Joe's BBQ",
            day: 1
        )

        #expect(item.tripId == tripId)
        #expect(item.category == .restaurant)
        #expect(item.title == "Joe's BBQ")
        #expect(item.day == 1)
        #expect(item.sortOrder == 0.0)
    }

    @Test("Item initializes with day and sortOrder")
    func item_InitializesWithDayAndSortOrder() {
        let tripId = UUID()
        let item = CustomItineraryItem(
            tripId: tripId,
            category: .restaurant,
            title: "Joe's BBQ",
            day: 1,
            sortOrder: 1.5
        )

        #expect(item.tripId == tripId)
        #expect(item.category == .restaurant)
        #expect(item.title == "Joe's BBQ")
        #expect(item.day == 1)
        #expect(item.sortOrder == 1.5)
    }

    @Test("SortOrder defaults to 0.0")
    func sortOrder_DefaultsToZero() {
        let item = CustomItineraryItem(
            tripId: UUID(),
            category: .activity,
            title: "City Tour",
            day: 2
        )

        #expect(item.sortOrder == 0.0)
    }

    @Test("Item category has correct icons")
    func category_HasCorrectIcons() {
        #expect(CustomItineraryItem.ItemCategory.restaurant.icon == "🍽️")
        #expect(CustomItineraryItem.ItemCategory.hotel.icon == "🏨")
        #expect(CustomItineraryItem.ItemCategory.activity.icon == "🎯")
        #expect(CustomItineraryItem.ItemCategory.note.icon == "📝")
    }

    @Test("Item is Codable")
    func item_IsCodable() throws {
        let item = CustomItineraryItem(
            tripId: UUID(),
            category: .hotel,
            title: "Hilton Downtown",
            day: 2,
            sortOrder: 3.5
        )

        let encoded = try JSONEncoder().encode(item)
        let decoded = try JSONDecoder().decode(CustomItineraryItem.self, from: encoded)

        #expect(decoded.id == item.id)
        #expect(decoded.title == item.title)
        #expect(decoded.day == 2)
        #expect(decoded.sortOrder == 3.5)
    }
}

Step 5: Run tests to verify they pass

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/CustomItineraryItemTests test

Expected: PASS

Step 6: Commit

git add SportsTime/Core/Models/Domain/CustomItineraryItem.swift SportsTimeTests/Domain/CustomItineraryItemTests.swift
git commit -m "refactor(model): replace anchors with day+sortOrder in CustomItineraryItem

BREAKING: Remove anchorType, anchorId, anchorDay
Add: day (Int), sortOrder (Double)

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

Task 2: Update CloudKit Model (CKCustomItineraryItem)

Files:

  • Modify: SportsTime/Core/Models/CloudKit/CKModels.swift (lines 628-713)

Step 1: Update CKCustomItineraryItem field keys and conversion

In CKModels.swift, replace the CKCustomItineraryItem struct (around line 628):

// MARK: - CKCustomItineraryItem

struct CKCustomItineraryItem {
    static let itemIdKey = "itemId"
    static let tripIdKey = "tripId"
    static let categoryKey = "category"
    static let titleKey = "title"
    static let dayKey = "day"                    // NEW: replaces anchorDay
    static let sortOrderDoubleKey = "sortOrderDouble"  // NEW: Double instead of Int
    static let createdAtKey = "createdAt"
    static let modifiedAtKey = "modifiedAt"
    // Location fields for mappable items
    static let latitudeKey = "latitude"
    static let longitudeKey = "longitude"
    static let addressKey = "address"
    // DEPRECATED - kept for migration reads only
    static let anchorTypeKey = "anchorType"
    static let anchorIdKey = "anchorId"
    static let anchorDayKey = "anchorDay"
    static let sortOrderKey = "sortOrder"

    let record: CKRecord

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

    init(item: CustomItineraryItem) {
        let record = CKRecord(
            recordType: CKRecordType.customItineraryItem,
            recordID: CKRecord.ID(recordName: item.id.uuidString)
        )
        record[CKCustomItineraryItem.itemIdKey] = item.id.uuidString
        record[CKCustomItineraryItem.tripIdKey] = item.tripId.uuidString
        record[CKCustomItineraryItem.categoryKey] = item.category.rawValue
        record[CKCustomItineraryItem.titleKey] = item.title
        record[CKCustomItineraryItem.dayKey] = item.day
        record[CKCustomItineraryItem.sortOrderDoubleKey] = item.sortOrder
        record[CKCustomItineraryItem.createdAtKey] = item.createdAt
        record[CKCustomItineraryItem.modifiedAtKey] = item.modifiedAt
        // Location fields (nil values are not stored in CloudKit)
        record[CKCustomItineraryItem.latitudeKey] = item.latitude
        record[CKCustomItineraryItem.longitudeKey] = item.longitude
        record[CKCustomItineraryItem.addressKey] = item.address
        self.record = record
    }

    func toItem() -> CustomItineraryItem? {
        guard let itemIdString = record[CKCustomItineraryItem.itemIdKey] as? String,
              let itemId = UUID(uuidString: itemIdString),
              let tripIdString = record[CKCustomItineraryItem.tripIdKey] as? String,
              let tripId = UUID(uuidString: tripIdString),
              let categoryString = record[CKCustomItineraryItem.categoryKey] as? String,
              let category = CustomItineraryItem.ItemCategory(rawValue: categoryString),
              let title = record[CKCustomItineraryItem.titleKey] as? String,
              let createdAt = record[CKCustomItineraryItem.createdAtKey] as? Date,
              let modifiedAt = record[CKCustomItineraryItem.modifiedAtKey] as? Date
        else { return nil }

        // Read new fields, with migration fallback from old fields
        let day: Int
        if let newDay = record[CKCustomItineraryItem.dayKey] as? Int {
            day = newDay
        } else if let oldDay = record[CKCustomItineraryItem.anchorDayKey] as? Int {
            // Migration: use old anchorDay
            day = oldDay
        } else {
            return nil
        }

        let sortOrder: Double
        if let newSortOrder = record[CKCustomItineraryItem.sortOrderDoubleKey] as? Double {
            sortOrder = newSortOrder
        } else if let oldSortOrder = record[CKCustomItineraryItem.sortOrderKey] as? Int {
            // Migration: convert old Int sortOrder to Double
            sortOrder = Double(oldSortOrder)
        } else {
            sortOrder = 0.0
        }

        // Location fields (optional - nil if not stored)
        let latitude = record[CKCustomItineraryItem.latitudeKey] as? Double
        let longitude = record[CKCustomItineraryItem.longitudeKey] as? Double
        let address = record[CKCustomItineraryItem.addressKey] as? String

        return CustomItineraryItem(
            id: itemId,
            tripId: tripId,
            category: category,
            title: title,
            day: day,
            sortOrder: sortOrder,
            createdAt: createdAt,
            modifiedAt: modifiedAt,
            latitude: latitude,
            longitude: longitude,
            address: address
        )
    }
}

Step 2: Run build to verify compilation

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build

Expected: Build errors in files that reference old anchor fields

Step 3: Commit (partial - model layer)

git add SportsTime/Core/Models/CloudKit/CKModels.swift
git commit -m "refactor(cloudkit): update CKCustomItineraryItem for day+sortOrder

Add migration fallback for existing records with old anchor fields.

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

Task 3: Update CustomItemService

Files:

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

Step 1: Update field references in CustomItemService

Replace the update methods in CustomItemService.swift:

func updateItem(_ item: CustomItineraryItem) async throws -> CustomItineraryItem {
    // Fetch existing record to get changeTag
    let recordID = CKRecord.ID(recordName: item.id.uuidString)
    let existingRecord: CKRecord

    do {
        existingRecord = try await publicDatabase.record(for: recordID)
    } catch let error as CKError {
        throw mapCloudKitError(error)
    } catch {
        throw CustomItemError.unknown(error)
    }

    // Update fields
    let now = Date()
    existingRecord[CKCustomItineraryItem.categoryKey] = item.category.rawValue
    existingRecord[CKCustomItineraryItem.titleKey] = item.title
    existingRecord[CKCustomItineraryItem.dayKey] = item.day
    existingRecord[CKCustomItineraryItem.sortOrderDoubleKey] = item.sortOrder
    existingRecord[CKCustomItineraryItem.modifiedAtKey] = now
    // Location fields (nil values clear the field in CloudKit)
    existingRecord[CKCustomItineraryItem.latitudeKey] = item.latitude
    existingRecord[CKCustomItineraryItem.longitudeKey] = item.longitude
    existingRecord[CKCustomItineraryItem.addressKey] = item.address

    do {
        try await publicDatabase.save(existingRecord)
        var updatedItem = item
        updatedItem.modifiedAt = now
        return updatedItem
    } catch let error as CKError {
        throw mapCloudKitError(error)
    } catch {
        throw CustomItemError.unknown(error)
    }
}

/// Batch update sortOrder for multiple items (for reordering)
func updateSortOrders(_ items: [CustomItineraryItem]) async throws {
    guard !items.isEmpty else { return }
    print("☁️ [CloudKit] Batch updating day+sortOrder for \(items.count) items")

    // Fetch all records
    let recordIDs = items.map { CKRecord.ID(recordName: $0.id.uuidString) }
    let fetchResults = try await publicDatabase.records(for: recordIDs)

    // Update each record's day and sortOrder
    var recordsToSave: [CKRecord] = []
    let now = Date()

    for item in items {
        let recordID = CKRecord.ID(recordName: item.id.uuidString)
        guard case .success(let record) = fetchResults[recordID] else { continue }

        record[CKCustomItineraryItem.dayKey] = item.day
        record[CKCustomItineraryItem.sortOrderDoubleKey] = item.sortOrder
        record[CKCustomItineraryItem.modifiedAtKey] = now
        recordsToSave.append(record)
    }

    // Save all in one batch operation
    let modifyOp = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: nil)
    modifyOp.savePolicy = .changedKeys

    try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
        modifyOp.modifyRecordsResultBlock = { result in
            switch result {
            case .success:
                print("☁️ [CloudKit] Batch day+sortOrder update complete")
                continuation.resume()
            case .failure(let error):
                print("☁️ [CloudKit] Batch update failed: \(error)")
                continuation.resume(throwing: error)
            }
        }
        publicDatabase.add(modifyOp)
    }
}

Step 2: Run build to verify

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build

Expected: More build errors in UI files (expected at this stage)

Step 3: Commit

git add SportsTime/Core/Services/CustomItemService.swift
git commit -m "refactor(service): update CustomItemService for day+sortOrder

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

Task 4: Update ItineraryRowItem Enum

Files:

  • Modify: SportsTime/Features/Trip/Views/ItineraryTableViewController.swift (lines 26-57)

Step 1: Simplify ItineraryRowItem - remove anchor parameters from addButton

Update the enum at the top of ItineraryTableViewController.swift:

/// Represents a row item in the itinerary
enum ItineraryRowItem: Identifiable, Equatable {
    case dayHeader(dayNumber: Int, date: Date, games: [RichGame])
    case travel(TravelSegment, dayNumber: Int)
    case customItem(CustomItineraryItem)
    case addButton(day: Int)  // Simplified - just needs day

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

    var isReorderable: Bool {
        switch self {
        case .dayHeader, .addButton:
            return false
        case .travel, .customItem:
            return true
        }
    }

    static func == (lhs: ItineraryRowItem, rhs: ItineraryRowItem) -> Bool {
        lhs.id == rhs.id
    }
}

Step 2: Update callbacks in ItineraryTableViewController

Change the callback signatures (around line 70):

// Callbacks
var onTravelMoved: ((String, Int) -> Void)?  // travelId, newDay
var onCustomItemMoved: ((UUID, Int, Double) -> Void)?  // itemId, newDay, newSortOrder
var onCustomItemTapped: ((CustomItineraryItem) -> Void)?
var onCustomItemDeleted: ((CustomItineraryItem) -> Void)?
var onAddButtonTapped: ((Int) -> Void)?  // Just day number

Step 3: Run build to find remaining errors

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | head -100

Expected: Build errors - note which files need updates

Step 4: Commit (partial)

git add SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
git commit -m "refactor(ui): simplify ItineraryRowItem callbacks for day+sortOrder

WIP: Will fix remaining compilation errors in next tasks

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

Task 5: Update ItineraryTableViewController Move Logic

Files:

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

Step 1: Replace determineAnchor with calculateSortOrder

Remove determineAnchor function and add new sort order calculation (around line 438):

// MARK: - Sort Order Calculation

/// Calculate sort order for item dropped at a given row within its day
private func calculateSortOrder(droppedAtRow row: Int, inDay targetDay: Int) -> Double {
    // Find all custom items currently in the target day
    let itemsInDay = flatItems.enumerated().compactMap { (index, item) -> (index: Int, sortOrder: Double)? in
        guard case .customItem(let custom) = item, custom.day == targetDay else { return nil }
        return (index, custom.sortOrder)
    }

    // If no items in this day, start at 1.0
    guard !itemsInDay.isEmpty else {
        return 1.0
    }

    // Find where the drop position falls relative to existing items
    let itemsBefore = itemsInDay.filter { $0.index < row }
    let itemsAfter = itemsInDay.filter { $0.index >= row }

    if itemsBefore.isEmpty {
        // Dropping before all items - use half of first item's sortOrder
        let firstOrder = itemsInDay.first?.sortOrder ?? 1.0
        return firstOrder / 2.0
    }

    if itemsAfter.isEmpty {
        // Dropping after all items - use last + 1
        let lastOrder = itemsBefore.last?.sortOrder ?? 0.0
        return lastOrder + 1.0
    }

    // Dropping between items - use midpoint
    let beforeOrder = itemsBefore.last?.sortOrder ?? 0.0
    let afterOrder = itemsAfter.first?.sortOrder ?? (beforeOrder + 2.0)
    return (beforeOrder + afterOrder) / 2.0
}

Step 2: Update moveRowAt to use new callback

Replace the move handling in tableView(_:moveRowAt:to:) (around line 250):

override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
    let item = flatItems[sourceIndexPath.row]

    // Remove from source
    flatItems.remove(at: sourceIndexPath.row)

    // Insert at destination
    flatItems.insert(item, at: destinationIndexPath.row)

    // Notify callbacks
    switch item {
    case .travel(let segment, _):
        // Find which day this travel is now associated with
        let newDay = dayForTravelAt(row: destinationIndexPath.row)
        let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
        onTravelMoved?(travelId, newDay)

    case .customItem(let customItem):
        let destinationDay = dayNumber(forRow: destinationIndexPath.row)
        let newSortOrder = calculateSortOrder(droppedAtRow: destinationIndexPath.row, inDay: destinationDay)
        onCustomItemMoved?(customItem.id, destinationDay, newSortOrder)

    default:
        break
    }
}

Step 3: Update cell configuration for addButton

Update configureAddButtonCell (around line 522):

private func configureAddButtonCell(_ cell: UITableViewCell, day: Int) {
    cell.contentConfiguration = UIHostingConfiguration {
        AddButtonRowView(colorScheme: colorScheme)
    }
    .margins(.all, 0)
    .background(.clear)

    cell.backgroundColor = .clear
    cell.selectionStyle = .default
}

Step 4: Update cellForRowAt for addButton case

Update the switch case (around line 237):

case .addButton(let day):
    let cell = tableView.dequeueReusableCell(withIdentifier: addButtonCellId, for: indexPath)
    configureAddButtonCell(cell, day: day)
    return cell

Step 5: Update didSelectRowAt for addButton case

Update the selection handling (around line 406):

case .addButton(let day):
    onAddButtonTapped?(day)

Step 6: Run build

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | head -100

Step 7: Commit

git add SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
git commit -m "refactor(ui): replace anchor inference with direct sortOrder calculation

- Remove determineAnchor function
- Add calculateSortOrder for direct position computation
- Simplify addButton to just use day number

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

Task 6: Update ItineraryTableViewWrapper

Files:

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

Step 1: Update callback signatures

Update the callback types (around line 20-24):

// Callbacks
var onTravelMoved: ((String, Int) -> Void)?
var onCustomItemMoved: ((UUID, Int, Double) -> Void)?  // itemId, day, sortOrder
var onCustomItemTapped: ((CustomItineraryItem) -> Void)?
var onCustomItemDeleted: ((CustomItineraryItem) -> Void)?
var onAddButtonTapped: ((Int) -> Void)?  // Just day

Step 2: Update init parameters

Update the initializer (around line 26-48):

init(
    trip: Trip,
    games: [RichGame],
    customItems: [CustomItineraryItem],
    travelDayOverrides: [String: Int],
    @ViewBuilder headerContent: () -> HeaderContent,
    onTravelMoved: ((String, Int) -> Void)? = nil,
    onCustomItemMoved: ((UUID, Int, Double) -> Void)? = nil,
    onCustomItemTapped: ((CustomItineraryItem) -> Void)? = nil,
    onCustomItemDeleted: ((CustomItineraryItem) -> Void)? = nil,
    onAddButtonTapped: ((Int) -> Void)? = nil
) {
    self.trip = trip
    self.games = games
    self.customItems = customItems
    self.travelDayOverrides = travelDayOverrides
    self.headerContent = headerContent()
    self.onTravelMoved = onTravelMoved
    self.onCustomItemMoved = onCustomItemMoved
    self.onCustomItemTapped = onCustomItemTapped
    self.onCustomItemDeleted = onCustomItemDeleted
    self.onAddButtonTapped = onAddButtonTapped
}

Step 3: Update buildItineraryData to use new model

Replace the custom item handling in buildItineraryData() (around line 163-202):

// Custom items for this day (sorted by sortOrder)
let itemsForDay = customItems.filter { $0.day == dayNum }
    .sorted { $0.sortOrder < $1.sortOrder }

for item in itemsForDay {
    items.append(ItineraryRowItem.customItem(item))
}

// ONE Add button per day - at the end
items.append(ItineraryRowItem.addButton(day: dayNum))

Step 4: Run build

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | head -100

Step 5: Commit

git add SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift
git commit -m "refactor(ui): update ItineraryTableViewWrapper for day+sortOrder

- Simplify custom item grouping (just filter by day, sort by sortOrder)
- Remove anchor-based item placement logic

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

Task 7: Update TripDetailView Callbacks

Files:

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

Step 1: Update onCustomItemMoved callback

Find the ItineraryTableViewWrapper usage (around line 186-242) and update:

onCustomItemMoved: { itemId, day, sortOrder in
    Task { @MainActor in
        guard let item = customItems.first(where: { $0.id == itemId }) else { return }
        await moveItem(item, toDay: day, sortOrder: sortOrder)
    }
},

Step 2: Update onAddButtonTapped callback

onAddButtonTapped: { day in
    addItemAnchor = AddItemAnchor(day: day)
}

Step 3: Update AddItemAnchor struct

Find the AddItemAnchor struct and simplify it:

struct AddItemAnchor: Identifiable {
    let id = UUID()
    let day: Int
}

Step 4: Add new moveItem function

Add a new simplified move function:

private func moveItem(_ item: CustomItineraryItem, toDay day: Int, sortOrder: Double) async {
    print("📦 [Move] Moving '\(item.title)' to day \(day), sortOrder \(sortOrder)")

    // Update local state immediately
    var updated = item
    updated.day = day
    updated.sortOrder = sortOrder
    updated.modifiedAt = Date()

    if let index = customItems.firstIndex(where: { $0.id == item.id }) {
        customItems[index] = updated
    }

    // Persist to CloudKit
    do {
        _ = try await CustomItemService.shared.updateItem(updated)
        print("✅ [Move] Saved to CloudKit")
    } catch {
        print("❌ [Move] CloudKit error: \(error)")
    }
}

Step 5: Remove old moveItemToBeginning function

Delete the old moveItemToBeginning function that used anchors.

Step 6: Run build

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | head -100

Step 7: Commit

git add SportsTime/Features/Trip/Views/TripDetailView.swift
git commit -m "refactor(ui): update TripDetailView for day+sortOrder callbacks

- Simplify AddItemAnchor to just day
- Replace moveItemToBeginning with direct moveItem
- Update callback handlers

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

Task 8: Update AddItemSheet

Files:

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

Step 1: Simplify AddItemSheet parameters

Update the struct properties and initializer (around line 11-48):

struct AddItemSheet: View {
    @Environment(\.dismiss) private var dismiss
    @Environment(\.colorScheme) private var colorScheme

    let tripId: UUID
    let day: Int
    let existingItem: CustomItineraryItem?
    var onSave: (CustomItineraryItem) -> Void

    // Entry mode
    enum EntryMode: String, CaseIterable {
        case searchPlaces = "Search Places"
        case custom = "Custom"
    }

    @State private var entryMode: EntryMode = .searchPlaces
    @State private var selectedCategory: CustomItineraryItem.ItemCategory = .restaurant
    @State private var title: String = ""
    @State private var isSaving = false

    // MapKit search state
    @State private var searchQuery = ""
    @State private var searchResults: [MKMapItem] = []
    @State private var selectedPlace: MKMapItem?
    @State private var isSearching = false

    private var isEditing: Bool { existingItem != nil }

Step 2: Update saveItem function

Replace the saveItem() function (around line 247):

private func saveItem() {
    isSaving = true

    let item: CustomItineraryItem

    if let existing = existingItem {
        // Editing existing item - preserve day and sortOrder
        let trimmedTitle = title.trimmingCharacters(in: .whitespaces)
        guard !trimmedTitle.isEmpty else { return }

        item = CustomItineraryItem(
            id: existing.id,
            tripId: existing.tripId,
            category: selectedCategory,
            title: trimmedTitle,
            day: existing.day,
            sortOrder: existing.sortOrder,
            createdAt: existing.createdAt,
            modifiedAt: Date(),
            latitude: existing.latitude,
            longitude: existing.longitude,
            address: existing.address
        )
    } else if entryMode == .searchPlaces, let place = selectedPlace {
        // Creating from MapKit search
        let placeName = place.name ?? "Unknown Place"
        let coordinate = place.placemark.coordinate

        item = CustomItineraryItem(
            tripId: tripId,
            category: selectedCategory,
            title: placeName,
            day: day,
            sortOrder: Double(Date().timeIntervalSince1970),  // Use timestamp for unique sortOrder
            latitude: coordinate.latitude,
            longitude: coordinate.longitude,
            address: formatAddress(for: place)
        )
    } else {
        // Creating custom item (no location)
        let trimmedTitle = title.trimmingCharacters(in: .whitespaces)
        guard !trimmedTitle.isEmpty else { return }

        item = CustomItineraryItem(
            tripId: tripId,
            category: selectedCategory,
            title: trimmedTitle,
            day: day,
            sortOrder: Double(Date().timeIntervalSince1970)
        )
    }

    onSave(item)
    dismiss()
}

Step 3: Update onAppear

.onAppear {
    if let existing = existingItem {
        selectedCategory = existing.category
        title = existing.title
        entryMode = .custom
    }
}

Step 4: Update Preview

#Preview {
    AddItemSheet(
        tripId: UUID(),
        day: 1,
        existingItem: nil
    ) { _ in }
}

Step 5: Run build

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | head -100

Step 6: Commit

git add SportsTime/Features/Trip/Views/AddItemSheet.swift
git commit -m "refactor(ui): simplify AddItemSheet to use day only

- Remove anchorType and anchorId parameters
- Use timestamp for initial sortOrder of new items

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

Task 9: Update TripDetailView Sheet Presentation

Files:

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

Step 1: Update sheet bindings for AddItemSheet

Find the .sheet(item: $addItemAnchor) (around line 119) and update:

.sheet(item: $addItemAnchor) { anchor in
    AddItemSheet(
        tripId: trip.id,
        day: anchor.day,
        existingItem: nil
    ) { item in
        Task { await saveCustomItem(item) }
    }
}
.sheet(item: $editingItem) { item in
    AddItemSheet(
        tripId: trip.id,
        day: item.day,
        existingItem: item
    ) { updatedItem in
        Task { await saveCustomItem(updatedItem) }
    }
}

Step 2: Run build

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build

Expected: PASS

Step 3: Commit

git add SportsTime/Features/Trip/Views/TripDetailView.swift
git commit -m "refactor(ui): update TripDetailView sheet bindings for simplified AddItemSheet

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

Task 10: Update routeWaypoints for Simplified Model

Files:

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

Step 1: Simplify routeWaypoints computed property

Replace the routeWaypoints property (around line 1154):

/// Route waypoints including both game stops and mappable custom items in itinerary order
private var routeWaypoints: [(name: String, coordinate: CLLocationCoordinate2D, isCustomItem: Bool)] {
    var waypoints: [(name: String, coordinate: CLLocationCoordinate2D, isCustomItem: Bool)] = []
    let days = tripDays

    // Group custom items by day, sorted by sortOrder
    let itemsByDay = Dictionary(grouping: mappableCustomItems) { $0.day }

    for (dayIndex, dayDate) in days.enumerated() {
        let dayNumber = dayIndex + 1

        // Find games on this day
        let gamesOnDay = gamesOn(date: dayDate)
        let calendar = Calendar.current
        let dayCity = gamesOnDay.first?.stadium.city ?? trip.stops.first(where: { stop in
            let arrival = calendar.startOfDay(for: stop.arrivalDate)
            let departure = calendar.startOfDay(for: stop.departureDate)
            let day = calendar.startOfDay(for: dayDate)
            return day >= arrival && day <= departure
        })?.city

        // Game stop for this day (only add once per city)
        if let city = dayCity {
            let alreadyHasCity = waypoints.contains(where: { wp in
                if wp.isCustomItem { return false }
                if wp.name == city { return true }
                if let stop = trip.stops.first(where: { $0.city == city }),
                   let stadiumId = stop.stadium,
                   let stadium = dataProvider.stadium(for: stadiumId),
                   wp.name == stadium.name { return true }
                return false
            })

            if !alreadyHasCity {
                if let stop = trip.stops.first(where: { $0.city == city }) {
                    if let stadiumId = stop.stadium,
                       let stadium = dataProvider.stadium(for: stadiumId) {
                        waypoints.append((stadium.name, stadium.coordinate, false))
                    } else if let coord = stop.coordinate {
                        waypoints.append((city, coord, false))
                    }
                }
            }
        }

        // Custom items for this day (in sortOrder)
        if let items = itemsByDay[dayNumber] {
            let sortedItems = items.sorted { $0.sortOrder < $1.sortOrder }
            for item in sortedItems {
                if let coord = item.coordinate {
                    waypoints.append((item.title, coord, true))
                }
            }
        }
    }

    return waypoints
}

Step 2: Remove debug logging (optional cleanup)

Remove the debug print statements if desired.

Step 3: Run full test suite

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test

Step 4: Commit

git add SportsTime/Features/Trip/Views/TripDetailView.swift
git commit -m "refactor(routing): simplify routeWaypoints for day+sortOrder model

Route now follows exact visual position: games first, then custom items
sorted by sortOrder within each day.

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

Task 11: Final Cleanup and Testing

Files:

  • All modified files

Step 1: Run full build

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build

Expected: BUILD SUCCEEDED

Step 2: Run all tests

Run: xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test

Expected: All tests pass

Step 3: Manual testing checklist

  • Add a custom item to Day 1
  • Add a custom item to Day 3
  • Drag item from Day 1 to Day 3
  • Verify item appears in correct position
  • Verify map route updates correctly
  • Edit a custom item
  • Delete a custom item
  • Drag travel segment to different day
  • Verify travel constraints are enforced

Step 4: Final commit

git add -A
git commit -m "feat(itinerary): complete refactor to day+sortOrder positioning

Replaces complex anchor-based system with simple (day, sortOrder) positioning.
Benefits:
- Items dropped exactly where you expect
- Route follows visual order
- No more disappearing items
- Simpler codebase

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

Summary

Task Description Estimated Complexity
1 Update CustomItineraryItem model Simple
2 Update CKCustomItineraryItem CloudKit model Simple
3 Update CustomItemService Simple
4 Update ItineraryRowItem enum Simple
5 Update ItineraryTableViewController move logic Medium
6 Update ItineraryTableViewWrapper Medium
7 Update TripDetailView callbacks Medium
8 Update AddItemSheet Simple
9 Update TripDetailView sheet presentation Simple
10 Update routeWaypoints Simple
11 Final cleanup and testing Simple

Total: 11 tasks, mostly simple refactoring with compile-time safety checks.