Redesign trip option cards and fix various UI/planning issues

TripOptionCard improvements:
- Replace horizontal route with vertical layout (start → end with arrow)
- Remove rank badges (1, 2, 3, etc.)
- Split stats into two rows: cities/miles and sports with game counts
- Clear selection when navigating back from detail view

Settings cleanup:
- Remove unused settings (preferred game time, playoff games, notifications)
- Convert remaining settings to sliders

Planning fixes:
- Fix multi-day driving calculation in canTransition
- Remove over-restrictive trip rejection in TravelEstimator
- Clear games cache when sport selection changes

UI polish:
- RoutePreviewStrip shows all cities (abbreviated)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-07 21:05:25 -06:00
parent 4184af60b5
commit 5bbfd30a70
12 changed files with 230 additions and 208 deletions

View File

@@ -227,6 +227,7 @@ struct TripPreferences: Codable, Hashable {
var lodgingType: LodgingType var lodgingType: LodgingType
var numberOfDrivers: Int var numberOfDrivers: Int
var maxDrivingHoursPerDriver: Double? var maxDrivingHoursPerDriver: Double?
var maxTripOptions: Int
init( init(
planningMode: PlanningMode = .dateRange, planningMode: PlanningMode = .dateRange,
@@ -246,7 +247,8 @@ struct TripPreferences: Codable, Hashable {
needsEVCharging: Bool = false, needsEVCharging: Bool = false,
lodgingType: LodgingType = .hotel, lodgingType: LodgingType = .hotel,
numberOfDrivers: Int = 1, numberOfDrivers: Int = 1,
maxDrivingHoursPerDriver: Double? = nil maxDrivingHoursPerDriver: Double? = nil,
maxTripOptions: Int = 10
) { ) {
self.planningMode = planningMode self.planningMode = planningMode
self.startLocation = startLocation self.startLocation = startLocation
@@ -266,6 +268,7 @@ struct TripPreferences: Codable, Hashable {
self.lodgingType = lodgingType self.lodgingType = lodgingType
self.numberOfDrivers = numberOfDrivers self.numberOfDrivers = numberOfDrivers
self.maxDrivingHoursPerDriver = maxDrivingHoursPerDriver self.maxDrivingHoursPerDriver = maxDrivingHoursPerDriver
self.maxTripOptions = maxTripOptions
} }
var totalDriverHoursPerDay: Double { var totalDriverHoursPerDay: Double {

View File

@@ -147,12 +147,12 @@ struct RoutePreviewStrip: View {
var body: some View { var body: some View {
HStack(spacing: 4) { 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 { if index > 0 {
// Connector line // Connector line
Rectangle() Rectangle()
.fill(Theme.routeGold.opacity(0.5)) .fill(Theme.routeGold.opacity(0.5))
.frame(width: 16, height: 2) .frame(width: 12, height: 2)
} }
// City dot with label // City dot with label
@@ -167,12 +167,6 @@ struct RoutePreviewStrip: View {
.lineLimit(1) .lineLimit(1)
} }
} }
if cities.count > 5 {
Text("+\(cities.count - 5)")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(Theme.textMuted(colorScheme))
}
} }
} }

View File

@@ -20,15 +20,7 @@ final class SettingsViewModel {
didSet { savePreferences() } didSet { savePreferences() }
} }
var preferredGameTime: PreferredGameTime { var maxTripOptions: Int {
didSet { savePreferences() }
}
var includePlayoffGames: Bool {
didSet { savePreferences() }
}
var notificationsEnabled: Bool {
didSet { savePreferences() } didSet { savePreferences() }
} }
@@ -56,19 +48,12 @@ final class SettingsViewModel {
self.selectedSports = Set(Sport.supported) 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") let savedDrivingHours = defaults.integer(forKey: "maxDrivingHoursPerDay")
self.maxDrivingHoursPerDay = savedDrivingHours == 0 ? 8 : savedDrivingHours self.maxDrivingHoursPerDay = savedDrivingHours == 0 ? 8 : savedDrivingHours
if let timeRaw = defaults.string(forKey: "preferredGameTime"), let savedMaxTripOptions = defaults.integer(forKey: "maxTripOptions")
let time = PreferredGameTime(rawValue: timeRaw) { self.maxTripOptions = savedMaxTripOptions == 0 ? 10 : savedMaxTripOptions
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
// Last sync // Last sync
self.lastSyncDate = defaults.object(forKey: "lastSyncDate") as? Date self.lastSyncDate = defaults.object(forKey: "lastSyncDate") as? Date
@@ -110,9 +95,7 @@ final class SettingsViewModel {
func resetToDefaults() { func resetToDefaults() {
selectedSports = Set(Sport.supported) selectedSports = Set(Sport.supported)
maxDrivingHoursPerDay = 8 maxDrivingHoursPerDay = 8
preferredGameTime = .evening maxTripOptions = 10
includePlayoffGames = true
notificationsEnabled = true
} }
// MARK: - Persistence // MARK: - Persistence
@@ -121,34 +104,6 @@ final class SettingsViewModel {
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
defaults.set(selectedSports.map(\.rawValue), forKey: "selectedSports") defaults.set(selectedSports.map(\.rawValue), forKey: "selectedSports")
defaults.set(maxDrivingHoursPerDay, forKey: "maxDrivingHoursPerDay") defaults.set(maxDrivingHoursPerDay, forKey: "maxDrivingHoursPerDay")
defaults.set(preferredGameTime.rawValue, forKey: "preferredGameTime") defaults.set(maxTripOptions, forKey: "maxTripOptions")
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"
}
} }
} }

