feat: improve planning engine travel handling, itinerary reordering, and scenario planners

Add TravelInfo initializers and city normalization helpers to fix repeat
city-pair disambiguation. Improve drag-and-drop reordering with segment
index tracking and source-row-aware zone calculation. Enhance all five
scenario planners with better next-day departure handling and travel
segment placement. Add comprehensive tests across all planners.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-13 08:55:23 -06:00
parent 1c97f35754
commit 9736773475
19 changed files with 928 additions and 171 deletions

View File

@@ -82,8 +82,13 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
controller.setTableHeader(hostingController.view)
// Load initial data
let (days, validRanges, allItemsForConstraints) = buildItineraryData()
controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: allItemsForConstraints)
let (days, validRanges, allItemsForConstraints, travelSegmentIndices) = buildItineraryData()
controller.reloadData(
days: days,
travelValidRanges: validRanges,
itineraryItems: allItemsForConstraints,
travelSegmentIndices: travelSegmentIndices
)
return controller
}
@@ -100,15 +105,21 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
// 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)
let (days, validRanges, allItemsForConstraints, travelSegmentIndices) = buildItineraryData()
controller.reloadData(
days: days,
travelValidRanges: validRanges,
itineraryItems: allItemsForConstraints,
travelSegmentIndices: travelSegmentIndices
)
}
// MARK: - Build Itinerary Data
private func buildItineraryData() -> ([ItineraryDayData], [String: ClosedRange<Int>], [ItineraryItem]) {
private func buildItineraryData() -> ([ItineraryDayData], [String: ClosedRange<Int>], [ItineraryItem], [UUID: Int]) {
let tripDays = calculateTripDays()
var travelValidRanges: [String: ClosedRange<Int>] = [:]
let travelSegmentIndices = Dictionary(uniqueKeysWithValues: trip.travelSegments.enumerated().map { ($1.id, $0) })
// Build game items from RichGame data for constraint validation
var gameItems: [ItineraryItem] = []
@@ -183,13 +194,7 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
day: placement.day,
sortOrder: placement.sortOrder,
kind: .travel(
TravelInfo(
fromCity: fromCity,
toCity: toCity,
segmentIndex: segmentIndex,
distanceMeters: segment.distanceMeters,
durationSeconds: segment.durationSeconds
)
TravelInfo(segment: segment, segmentIndex: segmentIndex)
)
)
travelItems.append(travelItem)
@@ -218,20 +223,13 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
.filter { $0.day == dayNum }
.sorted { $0.sortOrder < $1.sortOrder }
for travel in travelsForDay {
// Find the segment matching this travel by segment index (preferred) or city pair (legacy)
if let info = travel.travelInfo {
let seg: TravelSegment?
if let idx = info.segmentIndex, idx < trip.travelSegments.count {
seg = trip.travelSegments[idx]
} else {
seg = trip.travelSegments.first(where: {
$0.fromLocation.name.lowercased() == info.fromCity.lowercased()
&& $0.toLocation.name.lowercased() == info.toCity.lowercased()
})
}
if let seg {
rows.append(.travel(seg, dayNumber: dayNum))
}
guard let idx = info.segmentIndex,
idx >= 0,
idx < trip.travelSegments.count else { continue }
let seg = trip.travelSegments[idx]
guard info.matches(segment: seg) else { continue }
rows.append(.travel(seg, dayNumber: dayNum))
}
}
@@ -241,7 +239,7 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
switch r {
case .customItem(let it): return it.sortOrder
case .travel(let seg, _):
let segIdx = trip.travelSegments.firstIndex(where: { $0.id == seg.id }) ?? 0
let segIdx = travelSegmentIndices[seg.id] ?? 0
let id = stableTravelAnchorId(seg, at: segIdx)
return (travelOverrides[id]?.sortOrder)
?? (travelItems.first(where: { ti in
@@ -265,8 +263,8 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
)
days.append(dayData)
}
return (days, travelValidRanges, gameItems + itineraryItems + travelItems)
return (days, travelValidRanges, gameItems + itineraryItems + travelItems, travelSegmentIndices)
}
// MARK: - Helper Methods
@@ -294,7 +292,9 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
}
private func stableTravelAnchorId(_ segment: TravelSegment, at index: Int) -> String {
"travel:\(index):\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
let from = TravelInfo.normalizeCityName(segment.fromLocation.name)
let to = TravelInfo.normalizeCityName(segment.toLocation.name)
return "travel:\(index):\(from)->\(to)"
}
private func findLastGameDay(in city: String, tripDays: [Date]) -> Int {