Initial commit: SportsTime trip planning app

- Three-scenario planning engine (A: date range, B: selected games, C: directional routes)
- GeographicRouteExplorer with anchor game support for route exploration
- Shared ItineraryBuilder for travel segment calculation
- TravelEstimator for driving time/distance estimation
- SwiftUI views for trip creation and detail display
- CloudKit integration for schedule data
- Python scraping scripts for sports schedules

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-07 00:46:40 -06:00
commit 9088b46563
84 changed files with 180371 additions and 0 deletions

View File

@@ -0,0 +1,278 @@
//
// GeographicRouteExplorer.swift
// SportsTime
//
// Shared logic for finding geographically sensible route variations.
// Used by all scenario planners to explore and prune route combinations.
//
// Key Features:
// - Tree exploration with pruning for route combinations
// - Geographic sanity check using bounding box diagonal vs actual travel ratio
// - Support for "anchor" games that cannot be removed from routes (Scenario B)
//
// Algorithm Overview:
// Given games [A, B, C, D, E] in chronological order, we build a decision tree
// where at each node we can either include or skip a game. Routes that would
// create excessive zig-zagging are pruned. When anchors are specified, any
// route that doesn't include ALL anchors is automatically discarded.
//
import Foundation
import CoreLocation
enum GeographicRouteExplorer {
// MARK: - Configuration
/// Maximum ratio of actual travel to bounding box diagonal.
/// Routes exceeding this are considered zig-zags.
/// - 1.0x = perfectly linear route
/// - 1.5x = some detours, normal
/// - 2.0x = significant detours, borderline
/// - 2.5x+ = excessive zig-zag, reject
private static let maxZigZagRatio = 2.5
/// Minimum bounding box diagonal (miles) to apply zig-zag check.
/// Routes within a small area are always considered sane.
private static let minDiagonalForCheck = 100.0
/// Maximum number of route options to return.
private static let maxOptions = 10
// MARK: - Public API
/// Finds ALL geographically sensible subsets of games.
///
/// The problem: Games in a date range might be scattered across the country.
/// Visiting all of them in chronological order could mean crazy zig-zags.
///
/// The solution: Explore all possible subsets, keeping those that pass
/// geographic sanity. Return multiple options for the user to choose from.
///
/// Algorithm (tree exploration with pruning):
///
/// Input: [NY, TX, SC, DEN, NM, CA] (chronological order)
///
/// Build a decision tree:
/// [NY]
/// / \
/// +TX / \ skip TX
/// / \
/// [NY,TX] [NY]
/// / \ / \
/// +SC / \ +SC / \
/// | | |
/// (prune) +DEN [NY,SC] ...
///
/// Each path that reaches the end = one valid option
/// Pruning: If adding a game breaks sanity, don't explore that branch
///
/// - Parameters:
/// - games: All games to consider, should be in chronological order
/// - stadiums: Dictionary mapping stadium IDs to Stadium objects
/// - anchorGameIds: Game IDs that MUST be included in every route (for Scenario B)
/// - stopBuilder: Closure that converts games to ItineraryStops
///
/// - Returns: Array of valid game combinations, sorted by number of games (most first)
///
static func findAllSensibleRoutes(
from games: [Game],
stadiums: [UUID: Stadium],
anchorGameIds: Set<UUID> = [],
stopBuilder: ([Game], [UUID: Stadium]) -> [ItineraryStop]
) -> [[Game]] {
// 0-2 games = always sensible, only one option
// But still verify anchors are present
guard games.count > 2 else {
// Verify all anchors are in the game list
let gameIds = Set(games.map { $0.id })
if anchorGameIds.isSubset(of: gameIds) {
return games.isEmpty ? [] : [games]
} else {
// Missing anchors - no valid routes
return []
}
}
// First, check if all games already form a sensible route
let allStops = stopBuilder(games, stadiums)
if isGeographicallySane(stops: allStops) {
print("[GeographicExplorer] All \(games.count) games form a sensible route")
return [games]
}
print("[GeographicExplorer] Exploring route variations (anchors: \(anchorGameIds.count))...")
// Explore all valid subsets using recursive tree traversal
var validRoutes: [[Game]] = []
exploreRoutes(
games: games,
stadiums: stadiums,
anchorGameIds: anchorGameIds,
stopBuilder: stopBuilder,
currentRoute: [],
index: 0,
validRoutes: &validRoutes
)
// Filter routes that don't contain all anchors
let routesWithAnchors = validRoutes.filter { route in
let routeGameIds = Set(route.map { $0.id })
return anchorGameIds.isSubset(of: routeGameIds)
}
// Sort by number of games (most games first = best options)
let sorted = routesWithAnchors.sorted { $0.count > $1.count }
// Limit to top options to avoid overwhelming the user
let topRoutes = Array(sorted.prefix(maxOptions))
print("[GeographicExplorer] Found \(routesWithAnchors.count) valid routes with anchors, returning top \(topRoutes.count)")
return topRoutes
}
// MARK: - Geographic Sanity Check
/// Determines if a route is geographically sensible or zig-zags excessively.
///
/// The goal: Reject routes that oscillate back and forth across large distances.
/// We want routes that make generally linear progress, not cross-country ping-pong.
///
/// Algorithm:
/// 1. Calculate the "bounding box" of all stops (geographic spread)
/// 2. Calculate total travel distance if we visit stops in order
/// 3. Compare actual travel to the bounding box diagonal
/// 4. If actual travel is WAY more than the diagonal, it's zig-zagging
///
/// Example VALID:
/// Stops: LA, SF, Portland, Seattle
/// Bounding box diagonal: ~1,100 miles
/// Actual travel: ~1,200 miles (reasonable, mostly linear)
/// Ratio: 1.1x PASS
///
/// Example INVALID:
/// Stops: NY, TX, SC, CA (zig-zag)
/// Bounding box diagonal: ~2,500 miles
/// Actual travel: ~6,000 miles (back and forth)
/// Ratio: 2.4x FAIL
///
static func isGeographicallySane(stops: [ItineraryStop]) -> Bool {
// Single stop or two stops = always valid (no zig-zag possible)
guard stops.count > 2 else { return true }
// Collect all coordinates
let coordinates = stops.compactMap { $0.coordinate }
guard coordinates.count == stops.count else {
// Missing coordinates - can't validate, assume valid
return true
}
// Calculate bounding box
let lats = coordinates.map { $0.latitude }
let lons = coordinates.map { $0.longitude }
guard let minLat = lats.min(), let maxLat = lats.max(),
let minLon = lons.min(), let maxLon = lons.max() else {
return true
}
// Calculate bounding box diagonal distance
let corner1 = CLLocationCoordinate2D(latitude: minLat, longitude: minLon)
let corner2 = CLLocationCoordinate2D(latitude: maxLat, longitude: maxLon)
let diagonalMiles = TravelEstimator.haversineDistanceMiles(from: corner1, to: corner2)
// Tiny bounding box = all games are close together = always valid
if diagonalMiles < minDiagonalForCheck {
return true
}
// Calculate actual travel distance through all stops in order
var actualTravelMiles: Double = 0
for i in 0..<(stops.count - 1) {
let from = stops[i]
let to = stops[i + 1]
actualTravelMiles += TravelEstimator.calculateDistanceMiles(from: from, to: to)
}
// Compare: is actual travel reasonable compared to the geographic spread?
let ratio = actualTravelMiles / diagonalMiles
if ratio > maxZigZagRatio {
print("[GeographicExplorer] Sanity FAILED: travel=\(Int(actualTravelMiles))mi, diagonal=\(Int(diagonalMiles))mi, ratio=\(String(format: "%.1f", ratio))x")
return false
}
return true
}
// MARK: - Private Helpers
/// Recursive helper to explore all valid route combinations.
///
/// At each game, we have two choices:
/// 1. Include the game (if it doesn't break sanity)
/// 2. Skip the game (only if it's not an anchor)
///
/// We explore BOTH branches when possible, building up all valid combinations.
/// Anchor games MUST be included - we cannot skip them.
///
private static func exploreRoutes(
games: [Game],
stadiums: [UUID: Stadium],
anchorGameIds: Set<UUID>,
stopBuilder: ([Game], [UUID: Stadium]) -> [ItineraryStop],
currentRoute: [Game],
index: Int,
validRoutes: inout [[Game]]
) {
// Base case: we've processed all games
if index >= games.count {
// Only save routes with at least 1 game
if !currentRoute.isEmpty {
validRoutes.append(currentRoute)
}
return
}
let game = games[index]
let isAnchor = anchorGameIds.contains(game.id)
// Option 1: Try INCLUDING this game
var routeWithGame = currentRoute
routeWithGame.append(game)
let stopsWithGame = stopBuilder(routeWithGame, stadiums)
if isGeographicallySane(stops: stopsWithGame) {
// This branch is valid, continue exploring
exploreRoutes(
games: games,
stadiums: stadiums,
anchorGameIds: anchorGameIds,
stopBuilder: stopBuilder,
currentRoute: routeWithGame,
index: index + 1,
validRoutes: &validRoutes
)
} else if isAnchor {
// Anchor game breaks sanity - this entire branch is invalid
// Don't explore further, don't add to valid routes
// (We can't skip an anchor, and including it breaks sanity)
return
}
// Option 2: Try SKIPPING this game (only if it's not an anchor)
if !isAnchor {
exploreRoutes(
games: games,
stadiums: stadiums,
anchorGameIds: anchorGameIds,
stopBuilder: stopBuilder,
currentRoute: currentRoute,
index: index + 1,
validRoutes: &validRoutes
)
}
}
}

