- 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>
233 lines
7.2 KiB
Swift
233 lines
7.2 KiB
Swift
//
|
|
// 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)"
|
|
)]
|
|
}
|
|
}
|