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>
24 KiB
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
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
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:
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
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:
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:
/// 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
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:
let travelDayOverrides: [String: TravelDayOverride] // Changed from [String: Int]
Update init parameter:
init(
trip: Trip,
games: [RichGame],
customItems: [CustomItineraryItem],
travelDayOverrides: [String: TravelDayOverride], // Changed
// ...
)
Step 2: Update buildItineraryData to use sortOrder
In buildItineraryData(), update the override lookup:
// 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:
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
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:
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
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
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:):
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
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:
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:
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
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:
var onTravelMoved: ((String, Int, Double) -> Void)? // travelId, newDay, newSortOrder
Step 2: Update moveRowAt to pass sortOrder
In tableView(_:moveRowAt:to:):
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
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
var onTravelMoved: ((String, Int, Double) -> Void)? // travelId, newDay, newSortOrder
Step 2: Update makeUIViewController
controller.onTravelMoved = onTravelMoved
Step 3: Commit
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
@State private var travelDayOverrides: [String: TravelDayOverride] = [:]
Step 2: Update onTravelMoved handler
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:
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
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
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
case .games(let games, _, _):
let cell = tableView.dequeueReusableCell(withIdentifier: gamesCellId, for: indexPath)
configureGamesCell(cell, games: games)
return cell
Step 2: Update travel case
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
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
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
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
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:
- Tasks 1-3: Update data models (domain, CloudKit, service) to support sortOrder on travel
- Tasks 4-6: Update wrapper and controller interfaces for new data structure
- Tasks 7-9: Update drag constraints and callbacks
- Task 10: Update TripDetailView state management
- Tasks 11-12: Fix compilation, full build, manual test
- Task 13: Write regression tests
Key changes:
TravelDayOverridegainssortOrder: 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