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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user