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