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

@@ -12,7 +12,9 @@ struct TripCreationView: View {
@State private var cityInputType: CityInputType = .mustStop
@State private var showLocationBanner = true
@State private var showTripDetail = false
@State private var showTripOptions = false
@State private var completedTrip: Trip?
@State private var tripOptions: [ItineraryOption] = []
enum CityInputType {
case mustStop
@@ -112,22 +114,45 @@ struct TripCreationView: View {
Text(message)
}
}
.navigationDestination(isPresented: $showTripOptions) {
TripOptionsView(
options: tripOptions,
games: buildGamesDictionary(),
preferences: viewModel.currentPreferences,
convertToTrip: { option in
viewModel.convertOptionToTrip(option)
}
)
}
.navigationDestination(isPresented: $showTripDetail) {
if let trip = completedTrip {
TripDetailView(trip: trip, games: buildGamesDictionary())
}
}
.onChange(of: viewModel.viewState) { _, newState in
if case .completed(let trip) = newState {
switch newState {
case .selectingOption(let options):
tripOptions = options
showTripOptions = true
case .completed(let trip):
completedTrip = trip
showTripDetail = true
default:
break
}
}
.onChange(of: showTripOptions) { _, isShowing in
if !isShowing {
// User navigated back from options to editing
viewModel.viewState = .editing
tripOptions = []
}
}
.onChange(of: showTripDetail) { _, isShowing in
if !isShowing {
// User navigated back, reset to editing state
viewModel.viewState = .editing
// User navigated back from single-option detail to editing
completedTrip = nil
viewModel.viewState = .editing
}
}
.task {
@@ -826,6 +851,229 @@ struct LocationSearchSheet: View {
}
}
// MARK: - Trip Options View
struct TripOptionsView: View {
let options: [ItineraryOption]
let games: [UUID: RichGame]
let preferences: TripPreferences?
let convertToTrip: (ItineraryOption) -> Trip
@State private var selectedTrip: Trip?
@State private var showTripDetail = false
var body: some View {
ScrollView {
LazyVStack(spacing: 16) {
// Header
VStack(alignment: .leading, spacing: 8) {
Text("\(options.count) Trip Options Found")
.font(.title2)
.fontWeight(.bold)
Text("Select a trip to view details")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.padding(.top)
// Options list
ForEach(Array(options.enumerated()), id: \.offset) { index, option in
TripOptionCard(
option: option,
rank: index + 1,
games: games,
onSelect: {
selectedTrip = convertToTrip(option)
showTripDetail = true
}
)
.padding(.horizontal)
}
}
.padding(.bottom)
}
.navigationTitle("Choose Your Trip")
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(isPresented: $showTripDetail) {
if let trip = selectedTrip {
TripDetailView(trip: trip, games: games)
}
}
}
}
// MARK: - Trip Option Card
struct TripOptionCard: View {
let option: ItineraryOption
let rank: Int
let games: [UUID: RichGame]
let onSelect: () -> Void
private var cities: [String] {
option.stops.map { $0.city }
}
private var uniqueCities: Int {
Set(cities).count
}
private var totalGames: Int {
option.stops.flatMap { $0.games }.count
}
private var primaryCity: String {
// Find the city with most games
var cityCounts: [String: Int] = [:]
for stop in option.stops {
cityCounts[stop.city, default: 0] += stop.games.count
}
return cityCounts.max(by: { $0.value < $1.value })?.key ?? cities.first ?? "Unknown"
}
private var routeSummary: String {
let uniqueCityList = cities.removingDuplicates()
if uniqueCityList.count <= 3 {
return uniqueCityList.joined(separator: "")
}
return "\(uniqueCityList[0]) → ... → \(uniqueCityList.last ?? "")"
}
var body: some View {
Button(action: onSelect) {
VStack(alignment: .leading, spacing: 12) {
// Header with rank and primary city
HStack(alignment: .center) {
// Rank badge
Text("Option \(rank)")
.font(.caption)
.fontWeight(.bold)
.foregroundStyle(.white)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(rank == 1 ? Color.blue : Color.gray)
.clipShape(Capsule())
Spacer()
// Primary city label
Text(primaryCity)
.font(.headline)
.foregroundStyle(.primary)
}
// Route summary
Text(routeSummary)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
// Stats row
HStack(spacing: 20) {
StatPill(icon: "sportscourt.fill", value: "\(totalGames)", label: "games")
StatPill(icon: "mappin.circle.fill", value: "\(uniqueCities)", label: "cities")
StatPill(icon: "car.fill", value: formatDriving(option.totalDrivingHours), label: "driving")
}
// Games preview
if !option.stops.isEmpty {
VStack(alignment: .leading, spacing: 4) {
ForEach(option.stops.prefix(3), id: \.city) { stop in
HStack(spacing: 8) {
Circle()
.fill(Color.blue.opacity(0.3))
.frame(width: 8, height: 8)
Text(stop.city)
.font(.caption)
.fontWeight(.medium)
Text("\(stop.games.count) game\(stop.games.count == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
}
}
if option.stops.count > 3 {
Text("+ \(option.stops.count - 3) more stops")
.font(.caption)
.foregroundStyle(.tertiary)
.padding(.leading, 16)
}
}
}
// Tap to view hint
HStack {
Spacer()
Text("Tap to view details")
.font(.caption2)
.foregroundStyle(.tertiary)
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(rank == 1 ? Color.blue.opacity(0.3) : Color.clear, lineWidth: 2)
)
}
.buttonStyle(.plain)
}
private func formatDriving(_ hours: Double) -> String {
if hours < 1 {
return "\(Int(hours * 60))m"
}
let h = Int(hours)
let m = Int((hours - Double(h)) * 60)
if m == 0 {
return "\(h)h"
}
return "\(h)h \(m)m"
}
}
// MARK: - Stat Pill
struct StatPill: View {
let icon: String
let value: String
let label: String
var body: some View {
HStack(spacing: 4) {
Image(systemName: icon)
.font(.caption2)
.foregroundStyle(.blue)
Text(value)
.font(.caption)
.fontWeight(.semibold)
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Array Extension for Removing Duplicates
extension Array where Element: Hashable {
func removingDuplicates() -> [Element] {
var seen = Set<Element>()
return filter { seen.insert($0).inserted }
}
}
#Preview {
TripCreationView()
}