// // 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: - Timeline Item /// Unified timeline item for displaying trip itinerary. /// Enables: Stop → Travel → Stop → Rest → Travel → Stop pattern enum TimelineItem: Identifiable { case stop(ItineraryStop) case travel(TravelSegment) case rest(RestDay) var id: UUID { switch self { case .stop(let stop): return stop.id case .travel(let segment): return segment.id case .rest(let rest): return rest.id } } var date: Date { switch self { case .stop(let stop): return stop.arrivalDate case .travel(let segment): return segment.departureTime case .rest(let rest): return rest.date } } var isStop: Bool { if case .stop = self { return true } return false } var isTravel: Bool { if case .travel = self { return true } return false } var isRest: Bool { if case .rest = self { return true } return false } /// City/location name for display var locationName: String { switch self { case .stop(let stop): return stop.city case .travel(let segment): return "\(segment.fromLocation.name) → \(segment.toLocation.name)" case .rest(let rest): return rest.location.name } } } // MARK: - Rest Day /// A rest day - staying in one place with no travel or games. struct RestDay: Identifiable, Hashable { let id: UUID let date: Date let location: LocationInput let notes: String? init( id: UUID = UUID(), date: Date, location: LocationInput, notes: String? = nil ) { self.id = id self.date = date self.location = location self.notes = notes } func hash(into hasher: inout Hasher) { hasher.combine(id) } static func == (lhs: RestDay, rhs: RestDay) -> Bool { lhs.id == rhs.id } } // MARK: - Timeline Generation extension ItineraryOption { /// Generates a unified timeline from stops and travel segments. /// /// The timeline interleaves stops and travel in chronological order: /// Stop A → Travel A→B → Stop B → Rest Day → Travel B→C → Stop C /// /// Rest days are inserted when: /// - There's a gap between arrival at a stop and departure for next travel /// - Multi-day stays at a location without games /// func generateTimeline() -> [TimelineItem] { var timeline: [TimelineItem] = [] let calendar = Calendar.current for (index, stop) in stops.enumerated() { // Add the stop timeline.append(.stop(stop)) // Check for rest days at this stop (days between arrival and departure with no games) let restDays = calculateRestDays(at: stop, calendar: calendar) for restDay in restDays { timeline.append(.rest(restDay)) } // Add travel segment to next stop (if not last stop) if index < travelSegments.count { let segment = travelSegments[index] // Check if travel spans multiple days let travelDays = calculateTravelDays(for: segment, calendar: calendar) if travelDays.count > 1 { // Multi-day travel: could split into daily segments or keep as one // For now, keep as single segment with multi-day indicator timeline.append(.travel(segment)) } else { timeline.append(.travel(segment)) } } } return timeline } /// Calculates rest days at a stop (days with no games). private func calculateRestDays( at stop: ItineraryStop, calendar: Calendar ) -> [RestDay] { // If stop has no games, the entire stay could be considered rest // But typically we only insert rest days for multi-day stays guard stop.hasGames else { // Start/end locations without games - not rest days, just waypoints return [] } var restDays: [RestDay] = [] let arrivalDay = calendar.startOfDay(for: stop.arrivalDate) let departureDay = calendar.startOfDay(for: stop.departureDate) // If multi-day stay, check each day for games var currentDay = arrivalDay while currentDay <= departureDay { // Skip arrival and departure days (those have the stop itself) if currentDay != arrivalDay && currentDay != departureDay { // This is a day in between - could be rest or another game day // For simplicity, mark in-between days as rest let restDay = RestDay( date: currentDay, location: stop.location, notes: "Rest day in \(stop.city)" ) restDays.append(restDay) } currentDay = calendar.date(byAdding: .day, value: 1, to: currentDay) ?? currentDay } return restDays } /// Calculates which calendar days a travel segment spans. private func calculateTravelDays( for segment: TravelSegment, calendar: Calendar ) -> [Date] { var days: [Date] = [] let startDay = calendar.startOfDay(for: segment.departureTime) let endDay = calendar.startOfDay(for: segment.arrivalTime) var currentDay = startDay while currentDay <= endDay { days.append(currentDay) currentDay = calendar.date(byAdding: .day, value: 1, to: currentDay) ?? currentDay } return days } /// Timeline organized by date for calendar-style display. func timelineByDate() -> [Date: [TimelineItem]] { let calendar = Calendar.current var byDate: [Date: [TimelineItem]] = [:] for item in generateTimeline() { let day = calendar.startOfDay(for: item.date) byDate[day, default: []].append(item) } return byDate } /// All dates covered by the itinerary. func allDates() -> [Date] { let calendar = Calendar.current guard let firstStop = stops.first, let lastStop = stops.last else { return [] } var dates: [Date] = [] var currentDate = calendar.startOfDay(for: firstStop.arrivalDate) let endDate = calendar.startOfDay(for: lastStop.departureDate) while currentDate <= endDate { dates.append(currentDate) currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate) ?? currentDate } return dates } } // 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] } }