// // 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 case repeatCityViolation(cities: [String]) 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 } case (.repeatCityViolation(let c1), .repeatCityViolation(let c2)): return c1 == c2 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" case .repeatCityViolation(let cities): let cityList = cities.prefix(3).joined(separator: ", ") let suffix = cities.count > 3 ? " and \(cities.count - 3) more" : "" return "Cannot visit cities on multiple days: \(cityList)\(suffix)" } } } // 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 } } /// Sorts and ranks itinerary options based on leisure level preference. /// /// - Parameters: /// - options: The itinerary options to sort /// - leisureLevel: The user's leisure preference /// - Returns: Sorted and ranked options (all options, no limit) /// /// Sorting behavior: /// - Packed: Most games first, then least driving /// - Moderate: Best efficiency (games per driving hour) /// - Relaxed: Least driving first, then fewer games static func sortByLeisure( _ options: [ItineraryOption], leisureLevel: LeisureLevel ) -> [ItineraryOption] { let sorted = options.sorted { a, b in let aGames = a.totalGames let bGames = b.totalGames switch leisureLevel { case .packed: // Most games first, then least driving if aGames != bGames { return aGames > bGames } return a.totalDrivingHours < b.totalDrivingHours case .moderate: // Best efficiency (games per driving hour) let effA = a.totalDrivingHours > 0 ? Double(aGames) / a.totalDrivingHours : Double(aGames) let effB = b.totalDrivingHours > 0 ? Double(bGames) / b.totalDrivingHours : Double(bGames) if effA != effB { return effA > effB } return aGames > bGames case .relaxed: // Least driving first, then fewer games is fine if a.totalDrivingHours != b.totalDrivingHours { return a.totalDrivingHours < b.totalDrivingHours } return aGames < bGames } } // Re-rank after sorting (no limit - return all options) return sorted.enumerated().map { index, option in ItineraryOption( rank: index + 1, stops: option.stops, travelSegments: option.travelSegments, totalDrivingHours: option.totalDrivingHours, totalDistanceMiles: option.totalDistanceMiles, geographicRationale: option.geographicRationale ) } } } // 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: return nil // Travel is location-based, not date-based 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] // Travel is location-based - just add the segment // Multi-day travel indicated by durationHours > 8 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 } /// Timeline organized by date for calendar-style display. /// Note: Travel segments are excluded as they are location-based, not date-based. func timelineByDate() -> [Date: [TimelineItem]] { let calendar = Calendar.current var byDate: [Date: [TimelineItem]] = [:] for item in generateTimeline() { // Skip travel items - they don't have dates guard let itemDate = item.date else { continue } let day = calendar.startOfDay(for: itemDate) 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] } }