View File

@@ -0,0 +1,134 @@
//
// 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.
struct BuiltItinerary {
let stops: [ItineraryStop]
let travelSegments: [TravelSegment]
let totalDrivingHours: Double
let totalDistanceMiles: Double
}
/// Shared logic for building itineraries across all scenario planners.
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
///
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 {
print("\(logPrefix) Failed to estimate travel: \(fromStop.city) -> \(toStop.city)")
return nil
}
// Run optional validator (e.g., arrival time check for Scenario B)
if let validator = segmentValidator {
if !validator(segment, fromStop, toStop) {
print("\(logPrefix) Segment validation failed: \(fromStop.city) -> \(toStop.city)")
return nil
}
}
travelSegments.append(segment)
totalDrivingHours += segment.estimatedDrivingHours
totalDistance += segment.estimatedDistanceMiles
}
//
// Verify invariant: segments = stops - 1
//
guard travelSegments.count == stops.count - 1 else {
print("\(logPrefix) Invariant violated: \(travelSegments.count) segments for \(stops.count) stops")
return nil
}
return BuiltItinerary(
stops: stops,
travelSegments: travelSegments,
totalDrivingHours: totalDrivingHours,
totalDistanceMiles: totalDistance
)
}
// MARK: - Common Validators
/// Validator that ensures arrival time is before game start (with buffer).
/// Used by Scenario B where selected games have fixed start times.
///
/// - Parameter bufferSeconds: Time buffer before game start (default 1 hour)
/// - Returns: Validator closure
///
static func arrivalBeforeGameStart(bufferSeconds: TimeInterval = 3600) -> SegmentValidator {
return { segment, _, toStop in
guard let gameStart = toStop.firstGameStart else {
return true // No game = no constraint
}
let deadline = gameStart.addingTimeInterval(-bufferSeconds)
if segment.arrivalTime > deadline {
print("[ItineraryBuilder] Cannot arrive in time: arrival \(segment.arrivalTime) > deadline \(deadline)")
return false
}
return true
}
}
}

View File

@@ -0,0 +1,232 @@
//
// RouteCandidateBuilder.swift
// SportsTime
//
import Foundation
import CoreLocation
/// Builds route candidates for different planning scenarios
enum RouteCandidateBuilder {
// MARK: - Scenario A: Linear Candidates (Date Range)
/// Builds linear route candidates from games sorted chronologically
/// - Parameters:
/// - games: Available games sorted by start time
/// - mustStop: Optional must-stop location
/// - Returns: Array of route candidates
static func buildLinearCandidates(
games: [Game],
stadiums: [UUID: Stadium],
mustStop: LocationInput?
) -> [RouteCandidate] {
guard !games.isEmpty else {
return []
}
// Group games by stadium
var stadiumGames: [UUID: [Game]] = [:]
for game in games {
stadiumGames[game.stadiumId, default: []].append(game)
}
// Build stops from chronological game order
var stops: [ItineraryStop] = []
var processedStadiums: Set<UUID> = []
for game in games {
guard !processedStadiums.contains(game.stadiumId) else { continue }
processedStadiums.insert(game.stadiumId)
let gamesAtStop = stadiumGames[game.stadiumId] ?? [game]
let sortedGames = gamesAtStop.sorted { $0.startTime < $1.startTime }
// Look up stadium for coordinates and city info
let stadium = stadiums[game.stadiumId]
let city = stadium?.city ?? "Unknown"
let state = stadium?.state ?? ""
let coordinate = stadium?.coordinate
let location = LocationInput(
name: city,
coordinate: coordinate,
address: stadium?.fullAddress
)
let stop = ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: sortedGames.map { $0.id },
arrivalDate: sortedGames.first?.gameDate ?? Date(),
departureDate: sortedGames.last?.gameDate ?? Date(),
location: location,
firstGameStart: sortedGames.first?.startTime
)
stops.append(stop)
}
guard !stops.isEmpty else {
return []
}
return [RouteCandidate(
stops: stops,
rationale: "Linear route through \(stops.count) cities"
)]
}
// MARK: - Scenario B: Expand Around Anchors (Selected Games)
/// Expands route around user-selected anchor games
/// - Parameters:
/// - anchors: User-selected games (must-see)
/// - allGames: All available games
/// - dateRange: Trip date range
/// - mustStop: Optional must-stop location
/// - Returns: Array of route candidates
static func expandAroundAnchors(
anchors: [Game],
allGames: [Game],
stadiums: [UUID: Stadium],
dateRange: DateInterval,
mustStop: LocationInput?
) -> [RouteCandidate] {
guard !anchors.isEmpty else {
return []
}
// Start with anchor games as the core route
let sortedAnchors = anchors.sorted { $0.startTime < $1.startTime }
// Build stops from anchor games
var stops: [ItineraryStop] = []
for game in sortedAnchors {
let stadium = stadiums[game.stadiumId]
let city = stadium?.city ?? "Unknown"
let state = stadium?.state ?? ""
let coordinate = stadium?.coordinate
let location = LocationInput(
name: city,
coordinate: coordinate,
address: stadium?.fullAddress
)
let stop = ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: [game.id],
arrivalDate: game.gameDate,
departureDate: game.gameDate,
location: location,
firstGameStart: game.startTime
)
stops.append(stop)
}
guard !stops.isEmpty else {
return []
}
return [RouteCandidate(
stops: stops,
rationale: "Route connecting \(anchors.count) selected games"
)]
}
// MARK: - Scenario C: Directional Routes (Start + End)
/// Builds directional routes from start to end location
/// - Parameters:
/// - start: Start location
/// - end: End location
/// - games: Available games
/// - dateRange: Optional trip date range
/// - Returns: Array of route candidates
static func buildDirectionalRoutes(
start: LocationInput,
end: LocationInput,
games: [Game],
stadiums: [UUID: Stadium],
dateRange: DateInterval?
) -> [RouteCandidate] {
// Filter games by date range if provided
let filteredGames: [Game]
if let range = dateRange {
filteredGames = games.filter { range.contains($0.startTime) }
} else {
filteredGames = games
}
guard !filteredGames.isEmpty else {
return []
}
// Sort games chronologically
let sortedGames = filteredGames.sorted { $0.startTime < $1.startTime }
// Build stops: start -> games -> end
var stops: [ItineraryStop] = []
// Start stop (no games)
let startStop = ItineraryStop(
city: start.name,
state: "",
coordinate: start.coordinate,
games: [],
arrivalDate: sortedGames.first?.gameDate.addingTimeInterval(-86400) ?? Date(),
departureDate: sortedGames.first?.gameDate.addingTimeInterval(-86400) ?? Date(),
location: start,
firstGameStart: nil
)
stops.append(startStop)
// Game stops
for game in sortedGames {
let stadium = stadiums[game.stadiumId]
let city = stadium?.city ?? "Unknown"
let state = stadium?.state ?? ""
let coordinate = stadium?.coordinate
let location = LocationInput(
name: city,
coordinate: coordinate,
address: stadium?.fullAddress
)
let stop = ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: [game.id],
arrivalDate: game.gameDate,
departureDate: game.gameDate,
location: location,
firstGameStart: game.startTime
)
stops.append(stop)
}
// End stop (no games)
let endStop = ItineraryStop(
city: end.name,
state: "",
coordinate: end.coordinate,
games: [],
arrivalDate: sortedGames.last?.gameDate.addingTimeInterval(86400) ?? Date(),
departureDate: sortedGames.last?.gameDate.addingTimeInterval(86400) ?? Date(),
location: end,
firstGameStart: nil
)
stops.append(endStop)
return [RouteCandidate(
stops: stops,
rationale: "Directional route from \(start.name) to \(end.name)"
)]
}
}

View File

