Refactor travel segments and simplify trip options
Travel segment architecture: - Remove departureTime/arrivalTime from TravelSegment (location-based, not date-based) - Fix travel sections appearing after destination instead of between cities - Fix missing travel segments when revisiting same city (consecutive grouping) - Remove unwanted rest day at end of trip Planning engine fixes: - All three planners now group only consecutive games at same stadium - Visiting A → B → A creates 3 stops with proper travel between UI simplification: - Remove redundant sort options (mostDriving/leastDriving, mostCities/leastCities) - Remove unused "Find Other Sports Along Route" toggle (was dead code) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -451,9 +451,7 @@ struct HorizontalTimelineItemView: View {
|
||||
toLocation: LocationInput(name: "San Francisco"),
|
||||
travelMode: .drive,
|
||||
distanceMeters: 600000,
|
||||
durationSeconds: 21600,
|
||||
departureTime: Date(),
|
||||
arrivalTime: Date().addingTimeInterval(21600)
|
||||
durationSeconds: 21600
|
||||
)
|
||||
|
||||
let option = ItineraryOption(
|
||||
|
||||
@@ -555,18 +555,6 @@ struct TripCreationView: View {
|
||||
.tint(Theme.warmOrange)
|
||||
}
|
||||
|
||||
// Other Sports
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||
ThemedToggle(
|
||||
label: "Find Other Sports Along Route",
|
||||
isOn: $viewModel.catchOtherSports,
|
||||
icon: "sportscourt"
|
||||
)
|
||||
|
||||
Text("When enabled, we'll look for games from other sports happening along your route that fit your schedule.")
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1037,6 +1025,28 @@ struct LocationSearchSheet: View {
|
||||
|
||||
// MARK: - Trip Options View
|
||||
|
||||
// MARK: - Sort Options
|
||||
|
||||
enum TripSortOption: String, CaseIterable, Identifiable {
|
||||
case recommended = "Recommended"
|
||||
case mostGames = "Most Games"
|
||||
case leastGames = "Least Games"
|
||||
case mostMiles = "Most Miles"
|
||||
case leastMiles = "Least Miles"
|
||||
case bestEfficiency = "Best Efficiency"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .recommended: return "star.fill"
|
||||
case .mostGames, .leastGames: return "sportscourt"
|
||||
case .mostMiles, .leastMiles: return "road.lanes"
|
||||
case .bestEfficiency: return "gauge.with.dots.needle.33percent"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TripOptionsView: View {
|
||||
let options: [ItineraryOption]
|
||||
let games: [UUID: RichGame]
|
||||
@@ -1045,8 +1055,31 @@ struct TripOptionsView: View {
|
||||
|
||||
@State private var selectedTrip: Trip?
|
||||
@State private var showTripDetail = false
|
||||
@State private var sortOption: TripSortOption = .recommended
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
private var sortedOptions: [ItineraryOption] {
|
||||
switch sortOption {
|
||||
case .recommended:
|
||||
return options
|
||||
case .mostGames:
|
||||
return options.sorted { $0.totalGames > $1.totalGames }
|
||||
case .leastGames:
|
||||
return options.sorted { $0.totalGames < $1.totalGames }
|
||||
case .mostMiles:
|
||||
return options.sorted { $0.totalDistanceMiles > $1.totalDistanceMiles }
|
||||
case .leastMiles:
|
||||
return options.sorted { $0.totalDistanceMiles < $1.totalDistanceMiles }
|
||||
case .bestEfficiency:
|
||||
// Games per driving hour (higher is better)
|
||||
return options.sorted {
|
||||
let effA = $0.totalDrivingHours > 0 ? Double($0.totalGames) / $0.totalDrivingHours : 0
|
||||
let effB = $1.totalDrivingHours > 0 ? Double($1.totalGames) / $1.totalDrivingHours : 0
|
||||
return effA > effB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 20) {
|
||||
@@ -1065,10 +1098,15 @@ struct TripOptionsView: View {
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
.padding(.top, Theme.Spacing.xl)
|
||||
.padding(.bottom, Theme.Spacing.md)
|
||||
.padding(.bottom, Theme.Spacing.sm)
|
||||
|
||||
// Options list with staggered animation
|
||||
ForEach(Array(options.enumerated()), id: \.offset) { index, option in
|
||||
// Sort picker
|
||||
sortPicker
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
.padding(.bottom, Theme.Spacing.sm)
|
||||
|
||||
// Options list
|
||||
ForEach(Array(sortedOptions.enumerated()), id: \.element.id) { index, option in
|
||||
TripOptionCard(
|
||||
option: option,
|
||||
rank: index + 1,
|
||||
@@ -1092,6 +1130,38 @@ struct TripOptionsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var sortPicker: some View {
|
||||
Menu {
|
||||
ForEach(TripSortOption.allCases) { option in
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
sortOption = option
|
||||
}
|
||||
} label: {
|
||||
Label(option.rawValue, systemImage: option.icon)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: sortOption.icon)
|
||||
.font(.system(size: 14))
|
||||
Text(sortOption.rawValue)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(Capsule())
|
||||
.overlay(
|
||||
Capsule()
|
||||
.strokeBorder(Theme.textMuted(colorScheme).opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trip Option Card
|
||||
@@ -1524,7 +1594,7 @@ struct DateRangePicker: View {
|
||||
|
||||
private var daysOfWeekHeader: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(daysOfWeek, id: \.self) { day in
|
||||
ForEach(Array(daysOfWeek.enumerated()), id: \.offset) { _, day in
|
||||
Text(day)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
|
||||
@@ -262,24 +262,47 @@ struct TripDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build itinerary sections: days and travel between days
|
||||
/// Build itinerary sections: days with travel between different cities
|
||||
private var itinerarySections: [ItinerarySection] {
|
||||
var sections: [ItinerarySection] = []
|
||||
let calendar = Calendar.current
|
||||
|
||||
// Build day sections for days with games
|
||||
var daySections: [(dayNumber: Int, date: Date, city: String, games: [RichGame])] = []
|
||||
let days = tripDays
|
||||
|
||||
for (index, dayDate) in days.enumerated() {
|
||||
let dayNum = index + 1
|
||||
let gamesOnDay = gamesOn(date: dayDate)
|
||||
|
||||
if !gamesOnDay.isEmpty || index == 0 || index == days.count - 1 {
|
||||
sections.append(.day(dayNumber: dayNum, date: dayDate, games: gamesOnDay))
|
||||
// Get city from games (preferred) or from stops as fallback
|
||||
let cityForDay = gamesOnDay.first?.stadium.city ?? cityOn(date: dayDate) ?? ""
|
||||
|
||||
// Include days with games
|
||||
// Skip empty days at the end (departure day after last game)
|
||||
if !gamesOnDay.isEmpty {
|
||||
daySections.append((dayNum, dayDate, cityForDay, gamesOnDay))
|
||||
}
|
||||
}
|
||||
|
||||
// Build sections: insert travel BEFORE each day when coming from different city
|
||||
for (index, daySection) in daySections.enumerated() {
|
||||
|
||||
// Check if we need travel BEFORE this day (coming from different city)
|
||||
if index > 0 {
|
||||
let prevSection = daySections[index - 1]
|
||||
let prevCity = prevSection.city
|
||||
let currentCity = daySection.city
|
||||
|
||||
// If cities differ, find travel segment from prev -> current
|
||||
if !prevCity.isEmpty && !currentCity.isEmpty && prevCity != currentCity {
|
||||
if let travelSegment = findTravelSegment(from: prevCity, to: currentCity) {
|
||||
sections.append(.travel(travelSegment))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let travelAfterDay = travelDepartingAfter(date: dayDate, beforeNextGameDay: days.indices.contains(index + 1) ? days[index + 1] : nil)
|
||||
for segment in travelAfterDay {
|
||||
sections.append(.travel(segment))
|
||||
}
|
||||
// Add the day section
|
||||
sections.append(.day(dayNumber: daySection.dayNumber, date: daySection.date, games: daySection.games))
|
||||
}
|
||||
|
||||
return sections
|
||||
@@ -311,13 +334,27 @@ struct TripDetailView: View {
|
||||
}.sorted { $0.game.dateTime < $1.game.dateTime }
|
||||
}
|
||||
|
||||
private func travelDepartingAfter(date: Date, beforeNextGameDay: Date?) -> [TravelSegment] {
|
||||
/// Get the city for a given date (from the stop that covers that date)
|
||||
private func cityOn(date: Date) -> String? {
|
||||
let calendar = Calendar.current
|
||||
let dayEnd = calendar.startOfDay(for: date)
|
||||
let dayStart = calendar.startOfDay(for: date)
|
||||
|
||||
return trip.travelSegments.filter { segment in
|
||||
let segmentDay = calendar.startOfDay(for: segment.departureTime)
|
||||
return segmentDay == dayEnd
|
||||
return trip.stops.first { stop in
|
||||
let arrivalDay = calendar.startOfDay(for: stop.arrivalDate)
|
||||
let departureDay = calendar.startOfDay(for: stop.departureDate)
|
||||
return dayStart >= arrivalDay && dayStart <= departureDay
|
||||
}?.city
|
||||
}
|
||||
|
||||
/// Find travel segment that goes from one city to another
|
||||
private func findTravelSegment(from fromCity: String, to toCity: String) -> TravelSegment? {
|
||||
let fromLower = fromCity.lowercased().trimmingCharacters(in: .whitespaces)
|
||||
let toLower = toCity.lowercased().trimmingCharacters(in: .whitespaces)
|
||||
|
||||
return trip.travelSegments.first { segment in
|
||||
let segmentFrom = segment.fromLocation.name.lowercased().trimmingCharacters(in: .whitespaces)
|
||||
let segmentTo = segment.toLocation.name.lowercased().trimmingCharacters(in: .whitespaces)
|
||||
return segmentFrom == fromLower && segmentTo == toLower
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user