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

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