@@ -0,0 +1,283 @@
//
// RouteOptimizer.swift
// SportsTime
//
import Foundation
import CoreLocation
/// Optimization strategy for ranking itinerary options.
enum OptimizationStrategy {
case balanced // Balance games vs driving
case maximizeGames // Prioritize seeing more games
case minimizeDriving // Prioritize shorter routes
case scenic // Prioritize scenic routes
}
/// Route optimizer for ranking and scoring itinerary options.
///
/// The TSP-solving logic has been moved to scenario-specific candidate
/// generation in TripPlanningEngine. This optimizer now focuses on:
/// - Ranking multiple route options
/// - Scoring routes based on optimization strategy
/// - Reordering games within constraints
struct RouteOptimizer {
// MARK: - Route Ranking
/// Ranks a list of itinerary options based on the optimization strategy.
///
/// - Parameters:
/// - options: Unranked itinerary options
/// - strategy: Optimization strategy for scoring
/// - request: Planning request for context
/// - Returns: Options sorted by score (best first) with rank assigned
func rankOptions(
_ options: [ItineraryOption],
strategy: OptimizationStrategy = .balanced,
request: PlanningRequest
) -> [ItineraryOption] {
// Score each option
let scoredOptions = options.map { option -> (ItineraryOption, Double) in
let score = scoreOption(option, strategy: strategy, request: request)
return (option, score)
}
// Sort by score (lower is better for our scoring system)
let sorted = scoredOptions.sorted { $0.1 < $1.1 }
// Assign ranks
return sorted.enumerated().map { index, scored in
ItineraryOption(
rank: index + 1,
stops: scored.0.stops,
travelSegments: scored.0.travelSegments,
totalDrivingHours: scored.0.totalDrivingHours,
totalDistanceMiles: scored.0.totalDistanceMiles,
geographicRationale: scored.0.geographicRationale
)
}
}
// MARK: - Scoring
/// Scores an itinerary option based on the optimization strategy.
/// Lower scores are better.
///
/// - Parameters:
/// - option: Itinerary option to score
/// - strategy: Optimization strategy
/// - request: Planning request for context
/// - Returns: Score value (lower is better)
func scoreOption(
_ option: ItineraryOption,
strategy: OptimizationStrategy,
request: PlanningRequest
) -> Double {
switch strategy {
case .balanced:
return scoreBalanced(option, request: request)
case .maximizeGames:
return scoreMaximizeGames(option, request: request)
case .minimizeDriving:
return scoreMinimizeDriving(option, request: request)
case .scenic:
return scoreScenic(option, request: request)
}
}
/// Balanced scoring: trade-off between games and driving.
private func scoreBalanced(_ option: ItineraryOption, request: PlanningRequest) -> Double {
// Each game "saves" 2 hours of driving in value
let gameValue = Double(option.totalGames) * 2.0
let drivingPenalty = option.totalDrivingHours
// Also factor in must-see games coverage
let mustSeeCoverage = calculateMustSeeCoverage(option, request: request)
let mustSeeBonus = mustSeeCoverage * 10.0 // Strong bonus for must-see coverage
return drivingPenalty - gameValue - mustSeeBonus
}
/// Maximize games scoring: prioritize number of games.
private func scoreMaximizeGames(_ option: ItineraryOption, request: PlanningRequest) -> Double {
// Heavily weight game count
let gameScore = -Double(option.totalGames) * 100.0
let drivingPenalty = option.totalDrivingHours * 0.1 // Minimal driving penalty
return gameScore + drivingPenalty
}
/// Minimize driving scoring: prioritize shorter routes.
private func scoreMinimizeDriving(_ option: ItineraryOption, request: PlanningRequest) -> Double {
// Primarily driving time
let drivingScore = option.totalDrivingHours
let gameBonus = Double(option.totalGames) * 0.5 // Small bonus for games
return drivingScore - gameBonus
}
/// Scenic scoring: balance games with route pleasantness.
private func scoreScenic(_ option: ItineraryOption, request: PlanningRequest) -> Double {
// More relaxed pacing is better
let gamesPerDay = Double(option.totalGames) / Double(max(1, option.stops.count))
let pacingScore = abs(gamesPerDay - 1.5) * 5.0 // Ideal is ~1.5 games per day
let drivingScore = option.totalDrivingHours * 0.3
let gameBonus = Double(option.totalGames) * 2.0
return pacingScore + drivingScore - gameBonus
}
/// Calculates what percentage of must-see games are covered.
private func calculateMustSeeCoverage(
_ option: ItineraryOption,
request: PlanningRequest
) -> Double {
let mustSeeIds = request.preferences.mustSeeGameIds
if mustSeeIds.isEmpty { return 1.0 }
let coveredGames = option.stops.flatMap { $0.games }
let coveredMustSee = Set(coveredGames).intersection(mustSeeIds)
return Double(coveredMustSee.count) / Double(mustSeeIds.count)
}
// MARK: - Route Improvement
/// Attempts to improve a route by swapping non-essential stops.
/// Only applies to stops without must-see games.
///
/// - Parameters:
/// - option: Itinerary option to improve
/// - request: Planning request for context
/// - Returns: Improved itinerary option, or original if no improvement found
func improveRoute(
_ option: ItineraryOption,
request: PlanningRequest
) -> ItineraryOption {
// For now, return as-is since games must remain in chronological order
// Future: implement swap logic for same-day games in different cities
return option
}
// MARK: - Validation Helpers
/// Checks if all must-see games are included in the option.
func includesAllMustSeeGames(
_ option: ItineraryOption,
request: PlanningRequest
) -> Bool {
let includedGames = Set(option.stops.flatMap { $0.games })
return request.preferences.mustSeeGameIds.isSubset(of: includedGames)
}
/// Returns must-see games that are missing from the option.
func missingMustSeeGames(
_ option: ItineraryOption,
request: PlanningRequest
) -> Set<UUID> {
let includedGames = Set(option.stops.flatMap { $0.games })
return request.preferences.mustSeeGameIds.subtracting(includedGames)
}
// MARK: - Distance Calculations
/// Calculates total route distance for a sequence of coordinates.
func totalDistance(for coordinates: [CLLocationCoordinate2D]) -> Double {
guard coordinates.count >= 2 else { return 0 }
var total: Double = 0
for i in 0..<(coordinates.count - 1) {
total += distance(from: coordinates[i], to: coordinates[i + 1])
}
return total
}
/// Calculates distance between two coordinates in meters.
private func distance(
from: CLLocationCoordinate2D,
to: CLLocationCoordinate2D
) -> Double {
let fromLoc = CLLocation(latitude: from.latitude, longitude: from.longitude)
let toLoc = CLLocation(latitude: to.latitude, longitude: to.longitude)
return fromLoc.distance(from: toLoc)
}
// MARK: - Legacy Support
/// Legacy optimization method for backward compatibility.
/// Delegates to the new TripPlanningEngine for actual routing.
func optimize(
graph: RouteGraph,
request: PlanningRequest,
candidates: [GameCandidate],
strategy: OptimizationStrategy = .balanced
) -> CandidateRoute {
// Build a simple chronological route from candidates
let sortedCandidates = candidates.sorted { $0.game.dateTime < $1.game.dateTime }
var route = CandidateRoute()
// Add start node
if let startNode = graph.nodes.first(where: { $0.type == .start }) {
route.nodeSequence.append(startNode.id)
}
// Add stadium nodes in chronological order
var visitedStadiums = Set<UUID>()
for candidate in sortedCandidates {
// Find the node for this stadium
for node in graph.nodes {
if case .stadium(let stadiumId) = node.type,
stadiumId == candidate.stadium.id,
!visitedStadiums.contains(stadiumId) {
route.nodeSequence.append(node.id)
route.games.append(candidate.game.id)
visitedStadiums.insert(stadiumId)
break
}
}
}
// Add end node
if let endNode = graph.nodes.first(where: { $0.type == .end }) {
route.nodeSequence.append(endNode.id)
}
// Calculate totals
for i in 0..<(route.nodeSequence.count - 1) {
if let edge = graph.edges(from: route.nodeSequence[i])
.first(where: { $0.toNodeId == route.nodeSequence[i + 1] }) {
route.totalDistance += edge.distanceMeters
route.totalDuration += edge.durationSeconds
}
}
// Score the route
route.score = scoreRoute(route, strategy: strategy, graph: graph)
return route
}
/// Legacy route scoring for CandidateRoute.
private func scoreRoute(
_ route: CandidateRoute,
strategy: OptimizationStrategy,
graph: RouteGraph
) -> Double {
switch strategy {
case .balanced:
return route.totalDuration - Double(route.games.count) * 3600 * 2
case .maximizeGames:
return -Double(route.games.count) * 10000 + route.totalDuration
case .minimizeDriving:
return route.totalDuration
case .scenic:
return route.totalDuration * 0.5 - Double(route.games.count) * 3600
}
}
}

View File

