docs: add flexible itinerary ordering implementation plan

13-task plan covering:
- TravelDayOverride model update with sortOrder
- CloudKit schema update
- Flattening logic to sort games/travel/custom together
- Drag constraint updates for flexible custom items
- Regression tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-17 11:07:58 -06:00
parent 6e9b9f728b
commit 828059a12a

View File

@@ -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<Int>]) {
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..<flatItems.count {
switch flatItems[i] {
case .dayHeader:
// Hit next day boundary
break
case .games(_, _, let order):
nextSortOrder = order
case .travel(_, _, let order):
nextSortOrder = order
case .customItem(let item):
nextSortOrder = item.sortOrder
}
if nextSortOrder != nil { break }
if case .dayHeader = flatItems[i] { break }
}
// CALCULATE sortOrder based on what we found
switch (prevSortOrder, nextSortOrder) {
case (nil, nil):
return 1.0
case (let prev?, nil):
return prev + 1.0
case (nil, let next?):
return next / 2.0
case (let prev?, let next?):
return (prev + next) / 2.0
}
}
```
**Step 3: Commit**
```bash
git add SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
git commit -m "feat(controller): allow custom items anywhere, update sortOrder calc"
```
---
## Task 8: Update Travel Move Callback
**Files:**
- Modify: `SportsTime/Features/Trip/Views/ItineraryTableViewController.swift`
**Step 1: Update onTravelMoved callback signature**
Change callback to include sortOrder:
```swift
var onTravelMoved: ((String, Int, Double) -> 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