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:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user