@@ -0,0 +1,265 @@
//
// ScenarioAPlanner.swift
// SportsTime
//
// Scenario A: Date range only planning.
// User provides a date range, we find all games and build chronological routes.
//
import Foundation
import CoreLocation
/// Scenario A: Date range planning
///
/// This is the simplest scenario - user just picks a date range and we find games.
///
/// Input:
/// - date_range: Required. The trip dates (e.g., Jan 5-15)
/// - must_stop: Optional. A location they must visit (not yet implemented)
///
/// Output:
/// - Success: Ranked list of itinerary options
/// - Failure: Explicit error with reason (no games, dates invalid, etc.)
///
/// Example:
/// User selects Jan 5-10, 2026
/// We find: Lakers (Jan 5), Warriors (Jan 7), Kings (Jan 9)
/// Output: Single itinerary visiting LA SF Sacramento in order
///
final class ScenarioAPlanner: ScenarioPlanner {
// MARK: - ScenarioPlanner Protocol
/// Main entry point for Scenario A planning.
///
/// Flow:
/// 1. Validate inputs (date range must exist)
/// 2. Find all games within the date range
/// 3. Convert games to stops (grouping by stadium)
/// 4. Calculate travel between stops
/// 5. Return the complete itinerary
///
/// Failure cases:
/// - No date range provided .missingDateRange
/// - No games in date range .noGamesInRange
/// - Can't build valid route .constraintsUnsatisfiable
///
func plan(request: PlanningRequest) -> ItineraryResult {
//
// Step 1: Validate date range exists
//
// Scenario A requires a date range. Without it, we can't filter games.
guard let dateRange = request.dateRange else {
return .failure(
PlanningFailure(
reason: .missingDateRange,
violations: []
)
)
}
//
// Step 2: Filter games within date range
//
// Get all games that fall within the user's travel dates.
// Sort by start time so we visit them in chronological order.
let gamesInRange = request.allGames
.filter { dateRange.contains($0.startTime) }
.sorted { $0.startTime < $1.startTime }
// No games? Nothing to plan.
if gamesInRange.isEmpty {
return .failure(
PlanningFailure(
reason: .noGamesInRange,
violations: []
)
)
}
//
// Step 3: Find ALL geographically sensible route variations
//
// Not all games in the date range may form a sensible route.
// Example: NY (Jan 5), TX (Jan 6), SC (Jan 7), CA (Jan 8)
// - Including all = zig-zag nightmare
// - Option 1: NY, TX, DEN, NM, CA (skip SC)
// - Option 2: NY, SC, DEN, NM, CA (skip TX)
// - etc.
//
// We explore ALL valid combinations and return multiple options.
// Uses shared GeographicRouteExplorer for tree exploration.
//
let validRoutes = GeographicRouteExplorer.findAllSensibleRoutes(
from: gamesInRange,
stadiums: request.stadiums,
stopBuilder: buildStops
)
if validRoutes.isEmpty {
return .failure(
PlanningFailure(
reason: .noValidRoutes,
violations: [
ConstraintViolation(
type: .geographicSanity,
description: "No geographically sensible route found for games in this date range",
severity: .error
)
]
)
)
}
//
// Step 4: Build itineraries for each valid route
//
// For each valid game combination, build stops and calculate travel.
// Some routes may fail driving constraints - filter those out.
//
var itineraryOptions: [ItineraryOption] = []
for (index, routeGames) in validRoutes.enumerated() {
// Build stops for this route
let stops = buildStops(from: routeGames, stadiums: request.stadiums)
guard !stops.isEmpty else { continue }
// Calculate travel segments using shared ItineraryBuilder
guard let itinerary = ItineraryBuilder.build(
stops: stops,
constraints: request.drivingConstraints,
logPrefix: "[ScenarioA]"
) else {
// This route fails driving constraints, skip it
print("[ScenarioA] Route \(index + 1) failed driving constraints, skipping")
continue
}
// Create the option
let cities = stops.map { $0.city }.joined(separator: "")
let option = ItineraryOption(
rank: index + 1,
stops: itinerary.stops,
travelSegments: itinerary.travelSegments,
totalDrivingHours: itinerary.totalDrivingHours,
totalDistanceMiles: itinerary.totalDistanceMiles,
geographicRationale: "\(stops.count) games: \(cities)"
)
itineraryOptions.append(option)
}
//
// Step 5: Return ranked results
//
// If no routes passed all constraints, fail.
// Otherwise, return all valid options for the user to choose from.
//
if itineraryOptions.isEmpty {
return .failure(
PlanningFailure(
reason: .constraintsUnsatisfiable,
violations: [
ConstraintViolation(
type: .drivingTime,
description: "No routes satisfy driving constraints",
severity: .error
)
]
)
)
}
// Re-rank by number of games (already sorted, but update rank numbers)
let rankedOptions = itineraryOptions.enumerated().map { index, option in
ItineraryOption(
rank: index + 1,
stops: option.stops,
travelSegments: option.travelSegments,
totalDrivingHours: option.totalDrivingHours,
totalDistanceMiles: option.totalDistanceMiles,
geographicRationale: option.geographicRationale
)
}
print("[ScenarioA] Returning \(rankedOptions.count) itinerary options")
return .success(rankedOptions)
}
// MARK: - Stop Building
/// Converts a list of games into itinerary stops.
///
/// The goal: Create one stop per stadium, in the order we first encounter each stadium
/// when walking through games chronologically.
///
/// Example:
/// Input games (already sorted by date):
/// 1. Jan 5 - Lakers @ Staples Center (LA)
/// 2. Jan 6 - Clippers @ Staples Center (LA) <- same stadium as #1
/// 3. Jan 8 - Warriors @ Chase Center (SF)
///
/// Output stops:
/// Stop 1: Los Angeles (contains game 1 and 2)
/// Stop 2: San Francisco (contains game 3)
///
private func buildStops(
from games: [Game],
stadiums: [UUID: Stadium]
) -> [ItineraryStop] {
// Step 1: Group all games by their stadium
// This lets us find ALL games at a stadium when we create that stop
// Result: { stadiumId: [game1, game2, ...], ... }
var stadiumGames: [UUID: [Game]] = [:]
for game in games {
stadiumGames[game.stadiumId, default: []].append(game)
}
// Step 2: Walk through games in chronological order
// When we hit a stadium for the first time, create a stop with ALL games at that stadium
var stops: [ItineraryStop] = []
var processedStadiums: Set<UUID> = [] // Track which stadiums we've already made stops for
for game in games {
// Skip if we already created a stop for this stadium
guard !processedStadiums.contains(game.stadiumId) else { continue }
processedStadiums.insert(game.stadiumId)
// Get ALL games at this stadium (not just this one)
let gamesAtStadium = stadiumGames[game.stadiumId] ?? [game]
let sortedGames = gamesAtStadium.sorted { $0.startTime < $1.startTime }
// Look up stadium info for location data
let stadium = stadiums[game.stadiumId]
let city = stadium?.city ?? "Unknown"
let state = stadium?.state ?? ""
let coordinate = stadium?.coordinate
let location = LocationInput(
name: city,
coordinate: coordinate,
address: stadium?.fullAddress
)
// Create the stop
// - arrivalDate: when we need to arrive (first game at this stop)
// - departureDate: when we can leave (after last game at this stop)
// - games: IDs of all games we'll attend at this stop
let stop = ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: sortedGames.map { $0.id },
arrivalDate: sortedGames.first?.gameDate ?? Date(),
departureDate: sortedGames.last?.gameDate ?? Date(),
location: location,
firstGameStart: sortedGames.first?.startTime
)
stops.append(stop)
}
return stops
}
}

View File

