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

@@ -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
}

View File

@@ -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)
}

View File

@@ -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))
}

View File

@@ -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))
}

View File

@@ -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,