diff --git a/SportsTime/Core/Models/Domain/TripPreferences.swift b/SportsTime/Core/Models/Domain/TripPreferences.swift index 6231314..4096a05 100644 --- a/SportsTime/Core/Models/Domain/TripPreferences.swift +++ b/SportsTime/Core/Models/Domain/TripPreferences.swift @@ -227,6 +227,7 @@ struct TripPreferences: Codable, Hashable { var lodgingType: LodgingType var numberOfDrivers: Int var maxDrivingHoursPerDriver: Double? + var maxTripOptions: Int init( planningMode: PlanningMode = .dateRange, @@ -246,7 +247,8 @@ struct TripPreferences: Codable, Hashable { needsEVCharging: Bool = false, lodgingType: LodgingType = .hotel, numberOfDrivers: Int = 1, - maxDrivingHoursPerDriver: Double? = nil + maxDrivingHoursPerDriver: Double? = nil, + maxTripOptions: Int = 10 ) { self.planningMode = planningMode self.startLocation = startLocation @@ -266,6 +268,7 @@ struct TripPreferences: Codable, Hashable { self.lodgingType = lodgingType self.numberOfDrivers = numberOfDrivers self.maxDrivingHoursPerDriver = maxDrivingHoursPerDriver + self.maxTripOptions = maxTripOptions } var totalDriverHoursPerDay: Double { diff --git a/SportsTime/Core/Theme/AnimatedComponents.swift b/SportsTime/Core/Theme/AnimatedComponents.swift index 11a2e44..c03d8ec 100644 --- a/SportsTime/Core/Theme/AnimatedComponents.swift +++ b/SportsTime/Core/Theme/AnimatedComponents.swift @@ -147,12 +147,12 @@ struct RoutePreviewStrip: View { var body: some View { HStack(spacing: 4) { - ForEach(Array(cities.prefix(5).enumerated()), id: \.offset) { index, city in + ForEach(Array(cities.enumerated()), id: \.offset) { index, city in if index > 0 { // Connector line Rectangle() .fill(Theme.routeGold.opacity(0.5)) - .frame(width: 16, height: 2) + .frame(width: 12, height: 2) } // City dot with label @@ -167,12 +167,6 @@ struct RoutePreviewStrip: View { .lineLimit(1) } } - - if cities.count > 5 { - Text("+\(cities.count - 5)") - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(Theme.textMuted(colorScheme)) - } } } diff --git a/SportsTime/Features/Settings/ViewModels/SettingsViewModel.swift b/SportsTime/Features/Settings/ViewModels/SettingsViewModel.swift index 04d320b..1ebab8d 100644 --- a/SportsTime/Features/Settings/ViewModels/SettingsViewModel.swift +++ b/SportsTime/Features/Settings/ViewModels/SettingsViewModel.swift @@ -20,15 +20,7 @@ final class SettingsViewModel { didSet { savePreferences() } } - var preferredGameTime: PreferredGameTime { - didSet { savePreferences() } - } - - var includePlayoffGames: Bool { - didSet { savePreferences() } - } - - var notificationsEnabled: Bool { + var maxTripOptions: Int { didSet { savePreferences() } } @@ -56,19 +48,12 @@ final class SettingsViewModel { self.selectedSports = Set(Sport.supported) } - // Travel preferences - use local variable to avoid self access before init complete + // Travel preferences let savedDrivingHours = defaults.integer(forKey: "maxDrivingHoursPerDay") self.maxDrivingHoursPerDay = savedDrivingHours == 0 ? 8 : savedDrivingHours - if let timeRaw = defaults.string(forKey: "preferredGameTime"), - let time = PreferredGameTime(rawValue: timeRaw) { - self.preferredGameTime = time - } else { - self.preferredGameTime = .evening - } - - self.includePlayoffGames = defaults.object(forKey: "includePlayoffGames") as? Bool ?? true - self.notificationsEnabled = defaults.object(forKey: "notificationsEnabled") as? Bool ?? true + let savedMaxTripOptions = defaults.integer(forKey: "maxTripOptions") + self.maxTripOptions = savedMaxTripOptions == 0 ? 10 : savedMaxTripOptions // Last sync self.lastSyncDate = defaults.object(forKey: "lastSyncDate") as? Date @@ -110,9 +95,7 @@ final class SettingsViewModel { func resetToDefaults() { selectedSports = Set(Sport.supported) maxDrivingHoursPerDay = 8 - preferredGameTime = .evening - includePlayoffGames = true - notificationsEnabled = true + maxTripOptions = 10 } // MARK: - Persistence @@ -121,34 +104,6 @@ final class SettingsViewModel { let defaults = UserDefaults.standard defaults.set(selectedSports.map(\.rawValue), forKey: "selectedSports") defaults.set(maxDrivingHoursPerDay, forKey: "maxDrivingHoursPerDay") - defaults.set(preferredGameTime.rawValue, forKey: "preferredGameTime") - defaults.set(includePlayoffGames, forKey: "includePlayoffGames") - defaults.set(notificationsEnabled, forKey: "notificationsEnabled") - } -} - -// MARK: - Supporting Types - -enum PreferredGameTime: String, CaseIterable, Identifiable { - case any = "any" - case afternoon = "afternoon" - case evening = "evening" - - var id: String { rawValue } - - var displayName: String { - switch self { - case .any: return "Any Time" - case .afternoon: return "Afternoon" - case .evening: return "Evening" - } - } - - var description: String { - switch self { - case .any: return "No preference" - case .afternoon: return "1 PM - 5 PM" - case .evening: return "6 PM - 10 PM" - } + defaults.set(maxTripOptions, forKey: "maxTripOptions") } } diff --git a/SportsTime/Features/Settings/Views/SettingsView.swift b/SportsTime/Features/Settings/Views/SettingsView.swift index ab73229..4528c6a 100644 --- a/SportsTime/Features/Settings/Views/SettingsView.swift +++ b/SportsTime/Features/Settings/Views/SettingsView.swift @@ -17,12 +17,6 @@ struct SettingsView: View { // Travel Preferences travelSection - // Game Preferences - gamePreferencesSection - - // Notifications - notificationsSection - // Data Sync dataSection @@ -71,13 +65,38 @@ struct SettingsView: View { private var travelSection: some View { Section { - Stepper(value: $viewModel.maxDrivingHoursPerDay, in: 2...12) { + VStack(alignment: .leading, spacing: 8) { HStack { Text("Max Driving Per Day") Spacer() Text("\(viewModel.maxDrivingHoursPerDay) hours") .foregroundStyle(.secondary) } + Slider( + value: Binding( + get: { Double(viewModel.maxDrivingHoursPerDay) }, + set: { viewModel.maxDrivingHoursPerDay = Int($0) } + ), + in: 2...12, + step: 1 + ) + } + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Trip Options to Show") + Spacer() + Text("\(viewModel.maxTripOptions)") + .foregroundStyle(.secondary) + } + Slider( + value: Binding( + get: { Double(viewModel.maxTripOptions) }, + set: { viewModel.maxTripOptions = Int($0) } + ), + in: 1...20, + step: 1 + ) } } header: { Text("Travel Preferences") @@ -86,39 +105,6 @@ struct SettingsView: View { } } - // MARK: - Game Preferences Section - - private var gamePreferencesSection: some View { - Section { - Picker("Preferred Game Time", selection: $viewModel.preferredGameTime) { - ForEach(PreferredGameTime.allCases) { time in - VStack(alignment: .leading) { - Text(time.displayName) - } - .tag(time) - } - } - - Toggle("Include Playoff Games", isOn: $viewModel.includePlayoffGames) - } header: { - Text("Game Preferences") - } footer: { - Text("These preferences affect trip optimization.") - } - } - - // MARK: - Notifications Section - - private var notificationsSection: some View { - Section { - Toggle("Schedule Updates", isOn: $viewModel.notificationsEnabled) - } header: { - Text("Notifications") - } footer: { - Text("Get notified when games in your trips are rescheduled.") - } - } - // MARK: - Data Section private var dataSection: some View { diff --git a/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift b/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift index 3e3027d..262e1d1 100644 --- a/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift +++ b/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift @@ -47,7 +47,15 @@ final class TripCreationViewModel { var endLocation: LocationInput? // Sports - var selectedSports: Set = [.mlb] + var selectedSports: Set = [.mlb] { + didSet { + // Clear cached games when sports selection changes + if selectedSports != oldValue { + availableGames = [] + games = [] + } + } + } // Dates var startDate: Date = Date() @@ -266,6 +274,10 @@ final class TripCreationViewModel { await loadScheduleData() } + // Read max trip options from settings (default 10) + let savedMaxOptions = UserDefaults.standard.integer(forKey: "maxTripOptions") + let maxTripOptions = savedMaxOptions > 0 ? min(20, savedMaxOptions) : 10 + // Build preferences let preferences = TripPreferences( planningMode: planningMode, @@ -285,7 +297,8 @@ final class TripCreationViewModel { needsEVCharging: needsEVCharging, lodgingType: lodgingType, numberOfDrivers: numberOfDrivers, - maxDrivingHoursPerDriver: maxDrivingHoursPerDriver + maxDrivingHoursPerDriver: maxDrivingHoursPerDriver, + maxTripOptions: maxTripOptions ) // Build planning request @@ -447,6 +460,9 @@ final class TripCreationViewModel { /// Convert an itinerary option to a Trip (public for use by TripOptionsView) func convertOptionToTrip(_ option: ItineraryOption) -> Trip { + let savedMaxOptions = UserDefaults.standard.integer(forKey: "maxTripOptions") + let maxOptions = savedMaxOptions > 0 ? min(20, savedMaxOptions) : 10 + let preferences = currentPreferences ?? TripPreferences( planningMode: planningMode, startLocation: nil, @@ -465,7 +481,8 @@ final class TripCreationViewModel { needsEVCharging: needsEVCharging, lodgingType: lodgingType, numberOfDrivers: numberOfDrivers, - maxDrivingHoursPerDriver: maxDrivingHoursPerDriver + maxDrivingHoursPerDriver: maxDrivingHoursPerDriver, + maxTripOptions: maxOptions ) return convertToTrip(option: option, preferences: preferences) } diff --git a/SportsTime/Features/Trip/Views/TripCreationView.swift b/SportsTime/Features/Trip/Views/TripCreationView.swift index c9dc0a6..2b9c0bd 100644 --- a/SportsTime/Features/Trip/Views/TripCreationView.swift +++ b/SportsTime/Features/Trip/Views/TripCreationView.swift @@ -1106,10 +1106,9 @@ struct TripOptionsView: View { .padding(.bottom, Theme.Spacing.sm) // Options list - ForEach(Array(sortedOptions.enumerated()), id: \.element.id) { index, option in + ForEach(sortedOptions) { option in TripOptionCard( option: option, - rank: index + 1, games: games, onSelect: { selectedTrip = convertToTrip(option) @@ -1129,6 +1128,11 @@ struct TripOptionsView: View { TripDetailView(trip: trip, games: games) } } + .onChange(of: showTripDetail) { _, isShowing in + if !isShowing { + selectedTrip = nil + } + } } private var sortPicker: some View { @@ -1168,7 +1172,6 @@ struct TripOptionsView: View { struct TripOptionCard: View { let option: ItineraryOption - let rank: Int let games: [UUID: RichGame] let onSelect: () -> Void @Environment(\.colorScheme) private var colorScheme @@ -1184,34 +1187,50 @@ struct TripOptionCard: View { option.stops.flatMap { $0.games }.count } - private var routeDescription: String { - if uniqueCities.count <= 2 { - return uniqueCities.joined(separator: " → ") + private var uniqueSports: [Sport] { + let gameIds = option.stops.flatMap { $0.games } + let sports = gameIds.compactMap { games[$0]?.game.sport } + return Array(Set(sports)).sorted { $0.rawValue < $1.rawValue } + } + + private var gamesPerSport: [(sport: Sport, count: Int)] { + let gameIds = option.stops.flatMap { $0.games } + var countsBySport: [Sport: Int] = [:] + for gameId in gameIds { + if let sport = games[gameId]?.game.sport { + countsBySport[sport, default: 0] += 1 + } } - return "\(uniqueCities.first ?? "") → \(uniqueCities.count - 2) stops → \(uniqueCities.last ?? "")" + return countsBySport.sorted { $0.key.rawValue < $1.key.rawValue } + .map { (sport: $0.key, count: $0.value) } } var body: some View { Button(action: onSelect) { HStack(spacing: Theme.Spacing.md) { - // Left: Rank badge - Text("\(rank)") - .font(.system(size: 18, weight: .bold)) - .foregroundStyle(.white) - .frame(width: 36, height: 36) - .background(rank == 1 ? Theme.warmOrange : Theme.textMuted(colorScheme)) - .clipShape(Circle()) - - // Middle: Route info + // Route info VStack(alignment: .leading, spacing: 6) { - Text(routeDescription) - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(Theme.textPrimary(colorScheme)) - .lineLimit(1) + // Vertical route display + VStack(alignment: .leading, spacing: 0) { + Text(uniqueCities.first ?? "") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) - // Stats row + VStack(spacing: 0) { + Text("|") + .font(.system(size: 10)) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .foregroundStyle(Theme.warmOrange) + + Text(uniqueCities.last ?? "") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + } + + // Top stats row: cities and miles HStack(spacing: 12) { - Label("\(totalGames) games", systemImage: "sportscourt") Label("\(uniqueCities.count) cities", systemImage: "mappin") if option.totalDistanceMiles > 0 { Label("\(Int(option.totalDistanceMiles)) mi", systemImage: "car") @@ -1220,6 +1239,23 @@ struct TripOptionCard: View { .font(.system(size: 12)) .foregroundStyle(Theme.textSecondary(colorScheme)) + // Bottom row: sports with game counts + HStack(spacing: 6) { + ForEach(gamesPerSport, id: \.sport) { item in + HStack(spacing: 3) { + Image(systemName: item.sport.iconName) + .font(.system(size: 9)) + Text("\(item.sport.rawValue.uppercased()) \(item.count)") + .font(.system(size: 9, weight: .semibold)) + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(item.sport.themeColor.opacity(0.15)) + .foregroundStyle(item.sport.themeColor) + .clipShape(Capsule()) + } + } + // AI-generated description (after stats) if let description = aiDescription { Text(description) @@ -1250,7 +1286,7 @@ struct TripOptionCard: View { .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) .overlay { RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) - .stroke(rank == 1 ? Theme.warmOrange : Theme.surfaceGlow(colorScheme), lineWidth: rank == 1 ? 2 : 1) + .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) } } .buttonStyle(.plain) diff --git a/SportsTime/Planning/Engine/GameDAGRouter.swift b/SportsTime/Planning/Engine/GameDAGRouter.swift index 093c319..1bc9536 100644 --- a/SportsTime/Planning/Engine/GameDAGRouter.swift +++ b/SportsTime/Planning/Engine/GameDAGRouter.swift @@ -220,7 +220,7 @@ enum GameDAGRouter { /// /// Requirements: /// 1. B starts after A (time moves forward) - /// 2. Driving time is within daily limit + /// 2. We have enough days between games to complete the drive /// 3. We can arrive at B before B starts /// private static func canTransition( @@ -238,9 +238,8 @@ enum GameDAGRouter { // Get stadiums guard let fromStadium = stadiums[from.stadiumId], let toStadium = stadiums[to.stadiumId] else { - // Missing stadium info - use generous fallback - // Assume 300 miles at 60 mph = 5 hours, which is usually feasible - return true + // Missing stadium info - can't calculate distance, reject to be safe + return false } let fromCoord = fromStadium.coordinate @@ -254,16 +253,36 @@ enum GameDAGRouter { let drivingHours = distanceMiles / 60.0 // Average 60 mph - // Must be within daily limit - guard drivingHours <= constraints.maxDailyDrivingHours else { return false } - - // Calculate if we can arrive in time + // Calculate available driving time between games + // After game A ends (+ buffer), how much time until game B starts (- buffer)? let departureTime = from.startTime.addingTimeInterval(gameEndBufferHours * 3600) - let arrivalTime = departureTime.addingTimeInterval(drivingHours * 3600) + let deadline = to.startTime.addingTimeInterval(-3600) // 1 hour buffer before game + let availableSeconds = deadline.timeIntervalSince(departureTime) + let availableHours = availableSeconds / 3600.0 - // Must arrive before game starts (with 1 hour buffer) - let deadline = to.startTime.addingTimeInterval(-3600) - guard arrivalTime <= deadline else { return false } + // Calculate how many driving days we have + // Each day can have maxDailyDrivingHours of driving + let calendar = Calendar.current + let fromDay = calendar.startOfDay(for: from.startTime) + let toDay = calendar.startOfDay(for: to.startTime) + let daysBetween = calendar.dateComponents([.day], from: fromDay, to: toDay).day ?? 0 + + // Available driving hours = days between * max per day + // (If games are same day, daysBetween = 0, but we might still have hours available) + let maxDrivingHoursAvailable: Double + if daysBetween == 0 { + // Same day - only have hours between games + maxDrivingHoursAvailable = max(0, availableHours) + } else { + // Multi-day - can drive each day + maxDrivingHoursAvailable = Double(daysBetween) * constraints.maxDailyDrivingHours + } + + // Check if we have enough driving time + guard drivingHours <= maxDrivingHoursAvailable else { return false } + + // Also verify we can arrive before game starts (sanity check) + guard availableHours >= drivingHours else { return false } return true } diff --git a/SportsTime/Planning/Engine/ScenarioAPlanner.swift b/SportsTime/Planning/Engine/ScenarioAPlanner.swift index 3db550e..915a65c 100644 --- a/SportsTime/Planning/Engine/ScenarioAPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioAPlanner.swift @@ -193,19 +193,15 @@ final class ScenarioAPlanner: ScenarioPlanner { ) } - // Re-rank by number of games (already sorted, but update rank numbers) - let rankedOptions = itineraryOptions.enumerated().map { index, option in - ItineraryOption( - rank: index + 1, - stops: option.stops, - travelSegments: option.travelSegments, - totalDrivingHours: option.totalDrivingHours, - totalDistanceMiles: option.totalDistanceMiles, - geographicRationale: option.geographicRationale - ) - } + // Sort and rank based on leisure level + let leisureLevel = request.preferences.leisureLevel + let rankedOptions = ItineraryOption.sortByLeisure( + itineraryOptions, + leisureLevel: leisureLevel, + limit: request.preferences.maxTripOptions + ) - print("[ScenarioA] Returning \(rankedOptions.count) itinerary options") + print("[ScenarioA] Returning \(rankedOptions.count) itinerary options (leisure: \(leisureLevel.rawValue))") return .success(rankedOptions) } diff --git a/SportsTime/Planning/Engine/ScenarioBPlanner.swift b/SportsTime/Planning/Engine/ScenarioBPlanner.swift index 25dce15..511d4bf 100644 --- a/SportsTime/Planning/Engine/ScenarioBPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioBPlanner.swift @@ -160,27 +160,15 @@ final class ScenarioBPlanner: ScenarioPlanner { ) } - // Sort by total games (most first), then by driving hours (less first) - let sorted = allItineraryOptions.sorted { a, b in - if a.stops.flatMap({ $0.games }).count != b.stops.flatMap({ $0.games }).count { - return a.stops.flatMap({ $0.games }).count > b.stops.flatMap({ $0.games }).count - } - return a.totalDrivingHours < b.totalDrivingHours - } + // Sort and rank based on leisure level + let leisureLevel = request.preferences.leisureLevel + let rankedOptions = ItineraryOption.sortByLeisure( + allItineraryOptions, + leisureLevel: leisureLevel, + limit: request.preferences.maxTripOptions + ) - // Re-rank and limit - let rankedOptions = sorted.prefix(10).enumerated().map { index, option in - ItineraryOption( - rank: index + 1, - stops: option.stops, - travelSegments: option.travelSegments, - totalDrivingHours: option.totalDrivingHours, - totalDistanceMiles: option.totalDistanceMiles, - geographicRationale: option.geographicRationale - ) - } - - print("[ScenarioB] Returning \(rankedOptions.count) itinerary options") + print("[ScenarioB] Returning \(rankedOptions.count) itinerary options (leisure: \(leisureLevel.rawValue))") return .success(Array(rankedOptions)) } diff --git a/SportsTime/Planning/Engine/ScenarioCPlanner.swift b/SportsTime/Planning/Engine/ScenarioCPlanner.swift index 5e2856e..2c70d3a 100644 --- a/SportsTime/Planning/Engine/ScenarioCPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioCPlanner.swift @@ -42,9 +42,6 @@ final class ScenarioCPlanner: ScenarioPlanner { // MARK: - Configuration - /// Maximum number of itinerary options to return - private let maxOptions = 5 - /// Tolerance for "forward progress" - allow small increases in distance to end /// A stadium is "directional" if it doesn't increase distance to end by more than this ratio private let forwardProgressTolerance = 0.15 // 15% tolerance @@ -262,29 +259,15 @@ final class ScenarioCPlanner: ScenarioPlanner { ) } - // Sort by game count (most first), then by driving hours (less first) - let sorted = allItineraryOptions.sorted { a, b in - let aGames = a.stops.flatMap { $0.games }.count - let bGames = b.stops.flatMap { $0.games }.count - if aGames != bGames { - return aGames > bGames - } - return a.totalDrivingHours < b.totalDrivingHours - } + // Sort and rank based on leisure level + let leisureLevel = request.preferences.leisureLevel + let rankedOptions = ItineraryOption.sortByLeisure( + allItineraryOptions, + leisureLevel: leisureLevel, + limit: request.preferences.maxTripOptions + ) - // Take top N and re-rank - let rankedOptions = sorted.prefix(maxOptions).enumerated().map { index, option in - ItineraryOption( - rank: index + 1, - stops: option.stops, - travelSegments: option.travelSegments, - totalDrivingHours: option.totalDrivingHours, - totalDistanceMiles: option.totalDistanceMiles, - geographicRationale: option.geographicRationale - ) - } - - print("[ScenarioC] Returning \(rankedOptions.count) itinerary options") + print("[ScenarioC] Returning \(rankedOptions.count) itinerary options (leisure: \(leisureLevel.rawValue))") return .success(Array(rankedOptions)) } diff --git a/SportsTime/Planning/Engine/TravelEstimator.swift b/SportsTime/Planning/Engine/TravelEstimator.swift index 554a16a..60211fe 100644 --- a/SportsTime/Planning/Engine/TravelEstimator.swift +++ b/SportsTime/Planning/Engine/TravelEstimator.swift @@ -20,7 +20,7 @@ enum TravelEstimator { // MARK: - Travel Estimation /// Estimates a travel segment between two stops. - /// Returns nil only if the segment exceeds maximum driving time. + /// Always creates a segment - feasibility is checked by GameDAGRouter. static func estimate( from: ItineraryStop, to: ItineraryStop, @@ -30,12 +30,6 @@ enum TravelEstimator { let distanceMiles = calculateDistanceMiles(from: from, to: to) let drivingHours = distanceMiles / averageSpeedMph - // Reject if segment requires more than 2 days of driving - let maxDailyHours = constraints.maxDailyDrivingHours - if drivingHours > maxDailyHours * 2 { - return nil - } - return TravelSegment( fromLocation: from.location, toLocation: to.location, @@ -46,7 +40,7 @@ enum TravelEstimator { } /// Estimates a travel segment between two LocationInputs. - /// Returns nil if coordinates are missing or segment exceeds max driving time. + /// Returns nil only if coordinates are missing. static func estimate( from: LocationInput, to: LocationInput, @@ -62,11 +56,6 @@ enum TravelEstimator { let distanceMiles = distanceMeters * 0.000621371 * roadRoutingFactor let drivingHours = distanceMiles / averageSpeedMph - // Reject if > 2 days of driving - if drivingHours > constraints.maxDailyDrivingHours * 2 { - return nil - } - return TravelSegment( fromLocation: from, toLocation: to, diff --git a/SportsTime/Planning/Models/PlanningModels.swift b/SportsTime/Planning/Models/PlanningModels.swift index 6f06baa..8b6459a 100644 --- a/SportsTime/Planning/Models/PlanningModels.swift +++ b/SportsTime/Planning/Models/PlanningModels.swift @@ -176,6 +176,62 @@ struct ItineraryOption: Identifiable { 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 + /// - limit: Maximum number of options to return (default 10) + /// - Returns: Sorted and ranked options + /// + /// 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, + limit: Int = 10 + ) -> [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 + return Array(sorted.prefix(limit)).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