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:
281
SportsTime/Planning/Models/PlanningModels.swift
Normal file
281
SportsTime/Planning/Models/PlanningModels.swift
Normal file
@@ -0,0 +1,281 @@
|
||||
//
|
||||
// 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]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user