Refactor trip planning: DAG router + trip options UI + simplified itinerary

- Replace O(2^n) GeographicRouteExplorer with O(n) GameDAGRouter using DAG + beam search
- Add geographic diversity to route selection (returns routes from distinct regions)
- Add trip options selector UI (TripOptionsView, TripOptionCard) to choose between routes
- Simplify itinerary display: separate games and travel segments by date
- Remove complex ItineraryDay bundling, query games/travel directly per day
- Update ScenarioA/B/C planners to use GameDAGRouter
- Add new test suites for planners and travel estimator

🤖 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 12:26:17 -06:00
parent 405ebe68eb
commit ab89c25f2f
20 changed files with 6372 additions and 1960 deletions

View File

@@ -16,6 +16,7 @@ final class TripCreationViewModel {
enum ViewState: Equatable {
case editing
case planning
case selectingOption([ItineraryOption]) // Multiple options to choose from
case completed(Trip)
case error(String)
@@ -23,6 +24,7 @@ final class TripCreationViewModel {
switch (lhs, rhs) {
case (.editing, .editing): return true
case (.planning, .planning): return true
case (.selectingOption(let o1), .selectingOption(let o2)): return o1.count == o2.count
case (.completed(let t1), .completed(let t2)): return t1.id == t2.id
case (.error(let e1), .error(let e2)): return e1 == e2
default: return false
@@ -88,6 +90,7 @@ final class TripCreationViewModel {
private var teams: [UUID: Team] = [:]
private var stadiums: [UUID: Stadium] = [:]
private var games: [Game] = []
private(set) var currentPreferences: TripPreferences?
// MARK: - Computed Properties
@@ -300,13 +303,21 @@ final class TripCreationViewModel {
switch result {
case .success(let options):
guard let bestOption = options.first else {
guard !options.isEmpty else {
viewState = .error("No valid itinerary found")
return
}
// Convert ItineraryOption to Trip
let trip = convertToTrip(option: bestOption, preferences: preferences)
viewState = .completed(trip)
// Store preferences for later conversion
currentPreferences = preferences
if options.count == 1 {
// Only one option - go directly to detail
let trip = convertToTrip(option: options[0], preferences: preferences)
viewState = .completed(trip)
} else {
// Multiple options - show selection view
viewState = .selectingOption(options)
}
case .failure(let failure):
viewState = .error(failureMessage(for: failure))
@@ -423,6 +434,51 @@ final class TripCreationViewModel {
preferredCities = []
availableGames = []
isLoadingGames = false
currentPreferences = nil
}
/// Select a specific itinerary option and navigate to its detail
func selectOption(_ option: ItineraryOption) {
guard let preferences = currentPreferences else {
viewState = .error("Unable to load trip preferences")
return
}
let trip = convertToTrip(option: option, preferences: preferences)
viewState = .completed(trip)
}
/// Convert an itinerary option to a Trip (public for use by TripOptionsView)
func convertOptionToTrip(_ option: ItineraryOption) -> Trip {
let preferences = currentPreferences ?? TripPreferences(
planningMode: planningMode,
startLocation: nil,
endLocation: nil,
sports: selectedSports,
mustSeeGameIds: mustSeeGameIds,
travelMode: travelMode,
startDate: startDate,
endDate: endDate,
numberOfStops: useStopCount ? numberOfStops : nil,
tripDuration: useStopCount ? nil : tripDurationDays,
leisureLevel: leisureLevel,
mustStopLocations: mustStopLocations,
preferredCities: preferredCities,
routePreference: routePreference,
needsEVCharging: needsEVCharging,
lodgingType: lodgingType,
numberOfDrivers: numberOfDrivers,
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
catchOtherSports: catchOtherSports
)
return convertToTrip(option: option, preferences: preferences)
}
/// Go back to option selection from trip detail
func backToOptions() {
if case .completed = viewState {
// We'd need to store options to go back - for now, restart planning
viewState = .editing
}
}
// MARK: - Conversion Helpers