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