Two bugs fixed in "By Games" trip planning mode: 1. Calendar navigation: DateRangePicker now navigates to the selected game's month when startDate changes externally, instead of staying on the current month. 2. Date range calculation: Fixed race condition where date range was calculated before games were loaded. Now updateDateRangeForSelectedGames() is called after loadSummaryGames() completes. 3. Bonus games: planTrip() now uses the UI-selected 7-day date range instead of overriding it with just the anchor game dates. This allows ScenarioBPlanner to find additional games within the trip window. Added regression tests to verify gameFirst mode includes bonus games. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
313 lines
13 KiB
Swift
313 lines
13 KiB
Swift
//
|
|
// TripWizardView.swift
|
|
// SportsTime
|
|
//
|
|
// Wizard for trip creation.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct TripWizardView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@State private var viewModel = TripWizardViewModel()
|
|
@State private var showTripOptions = false
|
|
@State private var tripOptions: [ItineraryOption] = []
|
|
@State private var gamesForDisplay: [String: RichGame] = [:]
|
|
@State private var planningError: String?
|
|
@State private var showError = false
|
|
|
|
private let planningEngine = TripPlanningEngine()
|
|
|
|
/// Selected team name for display in ReviewStep
|
|
private var selectedTeamName: String? {
|
|
guard let teamId = viewModel.selectedTeamId else { return nil }
|
|
return AppDataProvider.shared.teams.first { $0.id == teamId }?.fullName
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(spacing: Theme.Spacing.lg) {
|
|
// Step 1: Planning Mode (always visible)
|
|
PlanningModeStep(selection: $viewModel.planningMode)
|
|
|
|
// All other steps appear together after planning mode selected
|
|
if viewModel.areStepsVisible {
|
|
Group {
|
|
// Mode-specific steps
|
|
if viewModel.showGamePickerStep {
|
|
GamePickerStep(
|
|
selectedSports: $viewModel.gamePickerSports,
|
|
selectedTeamIds: $viewModel.gamePickerTeamIds,
|
|
selectedGameIds: $viewModel.selectedGameIds,
|
|
startDate: $viewModel.startDate,
|
|
endDate: $viewModel.endDate
|
|
)
|
|
}
|
|
|
|
if viewModel.showTeamPickerStep {
|
|
TeamPickerStep(
|
|
selectedSport: $viewModel.teamPickerSport,
|
|
selectedTeamId: $viewModel.selectedTeamId
|
|
)
|
|
}
|
|
|
|
if viewModel.showLocationsStep {
|
|
LocationsStep(
|
|
startLocation: $viewModel.startLocation,
|
|
endLocation: $viewModel.endLocation
|
|
)
|
|
}
|
|
|
|
// Common steps (conditionally shown)
|
|
if viewModel.showDatesStep {
|
|
DatesStep(
|
|
startDate: $viewModel.startDate,
|
|
endDate: $viewModel.endDate,
|
|
hasSetDates: $viewModel.hasSetDates,
|
|
onDatesChanged: {
|
|
Task {
|
|
await viewModel.fetchSportAvailability()
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
if viewModel.showSportsStep {
|
|
SportsStep(
|
|
selectedSports: $viewModel.selectedSports,
|
|
sportAvailability: viewModel.sportAvailability,
|
|
isLoading: viewModel.isLoadingSportAvailability,
|
|
canSelectSport: viewModel.canSelectSport
|
|
)
|
|
}
|
|
|
|
if viewModel.showRegionsStep {
|
|
RegionsStep(selectedRegions: $viewModel.selectedRegions)
|
|
}
|
|
|
|
// Always shown steps
|
|
RoutePreferenceStep(
|
|
routePreference: $viewModel.routePreference,
|
|
hasSetRoutePreference: $viewModel.hasSetRoutePreference
|
|
)
|
|
|
|
RepeatCitiesStep(
|
|
allowRepeatCities: $viewModel.allowRepeatCities,
|
|
hasSetRepeatCities: $viewModel.hasSetRepeatCities
|
|
)
|
|
|
|
MustStopsStep(mustStopLocations: $viewModel.mustStopLocations)
|
|
|
|
ReviewStep(
|
|
planningMode: viewModel.planningMode ?? .dateRange,
|
|
selectedSports: viewModel.selectedSports,
|
|
startDate: viewModel.startDate,
|
|
endDate: viewModel.endDate,
|
|
selectedRegions: viewModel.selectedRegions,
|
|
routePreference: viewModel.routePreference,
|
|
allowRepeatCities: viewModel.allowRepeatCities,
|
|
mustStopLocations: viewModel.mustStopLocations,
|
|
isPlanning: viewModel.isPlanning,
|
|
canPlanTrip: viewModel.canPlanTrip,
|
|
fieldValidation: viewModel.fieldValidation,
|
|
onPlan: { Task { await planTrip() } },
|
|
selectedGameCount: viewModel.selectedGameIds.count,
|
|
selectedTeamName: selectedTeamName,
|
|
startLocationName: viewModel.startLocation?.name,
|
|
endLocationName: viewModel.endLocation?.name
|
|
)
|
|
}
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.padding(Theme.Spacing.md)
|
|
.animation(.easeInOut(duration: 0.2), value: viewModel.areStepsVisible)
|
|
}
|
|
.themedBackground()
|
|
.navigationTitle("Plan a Trip")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") { dismiss() }
|
|
}
|
|
}
|
|
.navigationDestination(isPresented: $showTripOptions) {
|
|
TripOptionsView(
|
|
options: tripOptions,
|
|
games: gamesForDisplay,
|
|
preferences: buildPreferences(),
|
|
convertToTrip: { option in
|
|
convertOptionToTrip(option)
|
|
}
|
|
)
|
|
}
|
|
.alert("Planning Error", isPresented: $showError) {
|
|
Button("OK") { showError = false }
|
|
} message: {
|
|
Text(planningError ?? "An unknown error occurred")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Planning
|
|
|
|
private func planTrip() async {
|
|
viewModel.isPlanning = true
|
|
defer { viewModel.isPlanning = false }
|
|
|
|
do {
|
|
var preferences = buildPreferences()
|
|
|
|
// Build dictionaries from arrays
|
|
let teamsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.teams.map { ($0.id, $0) })
|
|
let stadiumsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.stadiums.map { ($0.id, $0) })
|
|
|
|
// For gameFirst mode, use the UI-selected date range (set by GamePickerStep)
|
|
// The date range is a 7-day span centered on the selected game(s)
|
|
var games: [Game]
|
|
if viewModel.planningMode == .gameFirst && !viewModel.selectedGameIds.isEmpty {
|
|
// Fetch all games for the selected sports within the UI date range
|
|
// GamePickerStep already set viewModel.startDate/endDate to a 7-day span
|
|
let allGames = try await AppDataProvider.shared.allGames(for: preferences.sports)
|
|
|
|
// Validate that selected must-see games exist
|
|
let mustSeeGames = allGames.filter { viewModel.selectedGameIds.contains($0.id) }
|
|
|
|
if mustSeeGames.isEmpty {
|
|
planningError = "Could not find the selected games. Please try again."
|
|
showError = true
|
|
return
|
|
}
|
|
|
|
// Use the UI-selected date range (already set by GamePickerStep to 7-day span)
|
|
// Filter all games within this range - ScenarioBPlanner will use anchor games
|
|
// as required stops and add bonus games that fit geographically
|
|
let rangeStart = Calendar.current.startOfDay(for: preferences.startDate)
|
|
let rangeEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: preferences.endDate) ?? preferences.endDate
|
|
|
|
games = allGames.filter {
|
|
$0.dateTime >= rangeStart && $0.dateTime <= rangeEnd
|
|
}
|
|
} else {
|
|
// Standard mode: fetch games for date range
|
|
games = try await AppDataProvider.shared.filterGames(
|
|
sports: preferences.sports,
|
|
startDate: preferences.startDate,
|
|
endDate: preferences.endDate
|
|
)
|
|
}
|
|
|
|
// Build RichGame dictionary for display
|
|
var richGamesDict: [String: RichGame] = [:]
|
|
for game in games {
|
|
if let homeTeam = teamsById[game.homeTeamId],
|
|
let awayTeam = teamsById[game.awayTeamId],
|
|
let stadium = stadiumsById[game.stadiumId] {
|
|
let richGame = RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
|
richGamesDict[game.id] = richGame
|
|
}
|
|
}
|
|
|
|
// Build planning request
|
|
let request = PlanningRequest(
|
|
preferences: preferences,
|
|
availableGames: games,
|
|
teams: teamsById,
|
|
stadiums: stadiumsById
|
|
)
|
|
|
|
// Run planning engine
|
|
let result = planningEngine.planItineraries(request: request)
|
|
|
|
switch result {
|
|
case .success(let options):
|
|
if options.isEmpty {
|
|
planningError = "No valid trip options found for your criteria. Try expanding your date range or regions."
|
|
showError = true
|
|
} else {
|
|
tripOptions = options
|
|
gamesForDisplay = richGamesDict
|
|
showTripOptions = true
|
|
}
|
|
case .failure(let failure):
|
|
planningError = failure.message
|
|
showError = true
|
|
}
|
|
} catch {
|
|
planningError = error.localizedDescription
|
|
showError = true
|
|
}
|
|
}
|
|
|
|
private func buildPreferences() -> TripPreferences {
|
|
// Determine which sports to use based on mode
|
|
let sports: Set<Sport>
|
|
if viewModel.planningMode == .gameFirst {
|
|
sports = viewModel.gamePickerSports
|
|
} else if viewModel.planningMode == .followTeam, let sport = viewModel.teamPickerSport {
|
|
sports = [sport]
|
|
} else {
|
|
sports = viewModel.selectedSports
|
|
}
|
|
|
|
return TripPreferences(
|
|
planningMode: viewModel.planningMode ?? .dateRange,
|
|
startLocation: viewModel.startLocation,
|
|
endLocation: viewModel.endLocation,
|
|
sports: sports,
|
|
mustSeeGameIds: viewModel.selectedGameIds,
|
|
startDate: viewModel.startDate,
|
|
endDate: viewModel.endDate,
|
|
mustStopLocations: viewModel.mustStopLocations,
|
|
routePreference: viewModel.routePreference,
|
|
allowRepeatCities: viewModel.allowRepeatCities,
|
|
selectedRegions: viewModel.selectedRegions,
|
|
followTeamId: viewModel.selectedTeamId
|
|
)
|
|
}
|
|
|
|
private func convertOptionToTrip(_ option: ItineraryOption) -> Trip {
|
|
let preferences = buildPreferences()
|
|
|
|
// Convert ItineraryStops to TripStops
|
|
let tripStops = option.stops.enumerated().map { index, stop in
|
|
TripStop(
|
|
stopNumber: index + 1,
|
|
city: stop.city,
|
|
state: stop.state,
|
|
coordinate: stop.coordinate,
|
|
arrivalDate: stop.arrivalDate,
|
|
departureDate: stop.departureDate,
|
|
games: stop.games,
|
|
isRestDay: stop.games.isEmpty
|
|
)
|
|
}
|
|
|
|
return Trip(
|
|
name: generateTripName(from: tripStops),
|
|
preferences: preferences,
|
|
stops: tripStops,
|
|
travelSegments: option.travelSegments,
|
|
totalGames: option.totalGames,
|
|
totalDistanceMeters: option.totalDistanceMiles * 1609.34,
|
|
totalDrivingSeconds: option.totalDrivingHours * 3600
|
|
)
|
|
}
|
|
|
|
private func generateTripName(from stops: [TripStop]) -> String {
|
|
let cities = stops.compactMap { $0.city }.prefix(3)
|
|
if cities.count <= 1 {
|
|
return cities.first ?? "Road Trip"
|
|
}
|
|
return cities.joined(separator: " → ")
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
TripWizardView()
|
|
}
|