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

1243 lines
38 KiB
Markdown

# 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`:
```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`:
```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`:
```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**
```bash
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):
```swift
// 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)**
```bash
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`:
```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**
```bash
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`:
```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):
```swift
// 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)**
```bash
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):
```swift
// 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):
```swift
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):
```swift
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):
```swift
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):
```swift
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**
```bash
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):
```swift
// 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):
```swift
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):
```swift
// 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**
```bash
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:
```swift
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**
```swift
onAddButtonTapped: { day in
addItemAnchor = AddItemAnchor(day: day)
}
```
**Step 3: Update AddItemAnchor struct**
Find the `AddItemAnchor` struct and simplify it:
```swift
struct AddItemAnchor: Identifiable {
let id = UUID()
let day: Int
}
```
**Step 4: Add new moveItem function**
Add a new simplified move function:
```swift
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**
```bash
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):
```swift
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):
```swift
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**
```swift
.onAppear {
if let existing = existingItem {
selectedCategory = existing.category
title = existing.title
entryMode = .custom
}
}
```
**Step 4: Update Preview**
```swift
#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**
```bash
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:
```swift
.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**
```bash
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):
```swift
/// 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**
```bash
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**
```bash
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.