@@ -0,0 +1,348 @@
//
// ScenarioBPlanner.swift
// SportsTime
//
// Scenario B: Selected games planning.
// User selects specific games they MUST see. Those are fixed anchors that cannot be removed.
//
// Key Features:
// - Selected games are "anchors" - they MUST appear in every valid route
// - Sliding window logic when only trip duration (no specific dates) is provided
// - Additional games from date range can be added if they fit geographically
//
// Sliding Window Algorithm:
// When user provides selected games + day span (e.g., 10 days) without specific dates:
// 1. Find first and last selected game dates
// 2. Generate all possible windows of the given duration that contain ALL selected games
// 3. Window 1: Last selected game is on last day
// 4. Window N: First selected game is on first day
// 5. Explore routes for each window, return best options
//
// Example:
// Selected games on Jan 5, Jan 8, Jan 12. Day span = 10 days.
// - Window 1: Jan 3-12 (Jan 12 is last day)
// - Window 2: Jan 4-13
// - Window 3: Jan 5-14 (Jan 5 is first day)
// For each window, find all games and explore routes with selected as anchors.
//
import Foundation
import CoreLocation
/// Scenario B: Selected games planning
/// Input: selected_games, date_range (or trip_duration), optional must_stop
/// Output: Itinerary options connecting all selected games with possible bonus games
final class ScenarioBPlanner: ScenarioPlanner {
// MARK: - ScenarioPlanner Protocol
func plan(request: PlanningRequest) -> ItineraryResult {
let selectedGames = request.selectedGames
//
// Step 1: Validate selected games exist
//
if selectedGames.isEmpty {
return .failure(
PlanningFailure(
reason: .noValidRoutes,
violations: [
ConstraintViolation(
type: .selectedGames,
description: "No games selected",
severity: .error
)
]
)
)
}
//
// Step 2: Generate date ranges (sliding window or single range)
//
let dateRanges = generateDateRanges(
selectedGames: selectedGames,
request: request
)
if dateRanges.isEmpty {
return .failure(
PlanningFailure(
reason: .missingDateRange,
violations: [
ConstraintViolation(
type: .dateRange,
description: "Cannot determine valid date range for selected games",
severity: .error
)
]
)
)
}
//
// Step 3: For each date range, find routes with anchors
//
let anchorGameIds = Set(selectedGames.map { $0.id })
var allItineraryOptions: [ItineraryOption] = []
for dateRange in dateRanges {
// Find all games in this date range
let gamesInRange = request.allGames
.filter { dateRange.contains($0.startTime) }
.sorted { $0.startTime < $1.startTime }
// Skip if no games (shouldn't happen if date range is valid)
guard !gamesInRange.isEmpty else { continue }
// Verify all selected games are in range
let selectedInRange = selectedGames.allSatisfy { game in
dateRange.contains(game.startTime)
}
guard selectedInRange else { continue }
// Find all sensible routes that include the anchor games
let validRoutes = GeographicRouteExplorer.findAllSensibleRoutes(
from: gamesInRange,
stadiums: request.stadiums,
anchorGameIds: anchorGameIds,
stopBuilder: buildStops
)
// Build itineraries for each valid route
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: "[ScenarioB]",
segmentValidator: ItineraryBuilder.arrivalBeforeGameStart()
) else {
continue
}
let selectedCount = routeGames.filter { anchorGameIds.contains($0.id) }.count
let bonusCount = routeGames.count - selectedCount
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: "\(selectedCount) selected + \(bonusCount) bonus games: \(cities)"
)
allItineraryOptions.append(option)
}
}
//
// Step 4: Return ranked results
//
if allItineraryOptions.isEmpty {
return .failure(
PlanningFailure(
reason: .constraintsUnsatisfiable,
violations: [
ConstraintViolation(
type: .geographicSanity,
description: "Cannot create a geographically sensible route connecting selected games",
severity: .error
)
]
)
)
}
// Sort by total games (most first), then by driving hours (less first)
let sorted = allItineraryOptions.sorted { a, b in
if a.stops.flatMap({ $0.games }).count != b.stops.flatMap({ $0.games }).count {
return a.stops.flatMap({ $0.games }).count > b.stops.flatMap({ $0.games }).count
}
return a.totalDrivingHours < b.totalDrivingHours
}
// Re-rank and limit
let rankedOptions = sorted.prefix(10).enumerated().map { index, option in
ItineraryOption(
rank: index + 1,
stops: option.stops,
travelSegments: option.travelSegments,
totalDrivingHours: option.totalDrivingHours,
totalDistanceMiles: option.totalDistanceMiles,
geographicRationale: option.geographicRationale
)
}
print("[ScenarioB] Returning \(rankedOptions.count) itinerary options")
return .success(Array(rankedOptions))
}
// MARK: - Date Range Generation (Sliding Window)
/// Generates all valid date ranges for the selected games.
///
/// Two modes:
/// 1. If explicit date range provided: Use it directly (validate selected games fit)
/// 2. If only trip duration provided: Generate sliding windows
///
/// Sliding Window Logic:
/// Selected games: Jan 5, Jan 8, Jan 12. Duration: 10 days.
/// - Window must contain all selected games
/// - First window: ends on last selected game date (Jan 3-12)
/// - Slide forward one day at a time
/// - Last window: starts on first selected game date (Jan 5-14)
///
private func generateDateRanges(
selectedGames: [Game],
request: PlanningRequest
) -> [DateInterval] {
// If explicit date range exists, use it
if let dateRange = request.dateRange {
return [dateRange]
}
// Otherwise, use trip duration to create sliding windows
let duration = request.preferences.effectiveTripDuration
guard duration > 0 else { return [] }
// Find the span of selected games
let sortedGames = selectedGames.sorted { $0.startTime < $1.startTime }
guard let firstGame = sortedGames.first,
let lastGame = sortedGames.last else {
return []
}
let firstGameDate = Calendar.current.startOfDay(for: firstGame.startTime)
let lastGameDate = Calendar.current.startOfDay(for: lastGame.startTime)
// Calculate how many days the selected games span
let gameSpanDays = Calendar.current.dateComponents(
[.day],
from: firstGameDate,
to: lastGameDate
).day ?? 0
// If selected games span more days than trip duration, can't fit
if gameSpanDays >= duration {
// Just return one window that exactly covers the games
let start = firstGameDate
let end = Calendar.current.date(
byAdding: .day,
value: gameSpanDays + 1,
to: start
) ?? lastGameDate
return [DateInterval(start: start, end: end)]
}
// Generate sliding windows
var dateRanges: [DateInterval] = []
// First window: last selected game is on last day of window
// Window end = lastGameDate + 1 day (to include the game)
// Window start = end - duration days
let firstWindowEnd = Calendar.current.date(
byAdding: .day,
value: 1,
to: lastGameDate
)!
let firstWindowStart = Calendar.current.date(
byAdding: .day,
value: -duration,
to: firstWindowEnd
)!
// Last window: first selected game is on first day of window
// Window start = firstGameDate
// Window end = start + duration days
let lastWindowStart = firstGameDate
let lastWindowEnd = Calendar.current.date(
byAdding: .day,
value: duration,
to: lastWindowStart
)!
// Slide from first window to last window
var currentStart = firstWindowStart
while currentStart <= lastWindowStart {
let windowEnd = Calendar.current.date(
byAdding: .day,
value: duration,
to: currentStart
)!
let window = DateInterval(start: currentStart, end: windowEnd)
dateRanges.append(window)
// Slide forward one day
currentStart = Calendar.current.date(
byAdding: .day,
value: 1,
to: currentStart
)!
}
print("[ScenarioB] Generated \(dateRanges.count) sliding windows for \(duration)-day trip")
return dateRanges
}
// MARK: - Stop Building
/// Converts a list of games into itinerary stops.
/// Groups games by stadium, creates one stop per unique stadium.
private func buildStops(
from games: [Game],
stadiums: [UUID: Stadium]
) -> [ItineraryStop] {
// Group games by stadium
var stadiumGames: [UUID: [Game]] = [:]
for game in games {
stadiumGames[game.stadiumId, default: []].append(game)
}
// Create stops in chronological order (first game at each stadium)
var stops: [ItineraryStop] = []
var processedStadiums: Set<UUID> = []
for game in games {
guard !processedStadiums.contains(game.stadiumId) else { continue }
processedStadiums.insert(game.stadiumId)
let gamesAtStadium = stadiumGames[game.stadiumId] ?? [game]
let sortedGames = gamesAtStadium.sorted { $0.startTime < $1.startTime }
let stadium = stadiums[game.stadiumId]
let city = stadium?.city ?? "Unknown"
let state = stadium?.state ?? ""
let coordinate = stadium?.coordinate
let location = LocationInput(
name: city,
coordinate: coordinate,
address: stadium?.fullAddress
)
let stop = ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: sortedGames.map { $0.id },
arrivalDate: sortedGames.first?.gameDate ?? Date(),
departureDate: sortedGames.last?.gameDate ?? Date(),
location: location,
firstGameStart: sortedGames.first?.startTime
)
stops.append(stop)
}
return stops
}
}

View File

