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:
582
SportsTime/Planning/Engine/ScenarioCPlanner.swift
Normal file
582
SportsTime/Planning/Engine/ScenarioCPlanner.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user