perf: optimize featured cross-country trip generation and add tests
This commit is contained in:
@@ -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<CanonicalGame>(
|
||||
predicate: #Predicate<CanonicalGame> { 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<CanonicalGame>(
|
||||
predicate: #Predicate<CanonicalGame> { game in
|
||||
|
||||
@@ -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<String>()
|
||||
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<Date>()
|
||||
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<String>()
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user