fix: correct travel segment placement for next-day departures

Travel segments appeared one day too late on featured/suggested trips
when stops had next-morning departures. The placement formula double-
counted by using fromDayNum+1 as both minDay and defaultDay, then the
invalid-range fallback used minDay (the overshooting value) instead of
the arrival day. Also replaced TripDetailView's inline copy of the
placement logic with TravelPlacement.computeTravelByDay() so the UI
uses the same tested algorithm.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-11 09:53:25 -06:00
parent d63d311cab
commit 633f7d883f
3 changed files with 144 additions and 37 deletions

View File

@@ -45,7 +45,9 @@ enum TravelPlacement {
minDay = max(fromDayNum + 1, 1)
maxDay = min(toDayNum, tripDays.count)
defaultDay = minDay
// Cap default at the arrival day to prevent overshoot when
// departure is already the next morning (fromDayNum+1 > toDayNum).
defaultDay = min(fromDayNum + 1, toDayNum)
} else {
minDay = 1
maxDay = tripDays.count
@@ -56,7 +58,9 @@ enum TravelPlacement {
if minDay <= maxDay {
clampedDefault = max(minDay, min(defaultDay, maxDay))
} else {
clampedDefault = minDay
// Invalid range (departure day is at or past arrival day).
// Fall back to arrival day, clamped within trip bounds.
clampedDefault = max(1, min(defaultDay, tripDays.count))
}
travelByDay[clampedDefault] = segment

View File

@@ -827,46 +827,21 @@ struct TripDetailView: View {
var sections: [ItinerarySection] = []
let days = tripDays
// Pre-calculate which day each travel segment belongs to.
// Uses stop indices (not city name matching) so repeat cities work correctly.
// trip.travelSegments[i] connects trip.stops[i] trip.stops[i+1].
var travelByDay: [Int: TravelSegment] = [:]
// Use TravelPlacement for consistent day calculation (shared with tests).
var travelByDay = TravelPlacement.computeTravelByDay(trip: trip, tripDays: days)
// Apply user overrides on top of computed defaults.
for (segmentIndex, segment) in trip.travelSegments.enumerated() {
let travelId = stableTravelAnchorId(segment)
guard let override = travelOverrides[travelId] else { continue }
// Use stop dates for precise placement (handles repeat cities)
let minDay: Int
let maxDay: Int
let defaultDay: Int
if segmentIndex < trip.stops.count - 1 {
let fromStop = trip.stops[segmentIndex]
let toStop = trip.stops[segmentIndex + 1]
let fromDayNum = dayNumber(for: fromStop.departureDate)
let toDayNum = dayNumber(for: toStop.arrivalDate)
// Travel goes after the from stop's last day, up to the to stop's first day
minDay = max(fromDayNum + 1, 1)
maxDay = min(toDayNum, days.count)
defaultDay = minDay
} else {
// Fallback: segment doesn't align with stops
minDay = 1
maxDay = days.count
defaultDay = 1
}
let validRange = minDay <= maxDay ? minDay...maxDay : minDay...minDay
// Check for user override - only use if within valid range
if let override = travelOverrides[travelId],
// 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 }
// Place at overridden position
travelByDay[override.day] = segment
} else {
// Use default (clamped to valid range)
let clampedDefault = max(validRange.lowerBound, min(defaultDay, validRange.upperBound))
travelByDay[clampedDefault] = segment
}
}