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

@@ -323,117 +323,66 @@ struct TripDetailView: View {
private var itinerarySection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Route Options")
Text("Itinerary")
.font(.headline)
let combinations = computeRouteCombinations()
if combinations.count == 1 {
// Single route - show fully expanded
SingleRouteView(
route: combinations[0],
days: trip.itineraryDays(),
games: games
ForEach(tripDays, id: \.self) { dayDate in
SimpleDayCard(
dayNumber: dayNumber(for: dayDate),
date: dayDate,
gamesOnDay: gamesOn(date: dayDate),
travelOnDay: travelOn(date: dayDate)
)
} else {
// Multiple combinations - show each as expandable row
ForEach(Array(combinations.enumerated()), id: \.offset) { index, route in
RouteCombinationRow(
routeNumber: index + 1,
route: route,
days: trip.itineraryDays(),
games: games,
totalRoutes: combinations.count
)
}
}
}
}
/// Computes all possible route combinations across days
private func computeRouteCombinations() -> [[DayChoice]] {
let days = trip.itineraryDays()
/// All calendar days in the trip
private var tripDays: [Date] {
let calendar = Calendar.current
guard let startDate = trip.stops.first?.arrivalDate,
let endDate = trip.stops.last?.departureDate else { return [] }
// Build options for each day
var dayOptions: [[DayChoice]] = []
var days: [Date] = []
var current = calendar.startOfDay(for: startDate)
let end = calendar.startOfDay(for: endDate)
for day in days {
let dayStart = calendar.startOfDay(for: day.date)
// Find stops with games on this day
let stopsWithGames = day.stops.filter { stop in
stop.games.compactMap { games[$0] }.contains { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
}
if stopsWithGames.isEmpty {
// Rest day or travel day - use first stop or create empty
if let firstStop = day.stops.first {
dayOptions.append([DayChoice(dayNumber: day.dayNumber, stop: firstStop, game: nil)])
}
} else {
// Create choices for each stop with games
let choices = stopsWithGames.compactMap { stop -> DayChoice? in
let gamesAtStop = stop.games.compactMap { games[$0] }.filter { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
return DayChoice(dayNumber: day.dayNumber, stop: stop, game: gamesAtStop.first)
}
if !choices.isEmpty {
dayOptions.append(choices)
}
}
while current <= end {
days.append(current)
current = calendar.date(byAdding: .day, value: 1, to: current)!
}
// Compute cartesian product of all day options
return cartesianProduct(dayOptions)
return days
}
/// Computes cartesian product of arrays
private func cartesianProduct(_ arrays: [[DayChoice]]) -> [[DayChoice]] {
guard !arrays.isEmpty else { return [[]] }
var result: [[DayChoice]] = [[]]
for array in arrays {
var newResult: [[DayChoice]] = []
for existing in result {
for element in array {
newResult.append(existing + [element])
}
}
result = newResult
}
return result
}
/// Detects if there are games in different cities on the same day
private func detectConflicts(for day: ItineraryDay) -> DayConflictInfo {
/// Day number for a given date
private func dayNumber(for date: Date) -> Int {
guard let firstDay = tripDays.first else { return 1 }
let calendar = Calendar.current
let dayStart = calendar.startOfDay(for: day.date)
let days = calendar.dateComponents([.day], from: firstDay, to: date).day ?? 0
return days + 1
}
// Find all stops that have games on this specific day
let stopsWithGamesToday = day.stops.filter { stop in
stop.games.compactMap { games[$0] }.contains { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
/// Games scheduled on a specific date
private func gamesOn(date: Date) -> [RichGame] {
let calendar = Calendar.current
let dayStart = calendar.startOfDay(for: date)
// Get all game IDs from all stops
let allGameIds = trip.stops.flatMap { $0.games }
return allGameIds.compactMap { games[$0] }.filter { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
}
// Get unique cities with games today
let citiesWithGames = Set(stopsWithGamesToday.map { $0.city })
/// Travel segments departing on a specific date
private func travelOn(date: Date) -> [TravelSegment] {
let calendar = Calendar.current
let dayStart = calendar.startOfDay(for: date)
if citiesWithGames.count > 1 {
return DayConflictInfo(
hasConflict: true,
conflictingStops: stopsWithGamesToday,
conflictingCities: Array(citiesWithGames)
)
return trip.travelSegments.filter { segment in
calendar.startOfDay(for: segment.departureTime) == dayStart
}
return DayConflictInfo(hasConflict: false, conflictingStops: [], conflictingCities: [])
}
// MARK: - Actions
@@ -482,376 +431,116 @@ struct TripDetailView: View {
}
}
// MARK: - Day Conflict Info
// MARK: - Simple Day Card (queries games and travel separately by date)
struct DayConflictInfo {
let hasConflict: Bool
let conflictingStops: [TripStop]
let conflictingCities: [String]
var warningMessage: String {
guard hasConflict else { return "" }
let otherCities = conflictingCities.joined(separator: ", ")
return "Scheduling conflict: Games in \(otherCities) on the same day"
}
}
// MARK: - Day Choice (Route Option)
/// Represents a choice for a single day in a route
struct DayChoice: Hashable {
struct SimpleDayCard: View {
let dayNumber: Int
let stop: TripStop
let game: RichGame?
let date: Date
let gamesOnDay: [RichGame]
let travelOnDay: [TravelSegment]
func hash(into hasher: inout Hasher) {
hasher.combine(dayNumber)
hasher.combine(stop.city)
private var formattedDate: String {
let formatter = DateFormatter()
formatter.dateFormat = "EEEE, MMM d"
return formatter.string(from: date)
}
static func == (lhs: DayChoice, rhs: DayChoice) -> Bool {
lhs.dayNumber == rhs.dayNumber && lhs.stop.city == rhs.stop.city
}
}
// MARK: - Route Combination Row (Expandable full route)
struct RouteCombinationRow: View {
let routeNumber: Int
let route: [DayChoice]
let days: [ItineraryDay]
let games: [UUID: RichGame]
let totalRoutes: Int
@State private var isExpanded = false
/// Summary string like "CLE @ SD CHC @ ATH ATL @ LAD"
private var routeSummary: String {
route.compactMap { choice -> String? in
guard let game = choice.game else { return nil }
return game.matchupDescription
}.joined(separator: "")
private var isRestDay: Bool {
gamesOnDay.isEmpty && travelOnDay.isEmpty
}
/// Cities in the route
private var routeCities: String {
route.map { $0.stop.city }.joined(separator: "")
/// City where games are (from stadium)
private var gameCity: String? {
gamesOnDay.first?.stadium.city
}
var body: some View {
VStack(spacing: 0) {
// Header (always visible, tappable)
Button {
withAnimation(.easeInOut(duration: 0.25)) {
isExpanded.toggle()
}
} label: {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 6) {
// Route number badge
Text("Route \(routeNumber)")
.font(.caption)
.fontWeight(.bold)
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue)
.clipShape(Capsule())
VStack(alignment: .leading, spacing: 12) {
// Day header
HStack {
Text("Day \(dayNumber)")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(.blue)
// Game sequence summary
Text(routeSummary)
Text(formattedDate)
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
if isRestDay {
Text("Rest Day")
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.green.opacity(0.2))
.clipShape(Capsule())
}
}
// Games (each as its own row)
if !gamesOnDay.isEmpty {
VStack(alignment: .leading, spacing: 6) {
// City label
if let city = gameCity {
Label(city, systemImage: "mappin")
.font(.caption)
.foregroundStyle(.secondary)
}
ForEach(gamesOnDay, id: \.game.id) { richGame in
HStack {
Image(systemName: richGame.game.sport.iconName)
.foregroundStyle(.blue)
.frame(width: 20)
Text(richGame.matchupDescription)
.font(.subheadline)
Spacer()
Text(richGame.game.gameTime)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
// Travel segments (each as its own row, separate from games)
ForEach(travelOnDay) { segment in
HStack(spacing: 8) {
Image(systemName: segment.travelMode.iconName)
.foregroundStyle(.orange)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text("Drive to \(segment.toLocation.name)")
.font(.subheadline)
.fontWeight(.medium)
.foregroundStyle(.primary)
.lineLimit(2)
.multilineTextAlignment(.leading)
// Cities
Text(routeCities)
Text("\(segment.formattedDistance)\(segment.formattedDuration)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
.padding(8)
.background(Color(.tertiarySystemFill))
.clipShape(Circle())
}
.padding()
.background(Color(.secondarySystemBackground))
}
.buttonStyle(.plain)
// Expanded content - full day-by-day itinerary
if isExpanded {
VStack(spacing: 8) {
ForEach(route, id: \.dayNumber) { choice in
if let day = days.first(where: { $0.dayNumber == choice.dayNumber }) {
RouteDayCard(day: day, choice: choice, games: games)
}
}
}
.padding(12)
.background(Color(.secondarySystemBackground))
}
}
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.blue.opacity(0.2), lineWidth: 1)
)
}
}
// MARK: - Single Route View (Auto-expanded when only one option)
struct SingleRouteView: View {
let route: [DayChoice]
let days: [ItineraryDay]
let games: [UUID: RichGame]
var body: some View {
VStack(spacing: 12) {
ForEach(route, id: \.dayNumber) { choice in
if let day = days.first(where: { $0.dayNumber == choice.dayNumber }) {
RouteDayCard(day: day, choice: choice, games: games)
}
}
}
}
}
// MARK: - Route Day Card (Individual day within a route)
struct RouteDayCard: View {
let day: ItineraryDay
let choice: DayChoice
let games: [UUID: RichGame]
private var gamesOnThisDay: [RichGame] {
let calendar = Calendar.current
let dayStart = calendar.startOfDay(for: day.date)
return choice.stop.games.compactMap { games[$0] }.filter { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Day header
HStack {
Text("Day \(day.dayNumber)")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(.blue)
Text(day.formattedDate)
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
if gamesOnThisDay.isEmpty {
Text("Rest Day")
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.green.opacity(0.2))
.clipShape(Capsule())
}
}
// City
Label(choice.stop.city, systemImage: "mappin")
.font(.caption)
.foregroundStyle(.secondary)
// Travel
if day.hasTravelSegment {
ForEach(day.travelSegments) { segment in
HStack(spacing: 4) {
Image(systemName: segment.travelMode.iconName)
Text("\(segment.formattedDistance)\(segment.formattedDuration)")
}
.font(.caption)
.foregroundStyle(.orange)
}
}
// Games
ForEach(gamesOnThisDay, id: \.game.id) { richGame in
HStack {
Image(systemName: richGame.game.sport.iconName)
.foregroundStyle(.blue)
Text(richGame.matchupDescription)
.font(.subheadline)
Spacer()
Text(richGame.game.gameTime)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
// MARK: - Day Card
struct DayCard: View {
let day: ItineraryDay
let games: [UUID: RichGame]
var specificStop: TripStop? = nil
var conflictInfo: DayConflictInfo? = nil
/// The city to display for this card
var primaryCityForDay: String? {
// If a specific stop is provided (conflict mode), use that stop's city
if let stop = specificStop {
return stop.city
}
let calendar = Calendar.current
let dayStart = calendar.startOfDay(for: day.date)
// Find the stop with a game on this day
let primaryStop = day.stops.first { stop in
stop.games.compactMap { games[$0] }.contains { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
} ?? day.stops.first
return primaryStop?.city
}
/// Games to display on this card
var gamesOnThisDay: [RichGame] {
let calendar = Calendar.current
let dayStart = calendar.startOfDay(for: day.date)
// If a specific stop is provided (conflict mode), only show that stop's games
if let stop = specificStop {
return stop.games.compactMap { games[$0] }.filter { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
}
// Find the stop where we're actually located on this day
let primaryStop = day.stops.first { stop in
stop.games.compactMap { games[$0] }.contains { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
} ?? day.stops.first
guard let stop = primaryStop else { return [] }
return stop.games.compactMap { games[$0] }.filter { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
}
/// Whether this card has a scheduling conflict
var hasConflict: Bool {
conflictInfo?.hasConflict ?? false
}
/// Other cities with conflicting games (excluding current city)
var otherConflictingCities: [String] {
guard let info = conflictInfo, let currentCity = primaryCityForDay else { return [] }
return info.conflictingCities.filter { $0 != currentCity }
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Conflict warning banner
if hasConflict {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Conflict: Also scheduled in \(otherConflictingCities.joined(separator: ", "))")
.font(.caption)
.fontWeight(.medium)
}
.padding(.vertical, 8)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.orange.opacity(0.15))
.background(Color.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
// Day header
HStack {
Text("Day \(day.dayNumber)")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(.blue)
Text(day.formattedDate)
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
if day.isRestDay && !hasConflict {
Text("Rest Day")
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.green.opacity(0.2))
.clipShape(Capsule())
}
}
// City
if let city = primaryCityForDay {
Label(city, systemImage: "mappin")
.font(.caption)
.foregroundStyle(.secondary)
}
// Travel (only show if not in conflict mode, to avoid duplication)
if day.hasTravelSegment && specificStop == nil {
ForEach(day.travelSegments) { segment in
HStack(spacing: 4) {
Image(systemName: segment.travelMode.iconName)
Text("\(segment.formattedDistance)\(segment.formattedDuration)")
}
.font(.caption)
.foregroundStyle(.orange)
}
}
// Games
ForEach(gamesOnThisDay, id: \.game.id) { richGame in
HStack {
Image(systemName: richGame.game.sport.iconName)
.foregroundStyle(.blue)
Text(richGame.matchupDescription)
.font(.subheadline)
Spacer()
Text(richGame.game.gameTime)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.orange.opacity(0.3), lineWidth: 1)
)
}
}
.padding()
.background(hasConflict ? Color.orange.opacity(0.05) : Color(.secondarySystemBackground))
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(hasConflict ? Color.orange.opacity(0.3) : Color.clear, lineWidth: 1)
)
}
}