fix(itinerary): add city to game items for proper constraint validation

Travel constraint validation was not working because ItineraryConstraints
had no game items to validate against - games came from RichGame objects
but were never converted to ItineraryItem for constraint checking.

Changes:
- Add city parameter to ItemKind.game enum case
- Create game ItineraryItems from RichGame data in buildItineraryData()
- Update isValidTravelPosition to compare against actual game sortOrders
- Fix tests to use appropriate game sortOrder conventions

Now travel is properly constrained to appear before arrival city games
and after departure city games.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-18 22:46:40 -06:00
parent 72447c61fe
commit e72da7c5a7
11 changed files with 247 additions and 254 deletions

View File

@@ -80,14 +80,14 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
hostingController.view.translatesAutoresizingMaskIntoConstraints = true
controller.setTableHeader(hostingController.view)
// Load initial data
let (days, validRanges, allItemsForConstraints) = buildItineraryData()
controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: allItemsForConstraints)
return controller
}
func updateUIViewController(_ controller: ItineraryTableViewController, context: Context) {
controller.colorScheme = colorScheme
controller.onTravelMoved = onTravelMoved
@@ -95,37 +95,46 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
controller.onCustomItemTapped = onCustomItemTapped
controller.onCustomItemDeleted = onCustomItemDeleted
controller.onAddButtonTapped = onAddButtonTapped
// Update header content by updating the hosting controller's rootView
// This avoids recreating the view hierarchy and prevents infinite loops
context.coordinator.headerHostingController?.rootView = headerContent
let (days, validRanges, allItemsForConstraints) = buildItineraryData()
controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: allItemsForConstraints)
}
// MARK: - Build Itinerary Data
private func buildItineraryData() -> ([ItineraryDayData], [String: ClosedRange<Int>], [ItineraryItem]) {
let tripDays = calculateTripDays()
var travelValidRanges: [String: ClosedRange<Int>] = [:]
// Build game items from RichGame data for constraint validation
var gameItems: [ItineraryItem] = []
for (index, dayDate) in tripDays.enumerated() {
let dayNum = index + 1
let gamesOnDay = gamesOn(date: dayDate)
for (gameIndex, richGame) in gamesOnDay.enumerated() {
let gameItem = ItineraryItem(
tripId: trip.id,
day: dayNum,
sortOrder: Double(gameIndex) * 0.01, // Games have sortOrder ~0 (at the visual boundary)
kind: .game(gameId: richGame.game.id, city: richGame.stadium.city)
)
gameItems.append(gameItem)
}
}
// Build travel as semantic items with (day, sortOrder)
var travelItems: [ItineraryItem] = []
travelItems.reserveCapacity(trip.travelSegments.count)
func cityFromGameId(_ gameId: String) -> String? {
let comps = gameId.components(separatedBy: "-")
guard comps.count >= 2 else { return nil }
return comps[1]
}
func gamesIn(city: String, day: Int) -> [ItineraryItem] {
itineraryItems.filter { item in
gameItems.filter { item in
guard item.day == day else { return false }
guard case .game(let gid) = item.kind else { return false }
guard let c = cityFromGameId(gid) else { return false }
return cityMatches(c, searchCity: city)
guard let gameCity = item.gameCity else { return false }
return cityMatches(gameCity, searchCity: city)
}
}
@@ -249,7 +258,7 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
days.append(dayData)
}
return (days, travelValidRanges, itineraryItems + travelItems)
return (days, travelValidRanges, gameItems + itineraryItems + travelItems)
}
// MARK: - Helper Methods