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,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]
}
}