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:
@@ -321,6 +321,9 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
|
||||
/// All itinerary items (needed to build constraints)
|
||||
private var allItineraryItems: [ItineraryItem] = []
|
||||
|
||||
/// Canonical trip travel segment index keyed by TravelSegment UUID.
|
||||
private var travelSegmentIndices: [UUID: Int] = [:]
|
||||
|
||||
/// Trip day count for constraints
|
||||
private var tripDayCount: Int = 0
|
||||
@@ -472,10 +475,12 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
func reloadData(
|
||||
days: [ItineraryDayData],
|
||||
travelValidRanges: [String: ClosedRange<Int>],
|
||||
itineraryItems: [ItineraryItem] = []
|
||||
itineraryItems: [ItineraryItem] = [],
|
||||
travelSegmentIndices: [UUID: Int] = [:]
|
||||
) {
|
||||
self.travelValidRanges = travelValidRanges
|
||||
self.allItineraryItems = itineraryItems
|
||||
self.travelSegmentIndices = travelSegmentIndices
|
||||
self.tripDayCount = days.count
|
||||
|
||||
// Rebuild constraints with new data
|
||||
@@ -571,12 +576,35 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
/// Calculates invalid zones for a travel segment drag.
|
||||
/// Delegates to pure function and applies results to instance state.
|
||||
private func calculateTravelDragZones(segment: TravelSegment) {
|
||||
let sourceRow = flatItems.firstIndex { item in
|
||||
if case .travel(let rowSegment, _) = item {
|
||||
return rowSegment.id == segment.id
|
||||
}
|
||||
return false
|
||||
} ?? 0
|
||||
|
||||
let zones = ItineraryReorderingLogic.calculateTravelDragZones(
|
||||
segment: segment,
|
||||
sourceRow: sourceRow,
|
||||
flatItems: flatItems,
|
||||
travelValidRanges: travelValidRanges,
|
||||
constraints: constraints,
|
||||
findTravelItem: { [weak self] segment in self?.findItineraryItem(for: segment) }
|
||||
findTravelItem: { [weak self] segment in self?.findItineraryItem(for: segment) },
|
||||
makeTravelItem: { [weak self] segment in
|
||||
let segIdx = self?.travelSegmentIndices[segment.id]
|
||||
return ItineraryItem(
|
||||
tripId: self?.allItineraryItems.first?.tripId ?? UUID(),
|
||||
day: 1,
|
||||
sortOrder: 0,
|
||||
kind: .travel(
|
||||
TravelInfo(
|
||||
segment: segment,
|
||||
segmentIndex: segIdx
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
findTravelSortOrder: { [weak self] segment in self?.findItineraryItem(for: segment)?.sortOrder }
|
||||
)
|
||||
invalidRowIndices = zones.invalidRowIndices
|
||||
validDropRows = zones.validDropRows
|
||||
@@ -586,7 +614,20 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
/// Calculates invalid zones for a custom item drag.
|
||||
/// Delegates to pure function and applies results to instance state.
|
||||
private func calculateCustomItemDragZones(item: ItineraryItem) {
|
||||
let zones = ItineraryReorderingLogic.calculateCustomItemDragZones(item: item, flatItems: flatItems)
|
||||
let sourceRow = flatItems.firstIndex { row in
|
||||
if case .customItem(let current) = row {
|
||||
return current.id == item.id
|
||||
}
|
||||
return false
|
||||
} ?? 0
|
||||
|
||||
let zones = ItineraryReorderingLogic.calculateCustomItemDragZones(
|
||||
item: item,
|
||||
sourceRow: sourceRow,
|
||||
flatItems: flatItems,
|
||||
constraints: constraints,
|
||||
findTravelSortOrder: { [weak self] segment in self?.findItineraryItem(for: segment)?.sortOrder }
|
||||
)
|
||||
invalidRowIndices = zones.invalidRowIndices
|
||||
validDropRows = zones.validDropRows
|
||||
barrierGameIds = zones.barrierGameIds
|
||||
@@ -597,23 +638,31 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
/// Searches through allItineraryItems to find a matching travel item.
|
||||
/// Prefers matching by segmentIndex for disambiguation of repeat city pairs.
|
||||
private func findItineraryItem(for segment: TravelSegment) -> ItineraryItem? {
|
||||
let fromLower = segment.fromLocation.name.lowercased()
|
||||
let toLower = segment.toLocation.name.lowercased()
|
||||
if let segIdx = travelSegmentIndices[segment.id] {
|
||||
if let exact = allItineraryItems.first(where: { item in
|
||||
guard case .travel(let info) = item.kind else { return false }
|
||||
return info.segmentIndex == segIdx
|
||||
}) {
|
||||
return exact
|
||||
}
|
||||
}
|
||||
|
||||
// Find all matching travel items by city pair
|
||||
let matches = allItineraryItems.filter { item in
|
||||
guard case .travel(let info) = item.kind else { return false }
|
||||
return info.fromCity.lowercased() == fromLower
|
||||
&& info.toCity.lowercased() == toLower
|
||||
return info.matches(segment: segment)
|
||||
}
|
||||
|
||||
// If only one match, return it
|
||||
if matches.count <= 1 { return matches.first }
|
||||
|
||||
// Multiple matches (repeat city pair) — try to match by segment UUID identity
|
||||
// The segment.id is a UUID that identifies the specific TravelSegment instance
|
||||
// We match through the allItineraryItems which have segmentIndex set
|
||||
return matches.first ?? nil
|
||||
if let segIdx = travelSegmentIndices[segment.id] {
|
||||
if let indexed = matches.first(where: { $0.travelInfo?.segmentIndex == segIdx }) {
|
||||
return indexed
|
||||
}
|
||||
}
|
||||
|
||||
return matches.first(where: { $0.travelInfo?.segmentIndex != nil }) ?? matches.first
|
||||
}
|
||||
|
||||
/// Applies visual feedback during drag.
|
||||
@@ -767,8 +816,12 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
// Travel is positioned within a day using sortOrder (can be before/after games)
|
||||
let destinationDay = dayNumber(forRow: destinationIndexPath.row)
|
||||
let sortOrder = calculateSortOrder(at: destinationIndexPath.row)
|
||||
let segIdx = findItineraryItem(for: segment)?.travelInfo?.segmentIndex ?? 0
|
||||
let travelId = "travel:\(segIdx):\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
||||
let segIdx = travelSegmentIndices[segment.id]
|
||||
?? findItineraryItem(for: segment)?.travelInfo?.segmentIndex
|
||||
?? 0
|
||||
let from = TravelInfo.normalizeCityName(segment.fromLocation.name)
|
||||
let to = TravelInfo.normalizeCityName(segment.toLocation.name)
|
||||
let travelId = "travel:\(segIdx):\(from)->\(to)"
|
||||
onTravelMoved?(travelId, destinationDay, sortOrder)
|
||||
|
||||
case .customItem(let customItem):
|
||||
@@ -852,13 +905,14 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
constraints: constraints,
|
||||
findTravelItem: { [weak self] segment in self?.findItineraryItem(for: segment) },
|
||||
makeTravelItem: { [weak self] segment in
|
||||
ItineraryItem(
|
||||
let segIdx = self?.travelSegmentIndices[segment.id]
|
||||
return ItineraryItem(
|
||||
tripId: self?.allItineraryItems.first?.tripId ?? UUID(),
|
||||
day: 1,
|
||||
sortOrder: 0,
|
||||
kind: .travel(TravelInfo(
|
||||
fromCity: segment.fromLocation.name,
|
||||
toCity: segment.toLocation.name,
|
||||
segment: segment,
|
||||
segmentIndex: segIdx,
|
||||
distanceMeters: segment.distanceMeters,
|
||||
durationSeconds: segment.durationSeconds
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user