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

@@ -4,6 +4,7 @@
//
import Foundation
import SwiftUI
enum Sport: String, Codable, CaseIterable, Identifiable {
case mlb = "MLB"
@@ -34,6 +35,16 @@ enum Sport: String, Codable, CaseIterable, Identifiable {
}
}
var color: Color {
switch self {
case .mlb: return .red
case .nba: return .orange
case .nhl: return .blue
case .nfl: return .brown
case .mls: return .green
}
}
var seasonMonths: ClosedRange<Int> {
switch self {
case .mlb: return 3...10 // March - October

View File

@@ -297,8 +297,21 @@ actor StubDataProvider: DataProvider {
return Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: dateOnly)
}
// Venue name aliases for stadiums that changed names
private static let venueAliases: [String: String] = [
"daikin park": "minute maid park", // Houston Astros (renamed 2024)
"rate field": "guaranteed rate field", // Chicago White Sox
"george m. steinbrenner field": "tropicana field", // Tampa Bay spring training main stadium
"loandepot park": "loandepot park", // Miami - ensure case match
]
private func findStadiumId(venue: String, sport: Sport) -> UUID {
let venueLower = venue.lowercased()
var venueLower = venue.lowercased()
// Check for known aliases
if let aliasedName = Self.venueAliases[venueLower] {
venueLower = aliasedName
}
// Try exact match
if let stadium = stadiumsByVenue[venueLower] {
@@ -313,6 +326,7 @@ actor StubDataProvider: DataProvider {
}
// Generate deterministic ID for unknown venues
print("[StubDataProvider] No stadium match for venue: '\(venue)'")
return deterministicUUID(from: "venue_\(venue)")
}

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

View File

@@ -0,0 +1,472 @@
//
// TimelineItemView.swift
// SportsTime
//
// Unified timeline view components for displaying trip itinerary.
// Renders stops, travel segments, and rest days in a consistent format.
//
import SwiftUI
// MARK: - Timeline Item View
/// Renders a single timeline item (stop, travel, or rest).
struct TimelineItemView: View {
let item: TimelineItem
let games: [UUID: RichGame]
let isFirst: Bool
let isLast: Bool
init(
item: TimelineItem,
games: [UUID: RichGame],
isFirst: Bool = false,
isLast: Bool = false
) {
self.item = item
self.games = games
self.isFirst = isFirst
self.isLast = isLast
}
var body: some View {
HStack(alignment: .top, spacing: 12) {
// Timeline connector
timelineConnector
// Content
itemContent
}
}
// MARK: - Timeline Connector
@ViewBuilder
private var timelineConnector: some View {
VStack(spacing: 0) {
// Line from previous
if !isFirst {
Rectangle()
.fill(connectorColor)
.frame(width: 2, height: 16)
} else {
Spacer().frame(height: 16)
}
// Icon
itemIcon
.frame(width: 32, height: 32)
// Line to next
if !isLast {
Rectangle()
.fill(connectorColor)
.frame(width: 2)
.frame(maxHeight: .infinity)
}
}
.frame(width: 32)
}
private var connectorColor: Color {
Color.secondary.opacity(0.3)
}
@ViewBuilder
private var itemIcon: some View {
switch item {
case .stop(let stop):
if stop.hasGames {
Image(systemName: "sportscourt.fill")
.foregroundStyle(.white)
.frame(width: 32, height: 32)
.background(Circle().fill(.blue))
} else {
Image(systemName: "mappin.circle.fill")
.foregroundStyle(.orange)
.font(.title2)
}
case .travel(let segment):
Image(systemName: segment.travelMode == .drive ? "car.fill" : "airplane")
.foregroundStyle(.white)
.frame(width: 28, height: 28)
.background(Circle().fill(.green))
case .rest:
Image(systemName: "bed.double.fill")
.foregroundStyle(.white)
.frame(width: 28, height: 28)
.background(Circle().fill(.purple))
}
}
// MARK: - Item Content
@ViewBuilder
private var itemContent: some View {
switch item {
case .stop(let stop):
StopItemContent(stop: stop, games: games)
case .travel(let segment):
TravelItemContent(segment: segment)
case .rest(let rest):
RestItemContent(rest: rest)
}
}
}
// MARK: - Stop Item Content
struct StopItemContent: View {
let stop: ItineraryStop
let games: [UUID: RichGame]
private var gamesAtStop: [RichGame] {
stop.games.compactMap { games[$0] }
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Header
HStack {
Text(stop.city)
.font(.headline)
if !stop.state.isEmpty {
Text(stop.state)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Text(stop.arrivalDate.formatted(date: .abbreviated, time: .omitted))
.font(.caption)
.foregroundStyle(.secondary)
}
// Games
if !gamesAtStop.isEmpty {
ForEach(gamesAtStop, id: \.game.id) { richGame in
TimelineGameRow(richGame: richGame)
}
} else {
Text(stop.hasGames ? "Game details loading..." : "Waypoint")
.font(.subheadline)
.foregroundStyle(.secondary)
.italic()
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
// MARK: - Travel Item Content
struct TravelItemContent: View {
let segment: TravelSegment
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(segment.travelMode == .drive ? "Drive" : "Fly")
.font(.subheadline)
.fontWeight(.medium)
Text("")
.foregroundStyle(.secondary)
Text(segment.formattedDistance)
.font(.subheadline)
.foregroundStyle(.secondary)
Text("")
.foregroundStyle(.secondary)
Text(segment.formattedDuration)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Text("\(segment.fromLocation.name)\(segment.toLocation.name)")
.font(.caption)
.foregroundStyle(.secondary)
// EV Charging stops if applicable
if !segment.evChargingStops.isEmpty {
HStack(spacing: 4) {
Image(systemName: "bolt.fill")
.foregroundStyle(.green)
Text("\(segment.evChargingStops.count) charging stop(s)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(.vertical, 8)
.padding(.horizontal, 12)
.background(Color(.tertiarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
// MARK: - Rest Item Content
struct RestItemContent: View {
let rest: RestDay
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Rest Day")
.font(.subheadline)
.fontWeight(.medium)
Spacer()
Text(rest.date.formatted(date: .abbreviated, time: .omitted))
.font(.caption)
.foregroundStyle(.secondary)
}
Text(rest.location.name)
.font(.caption)
.foregroundStyle(.secondary)
if let notes = rest.notes {
Text(notes)
.font(.caption)
.foregroundStyle(.secondary)
.italic()
}
}
.padding(.vertical, 8)
.padding(.horizontal, 12)
.background(Color.purple.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
// MARK: - Timeline Game Row
struct TimelineGameRow: View {
let richGame: RichGame
var body: some View {
HStack(spacing: 8) {
// Sport icon
Image(systemName: richGame.game.sport.iconName)
.foregroundStyle(richGame.game.sport.color)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
// Matchup
Text(richGame.matchupDescription)
.font(.subheadline)
.fontWeight(.medium)
// Time and venue
HStack(spacing: 4) {
Text(richGame.game.dateTime.formatted(date: .omitted, time: .shortened))
Text("")
Text(richGame.stadium.name)
}
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.vertical, 4)
}
}
// MARK: - Timeline View
/// Full timeline view for an itinerary option.
struct TimelineView: View {
let option: ItineraryOption
let games: [UUID: RichGame]
private var timeline: [TimelineItem] {
option.generateTimeline()
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(timeline.enumerated()), id: \.element.id) { index, item in
TimelineItemView(
item: item,
games: games,
isFirst: index == 0,
isLast: index == timeline.count - 1
)
}
}
}
}
// MARK: - Horizontal Timeline View
/// Horizontal scrolling timeline for compact display.
struct HorizontalTimelineView: View {
let option: ItineraryOption
let games: [UUID: RichGame]
private var timeline: [TimelineItem] {
option.generateTimeline()
}
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
ForEach(Array(timeline.enumerated()), id: \.element.id) { index, item in
HStack(spacing: 0) {
HorizontalTimelineItemView(item: item, games: games)
// Connector to next
if index < timeline.count - 1 {
timelineConnector(for: item)
}
}
}
}
.padding(.horizontal)
}
}
@ViewBuilder
private func timelineConnector(for item: TimelineItem) -> some View {
if item.isTravel {
// Travel already shows direction, minimal connector
Rectangle()
.fill(Color.secondary.opacity(0.3))
.frame(width: 20, height: 2)
} else {
// Standard connector with arrow
HStack(spacing: 0) {
Rectangle()
.fill(Color.secondary.opacity(0.3))
.frame(width: 16, height: 2)
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(.secondary)
Rectangle()
.fill(Color.secondary.opacity(0.3))
.frame(width: 16, height: 2)
}
}
}
}
// MARK: - Horizontal Timeline Item View
struct HorizontalTimelineItemView: View {
let item: TimelineItem
let games: [UUID: RichGame]
var body: some View {
VStack(spacing: 4) {
itemIcon
Text(shortLabel)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.frame(width: 60)
}
.padding(.vertical, 8)
}
@ViewBuilder
private var itemIcon: some View {
switch item {
case .stop(let stop):
VStack(spacing: 2) {
Image(systemName: stop.hasGames ? "sportscourt.fill" : "mappin")
.foregroundStyle(stop.hasGames ? .blue : .orange)
Text(String(stop.city.prefix(3)).uppercased())
.font(.caption2)
.fontWeight(.bold)
}
.frame(width: 44, height: 44)
.background(Circle().fill(Color(.secondarySystemBackground)))
case .travel(let segment):
Image(systemName: segment.travelMode == .drive ? "car.fill" : "airplane")
.foregroundStyle(.green)
.frame(width: 32, height: 32)
case .rest:
Image(systemName: "bed.double.fill")
.foregroundStyle(.purple)
.frame(width: 32, height: 32)
}
}
private var shortLabel: String {
switch item {
case .stop(let stop):
return stop.city
case .travel(let segment):
return segment.formattedDuration
case .rest(let rest):
return rest.date.formatted(.dateTime.weekday(.abbreviated))
}
}
}
// MARK: - Preview
#Preview {
let stop1 = ItineraryStop(
city: "Los Angeles",
state: "CA",
coordinate: nil,
games: [],
arrivalDate: Date(),
departureDate: Date(),
location: LocationInput(name: "Los Angeles"),
firstGameStart: nil
)
let stop2 = ItineraryStop(
city: "San Francisco",
state: "CA",
coordinate: nil,
games: [],
arrivalDate: Date().addingTimeInterval(86400),
departureDate: Date().addingTimeInterval(86400),
location: LocationInput(name: "San Francisco"),
firstGameStart: nil
)
let segment = TravelSegment(
fromLocation: LocationInput(name: "Los Angeles"),
toLocation: LocationInput(name: "San Francisco"),
travelMode: .drive,
distanceMeters: 600000,
durationSeconds: 21600,
departureTime: Date(),
arrivalTime: Date().addingTimeInterval(21600)
)
let option = ItineraryOption(
rank: 1,
stops: [stop1, stop2],
travelSegments: [segment],
totalDrivingHours: 6,
totalDistanceMiles: 380,
geographicRationale: "LA → SF"
)
return ScrollView {
TimelineView(option: option, games: [:])
.padding()
}
}

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()
}

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

View File

@@ -0,0 +1,441 @@
//
// GameDAGRouter.swift
// SportsTime
//
// Time-expanded DAG + Beam Search algorithm for route finding.
//
// Key insight: This is NOT "which subset of N games should I attend?"
// This IS: "what time-respecting paths exist through a graph of games?"
//
// The algorithm:
// 1. Bucket games by calendar day
// 2. Build directed edges where time moves forward AND driving is feasible
// 3. Beam search: keep top K paths at each depth
// 4. Dominance pruning: discard inferior paths
//
// Complexity: O(days × beamWidth × avgNeighbors) 900 operations for 5-day, 78-game scenario
// (vs 2^78 for naive subset enumeration)
//
import Foundation
import CoreLocation
enum GameDAGRouter {
// MARK: - Configuration
/// Default beam width - how many partial routes to keep at each step
private static let defaultBeamWidth = 30
/// Maximum options to return
private static let maxOptions = 10
/// Buffer time after game ends before we can depart (hours)
private static let gameEndBufferHours: Double = 3.0
/// Maximum days ahead to consider for next game (1 = next day only, 2 = allows one off-day)
private static let maxDayLookahead = 2
// MARK: - Public API
/// Finds best routes through the game graph using DAG + beam search.
///
/// This replaces the exponential GeographicRouteExplorer with a polynomial-time algorithm.
///
/// - Parameters:
/// - games: All games to consider, in any order (will be sorted internally)
/// - stadiums: Dictionary mapping stadium IDs to Stadium objects
/// - constraints: Driving constraints (number of drivers, max hours per day)
/// - anchorGameIds: Games that MUST appear in every valid route (for Scenario B)
/// - beamWidth: How many partial routes to keep at each depth (default 30)
///
/// - Returns: Array of valid game combinations, sorted by score (most games, least driving)
///
static func findRoutes(
games: [Game],
stadiums: [UUID: Stadium],
constraints: DrivingConstraints,
anchorGameIds: Set<UUID> = [],
beamWidth: Int = defaultBeamWidth
) -> [[Game]] {
// Edge cases
guard !games.isEmpty else { return [] }
if games.count == 1 {
// Single game - just return it if it satisfies anchors
if anchorGameIds.isEmpty || anchorGameIds.contains(games[0].id) {
return [games]
}
return []
}
if games.count == 2 {
// Two games - check if both are reachable
let sorted = games.sorted { $0.startTime < $1.startTime }
if canTransition(from: sorted[0], to: sorted[1], stadiums: stadiums, constraints: constraints) {
if anchorGameIds.isSubset(of: Set(sorted.map { $0.id })) {
return [sorted]
}
}
// Can't connect them - return individual games if they satisfy anchors
if anchorGameIds.isEmpty {
return [[sorted[0]], [sorted[1]]]
}
return []
}
// Step 1: Sort games chronologically
let sortedGames = games.sorted { $0.startTime < $1.startTime }
// Step 2: Bucket games by calendar day
let buckets = bucketByDay(games: sortedGames)
let sortedDays = buckets.keys.sorted()
guard !sortedDays.isEmpty else { return [] }
print("[GameDAGRouter] \(games.count) games across \(sortedDays.count) days")
print("[GameDAGRouter] Games per day: \(sortedDays.map { buckets[$0]?.count ?? 0 })")
// Step 3: Initialize beam with first day's games
var beam: [[Game]] = []
if let firstDayGames = buckets[sortedDays[0]] {
for game in firstDayGames {
beam.append([game])
}
}
// Also include option to skip first day entirely and start later
// (handled by having multiple starting points in beam)
for dayIndex in sortedDays.dropFirst().prefix(maxDayLookahead - 1) {
if let dayGames = buckets[dayIndex] {
for game in dayGames {
beam.append([game])
}
}
}
print("[GameDAGRouter] Initial beam size: \(beam.count)")
// Step 4: Expand beam day by day
for (index, dayIndex) in sortedDays.dropFirst().enumerated() {
let todaysGames = buckets[dayIndex] ?? []
var nextBeam: [[Game]] = []
for path in beam {
guard let lastGame = path.last else { continue }
let lastGameDay = dayIndexFor(lastGame.startTime, referenceDate: sortedGames[0].startTime)
// Only consider games on this day or within lookahead
if dayIndex > lastGameDay + maxDayLookahead {
// This path is too far behind, keep it as-is
nextBeam.append(path)
continue
}
var addedAny = false
// Try adding each of today's games
for candidate in todaysGames {
if canTransition(from: lastGame, to: candidate, stadiums: stadiums, constraints: constraints) {
let newPath = path + [candidate]
nextBeam.append(newPath)
addedAny = true
}
}
// Also keep the path without adding a game today (allows off-days)
nextBeam.append(path)
}
// Dominance pruning + beam truncation
beam = pruneAndTruncate(nextBeam, beamWidth: beamWidth, stadiums: stadiums)
print("[GameDAGRouter] Day \(dayIndex): nextBeam=\(nextBeam.count), after prune=\(beam.count), max games=\(beam.map { $0.count }.max() ?? 0)")
}
// Step 5: Filter routes that contain all anchors
let routesWithAnchors = beam.filter { path in
let pathGameIds = Set(path.map { $0.id })
return anchorGameIds.isSubset(of: pathGameIds)
}
// Step 6: Ensure geographic diversity in results
// Group routes by their primary region (city with most games)
// Then pick the best route from each region
let diverseRoutes = selectDiverseRoutes(routesWithAnchors, stadiums: stadiums, maxCount: maxOptions)
print("[GameDAGRouter] Found \(routesWithAnchors.count) routes with anchors, returning \(diverseRoutes.count) diverse routes")
for (i, route) in diverseRoutes.prefix(5).enumerated() {
let cities = route.compactMap { stadiums[$0.stadiumId]?.city }.joined(separator: "")
print("[GameDAGRouter] Route \(i+1): \(route.count) games - \(cities)")
}
return diverseRoutes
}
/// Compatibility wrapper that matches GeographicRouteExplorer's interface.
/// This allows drop-in replacement in ScenarioAPlanner and ScenarioBPlanner.
static func findAllSensibleRoutes(
from games: [Game],
stadiums: [UUID: Stadium],
anchorGameIds: Set<UUID> = [],
stopBuilder: ([Game], [UUID: Stadium]) -> [ItineraryStop]
) -> [[Game]] {
// Use default driving constraints
let constraints = DrivingConstraints.default
return findRoutes(
games: games,
stadiums: stadiums,
constraints: constraints,
anchorGameIds: anchorGameIds
)
}
// MARK: - Day Bucketing
/// Groups games by calendar day index (0 = first day of trip, 1 = second day, etc.)
private static func bucketByDay(games: [Game]) -> [Int: [Game]] {
guard let firstGame = games.first else { return [:] }
let referenceDate = firstGame.startTime
var buckets: [Int: [Game]] = [:]
for game in games {
let dayIndex = dayIndexFor(game.startTime, referenceDate: referenceDate)
buckets[dayIndex, default: []].append(game)
}
return buckets
}
/// Calculates the day index for a date relative to a reference date.
private static func dayIndexFor(_ date: Date, referenceDate: Date) -> Int {
let calendar = Calendar.current
let refDay = calendar.startOfDay(for: referenceDate)
let dateDay = calendar.startOfDay(for: date)
let components = calendar.dateComponents([.day], from: refDay, to: dateDay)
return components.day ?? 0
}
// MARK: - Transition Feasibility
/// Determines if we can travel from game A to game B.
///
/// Requirements:
/// 1. B starts after A (time moves forward)
/// 2. Driving time is within daily limit
/// 3. We can arrive at B before B starts
///
private static func canTransition(
from: Game,
to: Game,
stadiums: [UUID: Stadium],
constraints: DrivingConstraints
) -> Bool {
// Time must move forward
guard to.startTime > from.startTime else { return false }
// Same stadium = always feasible (no driving needed)
if from.stadiumId == to.stadiumId { return true }
// Get stadiums
guard let fromStadium = stadiums[from.stadiumId],
let toStadium = stadiums[to.stadiumId] else {
// Missing stadium info - use generous fallback
// Assume 300 miles at 60 mph = 5 hours, which is usually feasible
return true
}
let fromCoord = fromStadium.coordinate
let toCoord = toStadium.coordinate
// Calculate driving time
let distanceMiles = TravelEstimator.haversineDistanceMiles(
from: CLLocationCoordinate2D(latitude: fromCoord.latitude, longitude: fromCoord.longitude),
to: CLLocationCoordinate2D(latitude: toCoord.latitude, longitude: toCoord.longitude)
) * 1.3 // Road routing factor
let drivingHours = distanceMiles / 60.0 // Average 60 mph
// Must be within daily limit
guard drivingHours <= constraints.maxDailyDrivingHours else { return false }
// Calculate if we can arrive in time
let departureTime = from.startTime.addingTimeInterval(gameEndBufferHours * 3600)
let arrivalTime = departureTime.addingTimeInterval(drivingHours * 3600)
// Must arrive before game starts (with 1 hour buffer)
let deadline = to.startTime.addingTimeInterval(-3600)
guard arrivalTime <= deadline else { return false }
return true
}
// MARK: - Geographic Diversity
/// Selects geographically diverse routes from the candidate set.
/// Groups routes by their primary city (where most games are) and picks the best from each region.
private static func selectDiverseRoutes(
_ routes: [[Game]],
stadiums: [UUID: Stadium],
maxCount: Int
) -> [[Game]] {
guard !routes.isEmpty else { return [] }
// Group routes by primary city (the city with the most games in the route)
var routesByRegion: [String: [[Game]]] = [:]
for route in routes {
let primaryCity = getPrimaryCity(for: route, stadiums: stadiums)
routesByRegion[primaryCity, default: []].append(route)
}
// Sort routes within each region by score (best first)
for (region, regionRoutes) in routesByRegion {
routesByRegion[region] = regionRoutes.sorted {
scorePath($0, stadiums: stadiums) > scorePath($1, stadiums: stadiums)
}
}
// Sort regions by their best route's score (so best regions come first)
let sortedRegions = routesByRegion.keys.sorted { region1, region2 in
let score1 = routesByRegion[region1]?.first.map { scorePath($0, stadiums: stadiums) } ?? 0
let score2 = routesByRegion[region2]?.first.map { scorePath($0, stadiums: stadiums) } ?? 0
return score1 > score2
}
print("[GameDAGRouter] Found \(sortedRegions.count) distinct regions: \(sortedRegions.prefix(10).joined(separator: ", "))")
// Pick routes round-robin from each region to ensure diversity
var selectedRoutes: [[Game]] = []
var regionIndices: [String: Int] = [:]
// First pass: get best route from each region
for region in sortedRegions {
if selectedRoutes.count >= maxCount { break }
if let regionRoutes = routesByRegion[region], !regionRoutes.isEmpty {
selectedRoutes.append(regionRoutes[0])
regionIndices[region] = 1
}
}
// Second pass: fill remaining slots with next-best routes from top regions
var round = 1
while selectedRoutes.count < maxCount {
var addedAny = false
for region in sortedRegions {
if selectedRoutes.count >= maxCount { break }
let idx = regionIndices[region] ?? 0
if let regionRoutes = routesByRegion[region], idx < regionRoutes.count {
selectedRoutes.append(regionRoutes[idx])
regionIndices[region] = idx + 1
addedAny = true
}
}
if !addedAny { break }
round += 1
if round > 5 { break } // Safety limit
}
return selectedRoutes
}
/// Gets the primary city for a route (where most games are played).
private static func getPrimaryCity(for route: [Game], stadiums: [UUID: Stadium]) -> String {
var cityCounts: [String: Int] = [:]
for game in route {
let city = stadiums[game.stadiumId]?.city ?? "Unknown"
cityCounts[city, default: 0] += 1
}
return cityCounts.max(by: { $0.value < $1.value })?.key ?? "Unknown"
}
// MARK: - Scoring and Pruning
/// Scores a path. Higher = better.
/// Prefers: more games, less driving, geographic coherence
private static func scorePath(_ path: [Game], stadiums: [UUID: Stadium]) -> Double {
let gameCount = Double(path.count)
// Calculate total driving
var totalDriving: Double = 0
for i in 0..<(path.count - 1) {
totalDriving += estimateDrivingHours(from: path[i], to: path[i + 1], stadiums: stadiums)
}
// Score: heavily weight game count, penalize driving
return gameCount * 100.0 - totalDriving * 2.0
}
/// Estimates driving hours between two games.
private static func estimateDrivingHours(
from: Game,
to: Game,
stadiums: [UUID: Stadium]
) -> Double {
// Same stadium = 0 driving
if from.stadiumId == to.stadiumId { return 0 }
guard let fromStadium = stadiums[from.stadiumId],
let toStadium = stadiums[to.stadiumId] else {
return 5.0 // Fallback: assume 5 hours
}
let fromCoord = fromStadium.coordinate
let toCoord = toStadium.coordinate
let distanceMiles = TravelEstimator.haversineDistanceMiles(
from: CLLocationCoordinate2D(latitude: fromCoord.latitude, longitude: fromCoord.longitude),
to: CLLocationCoordinate2D(latitude: toCoord.latitude, longitude: toCoord.longitude)
) * 1.3
return distanceMiles / 60.0
}
/// Prunes dominated paths and truncates to beam width.
private static func pruneAndTruncate(
_ paths: [[Game]],
beamWidth: Int,
stadiums: [UUID: Stadium]
) -> [[Game]] {
// Remove exact duplicates
var uniquePaths: [[Game]] = []
var seen = Set<String>()
for path in paths {
let key = path.map { $0.id.uuidString }.joined(separator: "-")
if !seen.contains(key) {
seen.insert(key)
uniquePaths.append(path)
}
}
// Sort by score (best first)
let sorted = uniquePaths.sorted { scorePath($0, stadiums: stadiums) > scorePath($1, stadiums: stadiums) }
// Dominance pruning: within same ending city, keep only best paths
var pruned: [[Game]] = []
var bestByEndCity: [String: Double] = [:]
for path in sorted {
guard let lastGame = path.last else { continue }
let endCity = stadiums[lastGame.stadiumId]?.city ?? "Unknown"
let score = scorePath(path, stadiums: stadiums)
// Keep if this is the best path ending in this city, or if score is within 20% of best
if let bestScore = bestByEndCity[endCity] {
if score >= bestScore * 0.8 {
pruned.append(path)
}
} else {
bestByEndCity[endCity] = score
pruned.append(path)
}
// Stop if we have enough
if pruned.count >= beamWidth * 2 {
break
}
}
// Final truncation
return Array(pruned.prefix(beamWidth))
}
}

View File

@@ -1,278 +0,0 @@
//
// GeographicRouteExplorer.swift
// SportsTime
//
// Shared logic for finding geographically sensible route variations.
// Used by all scenario planners to explore and prune route combinations.
//
// Key Features:
// - Tree exploration with pruning for route combinations
// - Geographic sanity check using bounding box diagonal vs actual travel ratio
// - Support for "anchor" games that cannot be removed from routes (Scenario B)
//
// Algorithm Overview:
// Given games [A, B, C, D, E] in chronological order, we build a decision tree
// where at each node we can either include or skip a game. Routes that would
// create excessive zig-zagging are pruned. When anchors are specified, any
// route that doesn't include ALL anchors is automatically discarded.
//
import Foundation
import CoreLocation
enum GeographicRouteExplorer {
// MARK: - Configuration
/// Maximum ratio of actual travel to bounding box diagonal.
/// Routes exceeding this are considered zig-zags.
/// - 1.0x = perfectly linear route
/// - 1.5x = some detours, normal
/// - 2.0x = significant detours, borderline
/// - 2.5x+ = excessive zig-zag, reject
private static let maxZigZagRatio = 2.5
/// Minimum bounding box diagonal (miles) to apply zig-zag check.
/// Routes within a small area are always considered sane.
private static let minDiagonalForCheck = 100.0
/// Maximum number of route options to return.
private static let maxOptions = 10
// MARK: - Public API
/// Finds ALL geographically sensible subsets of games.
///
/// The problem: Games in a date range might be scattered across the country.
/// Visiting all of them in chronological order could mean crazy zig-zags.
///
/// The solution: Explore all possible subsets, keeping those that pass
/// geographic sanity. Return multiple options for the user to choose from.
///
/// Algorithm (tree exploration with pruning):
///
/// Input: [NY, TX, SC, DEN, NM, CA] (chronological order)
///
/// Build a decision tree:
/// [NY]
/// / \
/// +TX / \ skip TX
/// / \
/// [NY,TX] [NY]
/// / \ / \
/// +SC / \ +SC / \
/// | | |
/// (prune) +DEN [NY,SC] ...
///
/// Each path that reaches the end = one valid option
/// Pruning: If adding a game breaks sanity, don't explore that branch
///
/// - Parameters:
/// - games: All games to consider, should be in chronological order
/// - stadiums: Dictionary mapping stadium IDs to Stadium objects
/// - anchorGameIds: Game IDs that MUST be included in every route (for Scenario B)
/// - stopBuilder: Closure that converts games to ItineraryStops
///
/// - Returns: Array of valid game combinations, sorted by number of games (most first)
///
static func findAllSensibleRoutes(
from games: [Game],
stadiums: [UUID: Stadium],
anchorGameIds: Set<UUID> = [],
stopBuilder: ([Game], [UUID: Stadium]) -> [ItineraryStop]
) -> [[Game]] {
// 0-2 games = always sensible, only one option
// But still verify anchors are present
guard games.count > 2 else {
// Verify all anchors are in the game list
let gameIds = Set(games.map { $0.id })
if anchorGameIds.isSubset(of: gameIds) {
return games.isEmpty ? [] : [games]
} else {
// Missing anchors - no valid routes
return []
}
}
// First, check if all games already form a sensible route
let allStops = stopBuilder(games, stadiums)
if isGeographicallySane(stops: allStops) {
print("[GeographicExplorer] All \(games.count) games form a sensible route")
return [games]
}
print("[GeographicExplorer] Exploring route variations (anchors: \(anchorGameIds.count))...")
// Explore all valid subsets using recursive tree traversal
var validRoutes: [[Game]] = []
exploreRoutes(
games: games,
stadiums: stadiums,
anchorGameIds: anchorGameIds,
stopBuilder: stopBuilder,
currentRoute: [],
index: 0,
validRoutes: &validRoutes
)
// Filter routes that don't contain all anchors
let routesWithAnchors = validRoutes.filter { route in
let routeGameIds = Set(route.map { $0.id })
return anchorGameIds.isSubset(of: routeGameIds)
}
// Sort by number of games (most games first = best options)
let sorted = routesWithAnchors.sorted { $0.count > $1.count }
// Limit to top options to avoid overwhelming the user
let topRoutes = Array(sorted.prefix(maxOptions))
print("[GeographicExplorer] Found \(routesWithAnchors.count) valid routes with anchors, returning top \(topRoutes.count)")
return topRoutes
}
// MARK: - Geographic Sanity Check
/// Determines if a route is geographically sensible or zig-zags excessively.
///
/// The goal: Reject routes that oscillate back and forth across large distances.
/// We want routes that make generally linear progress, not cross-country ping-pong.
///
/// Algorithm:
/// 1. Calculate the "bounding box" of all stops (geographic spread)
/// 2. Calculate total travel distance if we visit stops in order
/// 3. Compare actual travel to the bounding box diagonal
/// 4. If actual travel is WAY more than the diagonal, it's zig-zagging
///
/// Example VALID:
/// Stops: LA, SF, Portland, Seattle
/// Bounding box diagonal: ~1,100 miles
/// Actual travel: ~1,200 miles (reasonable, mostly linear)
/// Ratio: 1.1x PASS
///
/// Example INVALID:
/// Stops: NY, TX, SC, CA (zig-zag)
/// Bounding box diagonal: ~2,500 miles
/// Actual travel: ~6,000 miles (back and forth)
/// Ratio: 2.4x FAIL
///
static func isGeographicallySane(stops: [ItineraryStop]) -> Bool {
// Single stop or two stops = always valid (no zig-zag possible)
guard stops.count > 2 else { return true }
// Collect all coordinates
let coordinates = stops.compactMap { $0.coordinate }
guard coordinates.count == stops.count else {
// Missing coordinates - can't validate, assume valid
return true
}
// Calculate bounding box
let lats = coordinates.map { $0.latitude }
let lons = coordinates.map { $0.longitude }
guard let minLat = lats.min(), let maxLat = lats.max(),
let minLon = lons.min(), let maxLon = lons.max() else {
return true
}
// Calculate bounding box diagonal distance
let corner1 = CLLocationCoordinate2D(latitude: minLat, longitude: minLon)
let corner2 = CLLocationCoordinate2D(latitude: maxLat, longitude: maxLon)
let diagonalMiles = TravelEstimator.haversineDistanceMiles(from: corner1, to: corner2)
// Tiny bounding box = all games are close together = always valid
if diagonalMiles < minDiagonalForCheck {
return true
}
// Calculate actual travel distance through all stops in order
var actualTravelMiles: Double = 0
for i in 0..<(stops.count - 1) {
let from = stops[i]
let to = stops[i + 1]
actualTravelMiles += TravelEstimator.calculateDistanceMiles(from: from, to: to)
}
// Compare: is actual travel reasonable compared to the geographic spread?
let ratio = actualTravelMiles / diagonalMiles
if ratio > maxZigZagRatio {
print("[GeographicExplorer] Sanity FAILED: travel=\(Int(actualTravelMiles))mi, diagonal=\(Int(diagonalMiles))mi, ratio=\(String(format: "%.1f", ratio))x")
return false
}
return true
}
// MARK: - Private Helpers
/// Recursive helper to explore all valid route combinations.
///
/// At each game, we have two choices:
/// 1. Include the game (if it doesn't break sanity)
/// 2. Skip the game (only if it's not an anchor)
///
/// We explore BOTH branches when possible, building up all valid combinations.
/// Anchor games MUST be included - we cannot skip them.
///
private static func exploreRoutes(
games: [Game],
stadiums: [UUID: Stadium],
anchorGameIds: Set<UUID>,
stopBuilder: ([Game], [UUID: Stadium]) -> [ItineraryStop],
currentRoute: [Game],
index: Int,
validRoutes: inout [[Game]]
) {
// Base case: we've processed all games
if index >= games.count {
// Only save routes with at least 1 game
if !currentRoute.isEmpty {
validRoutes.append(currentRoute)
}
return
}
let game = games[index]
let isAnchor = anchorGameIds.contains(game.id)
// Option 1: Try INCLUDING this game
var routeWithGame = currentRoute
routeWithGame.append(game)
let stopsWithGame = stopBuilder(routeWithGame, stadiums)
if isGeographicallySane(stops: stopsWithGame) {
// This branch is valid, continue exploring
exploreRoutes(
games: games,
stadiums: stadiums,
anchorGameIds: anchorGameIds,
stopBuilder: stopBuilder,
currentRoute: routeWithGame,
index: index + 1,
validRoutes: &validRoutes
)
} else if isAnchor {
// Anchor game breaks sanity - this entire branch is invalid
// Don't explore further, don't add to valid routes
// (We can't skip an anchor, and including it breaks sanity)
return
}
// Option 2: Try SKIPPING this game (only if it's not an anchor)
if !isAnchor {
exploreRoutes(
games: games,
stadiums: stadiums,
anchorGameIds: anchorGameIds,
stopBuilder: stopBuilder,
currentRoute: currentRoute,
index: index + 1,
validRoutes: &validRoutes
)
}
}
}

View File

@@ -68,6 +68,9 @@ final class ScenarioAPlanner: ScenarioPlanner {
.filter { dateRange.contains($0.startTime) }
.sorted { $0.startTime < $1.startTime }
print("[ScenarioA] Found \(gamesInRange.count) games in date range")
print("[ScenarioA] Stadiums available: \(request.stadiums.count)")
// No games? Nothing to plan.
if gamesInRange.isEmpty {
return .failure(
@@ -89,14 +92,19 @@ final class ScenarioAPlanner: ScenarioPlanner {
// - etc.
//
// We explore ALL valid combinations and return multiple options.
// Uses shared GeographicRouteExplorer for tree exploration.
// Uses GameDAGRouter for polynomial-time beam search.
//
let validRoutes = GeographicRouteExplorer.findAllSensibleRoutes(
let validRoutes = GameDAGRouter.findAllSensibleRoutes(
from: gamesInRange,
stadiums: request.stadiums,
stopBuilder: buildStops
)
print("[ScenarioA] GameDAGRouter returned \(validRoutes.count) routes")
if !validRoutes.isEmpty {
print("[ScenarioA] Route sizes: \(validRoutes.map { $0.count })")
}
if validRoutes.isEmpty {
return .failure(
PlanningFailure(
@@ -120,10 +128,22 @@ final class ScenarioAPlanner: ScenarioPlanner {
//
var itineraryOptions: [ItineraryOption] = []
var routesAttempted = 0
var routesFailed = 0
for (index, routeGames) in validRoutes.enumerated() {
routesAttempted += 1
// Build stops for this route
let stops = buildStops(from: routeGames, stadiums: request.stadiums)
guard !stops.isEmpty else { continue }
guard !stops.isEmpty else {
print("[ScenarioA] Route \(index + 1) produced no stops, skipping")
routesFailed += 1
continue
}
// Log stop details
let stopCities = stops.map { "\($0.city) (coord: \($0.coordinate != nil))" }
print("[ScenarioA] Route \(index + 1): \(stops.count) stops - \(stopCities.joined(separator: ""))")
// Calculate travel segments using shared ItineraryBuilder
guard let itinerary = ItineraryBuilder.build(
@@ -133,6 +153,7 @@ final class ScenarioAPlanner: ScenarioPlanner {
) else {
// This route fails driving constraints, skip it
print("[ScenarioA] Route \(index + 1) failed driving constraints, skipping")
routesFailed += 1
continue
}
@@ -155,6 +176,8 @@ final class ScenarioAPlanner: ScenarioPlanner {
// If no routes passed all constraints, fail.
// Otherwise, return all valid options for the user to choose from.
//
print("[ScenarioA] Routes attempted: \(routesAttempted), failed: \(routesFailed), succeeded: \(itineraryOptions.count)")
if itineraryOptions.isEmpty {
return .failure(
PlanningFailure(

View File

@@ -103,7 +103,8 @@ final class ScenarioBPlanner: ScenarioPlanner {
guard selectedInRange else { continue }
// Find all sensible routes that include the anchor games
let validRoutes = GeographicRouteExplorer.findAllSensibleRoutes(
// Uses GameDAGRouter for polynomial-time beam search
let validRoutes = GameDAGRouter.findAllSensibleRoutes(
from: gamesInRange,
stadiums: request.stadiums,
anchorGameIds: anchorGameIds,

View File

@@ -194,8 +194,8 @@ final class ScenarioCPlanner: ScenarioPlanner {
guard !gamesInRange.isEmpty else { continue }
// Use GeographicRouteExplorer to find sensible routes
let validRoutes = GeographicRouteExplorer.findAllSensibleRoutes(
// Use GameDAGRouter for polynomial-time beam search
let validRoutes = GameDAGRouter.findAllSensibleRoutes(
from: gamesInRange,
stadiums: request.stadiums,
anchorGameIds: [], // No anchors in Scenario C

View File

@@ -167,7 +167,7 @@ enum TravelEstimator {
days.append(startDay)
// Add days if driving takes multiple days (8 hrs/day max)
let daysOfDriving = Int(ceil(drivingHours / 8.0))
let daysOfDriving = max(1, Int(ceil(drivingHours / 8.0)))
for dayOffset in 1..<daysOfDriving {
if let nextDay = calendar.date(byAdding: .day, value: dayOffset, to: startDay) {
days.append(nextDay)

View File

@@ -227,6 +227,220 @@ struct DrivingConstraints {
}
}
// MARK: - Timeline Item
/// Unified timeline item for displaying trip itinerary.
/// Enables: Stop Travel Stop Rest Travel Stop pattern
enum TimelineItem: Identifiable {
case stop(ItineraryStop)
case travel(TravelSegment)
case rest(RestDay)
var id: UUID {
switch self {
case .stop(let stop): return stop.id
case .travel(let segment): return segment.id
case .rest(let rest): return rest.id
}
}
var date: Date {
switch self {
case .stop(let stop): return stop.arrivalDate
case .travel(let segment): return segment.departureTime
case .rest(let rest): return rest.date
}
}
var isStop: Bool {
if case .stop = self { return true }
return false
}
var isTravel: Bool {
if case .travel = self { return true }
return false
}
var isRest: Bool {
if case .rest = self { return true }
return false
}
/// City/location name for display
var locationName: String {
switch self {
case .stop(let stop): return stop.city
case .travel(let segment): return "\(segment.fromLocation.name)\(segment.toLocation.name)"
case .rest(let rest): return rest.location.name
}
}
}
// MARK: - Rest Day
/// A rest day - staying in one place with no travel or games.
struct RestDay: Identifiable, Hashable {
let id: UUID
let date: Date
let location: LocationInput
let notes: String?
init(
id: UUID = UUID(),
date: Date,
location: LocationInput,
notes: String? = nil
) {
self.id = id
self.date = date
self.location = location
self.notes = notes
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: RestDay, rhs: RestDay) -> Bool {
lhs.id == rhs.id
}
}
// MARK: - Timeline Generation
extension ItineraryOption {
/// Generates a unified timeline from stops and travel segments.
///
/// The timeline interleaves stops and travel in chronological order:
/// Stop A Travel AB Stop B Rest Day Travel BC Stop C
///
/// Rest days are inserted when:
/// - There's a gap between arrival at a stop and departure for next travel
/// - Multi-day stays at a location without games
///
func generateTimeline() -> [TimelineItem] {
var timeline: [TimelineItem] = []
let calendar = Calendar.current
for (index, stop) in stops.enumerated() {
// Add the stop
timeline.append(.stop(stop))
// Check for rest days at this stop (days between arrival and departure with no games)
let restDays = calculateRestDays(at: stop, calendar: calendar)
for restDay in restDays {
timeline.append(.rest(restDay))
}
// Add travel segment to next stop (if not last stop)
if index < travelSegments.count {
let segment = travelSegments[index]
// Check if travel spans multiple days
let travelDays = calculateTravelDays(for: segment, calendar: calendar)
if travelDays.count > 1 {
// Multi-day travel: could split into daily segments or keep as one
// For now, keep as single segment with multi-day indicator
timeline.append(.travel(segment))
} else {
timeline.append(.travel(segment))
}
}
}
return timeline
}
/// Calculates rest days at a stop (days with no games).
private func calculateRestDays(
at stop: ItineraryStop,
calendar: Calendar
) -> [RestDay] {
// If stop has no games, the entire stay could be considered rest
// But typically we only insert rest days for multi-day stays
guard stop.hasGames else {
// Start/end locations without games - not rest days, just waypoints
return []
}
var restDays: [RestDay] = []
let arrivalDay = calendar.startOfDay(for: stop.arrivalDate)
let departureDay = calendar.startOfDay(for: stop.departureDate)
// If multi-day stay, check each day for games
var currentDay = arrivalDay
while currentDay <= departureDay {
// Skip arrival and departure days (those have the stop itself)
if currentDay != arrivalDay && currentDay != departureDay {
// This is a day in between - could be rest or another game day
// For simplicity, mark in-between days as rest
let restDay = RestDay(
date: currentDay,
location: stop.location,
notes: "Rest day in \(stop.city)"
)
restDays.append(restDay)
}
currentDay = calendar.date(byAdding: .day, value: 1, to: currentDay) ?? currentDay
}
return restDays
}
/// Calculates which calendar days a travel segment spans.
private func calculateTravelDays(
for segment: TravelSegment,
calendar: Calendar
) -> [Date] {
var days: [Date] = []
let startDay = calendar.startOfDay(for: segment.departureTime)
let endDay = calendar.startOfDay(for: segment.arrivalTime)
var currentDay = startDay
while currentDay <= endDay {
days.append(currentDay)
currentDay = calendar.date(byAdding: .day, value: 1, to: currentDay) ?? currentDay
}
return days
}
/// Timeline organized by date for calendar-style display.
func timelineByDate() -> [Date: [TimelineItem]] {
let calendar = Calendar.current
var byDate: [Date: [TimelineItem]] = [:]
for item in generateTimeline() {
let day = calendar.startOfDay(for: item.date)
byDate[day, default: []].append(item)
}
return byDate
}
/// All dates covered by the itinerary.
func allDates() -> [Date] {
let calendar = Calendar.current
guard let firstStop = stops.first,
let lastStop = stops.last else { return [] }
var dates: [Date] = []
var currentDate = calendar.startOfDay(for: firstStop.arrivalDate)
let endDate = calendar.startOfDay(for: lastStop.departureDate)
while currentDate <= endDate {
dates.append(currentDate)
currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate) ?? currentDate
}
return dates
}
}
// MARK: - Planning Request
/// Input to the planning engine.