From d97dec44b2db0363e5caae12fa094a7808d659fa Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 21 Jan 2026 16:37:19 -0600 Subject: [PATCH] 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 --- .../Views/Wizard/Steps/DateRangePicker.swift | 19 +++ .../Views/Wizard/Steps/GamePickerStep.swift | 7 +- .../Trip/Views/Wizard/TripWizardView.swift | 36 ++---- .../Planning/ScenarioBPlannerTests.swift | 121 ++++++++++++++++++ 4 files changed, 153 insertions(+), 30 deletions(-) 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")