fix: resolve travel anchor ID collision for repeat city pairs
Include segment index in travel anchor IDs ("travel:INDEX:from->to")
so Follow Team trips visiting the same city pair multiple times get
unique, independently addressable travel segments. Prevents override
dictionary collisions and incorrect validDayRange lookups.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -573,8 +573,8 @@ struct TripDetailView: View {
|
||||
handleDayDrop(providers: providers, dayNumber: dayNumber, gamesOnDay: gamesOnDay)
|
||||
}
|
||||
|
||||
case .travel(let segment):
|
||||
let travelId = stableTravelAnchorId(segment)
|
||||
case .travel(let segment, let segmentIndex):
|
||||
let travelId = stableTravelAnchorId(segment, at: segmentIndex)
|
||||
TravelSection(segment: segment)
|
||||
.staggeredAnimation(index: index)
|
||||
.overlay(alignment: .bottom) {
|
||||
@@ -770,8 +770,8 @@ struct TripDetailView: View {
|
||||
switch section {
|
||||
case .day(let dayNumber, _, _):
|
||||
return "day-\(dayNumber)"
|
||||
case .travel(let segment):
|
||||
return "travel-\(segment.fromLocation.name)-\(segment.toLocation.name)"
|
||||
case .travel(let segment, let segmentIndex):
|
||||
return "travel-\(segmentIndex)-\(segment.fromLocation.name)-\(segment.toLocation.name)"
|
||||
case .customItem(let item):
|
||||
return "item-\(item.id.uuidString)"
|
||||
case .addButton(let day):
|
||||
@@ -783,7 +783,7 @@ struct TripDetailView: View {
|
||||
// Find which day this travel segment belongs to by looking at sections
|
||||
// Travel appears BEFORE the arrival day, so look FORWARD to find arrival day
|
||||
for (index, section) in itinerarySections.enumerated() {
|
||||
if case .travel(let s) = section, s.id == segment.id {
|
||||
if case .travel(let s, _) = section, s.id == segment.id {
|
||||
// Look forward to find the arrival day
|
||||
for i in (index + 1)..<itinerarySections.count {
|
||||
if case .day(let dayNumber, _, _) = itinerarySections[i] {
|
||||
@@ -796,10 +796,10 @@ struct TripDetailView: View {
|
||||
}
|
||||
|
||||
/// Create a stable anchor ID for a travel segment (UUIDs regenerate on reload)
|
||||
private func stableTravelAnchorId(_ segment: TravelSegment) -> String {
|
||||
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)
|
||||
return "travel:\(from)->\(to)"
|
||||
return "travel:\(index):\(from)->\(to)"
|
||||
}
|
||||
|
||||
/// Move item to a new day and sortOrder position
|
||||
@@ -832,16 +832,21 @@ struct TripDetailView: View {
|
||||
|
||||
// Apply user overrides on top of computed defaults.
|
||||
for (segmentIndex, segment) in trip.travelSegments.enumerated() {
|
||||
let travelId = stableTravelAnchorId(segment)
|
||||
let travelId = stableTravelAnchorId(segment, at: segmentIndex)
|
||||
guard let override = travelOverrides[travelId] else { continue }
|
||||
|
||||
// Validate override is within valid day range
|
||||
if let validRange = validDayRange(for: travelId),
|
||||
validRange.contains(override.day) {
|
||||
// Remove from computed position
|
||||
travelByDay = travelByDay.filter { $0.value.id != segment.id }
|
||||
// Remove from computed position (search all days)
|
||||
for key in travelByDay.keys {
|
||||
travelByDay[key]?.removeAll { $0.id == segment.id }
|
||||
}
|
||||
travelByDay.keys.forEach { key in
|
||||
if travelByDay[key]?.isEmpty == true { travelByDay[key] = nil }
|
||||
}
|
||||
// Place at overridden position
|
||||
travelByDay[override.day] = segment
|
||||
travelByDay[override.day, default: []].append(segment)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -851,8 +856,9 @@ struct TripDetailView: View {
|
||||
let gamesOnDay = gamesOn(date: dayDate)
|
||||
|
||||
// Travel for this day (if any) - appears before day header
|
||||
if let travelSegment = travelByDay[dayNum] {
|
||||
sections.append(.travel(travelSegment))
|
||||
for travelSegment in (travelByDay[dayNum] ?? []) {
|
||||
let segIdx = trip.travelSegments.firstIndex(where: { $0.id == travelSegment.id }) ?? 0
|
||||
sections.append(.travel(travelSegment, segmentIndex: segIdx))
|
||||
}
|
||||
|
||||
// Day section - shows games or minimal rest day display
|
||||
@@ -938,8 +944,8 @@ struct TripDetailView: View {
|
||||
/// Get valid day range for a travel segment using stop indices.
|
||||
/// Uses the from/to stop dates so repeat cities don't confuse placement.
|
||||
private func validDayRange(for travelId: String) -> ClosedRange<Int>? {
|
||||
// Find the segment index matching this travel ID
|
||||
guard let segmentIndex = trip.travelSegments.firstIndex(where: { stableTravelAnchorId($0) == travelId }),
|
||||
// Parse segment index from travel ID (format: "travel:INDEX:from->to")
|
||||
guard let segmentIndex = Self.parseSegmentIndex(from: travelId),
|
||||
segmentIndex < trip.stops.count - 1 else {
|
||||
return nil
|
||||
}
|
||||
@@ -960,6 +966,15 @@ struct TripDetailView: View {
|
||||
return minDay...maxDay
|
||||
}
|
||||
|
||||
/// Parse the segment index from a travel anchor ID.
|
||||
/// Format: "travel:INDEX:from->to" → INDEX
|
||||
private static func parseSegmentIndex(from travelId: String) -> Int? {
|
||||
let stripped = travelId.replacingOccurrences(of: "travel:", with: "")
|
||||
let parts = stripped.components(separatedBy: ":")
|
||||
guard parts.count >= 2, let index = Int(parts[0]) else { return nil }
|
||||
return index
|
||||
}
|
||||
|
||||
// MARK: - Map Helpers
|
||||
|
||||
private func fetchDrivingRoutes() async {
|
||||
@@ -1231,8 +1246,8 @@ struct TripDetailView: View {
|
||||
}
|
||||
|
||||
// 2. Add travel items (from trip segments + overrides)
|
||||
for segment in trip.travelSegments {
|
||||
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
||||
for (segmentIndex, segment) in trip.travelSegments.enumerated() {
|
||||
let travelId = stableTravelAnchorId(segment, at: segmentIndex)
|
||||
|
||||
// Use override if available, otherwise default to day 1
|
||||
let override = travelOverrides[travelId]
|
||||
@@ -1246,6 +1261,7 @@ struct TripDetailView: View {
|
||||
kind: .travel(TravelInfo(
|
||||
fromCity: segment.fromLocation.name,
|
||||
toCity: segment.toLocation.name,
|
||||
segmentIndex: segmentIndex,
|
||||
distanceMeters: segment.distanceMeters,
|
||||
durationSeconds: segment.durationSeconds
|
||||
))
|
||||
@@ -1371,11 +1387,24 @@ struct TripDetailView: View {
|
||||
|
||||
for item in items where item.isTravel {
|
||||
guard let travelInfo = item.travelInfo else { continue }
|
||||
let travelId = "travel:\(travelInfo.fromCity.lowercased())->\(travelInfo.toCity.lowercased())"
|
||||
let from = travelInfo.fromCity.lowercased()
|
||||
let to = travelInfo.toCity.lowercased()
|
||||
|
||||
overrides[travelId] = TravelOverride(day: item.day, sortOrder: item.sortOrder)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
travelOverrides = overrides
|
||||
print("✅ [TravelOverrides] Extracted \(overrides.count) travel overrides (day + sortOrder)")
|
||||
} catch {
|
||||
@@ -1502,20 +1531,30 @@ 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 to extract cities (format: "travel:city1->city2")
|
||||
// Parse travel ID (format: "travel:INDEX:from->to")
|
||||
let segmentIndex = Self.parseSegmentIndex(from: travelAnchorId)
|
||||
let stripped = travelAnchorId.replacingOccurrences(of: "travel:", with: "")
|
||||
let parts = stripped.components(separatedBy: "->")
|
||||
guard parts.count == 2 else {
|
||||
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)")
|
||||
return
|
||||
}
|
||||
|
||||
let fromCity = parts[0]
|
||||
let toCity = parts[1]
|
||||
let fromCity = cityParts[0]
|
||||
let toCity = cityParts[1]
|
||||
|
||||
// Find existing travel item or create new one
|
||||
// Find existing travel item matching by segment index (preferred) or city pair (legacy)
|
||||
if let existingIndex = itineraryItems.firstIndex(where: {
|
||||
$0.isTravel && $0.travelInfo?.fromCity.lowercased() == fromCity && $0.travelInfo?.toCity.lowercased() == toCity
|
||||
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
|
||||
}
|
||||
// Legacy fallback: match by city pair
|
||||
return info.fromCity.lowercased() == fromCity && info.toCity.lowercased() == toCity
|
||||
}) {
|
||||
// Update existing
|
||||
var updated = itineraryItems[existingIndex]
|
||||
@@ -1526,7 +1565,7 @@ struct TripDetailView: View {
|
||||
await ItineraryItemService.shared.updateItem(updated)
|
||||
} else {
|
||||
// Create new travel item
|
||||
let travelInfo = TravelInfo(fromCity: fromCity, toCity: toCity)
|
||||
let travelInfo = TravelInfo(fromCity: fromCity, toCity: toCity, segmentIndex: segmentIndex)
|
||||
let item = ItineraryItem(
|
||||
tripId: trip.id,
|
||||
day: displayDay,
|
||||
@@ -1549,7 +1588,7 @@ struct TripDetailView: View {
|
||||
|
||||
enum ItinerarySection {
|
||||
case day(dayNumber: Int, date: Date, games: [RichGame])
|
||||
case travel(TravelSegment)
|
||||
case travel(TravelSegment, segmentIndex: Int)
|
||||
case customItem(ItineraryItem)
|
||||
case addButton(day: Int)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user