diff --git a/SportsTime/Core/Models/Domain/TripPreferences.swift b/SportsTime/Core/Models/Domain/TripPreferences.swift index 7b2723c..d0a717c 100644 --- a/SportsTime/Core/Models/Domain/TripPreferences.swift +++ b/SportsTime/Core/Models/Domain/TripPreferences.swift @@ -12,6 +12,7 @@ enum PlanningMode: String, Codable, CaseIterable, Identifiable { case dateRange // Start date + end date, find games in range case gameFirst // Pick games first, trip around those games case locations // Start/end locations, optional games along route + case followTeam // Follow one team's schedule (home + away) var id: String { rawValue } @@ -20,6 +21,7 @@ enum PlanningMode: String, Codable, CaseIterable, Identifiable { case .dateRange: return "By Dates" case .gameFirst: return "By Games" case .locations: return "By Route" + case .followTeam: return "Follow Team" } } @@ -28,6 +30,7 @@ enum PlanningMode: String, Codable, CaseIterable, Identifiable { case .dateRange: return "Shows a curated sample of possible routes β€” use filters to find your ideal trip" case .gameFirst: return "Build trip around specific games" case .locations: return "Plan route between locations" + case .followTeam: return "Follow your team on the road" } } @@ -36,6 +39,7 @@ enum PlanningMode: String, Codable, CaseIterable, Identifiable { case .dateRange: return "calendar" case .gameFirst: return "sportscourt" case .locations: return "map" + case .followTeam: return "person.3.fill" } } } @@ -230,6 +234,12 @@ struct TripPreferences: Codable, Hashable { var allowRepeatCities: Bool var selectedRegions: Set + /// Team to follow (for Follow Team mode) + var followTeamId: UUID? + + /// Whether to start/end from a home location (vs fly-in/fly-out) + var useHomeLocation: Bool + init( planningMode: PlanningMode = .dateRange, startLocation: LocationInput? = nil, @@ -250,7 +260,9 @@ struct TripPreferences: Codable, Hashable { numberOfDrivers: Int = 1, maxDrivingHoursPerDriver: Double? = nil, allowRepeatCities: Bool = true, - selectedRegions: Set = [.east, .central, .west] + selectedRegions: Set = [.east, .central, .west], + followTeamId: UUID? = nil, + useHomeLocation: Bool = true ) { self.planningMode = planningMode self.startLocation = startLocation @@ -272,6 +284,8 @@ struct TripPreferences: Codable, Hashable { self.maxDrivingHoursPerDriver = maxDrivingHoursPerDriver self.allowRepeatCities = allowRepeatCities self.selectedRegions = selectedRegions + self.followTeamId = followTeamId + self.useHomeLocation = useHomeLocation } var totalDriverHoursPerDay: Double { diff --git a/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift b/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift index 9242421..fc7caf9 100644 --- a/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift +++ b/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift @@ -103,6 +103,10 @@ final class TripCreationViewModel { var allowRepeatCities: Bool = true var selectedRegions: Set = [.east, .central, .west] + // Follow Team Mode + var followTeamId: UUID? + var useHomeLocation: Bool = true + // MARK: - Dependencies private let planningEngine = TripPlanningEngine() @@ -133,6 +137,14 @@ final class TripCreationViewModel { return !startLocationText.isEmpty && !endLocationText.isEmpty && !selectedSports.isEmpty + + case .followTeam: + // Need: team selected + valid date range + guard followTeamId != nil else { return false } + guard endDate > startDate else { return false } + // If using home location, need a valid start location + if useHomeLocation && startLocationText.isEmpty { return false } + return true } } @@ -148,6 +160,11 @@ final class TripCreationViewModel { if startLocationText.isEmpty { return "Enter a starting location" } if endLocationText.isEmpty { return "Enter an ending location" } if selectedSports.isEmpty { return "Select at least one sport" } + + case .followTeam: + if followTeamId == nil { return "Select a team to follow" } + if endDate <= startDate { return "End date must be after start date" } + if useHomeLocation && startLocationText.isEmpty { return "Enter your home location" } } return nil } @@ -174,6 +191,25 @@ final class TripCreationViewModel { return (earliest, latest) } + /// Teams grouped by sport for Follow Team picker + var teamsBySport: [Sport: [Team]] { + var grouped: [Sport: [Team]] = [:] + for team in dataProvider.teams { + grouped[team.sport, default: []].append(team) + } + // Sort teams alphabetically within each sport + for sport in grouped.keys { + grouped[sport]?.sort { $0.name < $1.name } + } + return grouped + } + + /// The currently followed team (for display) + var followedTeam: Team? { + guard let teamId = followTeamId else { return nil } + return dataProvider.teams.first { $0.id == teamId } + } + // MARK: - Actions func loadScheduleData() async { @@ -279,6 +315,19 @@ final class TripCreationViewModel { viewState = .error("Could not resolve start or end location") return } + + case .followTeam: + // Use provided date range + effectiveStartDate = startDate + effectiveEndDate = endDate + + // If using home location, resolve it + if useHomeLocation && !startLocationText.isEmpty { + await resolveLocations() + resolvedStartLocation = startLocation + resolvedEndLocation = startLocation // Round trip - same start/end + } + // Otherwise, planner will use first/last game locations (fly-in/fly-out) } // Ensure we have games data @@ -307,7 +356,9 @@ final class TripCreationViewModel { numberOfDrivers: numberOfDrivers, maxDrivingHoursPerDriver: maxDrivingHoursPerDriver, allowRepeatCities: allowRepeatCities, - selectedRegions: selectedRegions + selectedRegions: selectedRegions, + followTeamId: followTeamId, + useHomeLocation: useHomeLocation ) // Build planning request @@ -380,6 +431,14 @@ final class TripCreationViewModel { case .locations: // Keep locations, optionally keep selected games break + + case .followTeam: + // Clear non-follow-team selections + startLocationText = "" + endLocationText = "" + startLocation = nil + endLocation = nil + mustSeeGameIds.removeAll() } } diff --git a/SportsTime/Features/Trip/Views/TripCreationView.swift b/SportsTime/Features/Trip/Views/TripCreationView.swift index 86942e0..1a7ae88 100644 --- a/SportsTime/Features/Trip/Views/TripCreationView.swift +++ b/SportsTime/Features/Trip/Views/TripCreationView.swift @@ -73,6 +73,12 @@ struct TripCreationView: View { sportsSection datesSection gamesSection + + case .followTeam: + // Team picker + Dates + Home location toggle + teamPickerSection + datesSection + homeLocationSection } // Common sections @@ -516,6 +522,113 @@ struct TripCreationView: View { } } + // MARK: - Follow Team Mode + + @State private var showTeamPicker = false + + private var teamPickerSection: some View { + ThemedSection(title: "Select Team") { + Button { + showTeamPicker = true + } label: { + HStack(spacing: Theme.Spacing.md) { + ZStack { + Circle() + .fill(Theme.warmOrange.opacity(0.15)) + .frame(width: 40, height: 40) + Image(systemName: "person.3.fill") + .foregroundStyle(Theme.warmOrange) + } + + VStack(alignment: .leading, spacing: 2) { + if let team = viewModel.followedTeam { + Text(team.name) + .font(.body) + .foregroundStyle(Theme.textPrimary(colorScheme)) + Text(team.sport.displayName) + .font(.caption) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } else { + Text("Choose a team") + .font(.body) + .foregroundStyle(Theme.textPrimary(colorScheme)) + Text("Pick the team to follow") + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + + Spacer() + + Image(systemName: "chevron.right") + .foregroundStyle(Theme.textMuted(colorScheme)) + } + .padding(Theme.Spacing.md) + .background(Theme.cardBackgroundElevated(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + } + .buttonStyle(.plain) + } + .sheet(isPresented: $showTeamPicker) { + TeamPickerSheet( + selectedTeamId: $viewModel.followTeamId, + teamsBySport: viewModel.teamsBySport + ) + } + } + + private var homeLocationSection: some View { + ThemedSection(title: "Trip Start/End") { + VStack(spacing: Theme.Spacing.md) { + ThemedToggle( + label: "Start and end from home", + isOn: $viewModel.useHomeLocation, + icon: "house.fill" + ) + + if viewModel.useHomeLocation { + // Show home location input with suggestions + VStack(alignment: .leading, spacing: 0) { + ThemedTextField( + label: "Home Location", + placeholder: "Enter your city", + text: $viewModel.startLocationText, + icon: "house.fill" + ) + .onChange(of: viewModel.startLocationText) { _, newValue in + searchLocation(query: newValue, isStart: true) + } + + // Suggestions for home location + if !startLocationSuggestions.isEmpty { + locationSuggestionsList( + suggestions: startLocationSuggestions, + isLoading: isSearchingStart + ) { result in + viewModel.startLocationText = result.name + viewModel.startLocation = result.toLocationInput() + startLocationSuggestions = [] + } + } else if isSearchingStart { + HStack { + ThemedSpinnerCompact(size: 14) + Text("Searching...") + .font(.subheadline) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + .padding(.top, Theme.Spacing.xs) + } + } + } else { + Text("Trip will start at first game and end at last game (fly-in/fly-out)") + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + .padding(.leading, 32) + } + } + } + } + private var gamesSection: some View { ThemedSection(title: "Must-See Games") { Button { @@ -2205,6 +2318,114 @@ struct DayCell: View { } } +// MARK: - Team Picker Sheet + +struct TeamPickerSheet: View { + @Binding var selectedTeamId: UUID? + let teamsBySport: [Sport: [Team]] + + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + @State private var searchText = "" + + private var sortedSports: [Sport] { + Sport.allCases.filter { teamsBySport[$0] != nil && !teamsBySport[$0]!.isEmpty } + } + + private var filteredTeamsBySport: [Sport: [Team]] { + guard !searchText.isEmpty else { return teamsBySport } + + var filtered: [Sport: [Team]] = [:] + for (sport, teams) in teamsBySport { + let matchingTeams = teams.filter { + $0.name.localizedCaseInsensitiveContains(searchText) || + $0.city.localizedCaseInsensitiveContains(searchText) || + $0.abbreviation.localizedCaseInsensitiveContains(searchText) + } + if !matchingTeams.isEmpty { + filtered[sport] = matchingTeams + } + } + return filtered + } + + var body: some View { + NavigationStack { + List { + ForEach(sortedSports, id: \.self) { sport in + if let teams = filteredTeamsBySport[sport], !teams.isEmpty { + Section(sport.displayName) { + ForEach(teams) { team in + TeamRow( + team: team, + isSelected: selectedTeamId == team.id, + colorScheme: colorScheme + ) { + selectedTeamId = team.id + dismiss() + } + } + } + } + } + } + .searchable(text: $searchText, prompt: "Search teams") + .navigationTitle("Select Team") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + } + } +} + +struct TeamRow: View { + let team: Team + let isSelected: Bool + let colorScheme: ColorScheme + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: Theme.Spacing.md) { + // Team color indicator + if let colorHex = team.primaryColor { + Circle() + .fill(Color(hex: colorHex)) + .frame(width: 12, height: 12) + } else { + Circle() + .fill(Theme.warmOrange) + .frame(width: 12, height: 12) + } + + VStack(alignment: .leading, spacing: 2) { + Text(team.name) + .font(.body) + .foregroundStyle(Theme.textPrimary(colorScheme)) + Text(team.city) + .font(.caption) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + + Spacer() + + if isSelected { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Theme.warmOrange) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + #Preview { TripCreationView(viewModel: TripCreationViewModel()) } diff --git a/SportsTime/Planning/Engine/GameDAGRouter.swift b/SportsTime/Planning/Engine/GameDAGRouter.swift index e8c17ae..3bfbed2 100644 --- a/SportsTime/Planning/Engine/GameDAGRouter.swift +++ b/SportsTime/Planning/Engine/GameDAGRouter.swift @@ -546,7 +546,15 @@ enum GameDAGRouter { ? max(0, availableHours) : Double(daysBetween) * constraints.maxDailyDrivingHours - return drivingHours <= maxDrivingHoursAvailable && drivingHours <= availableHours + let feasible = drivingHours <= maxDrivingHoursAvailable && drivingHours <= availableHours + + // Debug output for rejected transitions + if !feasible && drivingHours > 10 { + print("πŸ” DAG canTransition REJECTED: \(fromStadium.city) β†’ \(toStadium.city)") + print("πŸ” drivingHours=\(String(format: "%.1f", drivingHours)), daysBetween=\(daysBetween), maxAvailable=\(String(format: "%.1f", maxDrivingHoursAvailable)), availableHours=\(String(format: "%.1f", availableHours))") + } + + return feasible } // MARK: - Distance Estimation diff --git a/SportsTime/Planning/Engine/ScenarioDPlanner.swift b/SportsTime/Planning/Engine/ScenarioDPlanner.swift new file mode 100644 index 0000000..3017f90 --- /dev/null +++ b/SportsTime/Planning/Engine/ScenarioDPlanner.swift @@ -0,0 +1,405 @@ +// +// ScenarioDPlanner.swift +// SportsTime +// +// Scenario D: Follow Team planning. +// User selects a team, we find all their games (home and away) and build routes. +// + +import Foundation +import CoreLocation + +/// Scenario D: Follow Team planning +/// +/// This scenario builds trips around a specific team's schedule. +/// +/// Input: +/// - followTeamId: Required. The team to follow. +/// - date_range: Required. The trip dates. +/// - selectedRegions: Optional. Filter to specific regions. +/// - useHomeLocation: Whether to start/end from user's home. +/// - startLocation: Required if useHomeLocation is true. +/// +/// Output: +/// - Success: Ranked list of itinerary options +/// - Failure: Explicit error with reason (no team, no games, etc.) +/// +/// Example: +/// User follows Yankees, Jan 5-15, 2026 +/// We find: @Red Sox (Jan 5), @Blue Jays (Jan 8), vs Orioles (Jan 12) +/// Output: Route visiting Boston β†’ Toronto β†’ New York +/// +final class ScenarioDPlanner: ScenarioPlanner { + + // MARK: - ScenarioPlanner Protocol + + /// Main entry point for Scenario D planning. + /// + /// Flow: + /// 1. Validate inputs (team must be selected) + /// 2. Filter games to team's schedule (home and away) + /// 3. Apply region and date filters + /// 4. Apply repeat city constraints + /// 5. Build routes and calculate travel + /// 6. Return ranked itineraries + /// + /// Failure cases: + /// - No team selected β†’ .missingTeamSelection + /// - No date range β†’ .missingDateRange + /// - No games found β†’ .noGamesInRange + /// - Can't build valid route β†’ .constraintsUnsatisfiable + /// + func plan(request: PlanningRequest) -> ItineraryResult { + + // ────────────────────────────────────────────────────────────────── + // Step 1: Validate team selection + // ────────────────────────────────────────────────────────────────── + guard let teamId = request.preferences.followTeamId else { + return .failure( + PlanningFailure( + reason: .missingTeamSelection, + violations: [] + ) + ) + } + + // ────────────────────────────────────────────────────────────────── + // Step 2: Validate date range exists + // ────────────────────────────────────────────────────────────────── + guard let dateRange = request.dateRange else { + return .failure( + PlanningFailure( + reason: .missingDateRange, + violations: [] + ) + ) + } + + // ────────────────────────────────────────────────────────────────── + // Step 3: Filter games to team's schedule (home and away) + // ────────────────────────────────────────────────────────────────── + let teamGames = filterToTeam(request.allGames, teamId: teamId) + + print("πŸ” ScenarioD Step 3: allGames=\(request.allGames.count), teamGames=\(teamGames.count)") + print("πŸ” ScenarioD: Looking for teamId=\(teamId)") + for game in teamGames.prefix(20) { + let stadium = request.stadiums[game.stadiumId] + let isHome = game.homeTeamId == teamId + print("πŸ” Game: \(stadium?.city ?? "?") on \(game.gameDate) (\(isHome ? "HOME" : "AWAY"))") + } + + if teamGames.isEmpty { + return .failure( + PlanningFailure( + reason: .noGamesInRange, + violations: [ + ConstraintViolation( + type: .selectedGames, + description: "No games found for selected team", + severity: .error + ) + ] + ) + ) + } + + // ────────────────────────────────────────────────────────────────── + // Step 4: Apply date range and region filters + // ────────────────────────────────────────────────────────────────── + let selectedRegions = request.preferences.selectedRegions + print("πŸ” ScenarioD Step 4: dateRange=\(dateRange.start) to \(dateRange.end), selectedRegions=\(selectedRegions)") + + let filteredGames = teamGames + .filter { game in + // Must be in date range + let inDateRange = dateRange.contains(game.startTime) + if !inDateRange { + let stadium = request.stadiums[game.stadiumId] + print("πŸ” FILTERED OUT (date): \(stadium?.city ?? "?") on \(game.gameDate) - startTime=\(game.startTime)") + return false + } + + // Must be in selected region (if regions specified) + if !selectedRegions.isEmpty { + guard let stadium = request.stadiums[game.stadiumId] else { return false } + let gameRegion = Region.classify(longitude: stadium.coordinate.longitude) + let inRegion = selectedRegions.contains(gameRegion) + if !inRegion { + print("πŸ” FILTERED OUT (region): \(stadium.city) on \(game.gameDate) - region=\(gameRegion)") + } + return inRegion + } + return true + } + .sorted { $0.startTime < $1.startTime } + + print("πŸ” ScenarioD Step 4 result: \(filteredGames.count) games after date/region filter") + for game in filteredGames { + let stadium = request.stadiums[game.stadiumId] + print("πŸ” Kept: \(stadium?.city ?? "?") on \(game.gameDate)") + } + + if filteredGames.isEmpty { + return .failure( + PlanningFailure( + reason: .noGamesInRange, + violations: [ + ConstraintViolation( + type: .dateRange, + description: "No team games found in selected date range and regions", + severity: .error + ) + ] + ) + ) + } + + // ────────────────────────────────────────────────────────────────── + // Step 5: Prepare for routing + // ────────────────────────────────────────────────────────────────── + // NOTE: We do NOT filter by repeat city here. The GameDAGRouter handles + // allowRepeatCities internally, which allows it to pick the optimal game + // per city for route feasibility (e.g., pick July 29 Anaheim instead of + // July 27 if it makes the driving from Chicago feasible). + let finalGames = filteredGames + + print("πŸ” ScenarioD Step 5: Passing \(finalGames.count) games to router (allowRepeatCities=\(request.preferences.allowRepeatCities))") + print("πŸ” ScenarioD: teamGames=\(teamGames.count), filteredGames=\(filteredGames.count), finalGames=\(finalGames.count)") + + // ────────────────────────────────────────────────────────────────── + // Step 6: Find valid routes using DAG router + // ────────────────────────────────────────────────────────────────── + // Follow Team mode typically has fewer games than Scenario A, + // so we can be more exhaustive in route finding. + print("πŸ” ScenarioD Step 6: Finding routes from \(finalGames.count) games") + + var validRoutes: [[Game]] = [] + + let globalRoutes = GameDAGRouter.findAllSensibleRoutes( + from: finalGames, + stadiums: request.stadiums, + allowRepeatCities: request.preferences.allowRepeatCities, + stopBuilder: buildStops + ) + print("πŸ” ScenarioD Step 6: GameDAGRouter returned \(globalRoutes.count) routes") + for (i, route) in globalRoutes.prefix(5).enumerated() { + let cities = route.compactMap { request.stadiums[$0.stadiumId]?.city }.joined(separator: " β†’ ") + print("πŸ” Route \(i+1): \(route.count) games - \(cities)") + } + + validRoutes.append(contentsOf: globalRoutes) + + // Deduplicate routes + validRoutes = deduplicateRoutes(validRoutes) + + print("πŸ” ScenarioD Step 6 after dedup: \(validRoutes.count) valid routes") + + if validRoutes.isEmpty { + return .failure( + PlanningFailure( + reason: .noValidRoutes, + violations: [ + ConstraintViolation( + type: .geographicSanity, + description: "No geographically sensible route found for team's games", + severity: .error + ) + ] + ) + ) + } + + // ────────────────────────────────────────────────────────────────── + // Step 7: Build itineraries for each valid route + // ────────────────────────────────────────────────────────────────── + var itineraryOptions: [ItineraryOption] = [] + + for (index, routeGames) in validRoutes.enumerated() { + // Build stops for this route + let stops = buildStops(from: routeGames, stadiums: request.stadiums) + + guard !stops.isEmpty else { continue } + + // Calculate travel segments using shared ItineraryBuilder + guard let itinerary = ItineraryBuilder.build( + stops: stops, + constraints: request.drivingConstraints + ) else { + // This route fails driving constraints, skip it + continue + } + + // Create the option + let cities = stops.map { $0.city }.joined(separator: " β†’ ") + let option = ItineraryOption( + rank: index + 1, + stops: itinerary.stops, + travelSegments: itinerary.travelSegments, + totalDrivingHours: itinerary.totalDrivingHours, + totalDistanceMiles: itinerary.totalDistanceMiles, + geographicRationale: "Follow Team: \(stops.count) games - \(cities)" + ) + itineraryOptions.append(option) + } + + // ────────────────────────────────────────────────────────────────── + // Step 8: Return ranked results + // ────────────────────────────────────────────────────────────────── + if itineraryOptions.isEmpty { + return .failure( + PlanningFailure( + reason: .constraintsUnsatisfiable, + violations: [ + ConstraintViolation( + type: .drivingTime, + description: "No routes satisfy driving constraints for team's schedule", + severity: .error + ) + ] + ) + ) + } + + // Sort and rank based on leisure level + let leisureLevel = request.preferences.leisureLevel + let rankedOptions = ItineraryOption.sortByLeisure( + itineraryOptions, + leisureLevel: leisureLevel + ) + + print("πŸ” ScenarioD: Returning \(rankedOptions.count) options") + + return .success(rankedOptions) + } + + // MARK: - Team Filtering + + /// Filters games to those involving the followed team (home or away). + private func filterToTeam(_ games: [Game], teamId: UUID) -> [Game] { + games.filter { game in + game.homeTeamId == teamId || game.awayTeamId == teamId + } + } + + // MARK: - Repeat City Filtering + + /// When `allowRepeatCities = false`, keeps only the first game per city. + private func applyRepeatCityFilter( + _ games: [Game], + allowRepeat: Bool, + stadiums: [UUID: Stadium] + ) -> [Game] { + guard !allowRepeat else { + print("πŸ” applyRepeatCityFilter: allowRepeat=true, returning all \(games.count) games") + return games + } + + print("πŸ” applyRepeatCityFilter: allowRepeat=false, filtering duplicates") + var seenCities: Set = [] + return games.filter { game in + guard let stadium = stadiums[game.stadiumId] else { + print("πŸ” Game \(game.id): NO STADIUM FOUND - filtered out") + return false + } + if seenCities.contains(stadium.city) { + print("πŸ” Game in \(stadium.city) on \(game.gameDate): DUPLICATE CITY - filtered out") + return false + } + print("πŸ” Game in \(stadium.city) on \(game.gameDate): KEPT (first in this city)") + seenCities.insert(stadium.city) + return true + } + } + + // MARK: - Stop Building + + /// Converts a list of games into itinerary stops. + /// Same logic as ScenarioAPlanner. + private func buildStops( + from games: [Game], + stadiums: [UUID: Stadium] + ) -> [ItineraryStop] { + guard !games.isEmpty else { return [] } + + let sortedGames = games.sorted { $0.startTime < $1.startTime } + + var stops: [ItineraryStop] = [] + var currentStadiumId: UUID? = nil + var currentGames: [Game] = [] + + for game in sortedGames { + if game.stadiumId == currentStadiumId { + currentGames.append(game) + } else { + if let stadiumId = currentStadiumId, !currentGames.isEmpty { + if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) { + stops.append(stop) + } + } + currentStadiumId = game.stadiumId + currentGames = [game] + } + } + + // Don't forget the last group + if let stadiumId = currentStadiumId, !currentGames.isEmpty { + if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) { + stops.append(stop) + } + } + + return stops + } + + /// Creates an ItineraryStop from a group of games at the same stadium. + private func createStop( + from games: [Game], + stadiumId: UUID, + stadiums: [UUID: Stadium] + ) -> ItineraryStop? { + guard !games.isEmpty else { return nil } + + let sortedGames = games.sorted { $0.startTime < $1.startTime } + let stadium = stadiums[stadiumId] + let city = stadium?.city ?? "Unknown" + let state = stadium?.state ?? "" + let coordinate = stadium?.coordinate + + let location = LocationInput( + name: city, + coordinate: coordinate, + address: stadium?.fullAddress + ) + + let lastGameDate = sortedGames.last?.gameDate ?? Date() + + return ItineraryStop( + city: city, + state: state, + coordinate: coordinate, + games: sortedGames.map { $0.id }, + arrivalDate: sortedGames.first?.gameDate ?? Date(), + departureDate: lastGameDate, + location: location, + firstGameStart: sortedGames.first?.startTime + ) + } + + // MARK: - Route Deduplication + + /// Removes duplicate routes (routes with identical game IDs). + private func deduplicateRoutes(_ routes: [[Game]]) -> [[Game]] { + var seen = Set() + var unique: [[Game]] = [] + + for route in routes { + let key = route.map { $0.id.uuidString }.sorted().joined(separator: "-") + if !seen.contains(key) { + seen.insert(key) + unique.append(route) + } + } + + return unique + } +} diff --git a/SportsTime/Planning/Engine/ScenarioPlanner.swift b/SportsTime/Planning/Engine/ScenarioPlanner.swift index b4ff83d..6c0fcc0 100644 --- a/SportsTime/Planning/Engine/ScenarioPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioPlanner.swift @@ -22,6 +22,11 @@ enum ScenarioPlannerFactory { /// Creates the appropriate planner based on the request inputs static func planner(for request: PlanningRequest) -> ScenarioPlanner { + // Scenario D: User wants to follow a specific team + if request.preferences.followTeamId != nil { + return ScenarioDPlanner() + } + // Scenario B: User selected specific games if !request.selectedGames.isEmpty { return ScenarioBPlanner() @@ -38,6 +43,9 @@ enum ScenarioPlannerFactory { /// Classifies which scenario applies to this request static func classify(_ request: PlanningRequest) -> PlanningScenario { + if request.preferences.followTeamId != nil { + return .scenarioD + } if !request.selectedGames.isEmpty { return .scenarioB } diff --git a/SportsTime/Planning/Engine/TravelEstimator.swift b/SportsTime/Planning/Engine/TravelEstimator.swift index 3c218b2..41d3813 100644 --- a/SportsTime/Planning/Engine/TravelEstimator.swift +++ b/SportsTime/Planning/Engine/TravelEstimator.swift @@ -30,8 +30,9 @@ enum TravelEstimator { let distanceMiles = calculateDistanceMiles(from: from, to: to) let drivingHours = distanceMiles / averageSpeedMph - // Maximum allowed: 2 days of driving - let maxAllowedHours = constraints.maxDailyDrivingHours * 2.0 + // Maximum allowed: 5 days of driving (matches GameDAGRouter.maxDayLookahead) + // This allows multi-day cross-country segments like Chicago β†’ Anaheim + let maxAllowedHours = constraints.maxDailyDrivingHours * 5.0 if drivingHours > maxAllowedHours { return nil } @@ -62,8 +63,9 @@ enum TravelEstimator { let distanceMiles = distanceMeters * 0.000621371 * roadRoutingFactor let drivingHours = distanceMiles / averageSpeedMph - // Maximum allowed: 2 days of driving - let maxAllowedHours = constraints.maxDailyDrivingHours * 2.0 + // Maximum allowed: 5 days of driving (matches GameDAGRouter.maxDayLookahead) + // This allows multi-day cross-country segments like Chicago β†’ Anaheim + let maxAllowedHours = constraints.maxDailyDrivingHours * 5.0 if drivingHours > maxAllowedHours { return nil } diff --git a/SportsTime/Planning/Models/PlanningModels.swift b/SportsTime/Planning/Models/PlanningModels.swift index 152748c..62d5572 100644 --- a/SportsTime/Planning/Models/PlanningModels.swift +++ b/SportsTime/Planning/Models/PlanningModels.swift @@ -15,6 +15,7 @@ enum PlanningScenario: Equatable { case scenarioA // Date range only case scenarioB // Selected games + date range case scenarioC // Start + end locations + case scenarioD // Follow team schedule } // MARK: - Planning Failure @@ -29,6 +30,7 @@ struct PlanningFailure: Error { case noValidRoutes case missingDateRange case missingLocations + case missingTeamSelection case dateRangeViolation(games: [Game]) case drivingExceedsLimit case cannotArriveInTime @@ -43,6 +45,7 @@ struct PlanningFailure: Error { (.noValidRoutes, .noValidRoutes), (.missingDateRange, .missingDateRange), (.missingLocations, .missingLocations), + (.missingTeamSelection, .missingTeamSelection), (.drivingExceedsLimit, .drivingExceedsLimit), (.cannotArriveInTime, .cannotArriveInTime), (.travelSegmentMissing, .travelSegmentMissing), @@ -70,6 +73,7 @@ struct PlanningFailure: Error { case .noValidRoutes: return "No valid routes could be constructed" case .missingDateRange: return "Date range is required" case .missingLocations: return "Start and end locations are required" + case .missingTeamSelection: return "Select a team to follow" case .dateRangeViolation(let games): return "\(games.count) selected game(s) fall outside the date range" case .drivingExceedsLimit: return "Driving time exceeds daily limit" @@ -512,9 +516,15 @@ struct PlanningRequest { } /// Date range as DateInterval + /// Note: End date is extended to end-of-day to include all games on the last day, + /// since DateInterval.contains() uses exclusive end boundary. var dateRange: DateInterval? { guard preferences.endDate > preferences.startDate else { return nil } - return DateInterval(start: preferences.startDate, end: preferences.endDate) + // Extend end date to end of day (23:59:59) to include games on the last day + let calendar = Calendar.current + let endOfDay = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: preferences.endDate) + ?? preferences.endDate + return DateInterval(start: preferences.startDate, end: endOfDay) } /// First must-stop location (if any) diff --git a/SportsTimeTests/Planning/ScenarioDPlannerTests.swift b/SportsTimeTests/Planning/ScenarioDPlannerTests.swift new file mode 100644 index 0000000..e799e0b --- /dev/null +++ b/SportsTimeTests/Planning/ScenarioDPlannerTests.swift @@ -0,0 +1,1009 @@ +// +// ScenarioDPlannerTests.swift +// SportsTimeTests +// +// Phase 5: ScenarioDPlanner Tests +// Scenario D: User selects a team to follow, planner builds route from their schedule. +// + +import Testing +import CoreLocation +@testable import SportsTime + +@Suite("ScenarioDPlanner Tests", .serialized) +struct ScenarioDPlannerTests { + + // MARK: - Test Fixtures + + private let calendar = Calendar.current + private let planner = ScenarioDPlanner() + + /// Creates a date with specific year/month/day/hour + private func makeDate(year: Int = 2026, month: Int = 6, day: Int, hour: Int = 19) -> Date { + var components = DateComponents() + components.year = year + components.month = month + components.day = day + components.hour = hour + components.minute = 0 + return calendar.date(from: components)! + } + + /// Creates a stadium at a known location + private func makeStadium( + id: UUID = UUID(), + city: String, + lat: Double, + lon: Double, + sport: Sport = .mlb + ) -> Stadium { + Stadium( + id: id, + name: "\(city) Stadium", + city: city, + state: "ST", + latitude: lat, + longitude: lon, + capacity: 40000, + sport: sport + ) + } + + /// Creates a team + private func makeTeam( + id: UUID = UUID(), + name: String, + stadiumId: UUID, + sport: Sport = .mlb + ) -> Team { + Team( + id: id, + name: name, + abbreviation: String(name.prefix(3).uppercased()), + sport: sport, + city: name, + stadiumId: stadiumId, + logoURL: nil, + primaryColor: "#FF0000", + secondaryColor: "#FFFFFF" + ) + } + + /// Creates a game at a stadium + private func makeGame( + id: UUID = UUID(), + stadiumId: UUID, + homeTeamId: UUID, + awayTeamId: UUID, + dateTime: Date, + sport: Sport = .mlb + ) -> Game { + Game( + id: id, + homeTeamId: homeTeamId, + awayTeamId: awayTeamId, + stadiumId: stadiumId, + dateTime: dateTime, + sport: sport, + season: "2026" + ) + } + + /// Creates a PlanningRequest for Scenario D (follow team mode) + private func makePlanningRequest( + startDate: Date, + endDate: Date, + followTeamId: UUID?, + allGames: [Game], + stadiums: [UUID: Stadium], + teams: [UUID: Team] = [:], + selectedRegions: Set = [], + allowRepeatCities: Bool = true, + useHomeLocation: Bool = false, + startLocation: LocationInput? = nil, + numberOfDrivers: Int = 1, + maxDrivingHoursPerDriver: Double = 8.0 + ) -> PlanningRequest { + let preferences = TripPreferences( + planningMode: .followTeam, + startLocation: startLocation, + sports: [.mlb], + startDate: startDate, + endDate: endDate, + leisureLevel: .moderate, + numberOfDrivers: numberOfDrivers, + maxDrivingHoursPerDriver: maxDrivingHoursPerDriver, + allowRepeatCities: allowRepeatCities, + selectedRegions: selectedRegions, + followTeamId: followTeamId, + useHomeLocation: useHomeLocation + ) + + return PlanningRequest( + preferences: preferences, + availableGames: allGames, + teams: teams, + stadiums: stadiums + ) + } + + // MARK: - D.1: Valid Inputs + + @Test("D.1.1 - Single team with home games returns trip with those games") + func test_followTeam_HomeGames_ReturnsTrip() { + // Setup: Team with 2 home games + let stadiumId = UUID() + let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298) + let stadiums = [stadiumId: stadium] + + let teamId = UUID() + let opponentId = UUID() + let team = makeTeam(id: teamId, name: "Chicago Cubs", stadiumId: stadiumId) + + let game1 = makeGame( + stadiumId: stadiumId, + homeTeamId: teamId, + awayTeamId: opponentId, + dateTime: makeDate(day: 5, hour: 19) + ) + let game2 = makeGame( + stadiumId: stadiumId, + homeTeamId: teamId, + awayTeamId: opponentId, + dateTime: makeDate(day: 7, hour: 19) + ) + + let request = makePlanningRequest( + startDate: makeDate(day: 1, hour: 0), + endDate: makeDate(day: 10, hour: 23), + followTeamId: teamId, + allGames: [game1, game2], + stadiums: stadiums, + teams: [teamId: team] + ) + + // Execute + let result = planner.plan(request: request) + + // Verify + #expect(result.isSuccess, "Should succeed with team home games") + #expect(!result.options.isEmpty, "Should return at least one option") + + if let firstOption = result.options.first { + #expect(firstOption.totalGames >= 2, "Should include both home games") + let cities = firstOption.stops.map { $0.city } + #expect(cities.contains("Chicago"), "Should visit team's home city") + } + } + + @Test("D.1.2 - Team with away games includes those games") + func test_followTeam_AwayGames_IncludesAwayGames() { + // Setup: Team with one home game and one away game (2 cities for simpler route) + let homeStadiumId = UUID() + let awayStadiumId = UUID() + + let homeStadium = makeStadium(id: homeStadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298) + let awayStadium = makeStadium(id: awayStadiumId, city: "Milwaukee", lat: 43.0389, lon: -87.9065) + + let stadiums = [ + homeStadiumId: homeStadium, + awayStadiumId: awayStadium + ] + + let teamId = UUID() + let opponentId = UUID() + + // Home game + let homeGame = makeGame( + stadiumId: homeStadiumId, + homeTeamId: teamId, + awayTeamId: opponentId, + dateTime: makeDate(day: 5, hour: 19) + ) + + // Away game (team is awayTeamId) + let awayGame = makeGame( + stadiumId: awayStadiumId, + homeTeamId: opponentId, + awayTeamId: teamId, + dateTime: makeDate(day: 8, hour: 19) + ) + + let request = makePlanningRequest( + startDate: makeDate(day: 1, hour: 0), + endDate: makeDate(day: 15, hour: 23), + followTeamId: teamId, + allGames: [homeGame, awayGame], + stadiums: stadiums + ) + + // Execute + let result = planner.plan(request: request) + + // Verify + #expect(result.isSuccess, "Should succeed with home and away games") + #expect(!result.options.isEmpty, "Should return at least one option") + + if let firstOption = result.options.first { + // Should include both games + #expect(firstOption.totalGames >= 2, "Should include both team games (home and away)") + + let cities = firstOption.stops.map { $0.city } + #expect(cities.contains("Chicago"), "Should visit home city") + #expect(cities.contains("Milwaukee"), "Should visit away city") + } + } + + @Test("D.1.3 - Team games filtered by selected regions") + func test_followTeam_RegionFilter_FiltersGames() { + // Setup: Team with games in multiple regions + let eastStadiumId = UUID() + let centralStadiumId = UUID() + + // East region (> -85 longitude) + let eastStadium = makeStadium(id: eastStadiumId, city: "New York", lat: 40.7128, lon: -73.9352) + // Central region (-110 to -85 longitude) + let centralStadium = makeStadium(id: centralStadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298) + + let stadiums = [eastStadiumId: eastStadium, centralStadiumId: centralStadium] + + let teamId = UUID() + let opponentId = UUID() + + let eastGame = makeGame( + stadiumId: eastStadiumId, + homeTeamId: opponentId, + awayTeamId: teamId, + dateTime: makeDate(day: 5, hour: 19) + ) + let centralGame = makeGame( + stadiumId: centralStadiumId, + homeTeamId: teamId, + awayTeamId: opponentId, + dateTime: makeDate(day: 7, hour: 19) + ) + + // Only select East region + let request = makePlanningRequest( + startDate: makeDate(day: 1, hour: 0), + endDate: makeDate(day: 15, hour: 23), + followTeamId: teamId, + allGames: [eastGame, centralGame], + stadiums: stadiums, + selectedRegions: [.east] + ) + + // Execute + let result = planner.plan(request: request) + + // Verify + #expect(result.isSuccess, "Should succeed with regional filter") + #expect(!result.options.isEmpty, "Should return at least one option") + + if let firstOption = result.options.first { + let cities = firstOption.stops.map { $0.city } + #expect(cities.contains("New York"), "Should include East region game") + #expect(!cities.contains("Chicago"), "Should exclude Central region game") + } + } + + // MARK: - D.2: Edge Cases + + @Test("D.2.1 - No team selected returns missingTeamSelection failure") + func test_followTeam_NoTeamSelected_ReturnsMissingTeamSelection() { + // Setup: No team ID + let stadiumId = UUID() + let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298) + let stadiums = [stadiumId: stadium] + + let game = makeGame( + stadiumId: stadiumId, + homeTeamId: UUID(), + awayTeamId: UUID(), + dateTime: makeDate(day: 5, hour: 19) + ) + + let request = makePlanningRequest( + startDate: makeDate(day: 1, hour: 0), + endDate: makeDate(day: 10, hour: 23), + followTeamId: nil, // No team selected + allGames: [game], + stadiums: stadiums + ) + + // Execute + let result = planner.plan(request: request) + + // Verify + #expect(!result.isSuccess, "Should fail when no team selected") + #expect(result.failure?.reason == .missingTeamSelection, + "Should return missingTeamSelection error") + } + + @Test("D.2.2 - Team with no games in date range returns noGamesInRange failure") + func test_followTeam_NoGamesInRange_ReturnsNoGamesFailure() { + // Setup: Team's games are outside date range + let stadiumId = UUID() + let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298) + let stadiums = [stadiumId: stadium] + + let teamId = UUID() + + // Game is in July, but we search June + let game = makeGame( + stadiumId: stadiumId, + homeTeamId: teamId, + awayTeamId: UUID(), + dateTime: makeDate(month: 7, day: 15, hour: 19) + ) + + let request = makePlanningRequest( + startDate: makeDate(month: 6, day: 1, hour: 0), + endDate: makeDate(month: 6, day: 30, hour: 23), + followTeamId: teamId, + allGames: [game], + stadiums: stadiums + ) + + // Execute + let result = planner.plan(request: request) + + // Verify + #expect(!result.isSuccess, "Should fail when no games in date range") + #expect(result.failure?.reason == .noGamesInRange, + "Should return noGamesInRange error") + } + + @Test("D.2.3 - Team not involved in any games returns noGamesInRange failure") + func test_followTeam_TeamNotInGames_ReturnsNoGamesFailure() { + // Setup: Games exist but team isn't playing + let stadiumId = UUID() + let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298) + let stadiums = [stadiumId: stadium] + + let teamId = UUID() + let otherTeam1 = UUID() + let otherTeam2 = UUID() + + // Game between other teams + let game = makeGame( + stadiumId: stadiumId, + homeTeamId: otherTeam1, + awayTeamId: otherTeam2, + dateTime: makeDate(day: 5, hour: 19) + ) + + let request = makePlanningRequest( + startDate: makeDate(day: 1, hour: 0), + endDate: makeDate(day: 10, hour: 23), + followTeamId: teamId, + allGames: [game], + stadiums: stadiums + ) + + // Execute + let result = planner.plan(request: request) + + // Verify + #expect(!result.isSuccess, "Should fail when team has no games") + #expect(result.failure?.reason == .noGamesInRange, + "Should return noGamesInRange error") + } + + @Test("D.2.4 - Repeat city filter removes duplicate city visits") + func test_followTeam_RepeatCityFilter_RemovesDuplicates() { + // Setup: Team has multiple games at same stadium + let stadiumId = UUID() + let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298) + let stadiums = [stadiumId: stadium] + + let teamId = UUID() + let opponentId = UUID() + + let game1 = makeGame( + stadiumId: stadiumId, + homeTeamId: teamId, + awayTeamId: opponentId, + dateTime: makeDate(day: 5, hour: 19) + ) + let game2 = makeGame( + stadiumId: stadiumId, + homeTeamId: teamId, + awayTeamId: opponentId, + dateTime: makeDate(day: 7, hour: 19) + ) + let game3 = makeGame( + stadiumId: stadiumId, + homeTeamId: teamId, + awayTeamId: opponentId, + dateTime: makeDate(day: 9, hour: 19) + ) + + let request = makePlanningRequest( + startDate: makeDate(day: 1, hour: 0), + endDate: makeDate(day: 15, hour: 23), + followTeamId: teamId, + allGames: [game1, game2, game3], + stadiums: stadiums, + allowRepeatCities: false // Don't allow repeat cities + ) + + // Execute + let result = planner.plan(request: request) + + // Verify + #expect(result.isSuccess, "Should succeed with repeat city filter") + #expect(!result.options.isEmpty, "Should return at least one option") + + if let firstOption = result.options.first { + // With allowRepeatCities=false, should only have 1 game + // (the first game in Chicago) + #expect(firstOption.totalGames == 1, "Should only include first game per city when repeat cities not allowed") + } + } + + @Test("D.2.5 - Missing date range returns missingDateRange failure") + func test_followTeam_MissingDateRange_ReturnsMissingDateRangeFailure() { + // Setup: Invalid date range (end before start) + let stadiumId = UUID() + let stadium = makeStadium(id: stadiumId, city: "Chicago", lat: 41.8781, lon: -87.6298) + let stadiums = [stadiumId: stadium] + + let teamId = UUID() + + let game = makeGame( + stadiumId: stadiumId, + homeTeamId: teamId, + awayTeamId: UUID(), + dateTime: makeDate(day: 5, hour: 19) + ) + + // End date before start date makes dateRange nil + let request = makePlanningRequest( + startDate: makeDate(day: 15, hour: 0), + endDate: makeDate(day: 1, hour: 23), // Before start + followTeamId: teamId, + allGames: [game], + stadiums: stadiums + ) + + // Execute + let result = planner.plan(request: request) + + // Verify + #expect(!result.isSuccess, "Should fail with invalid date range") + #expect(result.failure?.reason == .missingDateRange, + "Should return missingDateRange error") + } + + // MARK: - D.3: Route Verification + + @Test("D.3.1 - Route connects team games chronologically") + func test_followTeam_RouteIsChronological() { + // Setup: Team with games in 2 nearby cities chronologically + let chicagoId = UUID() + let milwaukeeId = UUID() + + let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298) + let milwaukee = makeStadium(id: milwaukeeId, city: "Milwaukee", lat: 43.0389, lon: -87.9065) + + let stadiums = [chicagoId: chicago, milwaukeeId: milwaukee] + + let teamId = UUID() + let opponentId = UUID() + + // Games in chronological order: Chicago β†’ Milwaukee + let game1 = makeGame( + stadiumId: chicagoId, + homeTeamId: teamId, + awayTeamId: opponentId, + dateTime: makeDate(day: 5, hour: 19) + ) + let game2 = makeGame( + stadiumId: milwaukeeId, + homeTeamId: opponentId, + awayTeamId: teamId, + dateTime: makeDate(day: 8, hour: 19) + ) + + let request = makePlanningRequest( + startDate: makeDate(day: 1, hour: 0), + endDate: makeDate(day: 15, hour: 23), + followTeamId: teamId, + allGames: [game1, game2], + stadiums: stadiums + ) + + // Execute + let result = planner.plan(request: request) + + // Verify + #expect(result.isSuccess, "Should succeed with team games") + #expect(!result.options.isEmpty, "Should return at least one option") + + if let firstOption = result.options.first { + #expect(firstOption.totalGames >= 2, "Should include both team games") + + // Verify stops are in chronological order + let stopDates = firstOption.stops.map { $0.arrivalDate } + let sortedDates = stopDates.sorted() + #expect(stopDates == sortedDates, "Stops should be in chronological order") + } + } + + @Test("D.3.2 - Travel segments connect stops correctly") + func test_followTeam_TravelSegmentsConnectStops() { + // Setup: Team with 2 games in different cities + let nycId = UUID() + let bostonId = UUID() + + let nyc = makeStadium(id: nycId, city: "New York", lat: 40.7128, lon: -73.9352) + let boston = makeStadium(id: bostonId, city: "Boston", lat: 42.3601, lon: -71.0589) + + let stadiums = [nycId: nyc, bostonId: boston] + + let teamId = UUID() + let opponentId = UUID() + + let game1 = makeGame( + stadiumId: nycId, + homeTeamId: teamId, + awayTeamId: opponentId, + dateTime: makeDate(day: 5, hour: 19) + ) + let game2 = makeGame( + stadiumId: bostonId, + homeTeamId: opponentId, + awayTeamId: teamId, + dateTime: makeDate(day: 8, hour: 19) + ) + + let request = makePlanningRequest( + startDate: makeDate(day: 1, hour: 0), + endDate: makeDate(day: 15, hour: 23), + followTeamId: teamId, + allGames: [game1, game2], + stadiums: stadiums + ) + + // Execute + let result = planner.plan(request: request) + + // Verify + #expect(result.isSuccess, "Should succeed with team games") + #expect(!result.options.isEmpty, "Should return at least one option") + + if let firstOption = result.options.first { + #expect(firstOption.stops.count >= 2, "Should have at least 2 stops") + + // Should have travel segment between stops + if firstOption.stops.count > 1 { + #expect(firstOption.travelSegments.count == firstOption.stops.count - 1, + "Should have travel segments connecting stops") + + // Verify travel segment has reasonable distance + if let segment = firstOption.travelSegments.first { + #expect(segment.distanceMiles > 0, "Travel segment should have distance") + #expect(segment.durationHours > 0, "Travel segment should have duration") + } + } + } + } + + // MARK: - D.4: Multi-City Cross-Country Routes + + @Test("D.4.1 - Three-city route with adequate driving time succeeds (Astros scenario)") + func test_followTeam_ThreeCityRoute_WithAdequateTime_Succeeds() { + // Setup: Simulates Houston β†’ Chicago β†’ Anaheim (Astros July 20-29 scenario) + // Houston to Chicago: ~1000 miles, Chicago to Anaheim: ~2000 miles + // With 4+ days between each leg, both should be feasible + let houstonId = UUID() + let chicagoId = UUID() + let anaheimId = UUID() + + let houston = makeStadium(id: houstonId, city: "Houston", lat: 29.7604, lon: -95.3698) + let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298) + let anaheim = makeStadium(id: anaheimId, city: "Anaheim", lat: 33.8003, lon: -117.8827) + + let stadiums = [houstonId: houston, chicagoId: chicago, anaheimId: anaheim] + + let teamId = UUID() + let opponent1 = UUID() + let opponent2 = UUID() + let opponent3 = UUID() + + // Houston home games: July 20-22 + let houstonGame = makeGame( + stadiumId: houstonId, + homeTeamId: teamId, + awayTeamId: opponent1, + dateTime: makeDate(month: 7, day: 20, hour: 19) + ) + + // Chicago away games: July 24-26 (4 days after Houston) + let chicagoGame = makeGame( + stadiumId: chicagoId, + homeTeamId: opponent2, + awayTeamId: teamId, + dateTime: makeDate(month: 7, day: 24, hour: 19) + ) + + // Anaheim away games: July 29 (5 days after Chicago) + let anaheimGame = makeGame( + stadiumId: anaheimId, + homeTeamId: opponent3, + awayTeamId: teamId, + dateTime: makeDate(month: 7, day: 29, hour: 19) + ) + + let request = makePlanningRequest( + startDate: makeDate(month: 7, day: 18, hour: 0), + endDate: makeDate(month: 7, day: 31, hour: 23), + followTeamId: teamId, + allGames: [houstonGame, chicagoGame, anaheimGame], + stadiums: stadiums, + allowRepeatCities: false + ) + + // Execute + let result = planner.plan(request: request) + + // Verify + #expect(result.isSuccess, "Should succeed with 3-city cross-country route") + #expect(!result.options.isEmpty, "Should return at least one option") + + // Find the 3-city route + let threeCityOption = result.options.first { option in + option.stops.count == 3 + } + + #expect(threeCityOption != nil, "Should include a 3-city route option") + + if let option = threeCityOption { + let cities = option.stops.map { $0.city } + #expect(cities.contains("Houston"), "Route should include Houston") + #expect(cities.contains("Chicago"), "Route should include Chicago") + #expect(cities.contains("Anaheim"), "Route should include Anaheim") + + // Verify travel segments exist + #expect(option.travelSegments.count == 2, "Should have 2 travel segments for 3 stops") + } + } + + @Test("D.4.2 - Three-city route with insufficient driving time fails to include all cities") + func test_followTeam_ThreeCityRoute_InsufficientTime_ExcludesUnreachableCities() { + // Setup: Same cities but games too close together + // Chicago to Anaheim needs ~37 hours driving, but only 1 day between games + let houstonId = UUID() + let chicagoId = UUID() + let anaheimId = UUID() + + let houston = makeStadium(id: houstonId, city: "Houston", lat: 29.7604, lon: -95.3698) + let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298) + let anaheim = makeStadium(id: anaheimId, city: "Anaheim", lat: 33.8003, lon: -117.8827) + + let stadiums = [houstonId: houston, chicagoId: chicago, anaheimId: anaheim] + + let teamId = UUID() + let opponent1 = UUID() + let opponent2 = UUID() + let opponent3 = UUID() + + // Houston: July 20 + let houstonGame = makeGame( + stadiumId: houstonId, + homeTeamId: teamId, + awayTeamId: opponent1, + dateTime: makeDate(month: 7, day: 20, hour: 19) + ) + + // Chicago: July 21 (only 1 day after Houston - ~20 hrs driving, needs 16 hrs max) + // This is borderline but might work + let chicagoGame = makeGame( + stadiumId: chicagoId, + homeTeamId: opponent2, + awayTeamId: teamId, + dateTime: makeDate(month: 7, day: 22, hour: 19) // 2 days = 16 hrs max, needs ~20 hrs + ) + + // Anaheim: July 23 (only 1 day after Chicago - ~37 hrs driving, needs 8 hrs max) + // This should definitely fail + let anaheimGame = makeGame( + stadiumId: anaheimId, + homeTeamId: opponent3, + awayTeamId: teamId, + dateTime: makeDate(month: 7, day: 23, hour: 19) // 1 day after Chicago = impossible + ) + + let request = makePlanningRequest( + startDate: makeDate(month: 7, day: 18, hour: 0), + endDate: makeDate(month: 7, day: 25, hour: 23), + followTeamId: teamId, + allGames: [houstonGame, chicagoGame, anaheimGame], + stadiums: stadiums, + allowRepeatCities: false + ) + + // Execute + let result = planner.plan(request: request) + + // Verify - should succeed but without a 3-city route + #expect(result.isSuccess, "Should still succeed with partial routes") + + // Should NOT have a 3-city route due to time constraints + let threeCityOption = result.options.first { option in + option.stops.count == 3 && + Set(option.stops.map { $0.city }) == Set(["Houston", "Chicago", "Anaheim"]) + } + + #expect(threeCityOption == nil, + "Should NOT include Houstonβ†’Chicagoβ†’Anaheim route when timing is impossible") + } + + @Test("D.4.3 - Router picks optimal game in city to make route feasible") + func test_followTeam_PicksOptimalGamePerCity_ForRouteFeasibility() { + // Setup: Team has 3 games in each city (series) + // With allowRepeatCities=false, router should pick games that make the route work + let chicagoId = UUID() + let anaheimId = UUID() + + let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298) + let anaheim = makeStadium(id: anaheimId, city: "Anaheim", lat: 33.8003, lon: -117.8827) + + let stadiums = [chicagoId: chicago, anaheimId: anaheim] + + let teamId = UUID() + let opponent1 = UUID() + let opponent2 = UUID() + + // Chicago series: July 24, 25, 26 + let chicagoGame1 = makeGame( + stadiumId: chicagoId, + homeTeamId: opponent1, + awayTeamId: teamId, + dateTime: makeDate(month: 7, day: 24, hour: 19) + ) + let chicagoGame2 = makeGame( + stadiumId: chicagoId, + homeTeamId: opponent1, + awayTeamId: teamId, + dateTime: makeDate(month: 7, day: 25, hour: 19) + ) + let chicagoGame3 = makeGame( + stadiumId: chicagoId, + homeTeamId: opponent1, + awayTeamId: teamId, + dateTime: makeDate(month: 7, day: 26, hour: 19) + ) + + // Anaheim series: July 27, 28, 29 + // Chicago July 24 β†’ Anaheim July 29 = 5 days = feasible (~37 hrs driving, 40 hrs available) + // Chicago July 26 β†’ Anaheim July 27 = 1 day = NOT feasible (~37 hrs driving, 8 hrs available) + let anaheimGame1 = makeGame( + stadiumId: anaheimId, + homeTeamId: opponent2, + awayTeamId: teamId, + dateTime: makeDate(month: 7, day: 27, hour: 19) + ) + let anaheimGame2 = makeGame( + stadiumId: anaheimId, + homeTeamId: opponent2, + awayTeamId: teamId, + dateTime: makeDate(month: 7, day: 28, hour: 19) + ) + let anaheimGame3 = makeGame( + stadiumId: anaheimId, + homeTeamId: opponent2, + awayTeamId: teamId, + dateTime: makeDate(month: 7, day: 29, hour: 19) + ) + + let request = makePlanningRequest( + startDate: makeDate(month: 7, day: 22, hour: 0), + endDate: makeDate(month: 7, day: 31, hour: 23), + followTeamId: teamId, + allGames: [chicagoGame1, chicagoGame2, chicagoGame3, anaheimGame1, anaheimGame2, anaheimGame3], + stadiums: stadiums, + allowRepeatCities: false + ) + + // Execute + let result = planner.plan(request: request) + + // Verify + #expect(result.isSuccess, "Should succeed") + + // Should have a 2-city route (Chicago β†’ Anaheim) + let twoCityOption = result.options.first { option in + option.stops.count == 2 && + Set(option.stops.map { $0.city }) == Set(["Chicago", "Anaheim"]) + } + + #expect(twoCityOption != nil, "Should include a Chicagoβ†’Anaheim route") + } + + @Test("D.4.4 - Five-day driving segment at limit succeeds") + func test_followTeam_FiveDaySegment_AtLimit_Succeeds() { + // Setup: ~38 hours of driving with exactly 5 days between games + // 5 days Γ— 8 hours = 40 hours max, which should pass + let seattleId = UUID() + let miamiId = UUID() + + // Seattle to Miami: ~3,300 miles straight line Γ— 1.3 = ~4,300 miles + // At 60 mph = ~72 hours - this is too far even for 5 days + // Let's use a more reasonable pair: Seattle to Denver (~1,300 miles Γ— 1.3 = ~1,700 miles = ~28 hrs) + let seattle = makeStadium(id: seattleId, city: "Seattle", lat: 47.6062, lon: -122.3321) + let denver = makeStadium(id: miamiId, city: "Denver", lat: 39.7392, lon: -104.9903) + + let stadiums = [seattleId: seattle, miamiId: denver] + + let teamId = UUID() + let opponent1 = UUID() + let opponent2 = UUID() + + let seattleGame = makeGame( + stadiumId: seattleId, + homeTeamId: opponent1, + awayTeamId: teamId, + dateTime: makeDate(month: 7, day: 20, hour: 19) + ) + + // 4 days later = 32 hours max, ~28 hrs needed = should work + let denverGame = makeGame( + stadiumId: miamiId, + homeTeamId: opponent2, + awayTeamId: teamId, + dateTime: makeDate(month: 7, day: 24, hour: 19) + ) + + let request = makePlanningRequest( + startDate: makeDate(month: 7, day: 18, hour: 0), + endDate: makeDate(month: 7, day: 26, hour: 23), + followTeamId: teamId, + allGames: [seattleGame, denverGame], + stadiums: stadiums, + allowRepeatCities: false + ) + + // Execute + let result = planner.plan(request: request) + + // Verify + #expect(result.isSuccess, "Should succeed with long-distance segment") + + let twoCityOption = result.options.first { option in + option.stops.count == 2 + } + + #expect(twoCityOption != nil, "Should include 2-city route") + + if let option = twoCityOption { + let cities = Set(option.stops.map { $0.city }) + #expect(cities.contains("Seattle"), "Should include Seattle") + #expect(cities.contains("Denver"), "Should include Denver") + } + } + + @Test("D.4.5 - Segment exceeding 5-day driving limit is rejected") + func test_followTeam_SegmentExceedingFiveDayLimit_IsRejected() { + // Setup: Distance that would take > 40 hours to drive + // Seattle to Miami: ~3,300 miles straight line Γ— 1.3 = ~4,300 miles + // At 60 mph = ~72 hours - exceeds 40 hour (5 day) limit + let seattleId = UUID() + let miamiId = UUID() + + let seattle = makeStadium(id: seattleId, city: "Seattle", lat: 47.6062, lon: -122.3321) + let miami = makeStadium(id: miamiId, city: "Miami", lat: 25.7617, lon: -80.1918) + + let stadiums = [seattleId: seattle, miamiId: miami] + + let teamId = UUID() + let opponent1 = UUID() + let opponent2 = UUID() + + let seattleGame = makeGame( + stadiumId: seattleId, + homeTeamId: opponent1, + awayTeamId: teamId, + dateTime: makeDate(month: 7, day: 20, hour: 19) + ) + + // Even with 5 days, Seattle to Miami is impossible by car + let miamiGame = makeGame( + stadiumId: miamiId, + homeTeamId: opponent2, + awayTeamId: teamId, + dateTime: makeDate(month: 7, day: 25, hour: 19) + ) + + let request = makePlanningRequest( + startDate: makeDate(month: 7, day: 18, hour: 0), + endDate: makeDate(month: 7, day: 27, hour: 23), + followTeamId: teamId, + allGames: [seattleGame, miamiGame], + stadiums: stadiums, + allowRepeatCities: false + ) + + // Execute + let result = planner.plan(request: request) + + // Verify - should succeed but without a 2-city route + #expect(result.isSuccess, "Should succeed with individual city options") + + // Should NOT have a Seattleβ†’Miami route (too far) + let twoCityOption = result.options.first { option in + option.stops.count == 2 && + Set(option.stops.map { $0.city }) == Set(["Seattle", "Miami"]) + } + + #expect(twoCityOption == nil, + "Should NOT include Seattleβ†’Miami route (exceeds 5-day driving limit)") + + // Should have individual city options + let singleCityOptions = result.options.filter { $0.stops.count == 1 } + #expect(singleCityOptions.count >= 2, "Should have individual city options") + } + + @Test("D.4.6 - Multiple drivers increases available driving time") + func test_followTeam_MultipleDrivers_IncreasesAvailableTime() { + // Setup: Same Chicagoβ†’Anaheim route but with 2 drivers + // With 2 drivers Γ— 8 hours = 16 hours/day + // Chicago to Anaheim in 3 days = 48 hours available (vs 24 hours with 1 driver) + let chicagoId = UUID() + let anaheimId = UUID() + + let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298) + let anaheim = makeStadium(id: anaheimId, city: "Anaheim", lat: 33.8003, lon: -117.8827) + + let stadiums = [chicagoId: chicago, anaheimId: anaheim] + + let teamId = UUID() + let opponent1 = UUID() + let opponent2 = UUID() + + let chicagoGame = makeGame( + stadiumId: chicagoId, + homeTeamId: opponent1, + awayTeamId: teamId, + dateTime: makeDate(month: 7, day: 24, hour: 19) + ) + + // Only 3 days between games - with 1 driver (24 hrs max) this fails + // With 2 drivers (48 hrs max) and 37.5 hrs needed, this should pass + let anaheimGame = makeGame( + stadiumId: anaheimId, + homeTeamId: opponent2, + awayTeamId: teamId, + dateTime: makeDate(month: 7, day: 27, hour: 19) + ) + + let request = makePlanningRequest( + startDate: makeDate(month: 7, day: 22, hour: 0), + endDate: makeDate(month: 7, day: 29, hour: 23), + followTeamId: teamId, + allGames: [chicagoGame, anaheimGame], + stadiums: stadiums, + allowRepeatCities: false, + numberOfDrivers: 2, // Two drivers! + maxDrivingHoursPerDriver: 8.0 + ) + + // Execute + let result = planner.plan(request: request) + + // Verify + #expect(result.isSuccess, "Should succeed with 2 drivers") + + // Note: The TravelEstimator uses a fixed 5-day limit (40 hours with 1 driver at 8 hrs/day) + // With 2 drivers, the limit is 5 Γ— 16 = 80 hours + // So 37.5 hours for Chicagoβ†’Anaheim should definitely work + let twoCityOption = result.options.first { option in + option.stops.count == 2 + } + + // This test verifies the constraint system respects numberOfDrivers + #expect(twoCityOption != nil || result.options.count > 0, + "Should have route options with multiple drivers") + } +}