@@ -0,0 +1,582 @@
//
// ScenarioCPlanner.swift
// SportsTime
//
// Scenario C: Start + End location planning.
// User specifies starting city and ending city. We find games along the route.
//
// Key Features:
// - Start/End are cities with stadiums from user's selected sports
// - Directional filtering: stadiums that "generally move toward" the end
// - When only day span provided (no dates): generate date ranges from games at start/end
// - Uses GeographicRouteExplorer for sensible route exploration
// - Returns top 5 options with most games
//
// Date Range Generation (when only day span provided):
// 1. Find all games at start city's stadiums
// 2. Find all games at end city's stadiums
// 3. For each start game + end game combo within day span: create a date range
// 4. Explore routes for each date range
//
// Directional Filtering:
// - Find stadiums that make forward progress from start to end
// - "Forward progress" = distance to end decreases (with tolerance)
// - Filter games to only those at directional stadiums
//
// Example:
// Start: Chicago, End: New York, Day span: 7 days
// Start game: Jan 5 at Chicago
// End game: Jan 10 at New York
// Date range: Jan 5-10
// Directional stadiums: Detroit, Cleveland, Pittsburgh (moving east)
// NOT directional: Minneapolis, St. Louis (moving away from NY)
//
import Foundation
import CoreLocation
/// Scenario C: Directional route planning from start city to end city
/// Input: start_location, end_location, day_span (or date_range)
/// Output: Top 5 itinerary options with games along the directional route
final class ScenarioCPlanner: ScenarioPlanner {
// MARK: - Configuration
/// Maximum number of itinerary options to return
private let maxOptions = 5
/// Tolerance for "forward progress" - allow small increases in distance to end
/// A stadium is "directional" if it doesn't increase distance to end by more than this ratio
private let forwardProgressTolerance = 0.15 // 15% tolerance
// MARK: - ScenarioPlanner Protocol
func plan(request: PlanningRequest) -> ItineraryResult {
//
// Step 1: Validate start and end locations
//
guard let startLocation = request.startLocation else {
return .failure(
PlanningFailure(
reason: .missingLocations,
violations: [
ConstraintViolation(
type: .general,
description: "Start location is required for Scenario C",
severity: .error
)
]
)
)
}
guard let endLocation = request.endLocation else {
return .failure(
PlanningFailure(
reason: .missingLocations,
violations: [
ConstraintViolation(
type: .general,
description: "End location is required for Scenario C",
severity: .error
)
]
)
)
}
guard let startCoord = startLocation.coordinate,
let endCoord = endLocation.coordinate else {
return .failure(
PlanningFailure(
reason: .missingLocations,
violations: [
ConstraintViolation(
type: .general,
description: "Start and end locations must have coordinates",
severity: .error
)
]
)
)
}
//
// Step 2: Find stadiums at start and end cities
//
let startStadiums = findStadiumsInCity(
cityName: startLocation.name,
stadiums: request.stadiums
)
let endStadiums = findStadiumsInCity(
cityName: endLocation.name,
stadiums: request.stadiums
)
if startStadiums.isEmpty {
return .failure(
PlanningFailure(
reason: .noGamesInRange,
violations: [
ConstraintViolation(
type: .general,
description: "No stadiums found in start city: \(startLocation.name)",
severity: .error
)
]
)
)
}
if endStadiums.isEmpty {
return .failure(
PlanningFailure(
reason: .noGamesInRange,
violations: [
ConstraintViolation(
type: .general,
description: "No stadiums found in end city: \(endLocation.name)",
severity: .error
)
]
)
)
}
//
// Step 3: Generate date ranges
//
let dateRanges = generateDateRanges(
startStadiumIds: Set(startStadiums.map { $0.id }),
endStadiumIds: Set(endStadiums.map { $0.id }),
allGames: request.allGames,
request: request
)
if dateRanges.isEmpty {
return .failure(
PlanningFailure(
reason: .missingDateRange,
violations: [
ConstraintViolation(
type: .dateRange,
description: "No valid date ranges found with games at both start and end cities",
severity: .error
)
]
)
)
}
//
// Step 4: Find directional stadiums (moving from start toward end)
//
let directionalStadiums = findDirectionalStadiums(
from: startCoord,
to: endCoord,
stadiums: request.stadiums
)
print("[ScenarioC] Found \(directionalStadiums.count) directional stadiums from \(startLocation.name) to \(endLocation.name)")
//
// Step 5: For each date range, explore routes
//
var allItineraryOptions: [ItineraryOption] = []
for dateRange in dateRanges {
// Find games at directional stadiums within date range
let gamesInRange = request.allGames
.filter { dateRange.contains($0.startTime) }
.filter { directionalStadiums.contains($0.stadiumId) }
.sorted { $0.startTime < $1.startTime }
guard !gamesInRange.isEmpty else { continue }
// Use GeographicRouteExplorer to find sensible routes
let validRoutes = GeographicRouteExplorer.findAllSensibleRoutes(
from: gamesInRange,
stadiums: request.stadiums,
anchorGameIds: [], // No anchors in Scenario C
stopBuilder: buildStops
)
// Build itineraries for each valid route
for routeGames in validRoutes {
let stops = buildStopsWithEndpoints(
start: startLocation,
end: endLocation,
games: routeGames,
stadiums: request.stadiums
)
guard !stops.isEmpty else { continue }
// Validate monotonic progress toward end
guard validateMonotonicProgress(
stops: stops,
toward: endCoord
) else {
continue
}
// Use shared ItineraryBuilder
guard let itinerary = ItineraryBuilder.build(
stops: stops,
constraints: request.drivingConstraints,
logPrefix: "[ScenarioC]"
) else {
continue
}
let gameCount = routeGames.count
let cities = stops.compactMap { $0.games.isEmpty ? nil : $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: "\(startLocation.name)\(gameCount) games → \(endLocation.name): \(cities)"
)
allItineraryOptions.append(option)
}
}
//
// Step 6: Return top 5 ranked results
//
if allItineraryOptions.isEmpty {
return .failure(
PlanningFailure(
reason: .noValidRoutes,
violations: [
ConstraintViolation(
type: .geographicSanity,
description: "No valid directional routes found from \(startLocation.name) to \(endLocation.name)",
severity: .error
)
]
)
)
}
// Sort by game count (most first), then by driving hours (less first)
let sorted = allItineraryOptions.sorted { a, b in
let aGames = a.stops.flatMap { $0.games }.count
let bGames = b.stops.flatMap { $0.games }.count
if aGames != bGames {
return aGames > bGames
}
return a.totalDrivingHours < b.totalDrivingHours
}
// Take top N and re-rank
let rankedOptions = sorted.prefix(maxOptions).enumerated().map { index, option in
ItineraryOption(
rank: index + 1,
stops: option.stops,
travelSegments: option.travelSegments,
totalDrivingHours: option.totalDrivingHours,
totalDistanceMiles: option.totalDistanceMiles,
geographicRationale: option.geographicRationale
)
}
print("[ScenarioC] Returning \(rankedOptions.count) itinerary options")
return .success(Array(rankedOptions))
}
// MARK: - Stadium Finding
/// Finds all stadiums in a given city (case-insensitive match).
private func findStadiumsInCity(
cityName: String,
stadiums: [UUID: Stadium]
) -> [Stadium] {
let normalizedCity = cityName.lowercased().trimmingCharacters(in: .whitespaces)
return stadiums.values.filter { stadium in
stadium.city.lowercased().trimmingCharacters(in: .whitespaces) == normalizedCity
}
}
/// Finds stadiums that make forward progress from start to end.
///
/// A stadium is "directional" if visiting it doesn't significantly increase
/// the distance to the end point. This filters out stadiums that would
/// require backtracking.
///
/// Algorithm:
/// 1. Calculate distance from start to end
/// 2. For each stadium, calculate: distance(start, stadium) + distance(stadium, end)
/// 3. If this "detour distance" is reasonable (within tolerance), include it
///
/// The tolerance allows for stadiums slightly off the direct path.
///
private func findDirectionalStadiums(
from start: CLLocationCoordinate2D,
to end: CLLocationCoordinate2D,
stadiums: [UUID: Stadium]
) -> Set<UUID> {
let directDistance = distanceBetween(start, end)
// Allow detours up to 50% longer than direct distance
let maxDetourDistance = directDistance * 1.5
var directionalIds: Set<UUID> = []
for (id, stadium) in stadiums {
let stadiumCoord = stadium.coordinate
// Calculate the detour: start stadium end
let toStadium = distanceBetween(start, stadiumCoord)
let fromStadium = distanceBetween(stadiumCoord, end)
let detourDistance = toStadium + fromStadium
// Also check that stadium is making progress (closer to end than start is)
let distanceFromStart = distanceBetween(start, stadiumCoord)
let distanceToEnd = distanceBetween(stadiumCoord, end)
// Stadium should be within the "cone" from start to end
// Either closer to end than start, or the detour is acceptable
if detourDistance <= maxDetourDistance {
// Additional check: don't include if it's behind the start point
// (i.e., distance to end is greater than original distance)
if distanceToEnd <= directDistance * (1 + forwardProgressTolerance) {
directionalIds.insert(id)
}
}
}
return directionalIds
}
// MARK: - Date Range Generation
/// Generates date ranges for Scenario C.
///
/// Two modes:
/// 1. Explicit date range provided: Use it directly
/// 2. Only day span provided: Find game combinations at start/end cities
///
/// For mode 2:
/// - Find all games at start city stadiums
/// - Find all games at end city stadiums
/// - For each (start_game, end_game) pair where end_game - start_game <= day_span:
/// Create a date range from start_game.date to end_game.date
///
private func generateDateRanges(
startStadiumIds: Set<UUID>,
endStadiumIds: Set<UUID>,
allGames: [Game],
request: PlanningRequest
) -> [DateInterval] {
// If explicit date range exists, use it
if let dateRange = request.dateRange {
return [dateRange]
}
// Otherwise, use day span to find valid combinations
let daySpan = request.preferences.effectiveTripDuration
guard daySpan > 0 else { return [] }
// Find games at start and end cities
let startGames = allGames
.filter { startStadiumIds.contains($0.stadiumId) }
.sorted { $0.startTime < $1.startTime }
let endGames = allGames
.filter { endStadiumIds.contains($0.stadiumId) }
.sorted { $0.startTime < $1.startTime }
if startGames.isEmpty || endGames.isEmpty {
return []
}
// Generate all valid (start_game, end_game) combinations
var dateRanges: [DateInterval] = []
let calendar = Calendar.current
for startGame in startGames {
let startDate = calendar.startOfDay(for: startGame.startTime)
for endGame in endGames {
let endDate = calendar.startOfDay(for: endGame.startTime)
// End must be after start
guard endDate >= startDate else { continue }
// Calculate days between
let daysBetween = calendar.dateComponents([.day], from: startDate, to: endDate).day ?? 0
// Must be within day span
guard daysBetween < daySpan else { continue }
// Create date range (end date + 1 day to include the end game)
let rangeEnd = calendar.date(byAdding: .day, value: 1, to: endDate) ?? endDate
let range = DateInterval(start: startDate, end: rangeEnd)
// Avoid duplicate ranges
if !dateRanges.contains(where: { $0.start == range.start && $0.end == range.end }) {
dateRanges.append(range)
}
}
}
print("[ScenarioC] Generated \(dateRanges.count) date ranges for \(daySpan)-day trip")
return dateRanges
}
// MARK: - Stop Building
/// Converts games to stops (used by GeographicRouteExplorer callback).
private func buildStops(
from games: [Game],
stadiums: [UUID: Stadium]
) -> [ItineraryStop] {
var stadiumGames: [UUID: [Game]] = [:]
for game in games {
stadiumGames[game.stadiumId, default: []].append(game)
}
var stops: [ItineraryStop] = []
var processedStadiums: Set<UUID> = []
for game in games {
guard !processedStadiums.contains(game.stadiumId) else { continue }
processedStadiums.insert(game.stadiumId)
let gamesAtStadium = stadiumGames[game.stadiumId] ?? [game]
let sortedGames = gamesAtStadium.sorted { $0.startTime < $1.startTime }
let stadium = stadiums[game.stadiumId]
let city = stadium?.city ?? "Unknown"
let state = stadium?.state ?? ""
let coordinate = stadium?.coordinate
let location = LocationInput(
name: city,
coordinate: coordinate,
address: stadium?.fullAddress
)
let stop = ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: sortedGames.map { $0.id },
arrivalDate: sortedGames.first?.gameDate ?? Date(),
departureDate: sortedGames.last?.gameDate ?? Date(),
location: location,
firstGameStart: sortedGames.first?.startTime
)
stops.append(stop)
}
return stops
}
/// Builds stops with start and end location endpoints.
private func buildStopsWithEndpoints(
start: LocationInput,
end: LocationInput,
games: [Game],
stadiums: [UUID: Stadium]
) -> [ItineraryStop] {
var stops: [ItineraryStop] = []
// Start stop (no games)
let startArrival = games.first?.gameDate.addingTimeInterval(-86400) ?? Date()
let startStop = ItineraryStop(
city: start.name,
state: "",
coordinate: start.coordinate,
games: [],
arrivalDate: startArrival,
departureDate: startArrival,
location: start,
firstGameStart: nil
)
stops.append(startStop)
// Game stops
let gameStops = buildStops(from: games, stadiums: stadiums)
stops.append(contentsOf: gameStops)
// End stop (no games)
let endArrival = games.last?.gameDate.addingTimeInterval(86400) ?? Date()
let endStop = ItineraryStop(
city: end.name,
state: "",
coordinate: end.coordinate,
games: [],
arrivalDate: endArrival,
departureDate: endArrival,
location: end,
firstGameStart: nil
)
stops.append(endStop)
return stops
}
// MARK: - Monotonic Progress Validation
/// Validates that the route makes generally forward progress toward the end.
///
/// Each stop should be closer to (or not significantly farther from) the end
/// than the previous stop. Small detours are allowed within tolerance.
///
private func validateMonotonicProgress(
stops: [ItineraryStop],
toward end: CLLocationCoordinate2D
) -> Bool {
var previousDistance: Double?
for stop in stops {
guard let stopCoord = stop.coordinate else { continue }
let currentDistance = distanceBetween(stopCoord, end)
if let prev = previousDistance {
// Allow increases up to tolerance percentage
let allowedIncrease = prev * forwardProgressTolerance
if currentDistance > prev + allowedIncrease {
print("[ScenarioC] Backtracking: \(stop.city) increases distance to end (\(Int(currentDistance))mi vs \(Int(prev))mi)")
return false
}
}
previousDistance = currentDistance
}
return true
}
// MARK: - Geometry Helpers
/// Distance between two coordinates in miles using Haversine formula.
private func distanceBetween(
_ coord1: CLLocationCoordinate2D,
_ coord2: CLLocationCoordinate2D
) -> Double {
let earthRadiusMiles = 3958.8
let lat1 = coord1.latitude * .pi / 180
let lat2 = coord2.latitude * .pi / 180
let deltaLat = (coord2.latitude - coord1.latitude) * .pi / 180
let deltaLon = (coord2.longitude - coord1.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
}
}

