diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/DateRangePicker.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/DateRangePicker.swift index 7ae52c2..8b54d76 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/DateRangePicker.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/DateRangePicker.swift @@ -90,6 +90,25 @@ struct DateRangePicker: View { selectionState = .complete } } + .onChange(of: startDate) { oldValue, newValue in + // Navigate calendar to show the new month when startDate changes externally + // (e.g., when user selects a game in GamePickerStep) + let oldMonth = calendar.component(.month, from: oldValue) + let newMonth = calendar.component(.month, from: newValue) + let oldYear = calendar.component(.year, from: oldValue) + let newYear = calendar.component(.year, from: newValue) + + if oldMonth != newMonth || oldYear != newYear { + withAnimation(.easeInOut(duration: 0.2)) { + displayedMonth = calendar.startOfDay(for: newValue) + } + } + + // Also update selection state to complete if we have a valid range + if endDate > newValue { + selectionState = .complete + } + } } private var selectedRangeSummary: some View { diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/GamePickerStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/GamePickerStep.swift index 1fbb8bc..882d12f 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/GamePickerStep.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/GamePickerStep.swift @@ -77,11 +77,6 @@ struct GamePickerStep: View { } .padding(Theme.Spacing.lg) .background(Theme.cardBackground(colorScheme)) - .onChange(of: selectedGameIds) { _, newValue in - Task { - await updateDateRangeForSelectedGames() - } - } .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) .overlay { RoundedRectangle(cornerRadius: Theme.CornerRadius.large) @@ -230,6 +225,8 @@ struct GamePickerStep: View { await MainActor.run { summaryGames = Array(Set(games)) } + // Update date range after games are loaded (not before) + await updateDateRangeForSelectedGames() } // MARK: - Date Range Section diff --git a/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift b/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift index 38550e0..e1b591a 100644 --- a/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift +++ b/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift @@ -164,13 +164,15 @@ struct TripWizardView: View { 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, derive date range from selected games + // 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 to find the must-see games + // 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) - // Find the selected must-see games + // Validate that selected must-see games exist let mustSeeGames = allGames.filter { viewModel.selectedGameIds.contains($0.id) } if mustSeeGames.isEmpty { @@ -179,30 +181,14 @@ struct TripWizardView: View { return } - // Derive date range from must-see games (with buffer) - let gameDates = mustSeeGames.map { $0.dateTime } - let minDate = gameDates.min() ?? Date() - let maxDate = gameDates.max() ?? Date() + // 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 - // Update preferences with derived date range - preferences = TripPreferences( - planningMode: preferences.planningMode, - startLocation: preferences.startLocation, - endLocation: preferences.endLocation, - sports: preferences.sports, - mustSeeGameIds: preferences.mustSeeGameIds, - startDate: Calendar.current.startOfDay(for: minDate), - endDate: Calendar.current.date(byAdding: .day, value: 1, to: maxDate) ?? maxDate, - mustStopLocations: preferences.mustStopLocations, - routePreference: preferences.routePreference, - allowRepeatCities: preferences.allowRepeatCities, - selectedRegions: preferences.selectedRegions, - followTeamId: preferences.followTeamId - ) - - // Use all games within the derived date range games = allGames.filter { - $0.dateTime >= preferences.startDate && $0.dateTime <= preferences.endDate + $0.dateTime >= rangeStart && $0.dateTime <= rangeEnd } } else { // Standard mode: fetch games for date range diff --git a/SportsTimeTests/Planning/ScenarioBPlannerTests.swift b/SportsTimeTests/Planning/ScenarioBPlannerTests.swift index a6452f2..31e7b56 100644 --- a/SportsTimeTests/Planning/ScenarioBPlannerTests.swift +++ b/SportsTimeTests/Planning/ScenarioBPlannerTests.swift @@ -138,6 +138,127 @@ struct ScenarioBPlannerTests { } } + // MARK: - Regression Tests: Bonus Games in Date Range + + @Test("plan: gameFirst mode includes bonus games within date range") + func plan_gameFirstMode_includesBonusGamesInDateRange() { + // Regression test: When a single anchor game is selected, the planner should + // find additional "bonus" games within the date range that fit geographically. + // Bug: planTrip() was overriding the 7-day date range with just anchor dates, + // causing only the anchor game to appear in results. + + let startDate = Date() + let endDate = startDate.addingTimeInterval(86400 * 7) // 7-day span + + // NYC and Boston are geographically close (drivable) + let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) + let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) + + // Anchor game on day 4 + let anchorGame = makeGame(id: "anchor-game", stadiumId: "nyc", dateTime: startDate.addingTimeInterval(86400 * 3)) + // Bonus game on day 2 (within date range, geographically sensible) + let bonusGame = makeGame(id: "bonus-game", stadiumId: "boston", dateTime: startDate.addingTimeInterval(86400 * 1)) + + let prefs = TripPreferences( + planningMode: .gameFirst, + sports: [.mlb], + mustSeeGameIds: ["anchor-game"], // Only anchor is selected + startDate: startDate, + endDate: endDate, + leisureLevel: .moderate, + lodgingType: .hotel, + numberOfDrivers: 2 + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [anchorGame, bonusGame], // Both games available + teams: [:], + stadiums: ["nyc": nycStadium, "boston": bostonStadium] + ) + + let result = planner.plan(request: request) + + guard case .success(let options) = result else { + Issue.record("Expected success with bonus game available") + return + } + + #expect(!options.isEmpty, "Should have trip options") + + // At least one option should include the bonus game + let optionsWithBonus = options.filter { option in + option.stops.flatMap { $0.games }.contains("bonus-game") + } + + #expect(!optionsWithBonus.isEmpty, "At least one route should include bonus game from date range") + + // ALL options must still contain the anchor game + for option in options { + let gameIds = option.stops.flatMap { $0.games } + #expect(gameIds.contains("anchor-game"), "Anchor game must be in every route") + } + } + + @Test("plan: gameFirst mode uses full date range not just anchor dates") + func plan_gameFirstMode_usesFullDateRange() { + // Regression test: Verify that the planner considers games across the entire + // date range, not just on the anchor game dates. + + let startDate = Date() + + // 7-day date range + let day1 = startDate + let day3 = startDate.addingTimeInterval(86400 * 2) + let day4 = startDate.addingTimeInterval(86400 * 3) // Anchor game day + let day6 = startDate.addingTimeInterval(86400 * 5) + let endDate = startDate.addingTimeInterval(86400 * 7) + + let nycStadium = makeStadium(id: "nyc", city: "New York", coordinate: nycCoord) + let bostonStadium = makeStadium(id: "boston", city: "Boston", coordinate: bostonCoord) + let phillyStadium = makeStadium(id: "philly", city: "Philadelphia", coordinate: phillyCoord) + + // Anchor game on day 4 + let anchorGame = makeGame(id: "anchor", stadiumId: "nyc", dateTime: day4) + // Games on other days + let day1Game = makeGame(id: "day1-game", stadiumId: "philly", dateTime: day1) + let day3Game = makeGame(id: "day3-game", stadiumId: "boston", dateTime: day3) + let day6Game = makeGame(id: "day6-game", stadiumId: "philly", dateTime: day6) + + let prefs = TripPreferences( + planningMode: .gameFirst, + sports: [.mlb], + mustSeeGameIds: ["anchor"], + startDate: startDate, + endDate: endDate, + leisureLevel: .moderate, + lodgingType: .hotel, + numberOfDrivers: 2 + ) + + let request = PlanningRequest( + preferences: prefs, + availableGames: [anchorGame, day1Game, day3Game, day6Game], + teams: [:], + stadiums: ["nyc": nycStadium, "boston": bostonStadium, "philly": phillyStadium] + ) + + let result = planner.plan(request: request) + + guard case .success(let options) = result else { + Issue.record("Expected success") + return + } + + // Collect all game IDs across all options + let allGameIdsInOptions = Set(options.flatMap { $0.stops.flatMap { $0.games } }) + + // At least some non-anchor games should appear in the results + // (we don't require ALL because geographic constraints may exclude some) + let bonusGamesFound = allGameIdsInOptions.subtracting(["anchor"]) + #expect(!bonusGamesFound.isEmpty, "Planner should find bonus games from full date range, not just anchor date") + } + // MARK: - Specification Tests: Sliding Window @Test("plan: gameFirst mode uses sliding window")