From 59ba2c6965d7a6953484753e8cf89ee95d0a466d Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 17 Jan 2026 09:12:16 -0600 Subject: [PATCH] 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 --- ...01-17-itinerary-refactor-implementation.md | 1242 +++++++++++++++++ 1 file changed, 1242 insertions(+) create mode 100644 docs/plans/2026-01-17-itinerary-refactor-implementation.md diff --git a/docs/plans/2026-01-17-itinerary-refactor-implementation.md b/docs/plans/2026-01-17-itinerary-refactor-implementation.md new file mode 100644 index 0000000..292248f --- /dev/null +++ b/docs/plans/2026-01-17-itinerary-refactor-implementation.md @@ -0,0 +1,1242 @@ +# 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 " +``` + +--- + +## 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 " +``` + +--- + +## 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) 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 " +``` + +--- + +## 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 " +``` + +--- + +## 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 " +``` + +--- + +## 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 " +``` + +--- + +## 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 " +``` + +--- + +## 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 " +``` + +--- + +## 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 " +``` + +--- + +## 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 " +``` + +--- + +## 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 " +``` + +--- + +## 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.