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>
91 lines
3.1 KiB
Swift
91 lines
3.1 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 an array of TravelSegments.
|
|
/// Multiple segments can land on the same day (e.g. back-to-back single-game stops).
|
|
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, default: []].append(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
|
|
}
|
|
}
|