View File

@@ -17,12 +17,6 @@ struct SettingsView: View {
// Travel Preferences // Travel Preferences
travelSection travelSection
// Game Preferences
gamePreferencesSection
// Notifications
notificationsSection
// Data Sync // Data Sync
dataSection dataSection
@@ -71,13 +65,38 @@ struct SettingsView: View {
private var travelSection: some View { private var travelSection: some View {
Section { Section {
Stepper(value: $viewModel.maxDrivingHoursPerDay, in: 2...12) { VStack(alignment: .leading, spacing: 8) {
HStack { HStack {
Text("Max Driving Per Day") Text("Max Driving Per Day")
Spacer() Spacer()
Text("\(viewModel.maxDrivingHoursPerDay) hours") Text("\(viewModel.maxDrivingHoursPerDay) hours")
.foregroundStyle(.secondary) .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: { } header: {
Text("Travel Preferences") 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 // MARK: - Data Section
private var dataSection: some View { private var dataSection: some View {

View File

@@ -47,7 +47,15 @@ final class TripCreationViewModel {
var endLocation: LocationInput? var endLocation: LocationInput?
// Sports // Sports
var selectedSports: Set<Sport> = [.mlb] var selectedSports: Set<Sport> = [.mlb] {
didSet {
// Clear cached games when sports selection changes
if selectedSports != oldValue {
availableGames = []
games = []
}
}
}
// Dates // Dates
var startDate: Date = Date() var startDate: Date = Date()
@@ -266,6 +274,10 @@ final class TripCreationViewModel {
await loadScheduleData() 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 // Build preferences
let preferences = TripPreferences( let preferences = TripPreferences(
planningMode: planningMode, planningMode: planningMode,
@@ -285,7 +297,8 @@ final class TripCreationViewModel {
needsEVCharging: needsEVCharging, needsEVCharging: needsEVCharging,
lodgingType: lodgingType, lodgingType: lodgingType,
numberOfDrivers: numberOfDrivers, numberOfDrivers: numberOfDrivers,
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
maxTripOptions: maxTripOptions
) )
// Build planning request // Build planning request
@@ -447,6 +460,9 @@ final class TripCreationViewModel {
/// Convert an itinerary option to a Trip (public for use by TripOptionsView) /// Convert an itinerary option to a Trip (public for use by TripOptionsView)
func convertOptionToTrip(_ option: ItineraryOption) -> Trip { func convertOptionToTrip(_ option: ItineraryOption) -> Trip {
let savedMaxOptions = UserDefaults.standard.integer(forKey: "maxTripOptions")
let maxOptions = savedMaxOptions > 0 ? min(20, savedMaxOptions) : 10
let preferences = currentPreferences ?? TripPreferences( let preferences = currentPreferences ?? TripPreferences(
planningMode: planningMode, planningMode: planningMode,
startLocation: nil, startLocation: nil,
@@ -465,7 +481,8 @@ final class TripCreationViewModel {
needsEVCharging: needsEVCharging, needsEVCharging: needsEVCharging,
lodgingType: lodgingType, lodgingType: lodgingType,
numberOfDrivers: numberOfDrivers, numberOfDrivers: numberOfDrivers,
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
maxTripOptions: maxOptions
) )
return convertToTrip(option: option, preferences: preferences) return convertToTrip(option: option, preferences: preferences)
} }

View File

@@ -1106,10 +1106,9 @@ struct TripOptionsView: View {
.padding(.bottom, Theme.Spacing.sm) .padding(.bottom, Theme.Spacing.sm)
// Options list // Options list
ForEach(Array(sortedOptions.enumerated()), id: \.element.id) { index, option in ForEach(sortedOptions) { option in
TripOptionCard( TripOptionCard(
option: option, option: option,
rank: index + 1,
games: games, games: games,
onSelect: { onSelect: {
selectedTrip = convertToTrip(option) selectedTrip = convertToTrip(option)
@@ -1129,6 +1128,11 @@ struct TripOptionsView: View {
TripDetailView(trip: trip, games: games) TripDetailView(trip: trip, games: games)
} }
} }
.onChange(of: showTripDetail) { _, isShowing in
if !isShowing {
selectedTrip = nil
}
}
} }
private var sortPicker: some View { private var sortPicker: some View {
@@ -1168,7 +1172,6 @@ struct TripOptionsView: View {
struct TripOptionCard: View { struct TripOptionCard: View {
let option: ItineraryOption let option: ItineraryOption
let rank: Int
let games: [UUID: RichGame] let games: [UUID: RichGame]
let onSelect: () -> Void let onSelect: () -> Void
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@@ -1184,34 +1187,50 @@ struct TripOptionCard: View {
option.stops.flatMap { $0.games }.count option.stops.flatMap { $0.games }.count
} }
private var routeDescription: String { private var uniqueSports: [Sport] {
if uniqueCities.count <= 2 { let gameIds = option.stops.flatMap { $0.games }
return uniqueCities.joined(separator: "") 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 { var body: some View {
Button(action: onSelect) { Button(action: onSelect) {
HStack(spacing: Theme.Spacing.md) { HStack(spacing: Theme.Spacing.md) {
// Left: Rank badge // Route info
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
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text(routeDescription) // Vertical route display
.font(.system(size: 16, weight: .semibold)) VStack(alignment: .leading, spacing: 0) {
.foregroundStyle(Theme.textPrimary(colorScheme)) Text(uniqueCities.first ?? "")
.lineLimit(1) .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) { HStack(spacing: 12) {
Label("\(totalGames) games", systemImage: "sportscourt")
Label("\(uniqueCities.count) cities", systemImage: "mappin") Label("\(uniqueCities.count) cities", systemImage: "mappin")
if option.totalDistanceMiles > 0 { if option.totalDistanceMiles > 0 {
Label("\(Int(option.totalDistanceMiles)) mi", systemImage: "car") Label("\(Int(option.totalDistanceMiles)) mi", systemImage: "car")
@@ -1220,6 +1239,23 @@ struct TripOptionCard: View {
.font(.system(size: 12)) .font(.system(size: 12))
.foregroundStyle(Theme.textSecondary(colorScheme)) .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) // AI-generated description (after stats)
if let description = aiDescription { if let description = aiDescription {
Text(description) Text(description)
@@ -1250,7 +1286,7 @@ struct TripOptionCard: View {
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay { .overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) 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) .buttonStyle(.plain)

View File

@@ -220,7 +220,7 @@ enum GameDAGRouter {
/// ///
/// Requirements: /// Requirements:
/// 1. B starts after A (time moves forward) /// 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 /// 3. We can arrive at B before B starts
/// ///
private static func canTransition( private static func canTransition(
@@ -238,9 +238,8 @@ enum GameDAGRouter {
// Get stadiums // Get stadiums
guard let fromStadium = stadiums[from.stadiumId], guard let fromStadium = stadiums[from.stadiumId],
let toStadium = stadiums[to.stadiumId] else { let toStadium = stadiums[to.stadiumId] else {
// Missing stadium info - use generous fallback // Missing stadium info - can't calculate distance, reject to be safe
// Assume 300 miles at 60 mph = 5 hours, which is usually feasible return false
return true
} }
let fromCoord = fromStadium.coordinate let fromCoord = fromStadium.coordinate
@@ -254,16 +253,36 @@ enum GameDAGRouter {
let drivingHours = distanceMiles / 60.0 // Average 60 mph let drivingHours = distanceMiles / 60.0 // Average 60 mph
// Must be within daily limit // Calculate available driving time between games
guard drivingHours <= constraints.maxDailyDrivingHours else { return false } // After game A ends (+ buffer), how much time until game B starts (- buffer)?
// Calculate if we can arrive in time
let departureTime = from.startTime.addingTimeInterval(gameEndBufferHours * 3600) 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) // Calculate how many driving days we have
let deadline = to.startTime.addingTimeInterval(-3600) // Each day can have maxDailyDrivingHours of driving
guard arrivalTime <= deadline else { return false } 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 return true
} }

View File

@@ -193,19 +193,15 @@ final class ScenarioAPlanner: ScenarioPlanner {
) )
} }
// Re-rank by number of games (already sorted, but update rank numbers) // Sort and rank based on leisure level
let rankedOptions = itineraryOptions.enumerated().map { index, option in let leisureLevel = request.preferences.leisureLevel
ItineraryOption( let rankedOptions = ItineraryOption.sortByLeisure(
rank: index + 1, itineraryOptions,
stops: option.stops, leisureLevel: leisureLevel,
travelSegments: option.travelSegments, limit: request.preferences.maxTripOptions
totalDrivingHours: option.totalDrivingHours, )
totalDistanceMiles: option.totalDistanceMiles,
geographicRationale: option.geographicRationale
)
}
print("[ScenarioA] Returning \(rankedOptions.count) itinerary options") print("[ScenarioA] Returning \(rankedOptions.count) itinerary options (leisure: \(leisureLevel.rawValue))")
return .success(rankedOptions) return .success(rankedOptions)
} }

View File

@@ -160,27 +160,15 @@ final class ScenarioBPlanner: ScenarioPlanner {
) )
} }
// Sort by total games (most first), then by driving hours (less first) // Sort and rank based on leisure level
let sorted = allItineraryOptions.sorted { a, b in let leisureLevel = request.preferences.leisureLevel
if a.stops.flatMap({ $0.games }).count != b.stops.flatMap({ $0.games }).count { let rankedOptions = ItineraryOption.sortByLeisure(
return a.stops.flatMap({ $0.games }).count > b.stops.flatMap({ $0.games }).count allItineraryOptions,
} leisureLevel: leisureLevel,
return a.totalDrivingHours < b.totalDrivingHours limit: request.preferences.maxTripOptions
} )
// Re-rank and limit print("[ScenarioB] Returning \(rankedOptions.count) itinerary options (leisure: \(leisureLevel.rawValue))")
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")
return .success(Array(rankedOptions)) return .success(Array(rankedOptions))
} }

View File

@@ -42,9 +42,6 @@ final class ScenarioCPlanner: ScenarioPlanner {
// MARK: - Configuration // MARK: - Configuration
/// Maximum number of itinerary options to return
private let maxOptions = 5
/// Tolerance for "forward progress" - allow small increases in distance to end /// 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 /// A stadium is "directional" if it doesn't increase distance to end by more than this ratio
private let forwardProgressTolerance = 0.15 // 15% tolerance 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) // Sort and rank based on leisure level
let sorted = allItineraryOptions.sorted { a, b in let leisureLevel = request.preferences.leisureLevel
let aGames = a.stops.flatMap { $0.games }.count let rankedOptions = ItineraryOption.sortByLeisure(
let bGames = b.stops.flatMap { $0.games }.count allItineraryOptions,
if aGames != bGames { leisureLevel: leisureLevel,
return aGames > bGames limit: request.preferences.maxTripOptions
} )
return a.totalDrivingHours < b.totalDrivingHours
}
// Take top N and re-rank print("[ScenarioC] Returning \(rankedOptions.count) itinerary options (leisure: \(leisureLevel.rawValue))")
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")
return .success(Array(rankedOptions)) return .success(Array(rankedOptions))
} }

View File

@@ -20,7 +20,7 @@ enum TravelEstimator {
// MARK: - Travel Estimation // MARK: - Travel Estimation
/// Estimates a travel segment between two stops. /// 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( static func estimate(
from: ItineraryStop, from: ItineraryStop,
to: ItineraryStop, to: ItineraryStop,
@@ -30,12 +30,6 @@ enum TravelEstimator {
let distanceMiles = calculateDistanceMiles(from: from, to: to) let distanceMiles = calculateDistanceMiles(from: from, to: to)
let drivingHours = distanceMiles / averageSpeedMph 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( return TravelSegment(
fromLocation: from.location, fromLocation: from.location,
toLocation: to.location, toLocation: to.location,
@@ -46,7 +40,7 @@ enum TravelEstimator {
} }
/// Estimates a travel segment between two LocationInputs. /// 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( static func estimate(
from: LocationInput, from: LocationInput,
to: LocationInput, to: LocationInput,
@@ -62,11 +56,6 @@ enum TravelEstimator {
let distanceMiles = distanceMeters * 0.000621371 * roadRoutingFactor let distanceMiles = distanceMeters * 0.000621371 * roadRoutingFactor
let drivingHours = distanceMiles / averageSpeedMph let drivingHours = distanceMiles / averageSpeedMph
// Reject if > 2 days of driving
if drivingHours > constraints.maxDailyDrivingHours * 2 {
return nil
}
return TravelSegment( return TravelSegment(
fromLocation: from, fromLocation: from,
toLocation: to, toLocation: to,

View File

@@ -176,6 +176,62 @@ struct ItineraryOption: Identifiable {
var totalGames: Int { var totalGames: Int {
stops.reduce(0) { $0 + $1.games.count } 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 // MARK: - Itinerary Stop