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:
Trey t
2026-01-07 19:39:53 -06:00
parent 40a6f879e3
commit 4184af60b5
29 changed files with 140675 additions and 144310 deletions

View File

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

View File

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

View File

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