- 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>
282 lines
7.8 KiB
Swift
282 lines
7.8 KiB
Swift
//
|
|
// PlanningModels.swift
|
|
// SportsTime
|
|
//
|
|
// Clean model types for trip planning.
|
|
//
|
|
|
|
import Foundation
|
|
import CoreLocation
|
|
|
|
// MARK: - Planning Scenario
|
|
|
|
/// Exactly one scenario per request. No blending.
|
|
enum PlanningScenario: Equatable {
|
|
case scenarioA // Date range only
|
|
case scenarioB // Selected games + date range
|
|
case scenarioC // Start + end locations
|
|
}
|
|
|
|
// MARK: - Planning Failure
|
|
|
|
/// Explicit failure with reason. No silent failures.
|
|
struct PlanningFailure: Error {
|
|
let reason: FailureReason
|
|
let violations: [ConstraintViolation]
|
|
|
|
enum FailureReason: Equatable {
|
|
case noGamesInRange
|
|
case noValidRoutes
|
|
case missingDateRange
|
|
case missingLocations
|
|
case dateRangeViolation(games: [Game])
|
|
case drivingExceedsLimit
|
|
case cannotArriveInTime
|
|
case travelSegmentMissing
|
|
case constraintsUnsatisfiable
|
|
case geographicBacktracking
|
|
|
|
static func == (lhs: FailureReason, rhs: FailureReason) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case (.noGamesInRange, .noGamesInRange),
|
|
(.noValidRoutes, .noValidRoutes),
|
|
(.missingDateRange, .missingDateRange),
|
|
(.missingLocations, .missingLocations),
|
|
(.drivingExceedsLimit, .drivingExceedsLimit),
|
|
(.cannotArriveInTime, .cannotArriveInTime),
|
|
(.travelSegmentMissing, .travelSegmentMissing),
|
|
(.constraintsUnsatisfiable, .constraintsUnsatisfiable),
|
|
(.geographicBacktracking, .geographicBacktracking):
|
|
return true
|
|
case (.dateRangeViolation(let g1), .dateRangeViolation(let g2)):
|
|
return g1.map { $0.id } == g2.map { $0.id }
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
init(reason: FailureReason, violations: [ConstraintViolation] = []) {
|
|
self.reason = reason
|
|
self.violations = violations
|
|
}
|
|
|
|
var message: String {
|
|
switch reason {
|
|
case .noGamesInRange: return "No games found within the date range"
|
|
case .noValidRoutes: return "No valid routes could be constructed"
|
|
case .missingDateRange: return "Date range is required"
|
|
case .missingLocations: return "Start and end locations are required"
|
|
case .dateRangeViolation(let games):
|
|
return "\(games.count) selected game(s) fall outside the date range"
|
|
case .drivingExceedsLimit: return "Driving time exceeds daily limit"
|
|
case .cannotArriveInTime: return "Cannot arrive before game starts"
|
|
case .travelSegmentMissing: return "Travel segment could not be created"
|
|
case .constraintsUnsatisfiable: return "Cannot satisfy all trip constraints"
|
|
case .geographicBacktracking: return "Route requires excessive backtracking"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Constraint Violation
|
|
|
|
struct ConstraintViolation: Equatable {
|
|
let type: ConstraintType
|
|
let description: String
|
|
let severity: ViolationSeverity
|
|
|
|
init(constraint: String, detail: String) {
|
|
self.type = .general
|
|
self.description = "\(constraint): \(detail)"
|
|
self.severity = .error
|
|
}
|
|
|
|
init(type: ConstraintType, description: String, severity: ViolationSeverity) {
|
|
self.type = type
|
|
self.description = description
|
|
self.severity = severity
|
|
}
|
|
}
|
|
|
|
enum ConstraintType: String, Equatable {
|
|
case dateRange
|
|
case drivingTime
|
|
case geographicSanity
|
|
case mustStop
|
|
case selectedGames
|
|
case gameReachability
|
|
case general
|
|
}
|
|
|
|
enum ViolationSeverity: Equatable {
|
|
case warning
|
|
case error
|
|
}
|
|
|
|
// MARK: - Must Stop Config
|
|
|
|
struct MustStopConfig {
|
|
static let defaultProximityMiles: Double = 25
|
|
let proximityMiles: Double
|
|
|
|
init(proximityMiles: Double = MustStopConfig.defaultProximityMiles) {
|
|
self.proximityMiles = proximityMiles
|
|
}
|
|
}
|
|
|
|
// MARK: - Itinerary Result
|
|
|
|
/// Either success with ranked options, or explicit failure.
|
|
enum ItineraryResult {
|
|
case success([ItineraryOption])
|
|
case failure(PlanningFailure)
|
|
|
|
var isSuccess: Bool {
|
|
if case .success = self { return true }
|
|
return false
|
|
}
|
|
|
|
var options: [ItineraryOption] {
|
|
if case .success(let opts) = self { return opts }
|
|
return []
|
|
}
|
|
|
|
var failure: PlanningFailure? {
|
|
if case .failure(let f) = self { return f }
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Route Candidate
|
|
|
|
/// Intermediate structure during planning.
|
|
struct RouteCandidate {
|
|
let stops: [ItineraryStop]
|
|
let rationale: String
|
|
}
|
|
|
|
// MARK: - Itinerary Option
|
|
|
|
/// A valid, ranked itinerary option.
|
|
struct ItineraryOption: Identifiable {
|
|
let id = UUID()
|
|
let rank: Int
|
|
let stops: [ItineraryStop]
|
|
let travelSegments: [TravelSegment]
|
|
let totalDrivingHours: Double
|
|
let totalDistanceMiles: Double
|
|
let geographicRationale: String
|
|
|
|
/// INVARIANT: travelSegments.count == stops.count - 1 (or 0 if single stop)
|
|
var isValid: Bool {
|
|
if stops.count <= 1 { return travelSegments.isEmpty }
|
|
return travelSegments.count == stops.count - 1
|
|
}
|
|
|
|
var totalGames: Int {
|
|
stops.reduce(0) { $0 + $1.games.count }
|
|
}
|
|
}
|
|
|
|
// MARK: - Itinerary Stop
|
|
|
|
/// A stop in the itinerary.
|
|
struct ItineraryStop: Identifiable, Hashable {
|
|
let id = UUID()
|
|
let city: String
|
|
let state: String
|
|
let coordinate: CLLocationCoordinate2D?
|
|
let games: [UUID]
|
|
let arrivalDate: Date
|
|
let departureDate: Date
|
|
let location: LocationInput
|
|
let firstGameStart: Date?
|
|
|
|
var hasGames: Bool { !games.isEmpty }
|
|
|
|
func hash(into hasher: inout Hasher) {
|
|
hasher.combine(id)
|
|
}
|
|
|
|
static func == (lhs: ItineraryStop, rhs: ItineraryStop) -> Bool {
|
|
lhs.id == rhs.id
|
|
}
|
|
}
|
|
|
|
// MARK: - Driving Constraints
|
|
|
|
/// Driving feasibility constraints.
|
|
struct DrivingConstraints {
|
|
let numberOfDrivers: Int
|
|
let maxHoursPerDriverPerDay: Double
|
|
|
|
static let `default` = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 8.0)
|
|
|
|
var maxDailyDrivingHours: Double {
|
|
Double(numberOfDrivers) * maxHoursPerDriverPerDay
|
|
}
|
|
|
|
init(numberOfDrivers: Int = 1, maxHoursPerDriverPerDay: Double = 8.0) {
|
|
self.numberOfDrivers = max(1, numberOfDrivers)
|
|
self.maxHoursPerDriverPerDay = max(1.0, maxHoursPerDriverPerDay)
|
|
}
|
|
|
|
init(from preferences: TripPreferences) {
|
|
self.numberOfDrivers = max(1, preferences.numberOfDrivers)
|
|
self.maxHoursPerDriverPerDay = preferences.maxDrivingHoursPerDriver ?? 8.0
|
|
}
|
|
}
|
|
|
|
// MARK: - Planning Request
|
|
|
|
/// Input to the planning engine.
|
|
struct PlanningRequest {
|
|
let preferences: TripPreferences
|
|
let availableGames: [Game]
|
|
let teams: [UUID: Team]
|
|
let stadiums: [UUID: Stadium]
|
|
|
|
// MARK: - Computed Properties for Engine
|
|
|
|
/// Games the user explicitly selected (anchors for Scenario B)
|
|
var selectedGames: [Game] {
|
|
availableGames.filter { preferences.mustSeeGameIds.contains($0.id) }
|
|
}
|
|
|
|
/// All available games
|
|
var allGames: [Game] {
|
|
availableGames
|
|
}
|
|
|
|
/// Start location (for Scenario C)
|
|
var startLocation: LocationInput? {
|
|
preferences.startLocation
|
|
}
|
|
|
|
/// End location (for Scenario C)
|
|
var endLocation: LocationInput? {
|
|
preferences.endLocation
|
|
}
|
|
|
|
/// Date range as DateInterval
|
|
var dateRange: DateInterval? {
|
|
guard preferences.endDate > preferences.startDate else { return nil }
|
|
return DateInterval(start: preferences.startDate, end: preferences.endDate)
|
|
}
|
|
|
|
/// First must-stop location (if any)
|
|
var mustStopLocation: LocationInput? {
|
|
preferences.mustStopLocations.first
|
|
}
|
|
|
|
/// Driving constraints
|
|
var drivingConstraints: DrivingConstraints {
|
|
DrivingConstraints(from: preferences)
|
|
}
|
|
|
|
/// Get stadium for a game
|
|
func stadium(for game: Game) -> Stadium? {
|
|
stadiums[game.stadiumId]
|
|
}
|
|
}
|