Files
Sportstime/SportsTime/Planning/Engine/TravelEstimator.swift
Trey t c94e373e33 fix: comprehensive codebase hardening — crashes, silent failures, performance, and security
Fixes ~95 issues from deep audit across 12 categories in 82 files:

- Crash prevention: double-resume in PhotoMetadataExtractor, force unwraps in
  DateRangePicker, array bounds checks in polls/achievements, ProGate hit-test
  bypass, Dictionary(uniqueKeysWithValues:) → uniquingKeysWith in 4 files
- Silent failure elimination: all 34 try? sites replaced with do/try/catch +
  logging (SavedTrip, TripDetailView, CanonicalSyncService, BootstrapService,
  CanonicalModels, CKModels, SportsTimeApp, and more)
- Performance: cached DateFormatters (7 files), O(1) team lookups via
  AppDataProvider, achievement definition dictionary, AnimatedBackground
  consolidated from 19 Tasks to 1, task cancellation in SharePreviewView
- Concurrency: UIKit drawing → MainActor.run, background fetch timeout guard,
  @MainActor on ThemeManager/AppearanceManager, SyncLogger read/write race fix
- Planning engine: game end time in travel feasibility, state-aware city
  normalization, exact city matching, DrivingConstraints parameter propagation
- IAP: unknown subscription states → expired, unverified transaction logging,
  entitlements updated before paywall dismiss, restore visible to all users
- Security: API key to Info.plist lookup, filename sanitization in PDF export,
  honest User-Agent, removed stale "Feels" analytics super properties
- Navigation: consolidated competing navigationDestination, boolean → value-based
- Testing: 8 sleep() → waitForExistence, duplicates extracted, Swift 6 compat
- Service bugs: infinite retry cap, duplicate achievement prevention, TOCTOU vote
  fix, PollVote.odg → voterId rename, deterministic placeholder IDs, parallel
  MKDirections, Sendable-safe POI struct

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:03:09 -06:00

269 lines
9.4 KiB
Swift

