Files
Sportstime/SportsTime/Planning/Engine/ItineraryBuilder.swift
Trey t 787a0f795e fix: 12 planning engine bugs + App Store preview export at 886x1920
Planning engine fixes (from adversarial code review):
- Bug #1: sortByLeisure tie-breaking uses totalDrivingHours
- Bug #2: allDates/calculateRestDays guard-let-break prevents infinite loop
- Bug #3: same-day trip no longer rejected (>= in dateRange guard)
- Bug #4: ScenarioD rationale shows game count not stop count
- Bug #5: ScenarioD departureDate advanced to next day after last game
- Bug #6: ScenarioC date range boundary uses <= instead of <
- Bug #7: DrivingConstraints clamps maxHoursPerDriverPerDay via max(1.0,...)
- Bug #8: effectiveTripDuration uses inclusive day counting (+1)
- Bug #9: TripWizardViewModel validates endDate >= startDate
- Bug #10: allDates() uses min/max instead of first/last for robustness
- Bug #12: arrivalBeforeGameStart accounts for game end time at departure
- Bug #15: ScenarioBPlanner replaces force unwraps with safe unwrapping

Tests: 16 regression test suites + updated existing test expectations
Marketing: Remotion canvas set to 886x1920 for App Store preview spec

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 17:08:50 -06:00

398 lines
16 KiB
Swift

