Files
Sportstime/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift
Trey t d97dec44b2 fix(planning): gameFirst mode now uses full date range and shows correct month
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>
2026-01-21 16:37:19 -06:00

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