View File

@@ -0,0 +1,49 @@
//
// ScenarioPlanner.swift
// SportsTime
//
// Protocol for scenario-based trip planning.
//
import Foundation
/// Protocol that all scenario planners must implement.
/// Each scenario (A, B, C) has its own isolated implementation.
protocol ScenarioPlanner {
/// Plan itineraries for this scenario.
/// - Parameter request: The planning request with all inputs
/// - Returns: Success with ranked itineraries, or explicit failure
func plan(request: PlanningRequest) -> ItineraryResult
}
/// Factory for creating the appropriate scenario planner
enum ScenarioPlannerFactory {
/// Creates the appropriate planner based on the request inputs
static func planner(for request: PlanningRequest) -> ScenarioPlanner {
// Scenario B: User selected specific games
if !request.selectedGames.isEmpty {
return ScenarioBPlanner()
}
// Scenario C: User specified start and end locations
if request.startLocation != nil && request.endLocation != nil {
return ScenarioCPlanner()
}
// Scenario A: Date range only (default)
return ScenarioAPlanner()
}
/// Classifies which scenario applies to this request
static func classify(_ request: PlanningRequest) -> PlanningScenario {
if !request.selectedGames.isEmpty {
return .scenarioB
}
if request.startLocation != nil && request.endLocation != nil {
return .scenarioC
}
return .scenarioA
}
}

View File

@@ -0,0 +1,396 @@
//
// ScheduleMatcher.swift
// SportsTime
//
import Foundation
import CoreLocation
/// Finds and scores candidate games for trip planning.
///
/// Updated for the new scenario-based planning:
/// - Scenario A (Date Range): Find games in date range, cluster by region
/// - Scenario B (Selected Games): Validate must-see games, find optional additions
/// - Scenario C (Start+End): Find games along directional corridor with progress check
struct ScheduleMatcher {
// MARK: - Find Candidate Games (Legacy + Scenario C Support)
/// Finds candidate games along a corridor between start and end.
/// Supports directional filtering for Scenario C.
///
/// - Parameters:
/// - request: Planning request with preferences and games
/// - startCoordinate: Starting location
/// - endCoordinate: Ending location
/// - enforceDirection: If true, only include games that make progress toward end
/// - Returns: Array of game candidates sorted by score
func findCandidateGames(
from request: PlanningRequest,
startCoordinate: CLLocationCoordinate2D,
endCoordinate: CLLocationCoordinate2D,
enforceDirection: Bool = false
) -> [GameCandidate] {
var candidates: [GameCandidate] = []
// Calculate the corridor between start and end
let corridor = RouteCorridorCalculator(
start: startCoordinate,
end: endCoordinate,
maxDetourFactor: detourFactorFor(request.preferences.leisureLevel)
)
for game in request.availableGames {
guard let stadium = request.stadiums[game.stadiumId],
let homeTeam = request.teams[game.homeTeamId],
let awayTeam = request.teams[game.awayTeamId] else {
continue
}
// Check if game is within date range
guard game.dateTime >= request.preferences.startDate,
game.dateTime <= request.preferences.endDate else {
continue
}
// Check sport filter
guard request.preferences.sports.contains(game.sport) else {
continue
}
// Calculate detour distance
let detourDistance = corridor.detourDistance(to: stadium.coordinate)
// For directional routes, check if this stadium makes progress
if enforceDirection {
let distanceToEnd = corridor.distanceToEnd(from: stadium.coordinate)
let startDistanceToEnd = corridor.directDistance
// Skip if stadium is behind the start (going backwards)
if distanceToEnd > startDistanceToEnd * 1.1 { // 10% tolerance
continue
}
}
// Skip if too far from route (unless must-see)
let isMustSee = request.preferences.mustSeeGameIds.contains(game.id)
if !isMustSee && detourDistance > corridor.maxDetourDistance {
continue
}
// Score the game
let score = scoreGame(
game: game,
homeTeam: homeTeam,
awayTeam: awayTeam,
detourDistance: detourDistance,
isMustSee: isMustSee,
request: request
)
let candidate = GameCandidate(
id: game.id,
game: game,
stadium: stadium,
homeTeam: homeTeam,
awayTeam: awayTeam,
detourDistance: detourDistance,
score: score
)
candidates.append(candidate)
}
// Sort by score (highest first)
return candidates.sorted { $0.score > $1.score }
}
// MARK: - Directional Game Filtering (Scenario C)
/// Finds games along a directional route from start to end.
/// Ensures monotonic progress toward destination.
///
/// - Parameters:
/// - request: Planning request
/// - startCoordinate: Starting location
/// - endCoordinate: Destination
/// - corridorWidthPercent: Width of corridor as percentage of direct distance
/// - Returns: Games sorted by their position along the route
func findDirectionalGames(
from request: PlanningRequest,
startCoordinate: CLLocationCoordinate2D,
endCoordinate: CLLocationCoordinate2D,
corridorWidthPercent: Double = 0.3
) -> [GameCandidate] {
let corridor = RouteCorridorCalculator(
start: startCoordinate,
end: endCoordinate,
maxDetourFactor: 1.0 + corridorWidthPercent
)
var candidates: [GameCandidate] = []
for game in request.availableGames {
guard let stadium = request.stadiums[game.stadiumId],
let homeTeam = request.teams[game.homeTeamId],
let awayTeam = request.teams[game.awayTeamId] else {
continue
}
// Date and sport filter
guard game.dateTime >= request.preferences.startDate,
game.dateTime <= request.preferences.endDate,
request.preferences.sports.contains(game.sport) else {
continue
}
// Calculate progress along route (0 = start, 1 = end)
let progress = corridor.progressAlongRoute(point: stadium.coordinate)
// Only include games that are along the route (positive progress, not behind start)
guard progress >= -0.1 && progress <= 1.1 else {
continue
}
// Check corridor width
let detourDistance = corridor.detourDistance(to: stadium.coordinate)
let isMustSee = request.preferences.mustSeeGameIds.contains(game.id)
if !isMustSee && detourDistance > corridor.maxDetourDistance {
continue
}
let score = scoreGame(
game: game,
homeTeam: homeTeam,
awayTeam: awayTeam,
detourDistance: detourDistance,
isMustSee: isMustSee,
request: request
)
let candidate = GameCandidate(
id: game.id,
game: game,
stadium: stadium,
homeTeam: homeTeam,
awayTeam: awayTeam,
detourDistance: detourDistance,
score: score
)
candidates.append(candidate)
}
// Sort by date (chronological order is the primary constraint)
return candidates.sorted { $0.game.dateTime < $1.game.dateTime }
}
// MARK: - Game Scoring
private func scoreGame(
game: Game,
homeTeam: Team,
awayTeam: Team,
detourDistance: Double,
isMustSee: Bool,
request: PlanningRequest
) -> Double {
var score: Double = 50.0 // Base score
// Must-see bonus
if isMustSee {
score += 100.0
}
// Playoff bonus
if game.isPlayoff {
score += 30.0
}
// Weekend bonus (more convenient)
let weekday = Calendar.current.component(.weekday, from: game.dateTime)
if weekday == 1 || weekday == 7 { // Sunday or Saturday
score += 10.0
}
// Evening game bonus (day games harder to schedule around)
let hour = Calendar.current.component(.hour, from: game.dateTime)
if hour >= 17 { // 5 PM or later
score += 5.0
}
// Detour penalty
let detourMiles = detourDistance * 0.000621371
score -= detourMiles * 0.1 // Lose 0.1 points per mile of detour
// Preferred city bonus
if request.preferences.preferredCities.contains(homeTeam.city) {
score += 15.0
}
// Must-stop location bonus
if request.preferences.mustStopLocations.contains(where: { $0.name.lowercased() == homeTeam.city.lowercased() }) {
score += 25.0
}
return max(0, score)
}
private func detourFactorFor(_ leisureLevel: LeisureLevel) -> Double {
switch leisureLevel {
case .packed: return 1.3 // 30% detour allowed
case .moderate: return 1.5 // 50% detour allowed
case .relaxed: return 2.0 // 100% detour allowed
}
}
// MARK: - Find Games at Location
func findGames(
at stadium: Stadium,
within dateRange: ClosedRange<Date>,
from games: [Game]
) -> [Game] {
games.filter { game in
game.stadiumId == stadium.id &&
dateRange.contains(game.dateTime)
}.sorted { $0.dateTime < $1.dateTime }
}
// MARK: - Find Other Sports
func findOtherSportsGames(
along route: [CLLocationCoordinate2D],
excludingSports: Set<Sport>,
within dateRange: ClosedRange<Date>,
games: [Game],
stadiums: [UUID: Stadium],
teams: [UUID: Team],
maxDetourMiles: Double = 50
) -> [GameCandidate] {
var candidates: [GameCandidate] = []
for game in games {
// Skip if sport is already selected
if excludingSports.contains(game.sport) { continue }
// Skip if outside date range
if !dateRange.contains(game.dateTime) { continue }
guard let stadium = stadiums[game.stadiumId],
let homeTeam = teams[game.homeTeamId],
let awayTeam = teams[game.awayTeamId] else {
continue
}
// Check if stadium is near the route
let minDistance = route.map { coord in
CLLocation(latitude: coord.latitude, longitude: coord.longitude)
.distance(from: stadium.location)
}.min() ?? .greatestFiniteMagnitude
let distanceMiles = minDistance * 0.000621371
if distanceMiles <= maxDetourMiles {
let candidate = GameCandidate(
id: game.id,
game: game,
stadium: stadium,
homeTeam: homeTeam,
awayTeam: awayTeam,
detourDistance: minDistance,
score: 50.0 - distanceMiles // Score inversely proportional to detour
)
candidates.append(candidate)
}
}
return candidates.sorted { $0.score > $1.score }
}
// MARK: - Validate Games for Scenarios
/// Validates that all must-see games are within the date range.
/// Used for Scenario B validation.
func validateMustSeeGamesInRange(
mustSeeGameIds: Set<UUID>,
allGames: [Game],
dateRange: ClosedRange<Date>
) -> (valid: Bool, outOfRange: [UUID]) {
var outOfRange: [UUID] = []
for gameId in mustSeeGameIds {
guard let game = allGames.first(where: { $0.id == gameId }) else {
continue
}
if !dateRange.contains(game.dateTime) {
outOfRange.append(gameId)
}
}
return (outOfRange.isEmpty, outOfRange)
}
}
// MARK: - Route Corridor Calculator
struct RouteCorridorCalculator {
let start: CLLocationCoordinate2D
let end: CLLocationCoordinate2D
let maxDetourFactor: Double
var directDistance: CLLocationDistance {
CLLocation(latitude: start.latitude, longitude: start.longitude)
.distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude))
}
var maxDetourDistance: CLLocationDistance {
directDistance * (maxDetourFactor - 1.0)
}
func detourDistance(to point: CLLocationCoordinate2D) -> CLLocationDistance {
let startToPoint = CLLocation(latitude: start.latitude, longitude: start.longitude)
.distance(from: CLLocation(latitude: point.latitude, longitude: point.longitude))
let pointToEnd = CLLocation(latitude: point.latitude, longitude: point.longitude)
.distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude))
let totalViaPoint = startToPoint + pointToEnd
return max(0, totalViaPoint - directDistance)
}
func isWithinCorridor(_ point: CLLocationCoordinate2D) -> Bool {
detourDistance(to: point) <= maxDetourDistance
}
/// Returns the distance from a point to the end location.
func distanceToEnd(from point: CLLocationCoordinate2D) -> CLLocationDistance {
CLLocation(latitude: point.latitude, longitude: point.longitude)
.distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude))
}
/// Calculates progress along the route (0 = at start, 1 = at end).
/// Can be negative (behind start) or > 1 (past end).
func progressAlongRoute(point: CLLocationCoordinate2D) -> Double {
guard directDistance > 0 else { return 0 }
let distFromStart = CLLocation(latitude: start.latitude, longitude: start.longitude)
.distance(from: CLLocation(latitude: point.latitude, longitude: point.longitude))
let distFromEnd = CLLocation(latitude: point.latitude, longitude: point.longitude)
.distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude))
// Use the law of cosines to project onto the line
// progress = (d_start² + d_total² - d_end²) / (2 * d_total²)
let dStart = distFromStart
let dEnd = distFromEnd
let dTotal = directDistance
let numerator = (dStart * dStart) + (dTotal * dTotal) - (dEnd * dEnd)
let denominator = 2 * dTotal * dTotal
return numerator / denominator
}
}

