Production changes: - TravelEstimator: remove 300mi fallback, return nil on missing coords - TripPlanningEngine: add warnings array, empty sports warning, inverted date range rejection, must-stop filter, segment validation gate - GameDAGRouter: add routePreference parameter with preference-aware bucket ordering and sorting in selectDiverseRoutes() - ScenarioA-E: pass routePreference through to GameDAGRouter - ScenarioA: track games with missing stadium data - ScenarioE: add region filtering for home games - TravelSegment: add requiresOvernightStop and travelDays() helpers Test changes: - GameDAGRouterTests: +252 lines for route preference verification - TripPlanningEngineTests: +153 lines for segment validation, date range, empty sports - ScenarioEPlannerTests: +119 lines for region filter tests - TravelEstimatorTests: remove obsolete fallback distance tests - ItineraryBuilderTests: update nil-coords test expectation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
593 lines
24 KiB
Swift
593 lines
24 KiB
Swift
//
|
|
// ScenarioEPlanner.swift
|
|
// SportsTime
|
|
//
|
|
// Scenario E: Team-First planning.
|
|
// User selects teams (not dates), we find optimal trip windows across the season.
|
|
//
|
|
// Key Features:
|
|
// - Selected teams determine which home games to visit
|
|
// - Sliding window logic generates all N-day windows across the season
|
|
// - Windows are filtered to those where ALL selected teams have home games
|
|
// - Routes are built and ranked by duration + miles
|
|
//
|
|
// Sliding Window Algorithm:
|
|
// 1. Fetch all home games for selected teams (full season)
|
|
// 2. Generate all N-day windows (N = selectedTeamIds.count * 2)
|
|
// 3. Filter to windows where each selected team has at least 1 home game
|
|
// 4. Cap at 50 windows (sample if more exist)
|
|
// 5. For each valid window, find routes using anchor strategies + fallback search
|
|
// 6. Rank by shortest duration + minimal miles
|
|
// 7. Return top 10 results
|
|
//
|
|
// Example:
|
|
// User selects: Yankees, Red Sox, Phillies
|
|
// Window duration: 3 * 2 = 6 days
|
|
// Valid windows: Any 6-day span where all 3 teams have a home game
|
|
// Output: Top 10 trip options, ranked by efficiency
|
|
//
|
|
|
|
import Foundation
|
|
import CoreLocation
|
|
|
|
/// Scenario E: Team-First planning
|
|
/// Input: selectedTeamIds, sports (for fetching games)
|
|
/// Output: Top 10 trip windows with routes visiting all selected teams' home stadiums
|
|
///
|
|
/// - Expected Behavior:
|
|
/// - No selected teams → returns .failure with .missingTeamSelection
|
|
/// - Fewer than 2 teams → returns .failure with .missingTeamSelection
|
|
/// - No home games found → returns .failure with .noGamesInRange
|
|
/// - No valid windows (no overlap) → returns .failure with .noValidRoutes
|
|
/// - Each returned route visits ALL selected teams' home stadiums
|
|
/// - Routes ranked by duration + miles (shorter is better)
|
|
/// - Returns maximum of 10 options
|
|
///
|
|
/// - Invariants:
|
|
/// - All routes contain at least one home game per selected team
|
|
/// - Window duration = selectedTeamIds.count * 2 days
|
|
/// - Maximum 50 windows evaluated (sampled if more exist)
|
|
/// - Maximum 10 results returned
|
|
///
|
|
final class ScenarioEPlanner: ScenarioPlanner {
|
|
|
|
// MARK: - Configuration
|
|
|
|
/// Maximum number of windows to evaluate (to prevent combinatorial explosion)
|
|
private let maxWindowsToEvaluate = 50
|
|
|
|
/// Maximum number of results to return
|
|
private let maxResultsToReturn = 10
|
|
|
|
// MARK: - ScenarioPlanner Protocol
|
|
|
|
func plan(request: PlanningRequest) -> ItineraryResult {
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 1: Validate team selection
|
|
// ──────────────────────────────────────────────────────────────────
|
|
let selectedTeamIds = request.preferences.selectedTeamIds
|
|
|
|
if selectedTeamIds.count < 2 {
|
|
return .failure(
|
|
PlanningFailure(
|
|
reason: .missingTeamSelection,
|
|
violations: [
|
|
ConstraintViolation(
|
|
type: .general,
|
|
description: "Team-First mode requires at least 2 teams selected",
|
|
severity: .error
|
|
)
|
|
]
|
|
)
|
|
)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 2: Collect all HOME games for selected teams
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// For Team-First mode, we only care about home games because
|
|
// the user wants to visit each team's home stadium.
|
|
var homeGamesByTeam: [String: [Game]] = [:]
|
|
var allHomeGames: [Game] = []
|
|
let selectedRegions = request.preferences.selectedRegions
|
|
|
|
for game in request.allGames {
|
|
if selectedTeamIds.contains(game.homeTeamId) {
|
|
// Apply region filter if regions are specified
|
|
if !selectedRegions.isEmpty {
|
|
guard let stadium = request.stadiums[game.stadiumId] else { continue }
|
|
let gameRegion = Region.classify(longitude: stadium.coordinate.longitude)
|
|
guard selectedRegions.contains(gameRegion) else { continue }
|
|
}
|
|
homeGamesByTeam[game.homeTeamId, default: []].append(game)
|
|
allHomeGames.append(game)
|
|
}
|
|
}
|
|
|
|
// Verify each team has at least one home game
|
|
for teamId in selectedTeamIds {
|
|
if homeGamesByTeam[teamId]?.isEmpty ?? true {
|
|
let teamName = request.teams[teamId]?.fullName ?? teamId
|
|
return .failure(
|
|
PlanningFailure(
|
|
reason: .noGamesInRange,
|
|
violations: [
|
|
ConstraintViolation(
|
|
type: .selectedGames,
|
|
description: "No home games found for \(teamName)",
|
|
severity: .error
|
|
)
|
|
]
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
print("🔍 ScenarioE Step 2: Found \(allHomeGames.count) total home games across \(selectedTeamIds.count) teams")
|
|
for (teamId, games) in homeGamesByTeam {
|
|
let teamName = request.teams[teamId]?.fullName ?? teamId
|
|
print("🔍 \(teamName): \(games.count) home games")
|
|
}
|
|
#endif
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 3: Generate sliding windows across the season
|
|
// ──────────────────────────────────────────────────────────────────
|
|
let windowDuration = request.preferences.teamFirstMaxDays
|
|
#if DEBUG
|
|
print("🔍 ScenarioE Step 3: Window duration = \(windowDuration) days")
|
|
#endif
|
|
|
|
let validWindows = generateValidWindows(
|
|
homeGamesByTeam: homeGamesByTeam,
|
|
windowDurationDays: windowDuration,
|
|
selectedTeamIds: selectedTeamIds
|
|
)
|
|
|
|
if validWindows.isEmpty {
|
|
return .failure(
|
|
PlanningFailure(
|
|
reason: .noValidRoutes,
|
|
violations: [
|
|
ConstraintViolation(
|
|
type: .dateRange,
|
|
description: "No \(windowDuration)-day windows found where all selected teams have home games",
|
|
severity: .error
|
|
)
|
|
]
|
|
)
|
|
)
|
|
}
|
|
|
|
#if DEBUG
|
|
print("🔍 ScenarioE Step 3: Found \(validWindows.count) valid windows")
|
|
#endif
|
|
|
|
// Sample windows if too many exist
|
|
let windowsToEvaluate: [DateInterval]
|
|
if validWindows.count > maxWindowsToEvaluate {
|
|
windowsToEvaluate = sampleWindows(validWindows, count: maxWindowsToEvaluate)
|
|
#if DEBUG
|
|
print("🔍 ScenarioE Step 3: Sampled down to \(windowsToEvaluate.count) windows")
|
|
#endif
|
|
} else {
|
|
windowsToEvaluate = validWindows
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 4: For each valid window, find routes
|
|
// ──────────────────────────────────────────────────────────────────
|
|
var allItineraryOptions: [ItineraryOption] = []
|
|
|
|
for (windowIndex, window) in windowsToEvaluate.enumerated() {
|
|
// Collect games in this window
|
|
var gamesByTeamInWindow: [String: [Game]] = [:]
|
|
var hasAllTeamsInWindow = true
|
|
|
|
for teamId in selectedTeamIds {
|
|
guard let teamGames = homeGamesByTeam[teamId] else { continue }
|
|
|
|
// Get all home games for this team in the window
|
|
let teamGamesInWindow = teamGames.filter { window.contains($0.startTime) }
|
|
|
|
if teamGamesInWindow.isEmpty {
|
|
// Window doesn't have a game for this team - skip this window
|
|
// This shouldn't happen since we pre-filtered windows
|
|
hasAllTeamsInWindow = false
|
|
break
|
|
}
|
|
|
|
gamesByTeamInWindow[teamId] = teamGamesInWindow
|
|
}
|
|
|
|
guard hasAllTeamsInWindow else { continue }
|
|
|
|
// Remove duplicate games (same game could be added multiple times if team plays multiple home games)
|
|
let gamesInWindow = gamesByTeamInWindow.values.flatMap { $0 }
|
|
let uniqueGames = Array(Set(gamesInWindow)).sorted { $0.startTime < $1.startTime }
|
|
guard !uniqueGames.isEmpty else { continue }
|
|
|
|
// Primary pass: earliest anchor set for each team.
|
|
let earliestAnchorIds = Set(gamesByTeamInWindow.values.compactMap { games in
|
|
games.min(by: { $0.startTime < $1.startTime })?.id
|
|
})
|
|
|
|
var candidateRoutes = GameDAGRouter.findAllSensibleRoutes(
|
|
from: uniqueGames,
|
|
stadiums: request.stadiums,
|
|
anchorGameIds: earliestAnchorIds,
|
|
allowRepeatCities: request.preferences.allowRepeatCities,
|
|
routePreference: request.preferences.routePreference,
|
|
stopBuilder: buildStops
|
|
)
|
|
var validRoutes = candidateRoutes.filter { route in
|
|
routeCoversAllSelectedTeams(route, selectedTeamIds: selectedTeamIds)
|
|
}
|
|
|
|
// Fallback pass: avoid over-constraining to earliest anchors.
|
|
if validRoutes.isEmpty {
|
|
let latestAnchorIds = Set(gamesByTeamInWindow.values.compactMap { games in
|
|
games.max(by: { $0.startTime < $1.startTime })?.id
|
|
})
|
|
|
|
if latestAnchorIds != earliestAnchorIds {
|
|
let latestAnchorRoutes = GameDAGRouter.findAllSensibleRoutes(
|
|
from: uniqueGames,
|
|
stadiums: request.stadiums,
|
|
anchorGameIds: latestAnchorIds,
|
|
allowRepeatCities: request.preferences.allowRepeatCities,
|
|
routePreference: request.preferences.routePreference,
|
|
stopBuilder: buildStops
|
|
)
|
|
candidateRoutes.append(contentsOf: latestAnchorRoutes)
|
|
}
|
|
|
|
let noAnchorRoutes = GameDAGRouter.findAllSensibleRoutes(
|
|
from: uniqueGames,
|
|
stadiums: request.stadiums,
|
|
allowRepeatCities: request.preferences.allowRepeatCities,
|
|
routePreference: request.preferences.routePreference,
|
|
stopBuilder: buildStops
|
|
)
|
|
candidateRoutes.append(contentsOf: noAnchorRoutes)
|
|
|
|
candidateRoutes = deduplicateRoutes(candidateRoutes)
|
|
validRoutes = candidateRoutes.filter { route in
|
|
routeCoversAllSelectedTeams(route, selectedTeamIds: selectedTeamIds)
|
|
}
|
|
}
|
|
|
|
validRoutes = deduplicateRoutes(validRoutes)
|
|
|
|
// Build itineraries for valid routes
|
|
for routeGames in validRoutes {
|
|
let stops = buildStops(from: routeGames, stadiums: request.stadiums)
|
|
guard !stops.isEmpty else { continue }
|
|
|
|
// Use shared ItineraryBuilder with arrival time validator
|
|
guard let itinerary = ItineraryBuilder.build(
|
|
stops: stops,
|
|
constraints: request.drivingConstraints,
|
|
logPrefix: "[ScenarioE]",
|
|
segmentValidator: ItineraryBuilder.arrivalBeforeGameStart()
|
|
) else {
|
|
continue
|
|
}
|
|
|
|
// Format window dates for rationale
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.dateFormat = "MMM d"
|
|
let windowDesc = "\(dateFormatter.string(from: window.start)) - \(dateFormatter.string(from: window.end))"
|
|
|
|
let teamsVisited = orderedTeamLabels(for: routeGames, teams: request.teams)
|
|
|
|
let cities = stops.map { $0.city }.joined(separator: " -> ")
|
|
|
|
let option = ItineraryOption(
|
|
rank: 0, // Will re-rank later
|
|
stops: itinerary.stops,
|
|
travelSegments: itinerary.travelSegments,
|
|
totalDrivingHours: itinerary.totalDrivingHours,
|
|
totalDistanceMiles: itinerary.totalDistanceMiles,
|
|
geographicRationale: "\(windowDesc): \(teamsVisited) - \(cities)"
|
|
)
|
|
allItineraryOptions.append(option)
|
|
}
|
|
|
|
// Early exit if we have enough options
|
|
if allItineraryOptions.count >= maxResultsToReturn * 5 {
|
|
#if DEBUG
|
|
print("🔍 ScenarioE: Early exit at window \(windowIndex + 1) with \(allItineraryOptions.count) options")
|
|
#endif
|
|
break
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Step 5: Rank and return top results
|
|
// ──────────────────────────────────────────────────────────────────
|
|
if allItineraryOptions.isEmpty {
|
|
return .failure(
|
|
PlanningFailure(
|
|
reason: .constraintsUnsatisfiable,
|
|
violations: [
|
|
ConstraintViolation(
|
|
type: .geographicSanity,
|
|
description: "No routes found that can visit all selected teams within driving constraints",
|
|
severity: .error
|
|
)
|
|
]
|
|
)
|
|
)
|
|
}
|
|
|
|
// Deduplicate options (same stop-day-game structure)
|
|
let uniqueOptions = deduplicateOptions(allItineraryOptions)
|
|
|
|
// Sort by: shortest duration first, then fewest miles
|
|
let sortedOptions = uniqueOptions.sorted { optionA, optionB in
|
|
// Calculate trip duration in days
|
|
let daysA = tripDurationDays(for: optionA)
|
|
let daysB = tripDurationDays(for: optionB)
|
|
|
|
if daysA != daysB {
|
|
return daysA < daysB // Shorter trips first
|
|
}
|
|
return optionA.totalDistanceMiles < optionB.totalDistanceMiles // Fewer miles second
|
|
}
|
|
|
|
// Take top N and re-rank
|
|
let topOptions = Array(sortedOptions.prefix(maxResultsToReturn))
|
|
let rankedOptions = topOptions.enumerated().map { index, option in
|
|
ItineraryOption(
|
|
rank: index + 1,
|
|
stops: option.stops,
|
|
travelSegments: option.travelSegments,
|
|
totalDrivingHours: option.totalDrivingHours,
|
|
totalDistanceMiles: option.totalDistanceMiles,
|
|
geographicRationale: option.geographicRationale
|
|
)
|
|
}
|
|
|
|
#if DEBUG
|
|
print("🔍 ScenarioE: Returning \(rankedOptions.count) options")
|
|
#endif
|
|
|
|
return .success(rankedOptions)
|
|
}
|
|
|
|
// MARK: - Window Generation
|
|
|
|
/// Generates all valid N-day windows across the season where ALL selected teams have home games.
|
|
///
|
|
/// Algorithm:
|
|
/// 1. Find the overall date range (earliest to latest game across all teams)
|
|
/// 2. Slide a window across this range, one day at a time
|
|
/// 3. Keep windows where each team has at least one home game
|
|
///
|
|
private func generateValidWindows(
|
|
homeGamesByTeam: [String: [Game]],
|
|
windowDurationDays: Int,
|
|
selectedTeamIds: Set<String>
|
|
) -> [DateInterval] {
|
|
|
|
// Find overall date range
|
|
let allGames = homeGamesByTeam.values.flatMap { $0 }
|
|
guard let earliest = allGames.map({ $0.startTime }).min(),
|
|
let latest = allGames.map({ $0.startTime }).max() else {
|
|
return []
|
|
}
|
|
|
|
let calendar = Calendar.current
|
|
let earliestDay = calendar.startOfDay(for: earliest)
|
|
let latestDay = calendar.startOfDay(for: latest)
|
|
|
|
// Generate sliding windows
|
|
var validWindows: [DateInterval] = []
|
|
var currentStart = earliestDay
|
|
|
|
while let windowEnd = calendar.date(byAdding: .day, value: windowDurationDays, to: currentStart),
|
|
windowEnd <= calendar.date(byAdding: .day, value: 1, to: latestDay)! {
|
|
|
|
let window = DateInterval(start: currentStart, end: windowEnd)
|
|
|
|
// Check if all teams have at least one home game in this window
|
|
let allTeamsHaveGame = selectedTeamIds.allSatisfy { teamId in
|
|
guard let teamGames = homeGamesByTeam[teamId] else { return false }
|
|
return teamGames.contains { window.contains($0.startTime) }
|
|
}
|
|
|
|
if allTeamsHaveGame {
|
|
validWindows.append(window)
|
|
}
|
|
|
|
// Slide window by one day
|
|
guard let nextStart = calendar.date(byAdding: .day, value: 1, to: currentStart) else {
|
|
break
|
|
}
|
|
currentStart = nextStart
|
|
}
|
|
|
|
return validWindows
|
|
}
|
|
|
|
/// Samples windows evenly across the season to avoid clustering.
|
|
private func sampleWindows(_ windows: [DateInterval], count: Int) -> [DateInterval] {
|
|
guard windows.count > count else { return windows }
|
|
|
|
// Take evenly spaced samples
|
|
var sampled: [DateInterval] = []
|
|
let step = Double(windows.count) / Double(count)
|
|
|
|
for i in 0..<count {
|
|
let index = Int(Double(i) * step)
|
|
if index < windows.count {
|
|
sampled.append(windows[index])
|
|
}
|
|
}
|
|
|
|
return sampled
|
|
}
|
|
|
|
// MARK: - Stop Building
|
|
|
|
/// Converts a list of games into itinerary stops.
|
|
/// Groups consecutive games at the same stadium into one stop.
|
|
private func buildStops(
|
|
from games: [Game],
|
|
stadiums: [String: Stadium]
|
|
) -> [ItineraryStop] {
|
|
guard !games.isEmpty else { return [] }
|
|
|
|
// Sort games chronologically
|
|
let sortedGames = games.sorted { $0.startTime < $1.startTime }
|
|
|
|
// Group consecutive games at the same stadium
|
|
var stops: [ItineraryStop] = []
|
|
var currentStadiumId: String? = nil
|
|
var currentGames: [Game] = []
|
|
|
|
for game in sortedGames {
|
|
if game.stadiumId == currentStadiumId {
|
|
// Same stadium as previous game - add to current group
|
|
currentGames.append(game)
|
|
} else {
|
|
// Different stadium - finalize previous stop (if any) and start new one
|
|
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
|
|
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
|
|
stops.append(stop)
|
|
}
|
|
}
|
|
currentStadiumId = game.stadiumId
|
|
currentGames = [game]
|
|
}
|
|
}
|
|
|
|
// Don't forget the last group
|
|
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
|
|
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
|
|
stops.append(stop)
|
|
}
|
|
}
|
|
|
|
return stops
|
|
}
|
|
|
|
/// Creates an ItineraryStop from a group of games at the same stadium.
|
|
private func createStop(
|
|
from games: [Game],
|
|
stadiumId: String,
|
|
stadiums: [String: Stadium]
|
|
) -> ItineraryStop? {
|
|
guard !games.isEmpty else { return nil }
|
|
|
|
let sortedGames = games.sorted { $0.startTime < $1.startTime }
|
|
let stadium = stadiums[stadiumId]
|
|
let city = stadium?.city ?? "Unknown"
|
|
let state = stadium?.state ?? ""
|
|
let coordinate = stadium?.coordinate
|
|
|
|
let location = LocationInput(
|
|
name: city,
|
|
coordinate: coordinate,
|
|
address: stadium?.fullAddress
|
|
)
|
|
|
|
// departureDate is same day as last game
|
|
let lastGameDate = sortedGames.last?.gameDate ?? Date()
|
|
|
|
return ItineraryStop(
|
|
city: city,
|
|
state: state,
|
|
coordinate: coordinate,
|
|
games: sortedGames.map { $0.id },
|
|
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
|
departureDate: lastGameDate,
|
|
location: location,
|
|
firstGameStart: sortedGames.first?.startTime
|
|
)
|
|
}
|
|
|
|
// MARK: - Deduplication
|
|
|
|
/// Removes duplicate itinerary options (same stop-day-game structure).
|
|
private func deduplicateOptions(_ options: [ItineraryOption]) -> [ItineraryOption] {
|
|
var seen = Set<String>()
|
|
var unique: [ItineraryOption] = []
|
|
let calendar = Calendar.current
|
|
|
|
for option in options {
|
|
// Key by stop city + day + game IDs to avoid collapsing distinct itineraries.
|
|
let key = option.stops.map { stop in
|
|
let day = Int(calendar.startOfDay(for: stop.arrivalDate).timeIntervalSince1970)
|
|
let gameKey = stop.games.sorted().joined(separator: ",")
|
|
return "\(stop.city)|\(day)|\(gameKey)"
|
|
}.joined(separator: "->")
|
|
if !seen.contains(key) {
|
|
seen.insert(key)
|
|
unique.append(option)
|
|
}
|
|
}
|
|
|
|
return unique
|
|
}
|
|
|
|
/// Removes duplicate game routes (same game IDs in any order).
|
|
private func deduplicateRoutes(_ routes: [[Game]]) -> [[Game]] {
|
|
var seen = Set<String>()
|
|
var unique: [[Game]] = []
|
|
|
|
for route in routes {
|
|
let key = route.map { $0.id }.sorted().joined(separator: "-")
|
|
if !seen.contains(key) {
|
|
seen.insert(key)
|
|
unique.append(route)
|
|
}
|
|
}
|
|
|
|
return unique
|
|
}
|
|
|
|
/// Returns true if a route includes at least one home game for each selected team.
|
|
private func routeCoversAllSelectedTeams(_ route: [Game], selectedTeamIds: Set<String>) -> Bool {
|
|
let homeTeamsInRoute = Set(route.map { $0.homeTeamId })
|
|
return selectedTeamIds.isSubset(of: homeTeamsInRoute)
|
|
}
|
|
|
|
/// Team labels in first-visit order for itinerary rationale text.
|
|
private func orderedTeamLabels(for route: [Game], teams: [String: Team]) -> String {
|
|
var seen = Set<String>()
|
|
var labels: [String] = []
|
|
|
|
for game in route {
|
|
let teamId = game.homeTeamId
|
|
guard !seen.contains(teamId) else { continue }
|
|
seen.insert(teamId)
|
|
labels.append(teams[teamId]?.abbreviation ?? teamId)
|
|
}
|
|
|
|
return labels.joined(separator: ", ")
|
|
}
|
|
|
|
// MARK: - Trip Duration Calculation
|
|
|
|
/// Calculates trip duration in days for an itinerary option.
|
|
private func tripDurationDays(for option: ItineraryOption) -> Int {
|
|
guard let firstStop = option.stops.first,
|
|
let lastStop = option.stops.last else {
|
|
return 0
|
|
}
|
|
|
|
let calendar = Calendar.current
|
|
let days = calendar.dateComponents(
|
|
[.day],
|
|
from: calendar.startOfDay(for: firstStop.arrivalDate),
|
|
to: calendar.startOfDay(for: lastStop.departureDate)
|
|
).day ?? 0
|
|
|
|
return max(1, days + 1)
|
|
}
|
|
}
|