- Add RegionMapSelector UI for geographic trip filtering (East/Central/West) - Add RouteFilters module for allowRepeatCities preference - Improve GameDAGRouter to preserve route length diversity - Routes now grouped by city count before scoring - Ensures 2-city trips appear alongside longer trips - Increased beam width and max options for better coverage - Add TripOptionsView filters (max cities slider, pace filter) - Remove TravelStyle section from trip creation (replaced by region selector) - Clean up debug logging from DataProvider and ScenarioAPlanner 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
535 lines
16 KiB
Swift
535 lines
16 KiB
Swift
//
|
|
// 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]
|
|
}
|
|
}
|