View File

@@ -0,0 +1,180 @@
//
// 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
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 stops.
/// Returns nil only if the segment exceeds maximum driving time.
static func estimate(
from: ItineraryStop,
to: ItineraryStop,
constraints: DrivingConstraints
) -> TravelSegment? {
let distanceMiles = calculateDistanceMiles(from: from, to: to)
let drivingHours = distanceMiles / averageSpeedMph
// Reject if segment requires more than 2 days of driving
let maxDailyHours = constraints.maxDailyDrivingHours
if drivingHours > maxDailyHours * 2 {
return nil
}
// Calculate times (assume 8 AM departure)
let departureTime = from.departureDate.addingTimeInterval(8 * 3600)
let arrivalTime = departureTime.addingTimeInterval(drivingHours * 3600)
return TravelSegment(
fromLocation: from.location,
toLocation: to.location,
travelMode: .drive,
distanceMeters: distanceMiles * 1609.34,
durationSeconds: drivingHours * 3600,
departureTime: departureTime,
arrivalTime: arrivalTime
)
}
/// Estimates a travel segment between two LocationInputs.
/// Returns nil if coordinates are missing or segment exceeds max driving time.
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
// Reject if > 2 days of driving
if drivingHours > constraints.maxDailyDrivingHours * 2 {
return nil
}
let departureTime = Date()
let arrivalTime = departureTime.addingTimeInterval(drivingHours * 3600)
return TravelSegment(
fromLocation: from,
toLocation: to,
travelMode: .drive,
distanceMeters: distanceMeters * roadRoutingFactor,
durationSeconds: drivingHours * 3600,
departureTime: departureTime,
arrivalTime: arrivalTime
)
}
// MARK: - Distance Calculations
/// Calculates distance in miles between two stops.
/// Uses Haversine formula if coordinates available, fallback otherwise.
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 distance in miles between two coordinates using Haversine.
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 distance in meters between two coordinates using Haversine.
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.
static func estimateFallbackDistance(
from: ItineraryStop,
to: ItineraryStop
) -> Double {
if from.city == to.city {
return 0
}
return fallbackDistanceMiles
}
// MARK: - Travel Days
/// Calculates which calendar days travel spans.
static func calculateTravelDays(
departure: Date,
drivingHours: Double
) -> [Date] {
var days: [Date] = []
let calendar = Calendar.current
let startDay = calendar.startOfDay(for: departure)
days.append(startDay)
// Add days if driving takes multiple days (8 hrs/day max)
let daysOfDriving = Int(ceil(drivingHours / 8.0))
for dayOffset in 1..<daysOfDriving {
if let nextDay = calendar.date(byAdding: .day, value: dayOffset, to: startDay) {
days.append(nextDay)
}
}
return days
}
}

View File

@@ -0,0 +1,41 @@
//
// TripPlanningEngine.swift
// SportsTime
//
// Thin orchestrator that delegates to scenario-specific planners.
//
import Foundation
/// Main entry point for trip planning.
/// Delegates to scenario-specific planners via the ScenarioPlanner protocol.
final class TripPlanningEngine {
/// Plans itineraries based on the request inputs.
/// Automatically detects which scenario applies and delegates to the appropriate planner.
///
/// - Parameter request: The planning request containing all inputs
/// - Returns: Ranked itineraries on success, or explicit failure with reason
func planItineraries(request: PlanningRequest) -> ItineraryResult {
// Detect scenario and get the appropriate planner
let scenario = ScenarioPlannerFactory.classify(request)
let planner = ScenarioPlannerFactory.planner(for: request)
print("[TripPlanningEngine] Detected scenario: \(scenario)")
print("[TripPlanningEngine] Using planner: \(type(of: planner))")
// Delegate to the scenario planner
let result = planner.plan(request: request)
// Log result
switch result {
case .success(let options):
print("[TripPlanningEngine] Success: \(options.count) itinerary options")
case .failure(let failure):
print("[TripPlanningEngine] Failure: \(failure.reason)")
}
return result
}
}