diff --git a/SportsTime/Core/Services/DataProvider.swift b/SportsTime/Core/Services/DataProvider.swift index 23e8088..7e6538a 100644 --- a/SportsTime/Core/Services/DataProvider.swift +++ b/SportsTime/Core/Services/DataProvider.swift @@ -154,7 +154,7 @@ final class AppDataProvider: ObservableObject { throw DataProviderError.contextNotConfigured } - let sportStrings = sports.map { $0.rawValue } + let sportStrings = Set(sports.map(\.rawValue)) let descriptor = FetchDescriptor( predicate: #Predicate { game in @@ -179,7 +179,7 @@ final class AppDataProvider: ObservableObject { throw DataProviderError.contextNotConfigured } - let sportStrings = sports.map { $0.rawValue } + let sportStrings = Set(sports.map(\.rawValue)) let descriptor = FetchDescriptor( predicate: #Predicate { game in diff --git a/SportsTime/Core/Services/SuggestedTripsGenerator.swift b/SportsTime/Core/Services/SuggestedTripsGenerator.swift index 4230344..07efb09 100644 --- a/SportsTime/Core/Services/SuggestedTripsGenerator.swift +++ b/SportsTime/Core/Services/SuggestedTripsGenerator.swift @@ -211,7 +211,7 @@ final class SuggestedTripsGenerator { } /// Performs heavy trip generation computation off the main actor. - /// Runs all 3 regions + 2 cross-country routes concurrently. + /// Runs all 3 regions + up to 2 directional cross-country routes concurrently. private nonisolated static func generateTripsInBackground( games: [Game], stadiums: [Stadium], @@ -222,7 +222,7 @@ final class SuggestedTripsGenerator { let stadiumsById = stadiums.reduce(into: [String: Stadium]()) { $0[$1.id] = $1 } let teamsById = teams.reduce(into: [String: Team]()) { $0[$1.id] = $1 } - // Run all 3 regions + 2 cross-country routes concurrently + // Run all 3 regions + 2 directional cross-country routes concurrently async let eastTrips = generateRegionTrips( region: .east, games: games, stadiums: stadiums, stadiumsById: stadiumsById, teamsById: teamsById, @@ -238,22 +238,35 @@ final class SuggestedTripsGenerator { stadiumsById: stadiumsById, teamsById: teamsById, startDate: startDate, endDate: endDate ) - async let crossCountry1 = generateCrossCountryTrip( - games: games, stadiums: stadiumsById, teams: teamsById, - startDate: startDate, endDate: endDate, excludeGames: [] + async let eastToWestCrossCountry = generateCrossCountryTrip( + games: games, + stadiums: stadiumsById, + teams: teamsById, + direction: .eastToWest ) - async let crossCountry2 = generateCrossCountryTrip( - games: games, stadiums: stadiumsById, teams: teamsById, - startDate: startDate, endDate: endDate, - excludeGames: [] // Can't depend on crossCountry1 without breaking parallelism + async let westToEastCrossCountry = generateCrossCountryTrip( + games: games, + stadiums: stadiumsById, + teams: teamsById, + direction: .westToEast ) var results: [SuggestedTrip] = [] results.append(contentsOf: await eastTrips) results.append(contentsOf: await centralTrips) results.append(contentsOf: await westTrips) - if let cc1 = await crossCountry1 { results.append(cc1) } - if let cc2 = await crossCountry2 { results.append(cc2) } + + var crossCountrySignatures = Set() + if let eastToWest = await eastToWestCrossCountry { + results.append(eastToWest) + crossCountrySignatures.insert(crossCountrySignature(for: eastToWest)) + } + if let westToEast = await westToEastCrossCountry { + let signature = crossCountrySignature(for: westToEast) + if !crossCountrySignatures.contains(signature) { + results.append(westToEast) + } + } return results } @@ -360,84 +373,65 @@ final class SuggestedTripsGenerator { return result } + private enum CrossCountryDirection { + case eastToWest + case westToEast + } + private nonisolated static func generateCrossCountryTrip( games: [Game], stadiums: [String: Stadium], teams: [String: Team], - startDate: Date, - endDate: Date, - excludeGames: [Game] + direction: CrossCountryDirection ) -> SuggestedTrip? { - - let excludeIds = Set(excludeGames.map { $0.id }) - let availableGames = games.filter { !excludeIds.contains($0.id) } - - guard !availableGames.isEmpty else { return nil } + guard !games.isEmpty else { return nil } // Group games by region var gamesByRegion: [Region: [Game]] = [:] - for game in availableGames { + for game in games { guard let stadium = stadiums[game.stadiumId] else { continue } gamesByRegion[stadium.region, default: []].append(game) } - print("📍 CrossCountry: Games by region - East: \(gamesByRegion[.east]?.count ?? 0), Central: \(gamesByRegion[.central]?.count ?? 0), West: \(gamesByRegion[.west]?.count ?? 0)") - // REQUIREMENT: Must have games in BOTH East AND West for a true coast-to-coast guard let eastGames = gamesByRegion[.east], !eastGames.isEmpty, let westGames = gamesByRegion[.west], !westGames.isEmpty else { - print("❌ CrossCountry: Need games in BOTH East and West regions") return nil } let centralGames = gamesByRegion[.central] ?? [] let calendar = Calendar.current - // Try both directions: East→West and West→East - let eastToWest = buildCoastToCoastRoute( - startGames: eastGames, - middleGames: centralGames, - endGames: westGames, - stadiums: stadiums, - calendar: calendar - ) + let selectedGames: [Game] + switch direction { + case .eastToWest: + selectedGames = buildCoastToCoastRoute( + startGames: eastGames, + middleGames: centralGames, + endGames: westGames, + stadiums: stadiums, + calendar: calendar + ) + case .westToEast: + selectedGames = buildCoastToCoastRoute( + startGames: westGames, + middleGames: centralGames, + endGames: eastGames, + stadiums: stadiums, + calendar: calendar + ) + } - let westToEast = buildCoastToCoastRoute( - startGames: westGames, - middleGames: centralGames, - endGames: eastGames, - stadiums: stadiums, - calendar: calendar - ) - - // Pick the better route (more games) - var selectedGames: [Game] - if eastToWest.count >= westToEast.count && eastToWest.count >= 3 { - selectedGames = eastToWest - print("🧭 CrossCountry: Using East→West route") - } else if westToEast.count >= 3 { - selectedGames = westToEast - print("🧭 CrossCountry: Using West→East route") - } else { - print("❌ CrossCountry: No valid coast-to-coast route found (E→W: \(eastToWest.count), W→E: \(westToEast.count))") + guard selectedGames.count >= 3 else { return nil } // Validate the route spans both coasts let routeRegions = Set(selectedGames.compactMap { stadiums[$0.stadiumId]?.region }) guard routeRegions.contains(.east) && routeRegions.contains(.west) else { - print("❌ CrossCountry: Route doesn't span both coasts: \(routeRegions)") return nil } - // Debug: show selected cities and their regions - let cityDetails = selectedGames.compactMap { game -> String? in - guard let stadium = stadiums[game.stadiumId] else { return nil } - let dateStr = game.dateTime.formatted(date: .abbreviated, time: .omitted) - return "\(stadium.city)(\(stadium.region.rawValue.prefix(1)),\(dateStr))" - } - print("✅ CrossCountry: Selected \(selectedGames.count) games: \(cityDetails.joined(separator: " → "))") - // Build trip directly from selected games (bypass planning engine) guard let firstGame = selectedGames.first, let lastGame = selectedGames.last else { return nil } @@ -474,12 +468,9 @@ final class SuggestedTripsGenerator { } guard tripStops.count >= 3 else { - print("❌ CrossCountry: Only \(tripStops.count) stops built (need at least 3)") return nil } - print("✅ CrossCountry: Built trip with \(tripStops.count) stops") - // Build the trip let preferences = TripPreferences( planningMode: .dateRange, @@ -554,14 +545,17 @@ final class SuggestedTripsGenerator { var segments: [TravelSegment] = [] let constraints = DrivingConstraints.default + let coordinatesByCity = stadiums.values.reduce(into: [String: CLLocationCoordinate2D]()) { partialResult, stadium in + partialResult[stadium.city] = stadium.coordinate + } for i in 0..<(stops.count - 1) { let fromStop = stops[i] let toStop = stops[i + 1] // Get coordinates from stops or from stadium lookup - let fromCoord = fromStop.coordinate ?? stadiums.values.first { $0.city == fromStop.city }?.coordinate - let toCoord = toStop.coordinate ?? stadiums.values.first { $0.city == toStop.city }?.coordinate + let fromCoord = fromStop.coordinate ?? coordinatesByCity[fromStop.city] + let toCoord = toStop.coordinate ?? coordinatesByCity[toStop.city] let fromLocation = LocationInput(name: fromStop.city, coordinate: fromCoord) let toLocation = LocationInput(name: toStop.city, coordinate: toCoord) @@ -576,8 +570,6 @@ final class SuggestedTripsGenerator { } private nonisolated static func convertToTrip(option: ItineraryOption, preferences: TripPreferences) -> Trip { - print("🔍 convertToTrip: option.stops.count = \(option.stops.count)") - let tripStops = option.stops.enumerated().map { index, stop in TripStop( stopNumber: index + 1, @@ -591,8 +583,6 @@ final class SuggestedTripsGenerator { ) } - print("🔍 convertToTrip: tripStops.count = \(tripStops.count)") - return Trip( name: generateTripName(from: tripStops), preferences: preferences, @@ -695,18 +685,24 @@ final class SuggestedTripsGenerator { stadiums: [String: Stadium], calendar: Calendar ) -> [Game] { + let maxAnchorsPerSide = 32 + // Sort all games by date let sortedStart = startGames.sorted { $0.dateTime < $1.dateTime } let sortedMiddle = middleGames.sorted { $0.dateTime < $1.dateTime } let sortedEnd = endGames.sorted { $0.dateTime < $1.dateTime } + // Bound the start/end search space to keep route generation responsive. + let candidateStarts = trimAnchorGames(sortedStart, calendar: calendar, keep: maxAnchorsPerSide) + let candidateEnds = trimAnchorGames(sortedEnd, calendar: calendar, keep: maxAnchorsPerSide) + // Maximum driving per day (roughly 500 miles) let maxDailyMiles = 500.0 - // Collect all valid routes to pick the shortest - var validRoutes: [(games: [Game], days: Int, distance: Double)] = [] + // Track only the best route found instead of collecting and sorting all candidates. + var bestRoute: (games: [Game], days: Int, distance: Double)? - for startGame in sortedStart { + for startGame in candidateStarts { guard let startStadium = stadiums[startGame.stadiumId] else { continue } // Find ending games that are AFTER the start game with enough time to drive cross-country @@ -714,7 +710,7 @@ final class SuggestedTripsGenerator { let minDaysForCrossCountry = 5 let maxDaysForTrip = 14 // Cap trips at 2 weeks for reasonable length - for endGame in sortedEnd { + for endGame in candidateEnds { guard let endStadium = stadiums[endGame.stadiumId] else { continue } let daysBetween = calendar.dateComponents([.day], from: startGame.dateTime, to: endGame.dateTime).day ?? 0 @@ -722,6 +718,16 @@ final class SuggestedTripsGenerator { // Need enough time to drive cross-country but not TOO long guard daysBetween >= minDaysForCrossCountry && daysBetween <= maxDaysForTrip else { continue } + // Early prune impossible anchor pairs before scanning middle games. + let maxMilesForWindow = Double(max(1, daysBetween)) * maxDailyMiles + let directDistance = haversineDistance( + lat1: startStadium.coordinate.latitude, + lon1: startStadium.coordinate.longitude, + lat2: endStadium.coordinate.latitude, + lon2: endStadium.coordinate.longitude + ) + guard directDistance <= maxMilesForWindow else { continue } + // Build route: start → middle games → end var route: [Game] = [startGame] var lastGame = startGame @@ -810,23 +816,22 @@ final class SuggestedTripsGenerator { // Final validation: ensure no same-day conflicts in entire route if validateNoSameDayConflicts(route, calendar: calendar) && route.count >= 3 { - validRoutes.append((games: route, days: daysBetween, distance: totalDistance)) + let candidate = (games: route, days: daysBetween, distance: totalDistance) + if let currentBest = bestRoute { + if candidate.days < currentBest.days || + (candidate.days == currentBest.days && candidate.distance < currentBest.distance) { + bestRoute = candidate + } + } else { + bestRoute = candidate + } } } } } - // Sort routes: prefer fewer days, then less distance - let sortedRoutes = validRoutes.sorted { a, b in - if a.days != b.days { - return a.days < b.days - } - return a.distance < b.distance - } - // Return the shortest valid route - if let best = sortedRoutes.first { - print("✅ C2C route: \(best.days) days, \(Int(best.distance)) miles, \(best.games.count) games") + if let best = bestRoute { return best.games } @@ -840,7 +845,6 @@ final class SuggestedTripsGenerator { let day = calendar.startOfDay(for: game.dateTime) if let existingStadiumId = gamesByDay[day] { if game.stadiumId != existingStadiumId { - print("⚠️ Same-day conflict: \(day) has games at different stadiums") return false } } @@ -849,6 +853,50 @@ final class SuggestedTripsGenerator { return true } + private nonisolated static func crossCountrySignature(for trip: SuggestedTrip) -> String { + trip.trip.stops.flatMap(\.games).joined(separator: "|") + } + + /// Samples anchor games across the full time window while enforcing one game per day. + private nonisolated static func trimAnchorGames(_ games: [Game], calendar: Calendar, keep: Int) -> [Game] { + guard keep > 0 else { return [] } + guard games.count > keep else { return games } + + var uniqueDays: [Game] = [] + var seenDays = Set() + for game in games { + let day = calendar.startOfDay(for: game.dateTime) + if seenDays.insert(day).inserted { + uniqueDays.append(game) + } + } + + let source = uniqueDays.isEmpty ? games : uniqueDays + guard source.count > keep else { return source } + + let step = max(1, source.count / keep) + var sampled: [Game] = [] + var seenGameIds = Set() + + var index = 0 + while index < source.count && sampled.count < keep { + let game = source[index] + if seenGameIds.insert(game.id).inserted { + sampled.append(game) + } + index += step + } + + if sampled.count < keep { + for game in source where seenGameIds.insert(game.id).inserted { + sampled.append(game) + if sampled.count == keep { break } + } + } + + return sampled + } + /// Haversine distance in miles between two coordinates private nonisolated static func haversineDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double) -> Double { let R = 3959.0 // Earth radius in miles @@ -861,3 +909,39 @@ final class SuggestedTripsGenerator { return R * c } } + +#if DEBUG +extension SuggestedTripsGenerator { + /// Test hook for directional cross-country generation without AppDataProvider wiring. + nonisolated static func _testGenerateCrossCountryTrip( + games: [Game], + stadiums: [String: Stadium], + teams: [String: Team], + eastToWest: Bool + ) -> SuggestedTrip? { + generateCrossCountryTrip( + games: games, + stadiums: stadiums, + teams: teams, + direction: eastToWest ? .eastToWest : .westToEast + ) + } + + /// Test hook for direct coast-to-coast route construction. + nonisolated static func _testBuildCoastToCoastRoute( + startGames: [Game], + middleGames: [Game], + endGames: [Game], + stadiums: [String: Stadium], + calendar: Calendar = .current + ) -> [Game] { + buildCoastToCoastRoute( + startGames: startGames, + middleGames: middleGames, + endGames: endGames, + stadiums: stadiums, + calendar: calendar + ) + } +} +#endif diff --git a/SportsTimeTests/Services/SuggestedTripsGeneratorTests.swift b/SportsTimeTests/Services/SuggestedTripsGeneratorTests.swift index 0ae0dc7..febb692 100644 --- a/SportsTimeTests/Services/SuggestedTripsGeneratorTests.swift +++ b/SportsTimeTests/Services/SuggestedTripsGeneratorTests.swift @@ -253,3 +253,235 @@ struct HaversineDistanceTests { return R * c } } + +// MARK: - Cross-Country Feature Trip Tests + +@Suite("SuggestedTripsGenerator Cross-Country") +struct CrossCountryFeatureTripTests { + + private struct CitySeed { + let name: String + let state: String + let latitude: Double + let longitude: Double + } + + // 20 known US cities spanning east/central/west regions. + private let citySeeds: [CitySeed] = [ + CitySeed(name: "Boston", state: "MA", latitude: 42.3601, longitude: -71.0589), + CitySeed(name: "New York", state: "NY", latitude: 40.7128, longitude: -74.0060), + CitySeed(name: "Philadelphia", state: "PA", latitude: 39.9526, longitude: -75.1652), + CitySeed(name: "Baltimore", state: "MD", latitude: 39.2904, longitude: -76.6122), + CitySeed(name: "Washington", state: "DC", latitude: 38.9072, longitude: -77.0369), + CitySeed(name: "Charlotte", state: "NC", latitude: 35.2271, longitude: -80.8431), + CitySeed(name: "Atlanta", state: "GA", latitude: 33.7490, longitude: -84.3880), + CitySeed(name: "Nashville", state: "TN", latitude: 36.1627, longitude: -86.7816), + CitySeed(name: "St Louis", state: "MO", latitude: 38.6270, longitude: -90.1994), + CitySeed(name: "Chicago", state: "IL", latitude: 41.8781, longitude: -87.6298), + CitySeed(name: "Minneapolis", state: "MN", latitude: 44.9778, longitude: -93.2650), + CitySeed(name: "Kansas City", state: "MO", latitude: 39.0997, longitude: -94.5786), + CitySeed(name: "Dallas", state: "TX", latitude: 32.7767, longitude: -96.7970), + CitySeed(name: "Denver", state: "CO", latitude: 39.7392, longitude: -104.9903), + CitySeed(name: "Albuquerque", state: "NM", latitude: 35.0844, longitude: -106.6504), + CitySeed(name: "Phoenix", state: "AZ", latitude: 33.4484, longitude: -112.0740), + CitySeed(name: "Las Vegas", state: "NV", latitude: 36.1699, longitude: -115.1398), + CitySeed(name: "Los Angeles", state: "CA", latitude: 34.0522, longitude: -118.2437), + CitySeed(name: "San Diego", state: "CA", latitude: 32.7157, longitude: -117.1611), + CitySeed(name: "Seattle", state: "WA", latitude: 47.6062, longitude: -122.3321), + ] + + private func canonicalToken(_ value: String) -> String { + value + .lowercased() + .replacingOccurrences(of: " ", with: "_") + .replacingOccurrences(of: ".", with: "") + } + + private func makeStadium(from city: CitySeed) -> Stadium { + let token = canonicalToken(city.name) + return Stadium( + id: "stadium_test_\(token)", + name: "\(city.name) Test Stadium", + city: city.name, + state: city.state, + latitude: city.latitude, + longitude: city.longitude, + capacity: 40000, + sport: .mlb + ) + } + + private func makeTeams(for stadium: Stadium) -> [Team] { + let token = canonicalToken(stadium.city) + let home = Team( + id: "team_test_home_\(token)", + name: "\(stadium.city) Home", + abbreviation: String(token.prefix(3)).uppercased(), + sport: .mlb, + city: stadium.city, + stadiumId: stadium.id + ) + let away = Team( + id: "team_test_away_\(token)", + name: "\(stadium.city) Away", + abbreviation: "A\(String(token.prefix(2)).uppercased())", + sport: .mlb, + city: stadium.city, + stadiumId: stadium.id + ) + return [home, away] + } + + private func makeGames( + from stadiums: [Stadium], + startDate: Date, + spacingDays: Int = 1, + idPrefix: String + ) -> [Game] { + var games: [Game] = [] + let calendar = Calendar.current + + for (index, stadium) in stadiums.enumerated() { + let gameDate = calendar.date(byAdding: .day, value: index * spacingDays, to: startDate) ?? startDate + let token = canonicalToken(stadium.city) + games.append( + Game( + id: "game_test_\(idPrefix)_\(token)_\(index)", + homeTeamId: "team_test_home_\(token)", + awayTeamId: "team_test_away_\(token)", + stadiumId: stadium.id, + dateTime: gameDate, + sport: .mlb, + season: "2026" + ) + ) + } + return games + } + + private func makeDataset(spacingDays: Int = 1) -> (games: [Game], stadiumsById: [String: Stadium], teamsById: [String: Team]) { + let stadiums = citySeeds.map(makeStadium) + let teams = stadiums.flatMap(makeTeams) + let sortedEastToWest = stadiums.sorted { $0.longitude > $1.longitude } + let sortedWestToEast = Array(sortedEastToWest.reversed()) + let baseDate = Date(timeIntervalSince1970: 1_736_000_000) // Fixed baseline for deterministic test behavior + + let eastToWestGames = makeGames( + from: sortedEastToWest, + startDate: baseDate, + spacingDays: spacingDays, + idPrefix: "e2w" + ) + + let secondLegStart = Calendar.current.date( + byAdding: .day, + value: (sortedEastToWest.count * spacingDays) + 2, + to: baseDate + ) ?? baseDate + + let westToEastGames = makeGames( + from: sortedWestToEast, + startDate: secondLegStart, + spacingDays: spacingDays, + idPrefix: "w2e" + ) + + let games = eastToWestGames + westToEastGames + + let stadiumsById = stadiums.reduce(into: [String: Stadium]()) { partialResult, stadium in + partialResult[stadium.id] = stadium + } + let teamsById = teams.reduce(into: [String: Team]()) { partialResult, team in + partialResult[team.id] = team + } + + return (games: games, stadiumsById: stadiumsById, teamsById: teamsById) + } + + private func routeRegions(for trip: SuggestedTrip, stadiumsById: [String: Stadium]) -> Set { + let gameIdsInTrip = Set(trip.trip.stops.flatMap(\.games)) + let tripGames = trip.richGames.values + .map(\.game) + .filter { gameIdsInTrip.contains($0.id) } + + return Set(tripGames.compactMap { game in + stadiumsById[game.stadiumId]?.region + }) + } + + @Test("Cross-country (20 cities): east-to-west generates a valid coast-to-coast trip") + func crossCountry_eastToWest_fromTwentyCities() { + let (games, stadiumsById, teamsById) = makeDataset() + + let trip = SuggestedTripsGenerator._testGenerateCrossCountryTrip( + games: games, + stadiums: stadiumsById, + teams: teamsById, + eastToWest: true + ) + + #expect(trip != nil) + guard let trip else { return } + + #expect(trip.region == .crossCountry) + #expect(trip.trip.stops.count >= 3) + #expect(trip.trip.totalGames >= 3) + + let regions = routeRegions(for: trip, stadiumsById: stadiumsById) + #expect(regions.contains(.east)) + #expect(regions.contains(.west)) + } + + @Test("Cross-country (20 cities): west-to-east generates a valid coast-to-coast trip") + func crossCountry_westToEast_fromTwentyCities() { + let (games, stadiumsById, teamsById) = makeDataset() + + let trip = SuggestedTripsGenerator._testGenerateCrossCountryTrip( + games: games, + stadiums: stadiumsById, + teams: teamsById, + eastToWest: false + ) + + #expect(trip != nil) + guard let trip else { return } + + #expect(trip.region == .crossCountry) + #expect(trip.trip.stops.count >= 3) + #expect(trip.trip.totalGames >= 3) + + let regions = routeRegions(for: trip, stadiumsById: stadiumsById) + #expect(regions.contains(.east)) + #expect(regions.contains(.west)) + } + + @Test("Cross-country performance: 20-city dataset stays under target average runtime") + func crossCountry_generationPerformance_twentyCityDataset() { + let (games, stadiumsById, teamsById) = makeDataset() + let iterations = 20 + var elapsedMillis: [Double] = [] + + for _ in 0..