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