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:
@@ -38,6 +38,7 @@ enum ItemKind: Codable, Hashable {
|
|||||||
struct TravelInfo: Codable, Hashable {
|
struct TravelInfo: Codable, Hashable {
|
||||||
let fromCity: String
|
let fromCity: String
|
||||||
let toCity: String
|
let toCity: String
|
||||||
|
var segmentIndex: Int? = nil // Position in trip.travelSegments (nil for legacy records)
|
||||||
var distanceMeters: Double?
|
var distanceMeters: Double?
|
||||||
var durationSeconds: Double?
|
var durationSeconds: Double?
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ extension ItineraryItem {
|
|||||||
let info = TravelInfo(
|
let info = TravelInfo(
|
||||||
fromCity: fromCity,
|
fromCity: fromCity,
|
||||||
toCity: toCity,
|
toCity: toCity,
|
||||||
|
segmentIndex: record["travelSegmentIndex"] as? Int,
|
||||||
distanceMeters: record["travelDistanceMeters"] as? Double,
|
distanceMeters: record["travelDistanceMeters"] as? Double,
|
||||||
durationSeconds: record["travelDurationSeconds"] as? Double
|
durationSeconds: record["travelDurationSeconds"] as? Double
|
||||||
)
|
)
|
||||||
@@ -198,6 +199,7 @@ extension ItineraryItem {
|
|||||||
record["kind"] = "travel"
|
record["kind"] = "travel"
|
||||||
record["travelFromCity"] = info.fromCity
|
record["travelFromCity"] = info.fromCity
|
||||||
record["travelToCity"] = info.toCity
|
record["travelToCity"] = info.toCity
|
||||||
|
record["travelSegmentIndex"] = info.segmentIndex
|
||||||
record["travelDistanceMeters"] = info.distanceMeters
|
record["travelDistanceMeters"] = info.distanceMeters
|
||||||
record["travelDurationSeconds"] = info.durationSeconds
|
record["travelDurationSeconds"] = info.durationSeconds
|
||||||
|
|
||||||
|
|||||||
@@ -531,11 +531,10 @@ enum ItineraryReorderingLogic {
|
|||||||
return valid
|
return valid
|
||||||
|
|
||||||
case .travel(let segment, _):
|
case .travel(let segment, _):
|
||||||
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
|
||||||
let validDayRange = travelValidRanges[travelId]
|
|
||||||
|
|
||||||
// Use existing model if available, otherwise create a default
|
// Use existing model if available, otherwise create a default
|
||||||
let model = findTravelItem(segment) ?? makeTravelItem(segment)
|
let model = findTravelItem(segment) ?? makeTravelItem(segment)
|
||||||
|
let travelId = travelIdForSegment(segment, in: travelValidRanges, model: model)
|
||||||
|
let validDayRange = travelValidRanges[travelId]
|
||||||
|
|
||||||
guard let constraints = constraints else {
|
guard let constraints = constraints else {
|
||||||
// No constraint engine, allow all rows except 0 and day headers
|
// No constraint engine, allow all rows except 0 and day headers
|
||||||
@@ -750,7 +749,8 @@ enum ItineraryReorderingLogic {
|
|||||||
constraints: ItineraryConstraints?,
|
constraints: ItineraryConstraints?,
|
||||||
findTravelItem: (TravelSegment) -> ItineraryItem?
|
findTravelItem: (TravelSegment) -> ItineraryItem?
|
||||||
) -> DragZones {
|
) -> DragZones {
|
||||||
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
let model = findTravelItem(segment)
|
||||||
|
let travelId = travelIdForSegment(segment, in: travelValidRanges, model: model)
|
||||||
|
|
||||||
guard let validRange = travelValidRanges[travelId] else {
|
guard let validRange = travelValidRanges[travelId] else {
|
||||||
return DragZones(invalidRowIndices: [], validDropRows: [], barrierGameIds: [])
|
return DragZones(invalidRowIndices: [], validDropRows: [], barrierGameIds: [])
|
||||||
@@ -820,6 +820,36 @@ enum ItineraryReorderingLogic {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Travel ID Lookup
|
||||||
|
|
||||||
|
/// Find the travel ID key for a segment in the travelValidRanges dictionary.
|
||||||
|
/// Keys are formatted as "travel:INDEX:from->to".
|
||||||
|
/// When multiple keys share the same city pair (repeat visits), matches by
|
||||||
|
/// checking all keys and preferring the one whose index matches the model's segmentIndex.
|
||||||
|
private static func travelIdForSegment(
|
||||||
|
_ segment: TravelSegment,
|
||||||
|
in travelValidRanges: [String: ClosedRange<Int>],
|
||||||
|
model: ItineraryItem? = nil
|
||||||
|
) -> String {
|
||||||
|
let suffix = "\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
||||||
|
let matchingKeys = travelValidRanges.keys.filter { $0.hasSuffix(suffix) }
|
||||||
|
|
||||||
|
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)"
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Utility Functions
|
// MARK: - Utility Functions
|
||||||
|
|
||||||
/// Finds the nearest value in a sorted array using binary search.
|
/// Finds the nearest value in a sorted array using binary search.
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ struct ItineraryDayData: Identifiable {
|
|||||||
/// ID format examples:
|
/// ID format examples:
|
||||||
/// - dayHeader: "day:3"
|
/// - dayHeader: "day:3"
|
||||||
/// - games: "games:3"
|
/// - games: "games:3"
|
||||||
/// - travel: "travel:detroit->milwaukee" (lowercase, stable across sessions)
|
/// - travel: "travel:0:detroit->milwaukee" (index:lowercase, stable across sessions)
|
||||||
/// - customItem: "item:550e8400-e29b-41d4-a716-446655440000"
|
/// - customItem: "item:550e8400-e29b-41d4-a716-446655440000"
|
||||||
/// - addButton: "add:3"
|
/// - addButton: "add:3"
|
||||||
enum ItineraryRowItem: Identifiable, Equatable {
|
enum ItineraryRowItem: Identifiable, Equatable {
|
||||||
@@ -253,16 +253,16 @@ enum ItineraryRowItem: Identifiable, Equatable {
|
|||||||
case customItem(ItineraryItem) // Reorderable: anywhere
|
case customItem(ItineraryItem) // Reorderable: anywhere
|
||||||
|
|
||||||
/// Stable identifier for table view diffing and external references.
|
/// Stable identifier for table view diffing and external references.
|
||||||
/// Travel IDs are lowercase to ensure consistency across sessions.
|
/// Travel IDs include segment index and are lowercase for consistency.
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .dayHeader(let dayNumber, _):
|
case .dayHeader(let dayNumber, _):
|
||||||
return "day:\(dayNumber)"
|
return "day:\(dayNumber)"
|
||||||
case .games(_, let dayNumber):
|
case .games(_, let dayNumber):
|
||||||
return "games:\(dayNumber)"
|
return "games:\(dayNumber)"
|
||||||
case .travel(let segment, _):
|
case .travel(let segment, let dayNumber):
|
||||||
// Lowercase ensures stable ID regardless of display capitalization
|
// Use segment UUID for unique diffing (segment index is not available here)
|
||||||
return "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
return "travel:\(segment.id.uuidString):\(dayNumber)"
|
||||||
case .customItem(let item):
|
case .customItem(let item):
|
||||||
return "item:\(item.id.uuidString)"
|
return "item:\(item.id.uuidString)"
|
||||||
}
|
}
|
||||||
@@ -594,14 +594,26 @@ final class ItineraryTableViewController: UITableViewController {
|
|||||||
|
|
||||||
/// Finds the ItineraryItem model for a travel segment.
|
/// Finds the ItineraryItem model for a travel segment.
|
||||||
///
|
///
|
||||||
/// Searches through allItineraryItems to find a matching travel item
|
/// Searches through allItineraryItems to find a matching travel item.
|
||||||
/// based on fromCity and toCity.
|
/// Prefers matching by segmentIndex for disambiguation of repeat city pairs.
|
||||||
private func findItineraryItem(for segment: TravelSegment) -> ItineraryItem? {
|
private func findItineraryItem(for segment: TravelSegment) -> ItineraryItem? {
|
||||||
return allItineraryItems.first { item in
|
let fromLower = segment.fromLocation.name.lowercased()
|
||||||
|
let toLower = segment.toLocation.name.lowercased()
|
||||||
|
|
||||||
|
// Find all matching travel items by city pair
|
||||||
|
let matches = allItineraryItems.filter { item in
|
||||||
guard case .travel(let info) = item.kind else { return false }
|
guard case .travel(let info) = item.kind else { return false }
|
||||||
return info.fromCity.lowercased() == segment.fromLocation.name.lowercased()
|
return info.fromCity.lowercased() == fromLower
|
||||||
&& info.toCity.lowercased() == segment.toLocation.name.lowercased()
|
&& info.toCity.lowercased() == toLower
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Applies visual feedback during drag.
|
/// Applies visual feedback during drag.
|
||||||
@@ -755,7 +767,8 @@ final class ItineraryTableViewController: UITableViewController {
|
|||||||
// Travel is positioned within a day using sortOrder (can be before/after games)
|
// Travel is positioned within a day using sortOrder (can be before/after games)
|
||||||
let destinationDay = dayNumber(forRow: destinationIndexPath.row)
|
let destinationDay = dayNumber(forRow: destinationIndexPath.row)
|
||||||
let sortOrder = calculateSortOrder(at: destinationIndexPath.row)
|
let sortOrder = calculateSortOrder(at: destinationIndexPath.row)
|
||||||
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
let segIdx = findItineraryItem(for: segment)?.travelInfo?.segmentIndex ?? 0
|
||||||
|
let travelId = "travel:\(segIdx):\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
||||||
onTravelMoved?(travelId, destinationDay, sortOrder)
|
onTravelMoved?(travelId, destinationDay, sortOrder)
|
||||||
|
|
||||||
case .customItem(let customItem):
|
case .customItem(let customItem):
|
||||||
|
|||||||
@@ -138,8 +138,8 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for segment in trip.travelSegments {
|
for (segmentIndex, segment) in trip.travelSegments.enumerated() {
|
||||||
let travelId = stableTravelAnchorId(segment)
|
let travelId = stableTravelAnchorId(segment, at: segmentIndex)
|
||||||
let fromCity = segment.fromLocation.name
|
let fromCity = segment.fromLocation.name
|
||||||
let toCity = segment.toLocation.name
|
let toCity = segment.toLocation.name
|
||||||
|
|
||||||
@@ -186,6 +186,7 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
|||||||
TravelInfo(
|
TravelInfo(
|
||||||
fromCity: fromCity,
|
fromCity: fromCity,
|
||||||
toCity: toCity,
|
toCity: toCity,
|
||||||
|
segmentIndex: segmentIndex,
|
||||||
distanceMeters: segment.distanceMeters,
|
distanceMeters: segment.distanceMeters,
|
||||||
durationSeconds: segment.durationSeconds
|
durationSeconds: segment.durationSeconds
|
||||||
)
|
)
|
||||||
@@ -217,13 +218,20 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
|||||||
.filter { $0.day == dayNum }
|
.filter { $0.day == dayNum }
|
||||||
.sorted { $0.sortOrder < $1.sortOrder }
|
.sorted { $0.sortOrder < $1.sortOrder }
|
||||||
for travel in travelsForDay {
|
for travel in travelsForDay {
|
||||||
// Find the segment matching this travel
|
// Find the segment matching this travel by segment index (preferred) or city pair (legacy)
|
||||||
if let info = travel.travelInfo,
|
if let info = travel.travelInfo {
|
||||||
let seg = trip.travelSegments.first(where: {
|
let seg: TravelSegment?
|
||||||
$0.fromLocation.name.lowercased() == info.fromCity.lowercased()
|
if let idx = info.segmentIndex, idx < trip.travelSegments.count {
|
||||||
&& $0.toLocation.name.lowercased() == info.toCity.lowercased()
|
seg = trip.travelSegments[idx]
|
||||||
}) {
|
} else {
|
||||||
rows.append(.travel(seg, dayNumber: dayNum))
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,12 +241,12 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
|||||||
switch r {
|
switch r {
|
||||||
case .customItem(let it): return it.sortOrder
|
case .customItem(let it): return it.sortOrder
|
||||||
case .travel(let seg, _):
|
case .travel(let seg, _):
|
||||||
let id = stableTravelAnchorId(seg)
|
let segIdx = trip.travelSegments.firstIndex(where: { $0.id == seg.id }) ?? 0
|
||||||
|
let id = stableTravelAnchorId(seg, at: segIdx)
|
||||||
return (travelOverrides[id]?.sortOrder)
|
return (travelOverrides[id]?.sortOrder)
|
||||||
?? (travelItems.first(where: { ti in
|
?? (travelItems.first(where: { ti in
|
||||||
guard case .travel(let inf) = ti.kind else { return false }
|
guard case .travel(let inf) = ti.kind else { return false }
|
||||||
return inf.fromCity.lowercased() == seg.fromLocation.name.lowercased()
|
return inf.segmentIndex == segIdx
|
||||||
&& inf.toCity.lowercased() == seg.toLocation.name.lowercased()
|
|
||||||
})?.sortOrder ?? 0.0)
|
})?.sortOrder ?? 0.0)
|
||||||
default:
|
default:
|
||||||
return 0.0
|
return 0.0
|
||||||
@@ -285,8 +293,8 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
|||||||
.sorted { $0.game.dateTime < $1.game.dateTime }
|
.sorted { $0.game.dateTime < $1.game.dateTime }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stableTravelAnchorId(_ segment: TravelSegment) -> String {
|
private func stableTravelAnchorId(_ segment: TravelSegment, at index: Int) -> String {
|
||||||
"travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
"travel:\(index):\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func findLastGameDay(in city: String, tripDays: [Date]) -> Int {
|
private func findLastGameDay(in city: String, tripDays: [Date]) -> Int {
|
||||||
|
|||||||
@@ -24,12 +24,13 @@ enum TravelPlacement {
|
|||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - trip: The trip containing stops and travel segments.
|
/// - trip: The trip containing stops and travel segments.
|
||||||
/// - tripDays: Array of dates (one per trip day, start-of-day normalized).
|
/// - tripDays: Array of dates (one per trip day, start-of-day normalized).
|
||||||
/// - Returns: Dictionary mapping day number (1-based) to TravelSegment.
|
/// - Returns: Dictionary mapping day number (1-based) to an array of TravelSegments.
|
||||||
|
/// Multiple segments can land on the same day (e.g. back-to-back single-game stops).
|
||||||
static func computeTravelByDay(
|
static func computeTravelByDay(
|
||||||
trip: Trip,
|
trip: Trip,
|
||||||
tripDays: [Date]
|
tripDays: [Date]
|
||||||
) -> [Int: TravelSegment] {
|
) -> [Int: [TravelSegment]] {
|
||||||
var travelByDay: [Int: TravelSegment] = [:]
|
var travelByDay: [Int: [TravelSegment]] = [:]
|
||||||
|
|
||||||
for (segmentIndex, segment) in trip.travelSegments.enumerated() {
|
for (segmentIndex, segment) in trip.travelSegments.enumerated() {
|
||||||
let minDay: Int
|
let minDay: Int
|
||||||
@@ -63,7 +64,7 @@ enum TravelPlacement {
|
|||||||
clampedDefault = max(1, min(defaultDay, tripDays.count))
|
clampedDefault = max(1, min(defaultDay, tripDays.count))
|
||||||
}
|
}
|
||||||
|
|
||||||
travelByDay[clampedDefault] = segment
|
travelByDay[clampedDefault, default: []].append(segment)
|
||||||
}
|
}
|
||||||
|
|
||||||
return travelByDay
|
return travelByDay
|
||||||
|
|||||||
@@ -573,8 +573,8 @@ struct TripDetailView: View {
|
|||||||
handleDayDrop(providers: providers, dayNumber: dayNumber, gamesOnDay: gamesOnDay)
|
handleDayDrop(providers: providers, dayNumber: dayNumber, gamesOnDay: gamesOnDay)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .travel(let segment):
|
case .travel(let segment, let segmentIndex):
|
||||||
let travelId = stableTravelAnchorId(segment)
|
let travelId = stableTravelAnchorId(segment, at: segmentIndex)
|
||||||
TravelSection(segment: segment)
|
TravelSection(segment: segment)
|
||||||
.staggeredAnimation(index: index)
|
.staggeredAnimation(index: index)
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
@@ -770,8 +770,8 @@ struct TripDetailView: View {
|
|||||||
switch section {
|
switch section {
|
||||||
case .day(let dayNumber, _, _):
|
case .day(let dayNumber, _, _):
|
||||||
return "day-\(dayNumber)"
|
return "day-\(dayNumber)"
|
||||||
case .travel(let segment):
|
case .travel(let segment, let segmentIndex):
|
||||||
return "travel-\(segment.fromLocation.name)-\(segment.toLocation.name)"
|
return "travel-\(segmentIndex)-\(segment.fromLocation.name)-\(segment.toLocation.name)"
|
||||||
case .customItem(let item):
|
case .customItem(let item):
|
||||||
return "item-\(item.id.uuidString)"
|
return "item-\(item.id.uuidString)"
|
||||||
case .addButton(let day):
|
case .addButton(let day):
|
||||||
@@ -783,7 +783,7 @@ struct TripDetailView: View {
|
|||||||
// Find which day this travel segment belongs to by looking at sections
|
// 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
|
// Travel appears BEFORE the arrival day, so look FORWARD to find arrival day
|
||||||
for (index, section) in itinerarySections.enumerated() {
|
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
|
// Look forward to find the arrival day
|
||||||
for i in (index + 1)..<itinerarySections.count {
|
for i in (index + 1)..<itinerarySections.count {
|
||||||
if case .day(let dayNumber, _, _) = itinerarySections[i] {
|
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)
|
/// 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 from = segment.fromLocation.name.lowercased().trimmingCharacters(in: .whitespaces)
|
||||||
let to = segment.toLocation.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
|
/// Move item to a new day and sortOrder position
|
||||||
@@ -832,16 +832,21 @@ struct TripDetailView: View {
|
|||||||
|
|
||||||
// Apply user overrides on top of computed defaults.
|
// Apply user overrides on top of computed defaults.
|
||||||
for (segmentIndex, segment) in trip.travelSegments.enumerated() {
|
for (segmentIndex, segment) in trip.travelSegments.enumerated() {
|
||||||
let travelId = stableTravelAnchorId(segment)
|
let travelId = stableTravelAnchorId(segment, at: segmentIndex)
|
||||||
guard let override = travelOverrides[travelId] else { continue }
|
guard let override = travelOverrides[travelId] else { continue }
|
||||||
|
|
||||||
// Validate override is within valid day range
|
// Validate override is within valid day range
|
||||||
if let validRange = validDayRange(for: travelId),
|
if let validRange = validDayRange(for: travelId),
|
||||||
validRange.contains(override.day) {
|
validRange.contains(override.day) {
|
||||||
// Remove from computed position
|
// Remove from computed position (search all days)
|
||||||
travelByDay = travelByDay.filter { $0.value.id != segment.id }
|
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
|
// 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)
|
let gamesOnDay = gamesOn(date: dayDate)
|
||||||
|
|
||||||
// Travel for this day (if any) - appears before day header
|
// Travel for this day (if any) - appears before day header
|
||||||
if let travelSegment = travelByDay[dayNum] {
|
for travelSegment in (travelByDay[dayNum] ?? []) {
|
||||||
sections.append(.travel(travelSegment))
|
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
|
// 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.
|
/// Get valid day range for a travel segment using stop indices.
|
||||||
/// Uses the from/to stop dates so repeat cities don't confuse placement.
|
/// Uses the from/to stop dates so repeat cities don't confuse placement.
|
||||||
private func validDayRange(for travelId: String) -> ClosedRange<Int>? {
|
private func validDayRange(for travelId: String) -> ClosedRange<Int>? {
|
||||||
// Find the segment index matching this travel ID
|
// Parse segment index from travel ID (format: "travel:INDEX:from->to")
|
||||||
guard let segmentIndex = trip.travelSegments.firstIndex(where: { stableTravelAnchorId($0) == travelId }),
|
guard let segmentIndex = Self.parseSegmentIndex(from: travelId),
|
||||||
segmentIndex < trip.stops.count - 1 else {
|
segmentIndex < trip.stops.count - 1 else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -960,6 +966,15 @@ struct TripDetailView: View {
|
|||||||
return minDay...maxDay
|
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
|
// MARK: - Map Helpers
|
||||||
|
|
||||||
private func fetchDrivingRoutes() async {
|
private func fetchDrivingRoutes() async {
|
||||||
@@ -1231,8 +1246,8 @@ struct TripDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Add travel items (from trip segments + overrides)
|
// 2. Add travel items (from trip segments + overrides)
|
||||||
for segment in trip.travelSegments {
|
for (segmentIndex, segment) in trip.travelSegments.enumerated() {
|
||||||
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
let travelId = stableTravelAnchorId(segment, at: segmentIndex)
|
||||||
|
|
||||||
// Use override if available, otherwise default to day 1
|
// Use override if available, otherwise default to day 1
|
||||||
let override = travelOverrides[travelId]
|
let override = travelOverrides[travelId]
|
||||||
@@ -1246,6 +1261,7 @@ struct TripDetailView: View {
|
|||||||
kind: .travel(TravelInfo(
|
kind: .travel(TravelInfo(
|
||||||
fromCity: segment.fromLocation.name,
|
fromCity: segment.fromLocation.name,
|
||||||
toCity: segment.toLocation.name,
|
toCity: segment.toLocation.name,
|
||||||
|
segmentIndex: segmentIndex,
|
||||||
distanceMeters: segment.distanceMeters,
|
distanceMeters: segment.distanceMeters,
|
||||||
durationSeconds: segment.durationSeconds
|
durationSeconds: segment.durationSeconds
|
||||||
))
|
))
|
||||||
@@ -1371,9 +1387,22 @@ struct TripDetailView: View {
|
|||||||
|
|
||||||
for item in items where item.isTravel {
|
for item in items where item.isTravel {
|
||||||
guard let travelInfo = item.travelInfo else { continue }
|
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
|
travelOverrides = overrides
|
||||||
@@ -1502,20 +1531,30 @@ struct TripDetailView: View {
|
|||||||
private func saveTravelDayOverride(travelAnchorId: String, displayDay: Int, sortOrder: Double) async {
|
private func saveTravelDayOverride(travelAnchorId: String, displayDay: Int, sortOrder: Double) async {
|
||||||
print("💾 [TravelOverrides] Saving override: \(travelAnchorId) -> day \(displayDay), sortOrder \(sortOrder)")
|
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 stripped = travelAnchorId.replacingOccurrences(of: "travel:", with: "")
|
||||||
let parts = stripped.components(separatedBy: "->")
|
let colonParts = stripped.components(separatedBy: ":")
|
||||||
guard parts.count == 2 else {
|
// 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)")
|
print("❌ [TravelOverrides] Invalid travel ID format: \(travelAnchorId)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let fromCity = parts[0]
|
let fromCity = cityParts[0]
|
||||||
let toCity = parts[1]
|
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: {
|
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
|
// Update existing
|
||||||
var updated = itineraryItems[existingIndex]
|
var updated = itineraryItems[existingIndex]
|
||||||
@@ -1526,7 +1565,7 @@ struct TripDetailView: View {
|
|||||||
await ItineraryItemService.shared.updateItem(updated)
|
await ItineraryItemService.shared.updateItem(updated)
|
||||||
} else {
|
} else {
|
||||||
// Create new travel item
|
// Create new travel item
|
||||||
let travelInfo = TravelInfo(fromCity: fromCity, toCity: toCity)
|
let travelInfo = TravelInfo(fromCity: fromCity, toCity: toCity, segmentIndex: segmentIndex)
|
||||||
let item = ItineraryItem(
|
let item = ItineraryItem(
|
||||||
tripId: trip.id,
|
tripId: trip.id,
|
||||||
day: displayDay,
|
day: displayDay,
|
||||||
@@ -1549,7 +1588,7 @@ struct TripDetailView: View {
|
|||||||
|
|
||||||
enum ItinerarySection {
|
enum ItinerarySection {
|
||||||
case day(dayNumber: Int, date: Date, games: [RichGame])
|
case day(dayNumber: Int, date: Date, games: [RichGame])
|
||||||
case travel(TravelSegment)
|
case travel(TravelSegment, segmentIndex: Int)
|
||||||
case customItem(ItineraryItem)
|
case customItem(ItineraryItem)
|
||||||
case addButton(day: Int)
|
case addButton(day: Int)
|
||||||
|
|
||||||
|
|||||||
@@ -833,7 +833,7 @@ final class ItineraryReorderingLogicTests: XCTestCase {
|
|||||||
])
|
])
|
||||||
|
|
||||||
let segment = H.makeTravelSegment(from: "CityA", to: "CityB")
|
let segment = H.makeTravelSegment(from: "CityA", to: "CityB")
|
||||||
let travelValidRanges = ["travel:citya->cityb": 1...3]
|
let travelValidRanges = ["travel:0:citya->cityb": 1...3]
|
||||||
|
|
||||||
let zones = Logic.calculateTravelDragZones(
|
let zones = Logic.calculateTravelDragZones(
|
||||||
segment: segment,
|
segment: segment,
|
||||||
|
|||||||
@@ -170,10 +170,11 @@ final class ItineraryRowFlatteningTests: XCTestCase {
|
|||||||
XCTAssertEqual(item.id, "games:2")
|
XCTAssertEqual(item.id, "games:2")
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_itineraryRowItem_travel_hasLowercaseId() {
|
func test_itineraryRowItem_travel_hasStableId() {
|
||||||
let segment = H.makeTravelSegment(from: "Chicago", to: "Detroit")
|
let segment = H.makeTravelSegment(from: "Chicago", to: "Detroit")
|
||||||
let item = ItineraryRowItem.travel(segment, dayNumber: 1)
|
let item = ItineraryRowItem.travel(segment, dayNumber: 1)
|
||||||
XCTAssertEqual(item.id, "travel:chicago->detroit", "Travel ID should be lowercase")
|
XCTAssertTrue(item.id.hasPrefix("travel:"), "Travel ID should start with 'travel:'")
|
||||||
|
XCTAssertTrue(item.id.contains(segment.id.uuidString), "Travel ID should contain segment UUID")
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_itineraryRowItem_customItem_hasUuidId() {
|
func test_itineraryRowItem_customItem_hasUuidId() {
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ final class ItinerarySemanticTravelTests: XCTestCase {
|
|||||||
|
|
||||||
let constraints = ItineraryConstraints(tripDayCount: 4, items: [gameItemA, gameItemB])
|
let constraints = ItineraryConstraints(tripDayCount: 4, items: [gameItemA, gameItemB])
|
||||||
|
|
||||||
let travelValidRanges = ["travel:citya->cityb": 1...4]
|
let travelValidRanges = ["travel:0:citya->cityb": 1...4]
|
||||||
|
|
||||||
let validRows = Logic.computeValidDestinationRowsProposed(
|
let validRows = Logic.computeValidDestinationRowsProposed(
|
||||||
flatItems: items,
|
flatItems: items,
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ final class ItineraryTravelConstraintTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
controller.reloadData(
|
controller.reloadData(
|
||||||
days: [dayData],
|
days: [dayData],
|
||||||
travelValidRanges: ["travel:chicago->detroit": 1...1],
|
travelValidRanges: ["travel:0:chicago->detroit": 1...1],
|
||||||
itineraryItems: [chicagoGame, travel]
|
itineraryItems: [chicagoGame, travel]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -214,7 +214,7 @@ final class ItineraryTravelConstraintTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
controller.reloadData(
|
controller.reloadData(
|
||||||
days: [day1, day2, day3],
|
days: [day1, day2, day3],
|
||||||
travelValidRanges: ["travel:chicago->detroit": 2...3],
|
travelValidRanges: ["travel:0:chicago->detroit": 2...3],
|
||||||
itineraryItems: [travelModelItem]
|
itineraryItems: [travelModelItem]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -222,7 +222,7 @@ final class ItineraryTravelConstraintTests: XCTestCase {
|
|||||||
// Move travel (row 2) to row 3 (after Day3 header = Day 3)
|
// Move travel (row 2) to row 3 (after Day3 header = Day 3)
|
||||||
controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 2, section: 0), to: IndexPath(row: 3, section: 0))
|
controller.tableView(controller.tableView, moveRowAt: IndexPath(row: 2, section: 0), to: IndexPath(row: 3, section: 0))
|
||||||
|
|
||||||
XCTAssertEqual(capturedTravelId, "travel:chicago->detroit")
|
XCTAssertEqual(capturedTravelId, "travel:0:chicago->detroit")
|
||||||
XCTAssertEqual(capturedDay, 3, "Travel should now be on Day 3")
|
XCTAssertEqual(capturedDay, 3, "Travel should now be on Day 3")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +231,7 @@ final class ItineraryTravelConstraintTests: XCTestCase {
|
|||||||
func test_moveValidation_travel_snapsToValidDayRange() {
|
func test_moveValidation_travel_snapsToValidDayRange() {
|
||||||
// Given: Travel with valid range Days 2-3
|
// Given: Travel with valid range Days 2-3
|
||||||
let travel = H.makeTravelSegment(from: "Chicago", to: "Detroit")
|
let travel = H.makeTravelSegment(from: "Chicago", to: "Detroit")
|
||||||
let travelId = "travel:chicago->detroit"
|
let travelId = "travel:0:chicago->detroit"
|
||||||
let travelItem = ItineraryRowItem.travel(travel, dayNumber: 2)
|
let travelItem = ItineraryRowItem.travel(travel, dayNumber: 2)
|
||||||
let travelModelItem = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 2, sortOrder: 1.0)
|
let travelModelItem = H.makeTravelItem(from: "Chicago", to: "Detroit", day: 2, sortOrder: 1.0)
|
||||||
|
|
||||||
|
|||||||
@@ -358,4 +358,121 @@ final class TravelPlacementTests: XCTestCase {
|
|||||||
XCTAssertLessThanOrEqual(day, 6, "B→C travel should be on or before Day 6")
|
XCTAssertLessThanOrEqual(day, 6, "B→C travel should be on or before Day 6")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Same-Day Collision (dictionary overwrite bug)
|
||||||
|
|
||||||
|
func test_twoSegmentsSameDay_neitherLost() {
|
||||||
|
// 3 back-to-back single-game stops with next-morning departures:
|
||||||
|
// Stop A: arrival May 1, departure May 2
|
||||||
|
// Stop B: arrival May 2, departure May 3
|
||||||
|
// Stop C: arrival May 3, departure May 4
|
||||||
|
//
|
||||||
|
// Segment 0 (A→B): fromDayNum=2, toDayNum=2 → defaultDay=2, clampedDefault=2
|
||||||
|
// Segment 1 (B→C): fromDayNum=3, toDayNum=3 → defaultDay=3, clampedDefault=3
|
||||||
|
//
|
||||||
|
// These don't collide, but a tighter scenario does:
|
||||||
|
// If A departs May 2 and B arrives May 2 AND departs May 2, C arrives May 2:
|
||||||
|
// Both segments resolve to Day 2 → collision.
|
||||||
|
//
|
||||||
|
// Realistic tight scenario:
|
||||||
|
// Stop A: May 1-1 (same-day), Stop B: May 2-2, Stop C: May 2-3
|
||||||
|
// Segment 0 (A→B): fromDayNum=1, toDayNum=2 → defaultDay=2
|
||||||
|
// Segment 1 (B→C): fromDayNum=2, toDayNum=2 → defaultDay=2
|
||||||
|
// Both = Day 2 → COLLISION: segment 0 overwritten.
|
||||||
|
let stops = [
|
||||||
|
makeStop(city: "CityA", arrival: may(1), departure: may(1)),
|
||||||
|
makeStop(city: "CityB", arrival: may(2), departure: may(2)),
|
||||||
|
makeStop(city: "CityC", arrival: may(2), departure: may(3))
|
||||||
|
]
|
||||||
|
let segments = [
|
||||||
|
makeSegment(from: "CityA", to: "CityB"),
|
||||||
|
makeSegment(from: "CityB", to: "CityC")
|
||||||
|
]
|
||||||
|
|
||||||
|
let trip = Trip(
|
||||||
|
name: "Tight Trip",
|
||||||
|
preferences: TripPreferences(),
|
||||||
|
stops: stops,
|
||||||
|
travelSegments: segments,
|
||||||
|
totalGames: 0,
|
||||||
|
totalDistanceMeters: 0,
|
||||||
|
totalDrivingSeconds: 0
|
||||||
|
)
|
||||||
|
|
||||||
|
let days = tripDays(from: may(1), to: may(3))
|
||||||
|
let result = TravelPlacement.computeTravelByDay(trip: trip, tripDays: days)
|
||||||
|
|
||||||
|
// Both segments must be present — neither should be silently dropped
|
||||||
|
let allSegments = result.values.flatMap { $0 }
|
||||||
|
XCTAssertEqual(allSegments.count, 2, "Both travel segments must be preserved (no collision)")
|
||||||
|
|
||||||
|
// Day 2 should have both segments
|
||||||
|
let day2Segments = result[2] ?? []
|
||||||
|
XCTAssertEqual(day2Segments.count, 2, "Day 2 should have 2 travel segments")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Repeat City Pair ID Collision
|
||||||
|
|
||||||
|
func test_repeatCityPair_overridesDoNotCollide() {
|
||||||
|
// Follow Team pattern: Houston→Cincinnati→Houston→Cincinnati
|
||||||
|
// Two segments share the same city pair (Houston→Cincinnati)
|
||||||
|
// Each must get a unique travel anchor ID so overrides don't collide.
|
||||||
|
let stops = [
|
||||||
|
makeStop(city: "Houston", arrival: may(1), departure: may(2)), // Stop 0
|
||||||
|
makeStop(city: "Cincinnati", arrival: may(4), departure: may(5)), // Stop 1
|
||||||
|
makeStop(city: "Houston", arrival: may(7), departure: may(8)), // Stop 2
|
||||||
|
makeStop(city: "Cincinnati", arrival: may(10), departure: may(11)) // Stop 3
|
||||||
|
]
|
||||||
|
|
||||||
|
let segments = [
|
||||||
|
makeSegment(from: "Houston", to: "Cincinnati"), // Seg 0: stops[0] → stops[1]
|
||||||
|
makeSegment(from: "Cincinnati", to: "Houston"), // Seg 1: stops[1] → stops[2]
|
||||||
|
makeSegment(from: "Houston", to: "Cincinnati") // Seg 2: stops[2] → stops[3]
|
||||||
|
]
|
||||||
|
|
||||||
|
// Simulate what stableTravelAnchorId should produce WITH segment index
|
||||||
|
let id0 = "travel:0:houston->cincinnati"
|
||||||
|
let id1 = "travel:1:cincinnati->houston"
|
||||||
|
let id2 = "travel:2:houston->cincinnati"
|
||||||
|
|
||||||
|
// All IDs must be unique
|
||||||
|
XCTAssertNotEqual(id0, id2, "Repeat city pair segments must have unique IDs")
|
||||||
|
XCTAssertEqual(Set([id0, id1, id2]).count, 3, "All 3 travel IDs must be unique")
|
||||||
|
|
||||||
|
// Simulate overrides dictionary — each segment gets its own entry
|
||||||
|
var overrides: [String: Int] = [:] // travel ID → day override
|
||||||
|
overrides[id0] = 3 // Seg 0 overridden to day 3
|
||||||
|
overrides[id2] = 9 // Seg 2 overridden to day 9
|
||||||
|
|
||||||
|
// Verify no collision — seg 0 override is NOT overwritten by seg 2
|
||||||
|
XCTAssertEqual(overrides[id0], 3, "Segment 0 override should be day 3")
|
||||||
|
XCTAssertEqual(overrides[id2], 9, "Segment 2 override should be day 9")
|
||||||
|
XCTAssertNil(overrides[id1], "Segment 1 has no override")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_singleSegmentPerDay_returnsArrayOfOne() {
|
||||||
|
// Ensure the new array return type still works for the simple case
|
||||||
|
let stops = [
|
||||||
|
makeStop(city: "Houston", arrival: may(1), departure: may(3)),
|
||||||
|
makeStop(city: "Chicago", arrival: may(5), departure: may(6))
|
||||||
|
]
|
||||||
|
let segments = [makeSegment(from: "Houston", to: "Chicago")]
|
||||||
|
|
||||||
|
let trip = Trip(
|
||||||
|
name: "Simple",
|
||||||
|
preferences: TripPreferences(),
|
||||||
|
stops: stops,
|
||||||
|
travelSegments: segments,
|
||||||
|
totalGames: 0,
|
||||||
|
totalDistanceMeters: 0,
|
||||||
|
totalDrivingSeconds: 0
|
||||||
|
)
|
||||||
|
|
||||||
|
let days = tripDays(from: may(1), to: may(6))
|
||||||
|
let result = TravelPlacement.computeTravelByDay(trip: trip, tripDays: days)
|
||||||
|
|
||||||
|
XCTAssertEqual(result.count, 1, "Should have 1 day with travel")
|
||||||
|
let day4Segments = result[4] ?? []
|
||||||
|
XCTAssertEqual(day4Segments.count, 1, "Day 4 should have exactly 1 travel segment")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user