//
// TravelEstimator.swift
// SportsTime
//
// Shared travel estimation logic used by all scenario planners.
// Estimating travel from A to B is the same regardless of planning scenario.
//
import Foundation
import CoreLocation
/// Travel estimation utilities for calculating distances and driving times.
///
/// Uses Haversine formula for coordinate-based distance with a road routing factor,
/// or fallback distances when coordinates are unavailable.
///
/// - Constants:
/// - averageSpeedMph: 60 mph
/// - roadRoutingFactor: 1.3 (accounts for roads vs straight-line distance)
/// - fallbackDistanceMiles: 300 miles (when coordinates unavailable)
///
/// - Invariants:
/// - All distance calculations are symmetric: distance(A,B) == distance(B,A)
/// - Road distance >= straight-line distance (roadRoutingFactor >= 1.0)
/// - Travel duration is always distance / averageSpeedMph
/// - Segments exceeding 5x maxDailyDrivingHours return nil (unreachable)
enum TravelEstimator {
// MARK: - Constants
private static let averageSpeedMph: Double = 60.0
private static let roadRoutingFactor: Double = 1.3 // Straight line to road distance
private static let fallbackDistanceMiles: Double = 300.0
// MARK: - Travel Estimation
/// Estimates a travel segment between two ItineraryStops.
///
/// - Parameters:
/// - from: Origin stop
/// - to: Destination stop
/// - constraints: Driving constraints (drivers, hours per day)
/// - Returns: TravelSegment or nil if unreachable
///
/// - Expected Behavior:
/// - With valid coordinates calculates distance using Haversine * roadRoutingFactor
/// - Missing coordinates uses fallback distance (300 miles)
/// - Same city (no coords) 0 distance, 0 duration
/// - Driving hours > 5x maxDailyDrivingHours returns nil
/// - Duration = distance / 60 mph
/// - Result distance in meters, duration in seconds
static func estimate(
from: ItineraryStop,
to: ItineraryStop,
constraints: DrivingConstraints
) -> TravelSegment? {
let distanceMiles = calculateDistanceMiles(from: from, to: to)
let drivingHours = distanceMiles / averageSpeedMph
// Maximum allowed: 5 days of driving as a conservative hard cap.
// This allows multi-day cross-country segments like Chicago Anaheim.
let maxAllowedHours = constraints.maxDailyDrivingHours * 5.0
if drivingHours > maxAllowedHours {
return nil
}
return TravelSegment(
fromLocation: from.location,
toLocation: to.location,
travelMode: .drive,
distanceMeters: distanceMiles * 1609.34,
durationSeconds: drivingHours * 3600
)
}
/// Estimates a travel segment between two LocationInputs.
///
/// - Parameters:
/// - from: Origin location
/// - to: Destination location
/// - constraints: Driving constraints
/// - Returns: TravelSegment or nil if unreachable/invalid
///
/// - Expected Behavior:
/// - Missing from.coordinate returns nil
/// - Missing to.coordinate returns nil
/// - Valid coordinates calculates distance using Haversine * roadRoutingFactor
/// - Driving hours > 5x maxDailyDrivingHours returns nil
/// - Duration = distance / 60 mph
static func estimate(
from: LocationInput,
to: LocationInput,
constraints: DrivingConstraints
) -> TravelSegment? {
guard let fromCoord = from.coordinate,
let toCoord = to.coordinate else {
return nil
}
let distanceMeters = haversineDistanceMeters(from: fromCoord, to: toCoord)
let distanceMiles = distanceMeters * 0.000621371 * roadRoutingFactor
let drivingHours = distanceMiles / averageSpeedMph
// Maximum allowed: 5 days of driving as a conservative hard cap.
// This allows multi-day cross-country segments like Chicago Anaheim.
let maxAllowedHours = constraints.maxDailyDrivingHours * 5.0
if drivingHours > maxAllowedHours {
return nil
}
return TravelSegment(
fromLocation: from,
toLocation: to,
travelMode: .drive,
distanceMeters: distanceMeters * roadRoutingFactor,
durationSeconds: drivingHours * 3600
)
}
// MARK: - Distance Calculations
/// Calculates road distance in miles between two ItineraryStops.
///
/// - Parameters:
/// - from: Origin stop
/// - to: Destination stop
/// - Returns: Distance in miles
///
/// - Expected Behavior:
/// - Both have coordinates Haversine distance * 1.3
/// - Either missing coordinates fallback distance
/// - Same city (no coords) 0 miles
/// - Different cities (no coords) 300 miles
static func calculateDistanceMiles(
from: ItineraryStop,
to: ItineraryStop
) -> Double {
if let fromCoord = from.coordinate,
let toCoord = to.coordinate {
return haversineDistanceMiles(from: fromCoord, to: toCoord) * roadRoutingFactor
}
return estimateFallbackDistance(from: from, to: to)
}
/// Calculates straight-line distance in miles using Haversine formula.
///
/// - Parameters:
/// - from: Origin coordinate
/// - to: Destination coordinate
/// - Returns: Straight-line distance in miles
///
/// - Expected Behavior:
/// - Same point 0 miles
/// - NYC to Boston ~190 miles (validates formula accuracy)
/// - Symmetric: distance(A,B) == distance(B,A)
/// - Uses Earth radius of 3958.8 miles
static func haversineDistanceMiles(
from: CLLocationCoordinate2D,
to: CLLocationCoordinate2D
) -> Double {
let earthRadiusMiles = 3958.8
let lat1 = from.latitude * .pi / 180
let lat2 = to.latitude * .pi / 180
let deltaLat = (to.latitude - from.latitude) * .pi / 180
let deltaLon = (to.longitude - from.longitude) * .pi / 180
let a = sin(deltaLat / 2) * sin(deltaLat / 2) +
cos(lat1) * cos(lat2) *
sin(deltaLon / 2) * sin(deltaLon / 2)
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
return earthRadiusMiles * c
}
/// Calculates straight-line distance in meters using Haversine formula.
///
/// - Parameters:
/// - from: Origin coordinate
/// - to: Destination coordinate
/// - Returns: Straight-line distance in meters
///
/// - Expected Behavior:
/// - Same point 0 meters
/// - Symmetric: distance(A,B) == distance(B,A)
/// - Uses Earth radius of 6,371,000 meters
/// - haversineDistanceMeters / 1609.34 haversineDistanceMiles
static func haversineDistanceMeters(
from: CLLocationCoordinate2D,
to: CLLocationCoordinate2D
) -> Double {
let earthRadiusMeters = 6371000.0
let lat1 = from.latitude * .pi / 180
let lat2 = to.latitude * .pi / 180
let deltaLat = (to.latitude - from.latitude) * .pi / 180
let deltaLon = (to.longitude - from.longitude) * .pi / 180
let a = sin(deltaLat / 2) * sin(deltaLat / 2) +
cos(lat1) * cos(lat2) *
sin(deltaLon / 2) * sin(deltaLon / 2)
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
return earthRadiusMeters * c
}
/// Fallback distance when coordinates aren't available.
///
/// - Parameters:
/// - from: Origin stop
/// - to: Destination stop
/// - Returns: Estimated distance in miles
///
/// - Expected Behavior:
/// - Same city 0 miles
/// - Different cities 300 miles (fallback constant)
static func estimateFallbackDistance(
from: ItineraryStop,
to: ItineraryStop
) -> Double {
if from.city == to.city {
return 0
}
return fallbackDistanceMiles
}
// MARK: - Travel Days
/// Calculates which calendar days a driving segment spans.
///
/// - Parameters:
/// - departure: Departure date/time
/// - drivingHours: Total driving hours
/// - drivingConstraints: Optional driving constraints to determine max daily hours (defaults to 8.0)
/// - Returns: Array of calendar days (start of day) that travel spans
///
/// - Expected Behavior:
/// - 0 hours [departure day]
/// - 1-maxDaily hours [departure day] (1 day)
/// - maxDaily+0.01 to 2*maxDaily hours [departure day, next day] (2 days)
/// - All dates are normalized to start of day (midnight)
/// - Uses maxDailyDrivingHours from constraints when provided
static func calculateTravelDays(
departure: Date,
drivingHours: Double,
drivingConstraints: DrivingConstraints? = nil
) -> [Date] {
var days: [Date] = []
let calendar = Calendar.current
let startDay = calendar.startOfDay(for: departure)
days.append(startDay)
// Use max daily hours from constraints, defaulting to 8.0
let maxDailyHours = drivingConstraints?.maxDailyDrivingHours ?? 8.0
let daysOfDriving = max(1, Int(ceil(drivingHours / maxDailyHours)))
for dayOffset in 1..<daysOfDriving {
if let nextDay = calendar.date(byAdding: .day, value: dayOffset, to: startDay) {
days.append(nextDay)
}
}
return days
}
}