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>
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.