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

@@ -831,21 +831,23 @@ enum ItineraryReorderingLogic {
in travelValidRanges: [String: ClosedRange<Int>],
model: ItineraryItem? = nil
) -> String {
let suffix = "\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
let from = TravelInfo.normalizeCityName(segment.fromLocation.name)
let to = TravelInfo.normalizeCityName(segment.toLocation.name)
let suffix = "\(from)->\(to)"
let matchingKeys = travelValidRanges.keys.filter { $0.hasSuffix(suffix) }
if let segIdx = model?.travelInfo?.segmentIndex {
let indexedKey = "travel:\(segIdx):\(suffix)"
if matchingKeys.contains(indexedKey) {
return indexedKey
}
return indexedKey
}
if matchingKeys.count == 1, let key = matchingKeys.first {
return key
}
// Multiple matches (repeat city pair) use segmentIndex from model to disambiguate
if let segIdx = model?.travelInfo?.segmentIndex {
let expected = "travel:\(segIdx):\(suffix)"
if matchingKeys.contains(expected) {
return expected
}
}
// Fallback: return first match or construct without index
return matchingKeys.first ?? "travel:\(suffix)"
}

View File

@@ -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
))

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 {

View File

@@ -797,8 +797,8 @@ struct TripDetailView: View {
/// Create a stable anchor ID for a travel segment (UUIDs regenerate on reload)
private func stableTravelAnchorId(_ segment: TravelSegment, at index: Int) -> String {
let from = segment.fromLocation.name.lowercased().trimmingCharacters(in: .whitespaces)
let to = segment.toLocation.name.lowercased().trimmingCharacters(in: .whitespaces)
let from = TravelInfo.normalizeCityName(segment.fromLocation.name)
let to = TravelInfo.normalizeCityName(segment.toLocation.name)
return "travel:\(index):\(from)->\(to)"
}
@@ -975,6 +975,17 @@ struct TripDetailView: View {
return index
}
/// Canonicalize travel IDs to the current segment's normalized city pair.
private func canonicalTravelAnchorId(from travelId: String) -> String? {
guard let segmentIndex = Self.parseSegmentIndex(from: travelId),
segmentIndex >= 0,
segmentIndex < trip.travelSegments.count else {
return nil
}
let segment = trip.travelSegments[segmentIndex]
return stableTravelAnchorId(segment, at: segmentIndex)
}
// MARK: - Map Helpers
private func fetchDrivingRoutes() async {
@@ -1259,8 +1270,7 @@ struct TripDetailView: View {
day: day,
sortOrder: sortOrder,
kind: .travel(TravelInfo(
fromCity: segment.fromLocation.name,
toCity: segment.toLocation.name,
segment: segment,
segmentIndex: segmentIndex,
distanceMeters: segment.distanceMeters,
durationSeconds: segment.durationSeconds
@@ -1387,22 +1397,32 @@ struct TripDetailView: View {
for item in items where item.isTravel {
guard let travelInfo = item.travelInfo else { continue }
let from = travelInfo.fromCity.lowercased()
let to = travelInfo.toCity.lowercased()
if let segIdx = travelInfo.segmentIndex {
// New format with segment index
let travelId = "travel:\(segIdx):\(from)->\(to)"
overrides[travelId] = TravelOverride(day: item.day, sortOrder: item.sortOrder)
} else {
// Legacy record without segment index derive index from trip segments
if let segIdx = trip.travelSegments.firstIndex(where: {
$0.fromLocation.name.lowercased() == from && $0.toLocation.name.lowercased() == to
}) {
let travelId = "travel:\(segIdx):\(from)->\(to)"
overrides[travelId] = TravelOverride(day: item.day, sortOrder: item.sortOrder)
if let segIdx = travelInfo.segmentIndex,
segIdx >= 0,
segIdx < trip.travelSegments.count {
let segment = trip.travelSegments[segIdx]
if !travelInfo.matches(segment: segment) {
print("⚠️ [TravelOverrides] Mismatched travel cities for segment \(segIdx); using canonical segment cities")
}
let travelId = stableTravelAnchorId(segment, at: segIdx)
overrides[travelId] = TravelOverride(day: item.day, sortOrder: item.sortOrder)
continue
}
// Legacy record without segment index: only accept if the city pair maps uniquely.
let matches = trip.travelSegments.enumerated().filter { _, segment in
travelInfo.matches(segment: segment)
}
guard matches.count == 1, let match = matches.first else {
print("⚠️ [TravelOverrides] Ignoring ambiguous legacy travel override \(travelInfo.fromCity)->\(travelInfo.toCity)")
continue
}
let segIdx = match.offset
let segment = match.element
let travelId = stableTravelAnchorId(segment, at: segIdx)
overrides[travelId] = TravelOverride(day: item.day, sortOrder: item.sortOrder)
}
travelOverrides = overrides
@@ -1478,8 +1498,11 @@ struct TripDetailView: View {
Task { @MainActor in
// Check if this is a travel segment being dropped
if droppedId.hasPrefix("travel:") {
guard let canonicalTravelId = self.canonicalTravelAnchorId(from: droppedId) else {
return
}
// Validate travel is within valid bounds (day-level)
if let validRange = self.validDayRange(for: droppedId) {
if let validRange = self.validDayRange(for: canonicalTravelId) {
guard validRange.contains(dayNumber) else {
return
}
@@ -1499,12 +1522,12 @@ struct TripDetailView: View {
let newSortOrder = max(maxSortOrderOnDay + 1.0, 1.0)
withAnimation {
self.travelOverrides[droppedId] = TravelOverride(day: dayNumber, sortOrder: newSortOrder)
self.travelOverrides[canonicalTravelId] = TravelOverride(day: dayNumber, sortOrder: newSortOrder)
}
// Persist to CloudKit as a travel ItineraryItem
await self.saveTravelDayOverride(
travelAnchorId: droppedId,
travelAnchorId: canonicalTravelId,
displayDay: dayNumber,
sortOrder: newSortOrder
)
@@ -1531,46 +1554,40 @@ struct TripDetailView: View {
private func saveTravelDayOverride(travelAnchorId: String, displayDay: Int, sortOrder: Double) async {
print("💾 [TravelOverrides] Saving override: \(travelAnchorId) -> day \(displayDay), sortOrder \(sortOrder)")
// Parse travel ID (format: "travel:INDEX:from->to")
let segmentIndex = Self.parseSegmentIndex(from: travelAnchorId)
let stripped = travelAnchorId.replacingOccurrences(of: "travel:", with: "")
let colonParts = stripped.components(separatedBy: ":")
// After removing "travel:", format is "INDEX:from->to"
let cityPart = colonParts.count >= 2 ? colonParts.dropFirst().joined(separator: ":") : stripped
let cityParts = cityPart.components(separatedBy: "->")
guard cityParts.count == 2 else {
print("❌ [TravelOverrides] Invalid travel ID format: \(travelAnchorId)")
guard let segmentIndex = Self.parseSegmentIndex(from: travelAnchorId),
segmentIndex >= 0,
segmentIndex < trip.travelSegments.count else {
print("❌ [TravelOverrides] Invalid travel segment index in ID: \(travelAnchorId)")
return
}
let fromCity = cityParts[0]
let toCity = cityParts[1]
let segment = trip.travelSegments[segmentIndex]
let canonicalTravelId = stableTravelAnchorId(segment, at: segmentIndex)
let canonicalInfo = TravelInfo(segment: segment, segmentIndex: segmentIndex)
// Find existing travel item matching by segment index (preferred) or city pair (legacy)
// Find existing travel item matching by segment index (preferred) or city pair (legacy fallback).
if let existingIndex = itineraryItems.firstIndex(where: {
guard $0.isTravel, let info = $0.travelInfo else { return false }
// Match by segment index if available
if let idx = segmentIndex, let itemIdx = info.segmentIndex {
return idx == itemIdx
if let itemIdx = info.segmentIndex {
return itemIdx == segmentIndex
}
// Legacy fallback: match by city pair
return info.fromCity.lowercased() == fromCity && info.toCity.lowercased() == toCity
return info.matches(segment: segment)
}) {
// Update existing
var updated = itineraryItems[existingIndex]
updated.day = displayDay
updated.sortOrder = sortOrder
updated.modifiedAt = Date()
updated.kind = .travel(canonicalInfo)
itineraryItems[existingIndex] = updated
await ItineraryItemService.shared.updateItem(updated)
} else {
// Create new travel item
let travelInfo = TravelInfo(fromCity: fromCity, toCity: toCity, segmentIndex: segmentIndex)
let item = ItineraryItem(
tripId: trip.id,
day: displayDay,
sortOrder: sortOrder,
kind: .travel(travelInfo)
kind: .travel(canonicalInfo)
)
itineraryItems.append(item)
do {
@@ -1580,6 +1597,9 @@ struct TripDetailView: View {
return
}
}
if canonicalTravelId != travelAnchorId {
print(" [TravelOverrides] Canonicalized travel ID to \(canonicalTravelId)")
}
print("✅ [TravelOverrides] Saved to CloudKit")
}
}