diff --git a/docs/plans/2026-01-17-flexible-itinerary-ordering-plan.md b/docs/plans/2026-01-17-flexible-itinerary-ordering-plan.md new file mode 100644 index 0000000..a28f973 --- /dev/null +++ b/docs/plans/2026-01-17-flexible-itinerary-ordering-plan.md @@ -0,0 +1,784 @@ +# Flexible Itinerary Ordering Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Enable fully flexible ordering of itinerary items - custom items can go anywhere, travel is reorderable within day constraints, games have sortOrder but are immovable. + +**Architecture:** Extend existing `TravelDayOverride` model with `sortOrder` field. Update flattening logic to sort all orderable items (games, travel, custom) together by sortOrder within each day. Games get auto-assigned sortOrder (100, 101...) but no drag handle. + +**Tech Stack:** Swift, SwiftUI, UIKit (UITableViewController), CloudKit + +--- + +## Task 1: Update TravelDayOverride Domain Model + +**Files:** +- Modify: `SportsTime/Core/Models/Domain/TravelDayOverride.swift` + +**Step 1: Add sortOrder field to TravelDayOverride** + +```swift +struct TravelDayOverride: Identifiable, Codable, Hashable { + let id: UUID + let tripId: UUID + let travelAnchorId: String + var displayDay: Int + var sortOrder: Double // NEW: Position within day (allows interleaving with custom items) + let createdAt: Date + var modifiedAt: Date + + init( + id: UUID = UUID(), + tripId: UUID, + travelAnchorId: String, + displayDay: Int, + sortOrder: Double = 50.0, // Default before games (games start at 100) + createdAt: Date = Date(), + modifiedAt: Date = Date() + ) { + self.id = id + self.tripId = tripId + self.travelAnchorId = travelAnchorId + self.displayDay = displayDay + self.sortOrder = sortOrder + self.createdAt = createdAt + self.modifiedAt = modifiedAt + } +} +``` + +**Step 2: Build to verify compilation** + +Run: `cd /Users/treyt/Desktop/code/SportsTime && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20` + +Expected: Build succeeds (existing code still works due to default parameter) + +**Step 3: Commit** + +```bash +git add SportsTime/Core/Models/Domain/TravelDayOverride.swift +git commit -m "feat(model): add sortOrder to TravelDayOverride" +``` + +--- + +## Task 2: Update CloudKit Model for TravelDayOverride + +**Files:** +- Modify: `SportsTime/Core/Models/CloudKit/CKModels.swift` (CKTravelDayOverride section) + +**Step 1: Add sortOrder field to CKTravelDayOverride** + +Find `CKTravelDayOverride` struct and add: + +```swift +struct CKTravelDayOverride { + static let overrideIdKey = "overrideId" + static let tripIdKey = "tripId" + static let travelAnchorIdKey = "travelAnchorId" + static let displayDayKey = "displayDay" + static let sortOrderKey = "sortOrder" // NEW + static let createdAtKey = "createdAt" + static let modifiedAtKey = "modifiedAt" + + let record: CKRecord + + init(record: CKRecord) { + self.record = record + } + + init(override: TravelDayOverride) { + let record = CKRecord( + recordType: CKRecordType.travelDayOverride, + recordID: CKRecord.ID(recordName: override.id.uuidString) + ) + record[CKTravelDayOverride.overrideIdKey] = override.id.uuidString + record[CKTravelDayOverride.tripIdKey] = override.tripId.uuidString + record[CKTravelDayOverride.travelAnchorIdKey] = override.travelAnchorId + record[CKTravelDayOverride.displayDayKey] = override.displayDay + record[CKTravelDayOverride.sortOrderKey] = override.sortOrder // NEW + record[CKTravelDayOverride.createdAtKey] = override.createdAt + record[CKTravelDayOverride.modifiedAtKey] = override.modifiedAt + self.record = record + } + + func toOverride() -> TravelDayOverride? { + guard let overrideIdString = record[CKTravelDayOverride.overrideIdKey] as? String, + let overrideId = UUID(uuidString: overrideIdString), + let tripIdString = record[CKTravelDayOverride.tripIdKey] as? String, + let tripId = UUID(uuidString: tripIdString), + let travelAnchorId = record[CKTravelDayOverride.travelAnchorIdKey] as? String, + let displayDay = record[CKTravelDayOverride.displayDayKey] as? Int, + let createdAt = record[CKTravelDayOverride.createdAtKey] as? Date, + let modifiedAt = record[CKTravelDayOverride.modifiedAtKey] as? Date + else { return nil } + + // Migration: default sortOrder for existing records without it + let sortOrder = record[CKTravelDayOverride.sortOrderKey] as? Double ?? 50.0 + + return TravelDayOverride( + id: overrideId, + tripId: tripId, + travelAnchorId: travelAnchorId, + displayDay: displayDay, + sortOrder: sortOrder, + createdAt: createdAt, + modifiedAt: modifiedAt + ) + } +} +``` + +**Step 2: Build to verify compilation** + +Run: `cd /Users/treyt/Desktop/code/SportsTime && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20` + +Expected: Build succeeds + +**Step 3: Commit** + +```bash +git add SportsTime/Core/Models/CloudKit/CKModels.swift +git commit -m "feat(cloudkit): add sortOrder to CKTravelDayOverride" +``` + +--- + +## Task 3: Update TravelOverrideService + +**Files:** +- Modify: `SportsTime/Core/Services/TravelOverrideService.swift` + +**Step 1: Update saveOverride to persist sortOrder** + +In `saveOverride(_:)`, add the sortOrder field when updating existing record: + +```swift +if let existing = existingOverride { + let recordID = CKRecord.ID(recordName: existing.id.uuidString) + do { + let existingRecord = try await publicDatabase.record(for: recordID) + existingRecord[CKTravelDayOverride.displayDayKey] = override.displayDay + existingRecord[CKTravelDayOverride.sortOrderKey] = override.sortOrder // NEW + existingRecord[CKTravelDayOverride.modifiedAtKey] = now + record = existingRecord + // ... + } +} +``` + +**Step 2: Update fetchOverridesAsDictionary return type** + +Change from `[String: Int]` to return full `TravelDayOverride` objects: + +```swift +/// Fetch all travel overrides for a trip, keyed by travelAnchorId +/// If duplicate travelAnchorIds exist (legacy data), takes the most recently modified one +func fetchOverridesAsDictionary(forTripId tripId: UUID) async throws -> [String: TravelDayOverride] { + let overrides = try await fetchOverrides(forTripId: tripId) + let sorted = overrides.sorted { $0.modifiedAt > $1.modifiedAt } + return Dictionary(sorted.map { ($0.travelAnchorId, $0) }) { first, _ in first } +} +``` + +**Step 3: Build to verify compilation** + +Run: `cd /Users/treyt/Desktop/code/SportsTime && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20` + +Expected: Build fails (TripDetailView uses old return type) - this is expected + +**Step 4: Commit** + +```bash +git add SportsTime/Core/Services/TravelOverrideService.swift +git commit -m "feat(service): update TravelOverrideService for sortOrder" +``` + +--- + +## Task 4: Update ItineraryTableViewWrapper Interface + +**Files:** +- Modify: `SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift` + +**Step 1: Change travelDayOverrides type** + +Update the property and init: + +```swift +let travelDayOverrides: [String: TravelDayOverride] // Changed from [String: Int] +``` + +Update init parameter: + +```swift +init( + trip: Trip, + games: [RichGame], + customItems: [CustomItineraryItem], + travelDayOverrides: [String: TravelDayOverride], // Changed + // ... +) +``` + +**Step 2: Update buildItineraryData to use sortOrder** + +In `buildItineraryData()`, update the override lookup: + +```swift +// Use override if valid, otherwise use default +if let override = travelDayOverrides[travelId], validRange.contains(override.displayDay) { + travelByDay[override.displayDay] = (segment, override.sortOrder) +} else { + let clampedDefault = max(validRange.lowerBound, min(defaultDay, validRange.upperBound)) + travelByDay[clampedDefault] = (segment, 50.0) // Default sortOrder before games +} +``` + +**Step 3: Update ItineraryDayData to store travel sortOrder** + +Change `travelBefore` to include sortOrder: + +```swift +struct ItineraryDayData: Identifiable { + let id: Int + let dayNumber: Int + let date: Date + let games: [RichGame] + var items: [ItineraryRowItem] + var travelBefore: (segment: TravelSegment, sortOrder: Double)? // Changed + // ... +} +``` + +**Step 4: Build (expect failures - controller needs update)** + +Run: `cd /Users/treyt/Desktop/code/SportsTime && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | grep -E "error:|warning:" | head -20` + +**Step 5: Commit** + +```bash +git add SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift +git commit -m "feat(wrapper): update interface for travel sortOrder" +``` + +--- + +## Task 5: Update ItineraryRowItem to Include sortOrder + +**Files:** +- Modify: `SportsTime/Features/Trip/Views/ItineraryTableViewController.swift` + +**Step 1: Add sortOrder to ItineraryRowItem enum** + +Update cases to include sortOrder where needed: + +```swift +enum ItineraryRowItem: Identifiable, Equatable { + case dayHeader(dayNumber: Int, date: Date) + case games([RichGame], dayNumber: Int, sortOrder: Double) // Added sortOrder + case travel(TravelSegment, dayNumber: Int, sortOrder: Double) // Added sortOrder + case customItem(CustomItineraryItem) // Already has sortOrder in the model + + /// Get sortOrder for sorting within a day (nil for headers) + var sortOrder: Double? { + switch self { + case .dayHeader: + return nil + case .games(_, _, let order): + return order + case .travel(_, _, let order): + return order + case .customItem(let item): + return item.sortOrder + } + } + + // Update id and isReorderable as needed... +} +``` + +**Step 2: Update id property** + +```swift +var id: String { + switch self { + case .dayHeader(let dayNumber, _): + return "day:\(dayNumber)" + case .games(_, let dayNumber, _): + return "games:\(dayNumber)" + case .travel(let segment, _, _): + return "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" + case .customItem(let item): + return "item:\(item.id.uuidString)" + } +} +``` + +**Step 3: Commit** + +```bash +git add SportsTime/Features/Trip/Views/ItineraryTableViewController.swift +git commit -m "feat(controller): add sortOrder to ItineraryRowItem" +``` + +--- + +## Task 6: Update Flattening Logic to Sort All Items Together + +**Files:** +- Modify: `SportsTime/Features/Trip/Views/ItineraryTableViewController.swift` + +**Step 1: Rewrite reloadData flattening** + +Replace the flattening loop in `reloadData(days:travelValidRanges:)`: + +```swift +func reloadData(days: [ItineraryDayData], travelValidRanges: [String: ClosedRange]) { + self.travelValidRanges = travelValidRanges + + flatItems = [] + + for day in days { + // 1. Day header (fixed, always first) + flatItems.append(.dayHeader(dayNumber: day.dayNumber, date: day.date)) + + // 2. Collect all orderable items for this day + var orderableItems: [ItineraryRowItem] = [] + + // Games get sortOrder starting at 100 + for (index, game) in day.games.enumerated() { + let gameSortOrder = 100.0 + Double(index) + orderableItems.append(.games([game], dayNumber: day.dayNumber, sortOrder: gameSortOrder)) + } + + // Travel (if arriving this day) + if let (travel, sortOrder) = day.travelBefore { + orderableItems.append(.travel(travel, dayNumber: day.dayNumber, sortOrder: sortOrder)) + } + + // Custom items + for item in day.items { + if case .customItem = item { + orderableItems.append(item) + } + } + + // 3. Sort all orderable items by sortOrder + orderableItems.sort { ($0.sortOrder ?? 0) < ($1.sortOrder ?? 0) } + + // 4. Append sorted items + flatItems.append(contentsOf: orderableItems) + } + + tableView.reloadData() +} +``` + +**Step 2: Build to verify** + +Run: `cd /Users/treyt/Desktop/code/SportsTime && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20` + +**Step 3: Commit** + +```bash +git add SportsTime/Features/Trip/Views/ItineraryTableViewController.swift +git commit -m "feat(controller): sort all orderable items together by sortOrder" +``` + +--- + +## Task 7: Update Drag Constraints for Custom Items + +**Files:** +- Modify: `SportsTime/Features/Trip/Views/ItineraryTableViewController.swift` + +**Step 1: Update targetIndexPathForMoveFromRowAt for custom items** + +Remove the constraints that prevent custom items from going before games/travel. In the `.customItem` case: + +```swift +case .customItem: + // Custom items can go anywhere except: + // 1. Before position 0 + // 2. Directly ON a day header (redirect to after header) + + // Don't drop ON a day header - go after it instead + if proposedRow < flatItems.count, case .dayHeader = flatItems[proposedRow] { + return IndexPath(row: proposedRow + 1, section: 0) + } + + // Allow dropping anywhere else (before games, before travel, etc.) + return IndexPath(row: proposedRow, section: 0) +``` + +**Step 2: Update calculateSortOrder to consider all orderable items** + +Update `calculateSortOrder(at:)` to scan for games and travel, not just custom items: + +```swift +private func calculateSortOrder(at row: Int) -> Double { + var prevSortOrder: Double? + var nextSortOrder: Double? + + // SCAN BACKWARDS to find previous orderable item in this day + for i in stride(from: row - 1, through: 0, by: -1) { + switch flatItems[i] { + case .dayHeader: + // Hit day boundary - no previous item in this day + break + case .games(_, _, let order): + prevSortOrder = order + case .travel(_, _, let order): + prevSortOrder = order + case .customItem(let item): + prevSortOrder = item.sortOrder + } + if prevSortOrder != nil { break } + if case .dayHeader = flatItems[i] { break } + } + + // SCAN FORWARDS to find next orderable item in this day + for i in row.. Void)? // travelId, newDay, newSortOrder +``` + +**Step 2: Update moveRowAt to pass sortOrder** + +In `tableView(_:moveRowAt:to:)`: + +```swift +case .travel(let segment, _): + let newDay = dayForTravelAt(row: destinationIndexPath.row) + let sortOrder = calculateSortOrder(at: destinationIndexPath.row) + let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" + onTravelMoved?(travelId, newDay, sortOrder) +``` + +**Step 3: Commit** + +```bash +git add SportsTime/Features/Trip/Views/ItineraryTableViewController.swift +git commit -m "feat(controller): include sortOrder in travel move callback" +``` + +--- + +## Task 9: Update ItineraryTableViewWrapper Callback + +**Files:** +- Modify: `SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift` + +**Step 1: Update onTravelMoved callback type** + +```swift +var onTravelMoved: ((String, Int, Double) -> Void)? // travelId, newDay, newSortOrder +``` + +**Step 2: Update makeUIViewController** + +```swift +controller.onTravelMoved = onTravelMoved +``` + +**Step 3: Commit** + +```bash +git add SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift +git commit -m "feat(wrapper): update travel move callback for sortOrder" +``` + +--- + +## Task 10: Update TripDetailView State and Handlers + +**Files:** +- Modify: `SportsTime/Features/Trip/Views/TripDetailView.swift` + +**Step 1: Update state type** + +```swift +@State private var travelDayOverrides: [String: TravelDayOverride] = [:] +``` + +**Step 2: Update onTravelMoved handler** + +```swift +onTravelMoved: { travelId, newDay, newSortOrder in + Task { @MainActor in + // Create or update the override + let override = TravelDayOverride( + id: travelDayOverrides[travelId]?.id ?? UUID(), + tripId: trip.id, + travelAnchorId: travelId, + displayDay: newDay, + sortOrder: newSortOrder, + createdAt: travelDayOverrides[travelId]?.createdAt ?? Date(), + modifiedAt: Date() + ) + + withAnimation { + travelDayOverrides[travelId] = override + } + + await saveTravelDayOverride(override) + } +} +``` + +**Step 3: Update saveTravelDayOverride function** + +Change signature and implementation: + +```swift +private func saveTravelDayOverride(_ override: TravelDayOverride) async { + do { + _ = try await TravelOverrideService.shared.saveOverride(override) + print("✅ [TravelOverride] Saved: \(override.travelAnchorId) -> day \(override.displayDay), sortOrder \(override.sortOrder)") + } catch { + print("❌ [TravelOverride] Failed to save: \(error)") + } +} +``` + +**Step 4: Update loadCustomItems to use new return type** + +```swift +let overrides = try await TravelOverrideService.shared.fetchOverridesAsDictionary(forTripId: trip.id) +print("✅ [TravelOverrides] Loaded \(overrides.count) travel day overrides") +travelDayOverrides = overrides +``` + +**Step 5: Build and fix any remaining compilation errors** + +Run: `cd /Users/treyt/Desktop/code/SportsTime && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -30` + +**Step 6: Commit** + +```bash +git add SportsTime/Features/Trip/Views/TripDetailView.swift +git commit -m "feat(tripdetail): update state and handlers for travel sortOrder" +``` + +--- + +## Task 11: Update Cell Configuration for Games + +**Files:** +- Modify: `SportsTime/Features/Trip/Views/ItineraryTableViewController.swift` + +**Step 1: Update cellForRowAt for games case** + +```swift +case .games(let games, _, _): + let cell = tableView.dequeueReusableCell(withIdentifier: gamesCellId, for: indexPath) + configureGamesCell(cell, games: games) + return cell +``` + +**Step 2: Update travel case** + +```swift +case .travel(let segment, _, _): + let cell = tableView.dequeueReusableCell(withIdentifier: travelCellId, for: indexPath) + configureTravelCell(cell, segment: segment) + return cell +``` + +**Step 3: Build to verify** + +Run: `cd /Users/treyt/Desktop/code/SportsTime && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20` + +**Step 4: Commit** + +```bash +git add SportsTime/Features/Trip/Views/ItineraryTableViewController.swift +git commit -m "fix(controller): update cell configuration for new enum cases" +``` + +--- + +## Task 12: Full Build and Manual Test + +**Step 1: Full clean build** + +Run: `cd /Users/treyt/Desktop/code/SportsTime && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' clean build 2>&1 | tail -30` + +Expected: BUILD SUCCEEDED + +**Step 2: Run tests** + +Run: `cd /Users/treyt/Desktop/code/SportsTime && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test 2>&1 | tail -50` + +Expected: All tests pass + +**Step 3: Manual verification checklist** + +- [ ] Open app, navigate to a trip with games and travel +- [ ] Verify games appear with no drag handle +- [ ] Verify custom items have drag handle +- [ ] Verify travel has drag handle +- [ ] Drag custom item BEFORE a game - should work +- [ ] Drag custom item AFTER a game - should work +- [ ] Drag travel to different position within same day - should work +- [ ] Drag travel to different valid day - should work +- [ ] Drag travel to invalid day - should snap to nearest valid + +**Step 4: Commit** + +```bash +git add -A +git commit -m "feat: complete flexible itinerary ordering implementation" +``` + +--- + +## Task 13: Write Regression Tests + +**Files:** +- Create: `SportsTimeTests/Features/ItineraryOrderingTests.swift` + +**Step 1: Create test file with ordering tests** + +```swift +import XCTest +@testable import SportsTime + +final class ItineraryOrderingTests: XCTestCase { + + // MARK: - sortOrder Calculation Tests + + func test_calculateSortOrder_betweenTwoItems_returnsMidpoint() { + // Given items at sortOrder 1.0 and 3.0 + // When calculating sortOrder for position between them + // Then should return 2.0 + } + + func test_calculateSortOrder_beforeFirstItem_returnsHalf() { + // Given first item at sortOrder 2.0 + // When calculating sortOrder for position before it + // Then should return 1.0 (half of 2.0) + } + + func test_calculateSortOrder_afterLastItem_returnsLastPlusOne() { + // Given last item at sortOrder 5.0 + // When calculating sortOrder for position after it + // Then should return 6.0 + } + + func test_calculateSortOrder_emptyDay_returnsOne() { + // Given day with no orderable items + // When calculating sortOrder + // Then should return 1.0 + } + + // MARK: - Travel Constraint Tests + + func test_travelConstraint_cannotMoveBefore DepartureGame() { + // Given travel from City A to City B + // And City A has game on Day 2 + // When attempting to move travel to Day 1 + // Then travel should snap to Day 3 (minimum valid) + } + + func test_travelConstraint_canMoveToArrivalGameDay() { + // Given travel from City A to City B + // And City B has game on Day 5 + // When moving travel to Day 5 + // Then should be allowed (arrive morning of game) + } + + // MARK: - Custom Item Flexibility Tests + + func test_customItem_canBePositionedBeforeGame() { + // Given a day with a game at sortOrder 100 + // When custom item dropped before game + // Then custom item should get sortOrder < 100 + } + + func test_customItem_canBePositionedBetweenTravelAndGame() { + // Given travel at sortOrder 50, game at sortOrder 100 + // When custom item dropped between them + // Then custom item should get sortOrder ~75 + } +} +``` + +**Step 2: Run tests** + +Run: `cd /Users/treyt/Desktop/code/SportsTime && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/ItineraryOrderingTests test 2>&1 | tail -30` + +**Step 3: Commit** + +```bash +git add SportsTimeTests/Features/ItineraryOrderingTests.swift +git commit -m "test: add regression tests for flexible itinerary ordering" +``` + +--- + +## Summary + +This plan implements flexible itinerary ordering in 13 tasks: + +1. **Tasks 1-3:** Update data models (domain, CloudKit, service) to support sortOrder on travel +2. **Tasks 4-6:** Update wrapper and controller interfaces for new data structure +3. **Tasks 7-9:** Update drag constraints and callbacks +4. **Task 10:** Update TripDetailView state management +5. **Tasks 11-12:** Fix compilation, full build, manual test +6. **Task 13:** Write regression tests + +Key changes: +- `TravelDayOverride` gains `sortOrder: Double` +- Games get auto-assigned sortOrder (100, 101, 102...) +- All orderable items (games, travel, custom) sort together within each day +- Custom items can now go anywhere (before/after games) +- Travel remains constrained to valid day range but can be positioned within a day