//
// ItineraryBuilder.swift
// SportsTime
//
// Shared utility for building itineraries with travel segments.
// Used by all scenario planners to convert stops into complete itineraries.
//
import Foundation
/// Result of building an itinerary from stops.
///
/// - Invariants:
/// - travelSegments.count == stops.count - 1 (or 0 if stops.count <= 1)
/// - totalDrivingHours >= 0
/// - totalDistanceMiles >= 0
struct BuiltItinerary {
let stops: [ItineraryStop]
let travelSegments: [TravelSegment]
let totalDrivingHours: Double
let totalDistanceMiles: Double
}
/// Shared logic for building itineraries across all scenario planners.
///
/// Connects stops with travel segments and validates feasibility.
///
/// - Expected Behavior:
/// - Empty stops empty itinerary (no travel)
/// - Single stop single-stop itinerary (no travel)
/// - Multiple stops segments between each consecutive pair
/// - Infeasible segment (exceeds driving limits) returns nil
/// - Validator returns false returns nil
///
/// - Invariants:
/// - travelSegments.count == stops.count - 1 for successful builds
/// - All segments pass TravelEstimator feasibility check
/// - All segments pass optional custom validator
enum ItineraryBuilder {
/// Validation that can be performed on each travel segment.
/// Return `true` if the segment is valid, `false` to reject the itinerary.
typealias SegmentValidator = (TravelSegment, _ fromStop: ItineraryStop, _ toStop: ItineraryStop) -> Bool
/// Builds a complete itinerary with travel segments between consecutive stops.
///
/// Algorithm:
/// 1. Handle edge case: single stop = no travel needed
/// 2. For each consecutive pair of stops, estimate travel
/// 3. Optionally validate each segment with custom validator
/// 4. Accumulate driving hours and distance
/// 5. Verify invariant: travelSegments.count == stops.count - 1
///
/// - Parameters:
/// - stops: The stops to connect with travel segments
/// - constraints: Driving constraints (drivers, max hours per day)
/// - logPrefix: Prefix for log messages (e.g., "[ScenarioA]")
/// - segmentValidator: Optional validation for each segment
///
/// - Returns: Built itinerary if successful, nil if any segment fails
///
/// - Expected Behavior:
/// - Empty stops BuiltItinerary with empty segments
/// - Single stop BuiltItinerary with empty segments
/// - Two stops one segment connecting them
/// - N stops N-1 segments
/// - TravelEstimator returns nil returns nil
/// - Validator returns false returns nil
/// - totalDrivingHours = sum of segment.estimatedDrivingHours
/// - totalDistanceMiles = sum of segment.estimatedDistanceMiles
static func build(
stops: [ItineraryStop],
constraints: DrivingConstraints,
logPrefix: String = "[ItineraryBuilder]",
segmentValidator: SegmentValidator? = nil
) -> BuiltItinerary? {
//
// Edge case: Single stop or empty = no travel needed
//
if stops.count <= 1 {
return BuiltItinerary(
stops: stops,
travelSegments: [],
totalDrivingHours: 0,
totalDistanceMiles: 0
)
}
//
// Build travel segments between consecutive stops
//
var travelSegments: [TravelSegment] = []
var totalDrivingHours: Double = 0
var totalDistance: Double = 0
for index in 0..<(stops.count - 1) {
let fromStop = stops[index]
let toStop = stops[index + 1]
// Estimate travel for this segment
guard let segment = TravelEstimator.estimate(
from: fromStop,
to: toStop,
constraints: constraints
) else {
return nil
}
// Run optional validator (e.g., arrival time check for Scenario B)
if let validator = segmentValidator {
if !validator(segment, fromStop, toStop) {
return nil
}
}
travelSegments.append(segment)
totalDrivingHours += segment.estimatedDrivingHours
totalDistance += segment.estimatedDistanceMiles
}
//
// Verify invariant: segments = stops - 1
//
guard travelSegments.count == stops.count - 1 else {
return nil
}
return BuiltItinerary(
stops: stops,
travelSegments: travelSegments,
totalDrivingHours: totalDrivingHours,
totalDistanceMiles: totalDistance
)
}
// MARK: - EV Charger Enrichment
/// Enriches an itinerary option with EV charging stops along each route segment.
///
/// This is a post-processing step that can be called after the sync planning completes.
/// It fetches real routes from MapKit and searches for EV chargers along each segment.
///
/// - Parameter option: The itinerary option to enrich
/// - Returns: A new itinerary option with EV chargers populated in travel segments
///
static func enrichWithEVChargers(_ option: ItineraryOption) async -> ItineraryOption {
var enrichedSegments: [TravelSegment] = []
for segment in option.travelSegments {
// Get coordinates
guard let fromCoord = segment.fromLocation.coordinate,
let toCoord = segment.toLocation.coordinate else {
// No coordinates - keep segment as-is
enrichedSegments.append(segment)
continue
}
do {
// Fetch real route from MapKit
let routeInfo = try await LocationService.shared.calculateDrivingRoute(
from: fromCoord,
to: toCoord
)
// Find EV chargers along the route
var evChargers: [EVChargingStop] = []
if let polyline = routeInfo.polyline {
do {
evChargers = try await EVChargingService.shared.findChargersAlongRoute(
polyline: polyline,
searchRadiusMiles: 5.0,
intervalMiles: 100.0
)
} catch {
// EV charger search failed - continue without chargers
}
}
// Create enriched segment with real route data and EV chargers
let enrichedSegment = TravelSegment(
id: segment.id,
fromLocation: segment.fromLocation,
toLocation: segment.toLocation,
travelMode: segment.travelMode,
distanceMeters: routeInfo.distance,
durationSeconds: routeInfo.expectedTravelTime,
scenicScore: segment.scenicScore,
evChargingStops: evChargers
)
enrichedSegments.append(enrichedSegment)
} catch {
// Route calculation failed - keep original segment
enrichedSegments.append(segment)
}
}
// Recalculate totals with enriched segments
let totalDrivingHours = enrichedSegments.reduce(0.0) { $0 + $1.estimatedDrivingHours }
let totalDistanceMiles = enrichedSegments.reduce(0.0) { $0 + $1.estimatedDistanceMiles }
return ItineraryOption(
rank: option.rank,
stops: option.stops,
travelSegments: enrichedSegments,
totalDrivingHours: totalDrivingHours,
totalDistanceMiles: totalDistanceMiles,
geographicRationale: option.geographicRationale
)
}
/// Enriches multiple itinerary options with EV chargers.
/// Processes in parallel for better performance.
static func enrichWithEVChargers(_ options: [ItineraryOption]) async -> [ItineraryOption] {
await withTaskGroup(of: (Int, ItineraryOption).self) { group in
for (index, option) in options.enumerated() {
group.addTask {
let enriched = await enrichWithEVChargers(option)
return (index, enriched)
}
}
var results = [(Int, ItineraryOption)]()
for await result in group {
results.append(result)
}
// Sort by original index to maintain order
return results.sorted { $0.0 < $1.0 }.map { $0.1 }
}
}
// MARK: - Async Build with EV Chargers
/// Builds an itinerary with real route data and optional EV charger discovery.
///
/// This async version uses MapKit to get actual driving routes (instead of Haversine estimates)
/// and can optionally search for EV charging stations along each route segment.
///
/// - Parameters:
/// - stops: The stops to connect with travel segments
/// - constraints: Driving constraints (drivers, max hours per day)
/// - includeEVChargers: Whether to search for EV chargers along routes
/// - logPrefix: Prefix for log messages
/// - segmentValidator: Optional validation for each segment
///
/// - Returns: Built itinerary if successful, nil if any segment fails
///
static func buildAsync(
stops: [ItineraryStop],
constraints: DrivingConstraints,
includeEVChargers: Bool = false,
logPrefix: String = "[ItineraryBuilder]",
segmentValidator: SegmentValidator? = nil
) async -> BuiltItinerary? {
// Edge case: Single stop or empty = no travel needed
if stops.count <= 1 {
return BuiltItinerary(
stops: stops,
travelSegments: [],
totalDrivingHours: 0,
totalDistanceMiles: 0
)
}
var travelSegments: [TravelSegment] = []
var totalDrivingHours: Double = 0
var totalDistance: Double = 0
for index in 0..<(stops.count - 1) {
let fromStop = stops[index]
let toStop = stops[index + 1]
// Get coordinates
guard let fromCoord = fromStop.coordinate,
let toCoord = toStop.coordinate else {
// Fall back to estimate
guard let segment = TravelEstimator.estimate(
from: fromStop,
to: toStop,
constraints: constraints
) else {
return nil
}
travelSegments.append(segment)
totalDrivingHours += segment.estimatedDrivingHours
totalDistance += segment.estimatedDistanceMiles
continue
}
// Fetch real route from MapKit
do {
let routeInfo = try await LocationService.shared.calculateDrivingRoute(
from: fromCoord,
to: toCoord
)
// Find EV chargers if requested and polyline is available
var evChargers: [EVChargingStop] = []
if includeEVChargers, let polyline = routeInfo.polyline {
do {
evChargers = try await EVChargingService.shared.findChargersAlongRoute(
polyline: polyline,
searchRadiusMiles: 5.0,
intervalMiles: 100.0
)
} catch {
// Continue without chargers - not a critical failure
}
}
let segment = TravelSegment(
fromLocation: fromStop.location,
toLocation: toStop.location,
travelMode: .drive,
distanceMeters: routeInfo.distance,
durationSeconds: routeInfo.expectedTravelTime,
evChargingStops: evChargers
)
// Run optional validator
if let validator = segmentValidator {
if !validator(segment, fromStop, toStop) {
return nil
}
}
travelSegments.append(segment)
totalDrivingHours += segment.estimatedDrivingHours
totalDistance += segment.estimatedDistanceMiles
} catch {
// Fall back to estimate
guard let segment = TravelEstimator.estimate(
from: fromStop,
to: toStop,
constraints: constraints
) else {
return nil
}
travelSegments.append(segment)
totalDrivingHours += segment.estimatedDrivingHours
totalDistance += segment.estimatedDistanceMiles
}
}
// Verify invariant
guard travelSegments.count == stops.count - 1 else {
return nil
}
return BuiltItinerary(
stops: stops,
travelSegments: travelSegments,
totalDrivingHours: totalDrivingHours,
totalDistanceMiles: totalDistance
)
}
// MARK: - Common Validators
/// Validator that ensures travel duration allows arrival before game start.
/// Used by Scenario B where selected games have fixed start times.
///
/// This checks if the travel duration is short enough that the user could
/// theoretically leave after the previous game and arrive before the next.
///
/// - Parameter bufferSeconds: Time buffer before game start (default 1 hour)
/// - Returns: Validator closure
///
static func arrivalBeforeGameStart(bufferSeconds: TimeInterval = 3600) -> SegmentValidator {
return { segment, fromStop, toStop in
guard let gameStart = toStop.firstGameStart else {
return true // No game = no constraint
}
// Account for game end time at fromStop can't depart during a game
let typicalGameDuration: TimeInterval = 10800 // 3 hours
var earliestDeparture = fromStop.departureDate
if let fromGameStart = fromStop.firstGameStart {
let gameEnd = fromGameStart.addingTimeInterval(typicalGameDuration)
earliestDeparture = max(earliestDeparture, gameEnd)
}
let travelDuration = segment.durationSeconds
let earliestArrival = earliestDeparture.addingTimeInterval(travelDuration)
let deadline = gameStart.addingTimeInterval(-bufferSeconds)
if earliestArrival > deadline {
return false
}
return true
}
}
}