Files
Sportstime/SportsTime/Features/Trip/Views/TravelPlacement.swift
Trey t 633f7d883f 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>
2026-02-11 09:53:25 -06:00

90 lines
3.0 KiB
Swift

//
// TravelPlacement.swift
// SportsTime
//
// Computes which day number each travel segment should be displayed on.
// Extracted from TripDetailView for testability.
//
import Foundation
enum TravelPlacement {
/// Result of computing travel placement for a trip.
struct Placement {
let day: Int
let segmentIndex: Int
}
/// Computes 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]` to `trip.stops[i+1]`.
///
/// - Parameters:
/// - trip: The trip containing stops and travel segments.
/// - tripDays: Array of dates (one per trip day, start-of-day normalized).
/// - Returns: Dictionary mapping day number (1-based) to TravelSegment.
static func computeTravelByDay(
trip: Trip,
tripDays: [Date]
) -> [Int: TravelSegment] {
var travelByDay: [Int: TravelSegment] = [:]
for (segmentIndex, segment) in trip.travelSegments.enumerated() {
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, in: tripDays)
let toDayNum = dayNumber(for: toStop.arrivalDate, in: tripDays)
minDay = max(fromDayNum + 1, 1)
maxDay = min(toDayNum, tripDays.count)
// 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
defaultDay = 1
}
let clampedDefault: Int
if minDay <= maxDay {
clampedDefault = max(minDay, min(defaultDay, maxDay))
} else {
// 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
}
return travelByDay
}
/// Convert a date to a 1-based day number within the trip days array.
/// Returns 0 if before trip start, tripDays.count + 1 if after trip end.
static func dayNumber(for date: Date, in tripDays: [Date]) -> Int {
let calendar = Calendar.current
let target = calendar.startOfDay(for: date)
for (index, tripDay) in tripDays.enumerated() {
if calendar.startOfDay(for: tripDay) == target {
return index + 1
}
}
if let firstDay = tripDays.first, target < firstDay {
return 0
}
return tripDays.count + 1
}
}