perf: move trip generation off main actor
Fixes launch freeze by running TripPlanningEngine in background task. UI now responsive immediately while trips generate. - Move heavy computation to Task.detached with .userInitiated priority - Change all helper methods to nonisolated static functions - Create local TripPlanningEngine in background task instead of instance property Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -48,7 +48,6 @@ final class SuggestedTripsGenerator {
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let dataProvider = AppDataProvider.shared
|
||||
private let planningEngine = TripPlanningEngine()
|
||||
private let loadingTextGenerator = LoadingTextGenerator.shared
|
||||
|
||||
// MARK: - Grouped Trips
|
||||
@@ -119,77 +118,21 @@ final class SuggestedTripsGenerator {
|
||||
}
|
||||
|
||||
// Build lookups (use reduce to handle potential duplicate UUIDs gracefully)
|
||||
let stadiumsById = dataProvider.stadiums.reduce(into: [String: Stadium]()) { $0[$1.id] = $1 }
|
||||
let teamsById = dataProvider.teams.reduce(into: [String: Team]()) { $0[$1.id] = $1 }
|
||||
let stadiums = dataProvider.stadiums
|
||||
let teams = dataProvider.teams
|
||||
|
||||
var generatedTrips: [SuggestedTrip] = []
|
||||
|
||||
// Generate regional trips (East, Central, West)
|
||||
for region in [Region.east, Region.central, Region.west] {
|
||||
let regionStadiumIds = Set(
|
||||
dataProvider.stadiums
|
||||
.filter { $0.region == region }
|
||||
.map { $0.id }
|
||||
)
|
||||
|
||||
let regionGames = games.filter { regionStadiumIds.contains($0.stadiumId) }
|
||||
|
||||
guard !regionGames.isEmpty else { continue }
|
||||
|
||||
// Single sport trip
|
||||
if let singleSportTrip = generateRegionalTrip(
|
||||
games: regionGames,
|
||||
region: region,
|
||||
singleSport: true,
|
||||
stadiums: stadiumsById,
|
||||
teams: teamsById,
|
||||
startDate: startDate,
|
||||
endDate: endDate
|
||||
) {
|
||||
generatedTrips.append(singleSportTrip)
|
||||
}
|
||||
|
||||
// Multi-sport trip
|
||||
if let multiSportTrip = generateRegionalTrip(
|
||||
games: regionGames,
|
||||
region: region,
|
||||
singleSport: false,
|
||||
stadiums: stadiumsById,
|
||||
teams: teamsById,
|
||||
startDate: startDate,
|
||||
endDate: endDate
|
||||
) {
|
||||
generatedTrips.append(multiSportTrip)
|
||||
} else if let fallbackTrip = generateRegionalTrip(
|
||||
// Fallback: if multi-sport fails, try another single-sport
|
||||
games: regionGames,
|
||||
region: region,
|
||||
singleSport: true,
|
||||
stadiums: stadiumsById,
|
||||
teams: teamsById,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
excludingSport: generatedTrips.last?.sports.first
|
||||
) {
|
||||
generatedTrips.append(fallbackTrip)
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-country trips (2)
|
||||
for i in 0..<2 {
|
||||
if let crossCountryTrip = generateCrossCountryTrip(
|
||||
// Move heavy computation to background task
|
||||
let result = await Task.detached(priority: .userInitiated) {
|
||||
Self.generateTripsInBackground(
|
||||
games: games,
|
||||
stadiums: stadiumsById,
|
||||
teams: teamsById,
|
||||
stadiums: stadiums,
|
||||
teams: teams,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
excludeGames: i > 0 ? generatedTrips.last?.richGames.values.map { $0.game } ?? [] : []
|
||||
) {
|
||||
generatedTrips.append(crossCountryTrip)
|
||||
}
|
||||
}
|
||||
endDate: endDate
|
||||
)
|
||||
}.value
|
||||
|
||||
suggestedTrips = generatedTrips
|
||||
suggestedTrips = result
|
||||
} catch {
|
||||
self.error = "Failed to generate trips: \(error.localizedDescription)"
|
||||
}
|
||||
@@ -202,9 +145,99 @@ final class SuggestedTripsGenerator {
|
||||
await generateTrips()
|
||||
}
|
||||
|
||||
// MARK: - Background Trip Generation
|
||||
|
||||
/// Performs heavy trip generation computation off the main actor
|
||||
private nonisolated static func generateTripsInBackground(
|
||||
games: [Game],
|
||||
stadiums: [Stadium],
|
||||
teams: [Team],
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
) -> [SuggestedTrip] {
|
||||
// Build lookups (use reduce to handle potential duplicate UUIDs gracefully)
|
||||
let stadiumsById = stadiums.reduce(into: [String: Stadium]()) { $0[$1.id] = $1 }
|
||||
let teamsById = teams.reduce(into: [String: Team]()) { $0[$1.id] = $1 }
|
||||
|
||||
// Create a local planning engine for this background task
|
||||
let planningEngine = TripPlanningEngine()
|
||||
|
||||
var generatedTrips: [SuggestedTrip] = []
|
||||
|
||||
// Generate regional trips (East, Central, West)
|
||||
for region in [Region.east, Region.central, Region.west] {
|
||||
let regionStadiumIds = Set(
|
||||
stadiums
|
||||
.filter { $0.region == region }
|
||||
.map { $0.id }
|
||||
)
|
||||
|
||||
let regionGames = games.filter { regionStadiumIds.contains($0.stadiumId) }
|
||||
|
||||
guard !regionGames.isEmpty else { continue }
|
||||
|
||||
// Single sport trip
|
||||
if let singleSportTrip = generateRegionalTrip(
|
||||
games: regionGames,
|
||||
region: region,
|
||||
singleSport: true,
|
||||
stadiums: stadiumsById,
|
||||
teams: teamsById,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
planningEngine: planningEngine
|
||||
) {
|
||||
generatedTrips.append(singleSportTrip)
|
||||
}
|
||||
|
||||
// Multi-sport trip
|
||||
if let multiSportTrip = generateRegionalTrip(
|
||||
games: regionGames,
|
||||
region: region,
|
||||
singleSport: false,
|
||||
stadiums: stadiumsById,
|
||||
teams: teamsById,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
planningEngine: planningEngine
|
||||
) {
|
||||
generatedTrips.append(multiSportTrip)
|
||||
} else if let fallbackTrip = generateRegionalTrip(
|
||||
// Fallback: if multi-sport fails, try another single-sport
|
||||
games: regionGames,
|
||||
region: region,
|
||||
singleSport: true,
|
||||
stadiums: stadiumsById,
|
||||
teams: teamsById,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
excludingSport: generatedTrips.last?.sports.first,
|
||||
planningEngine: planningEngine
|
||||
) {
|
||||
generatedTrips.append(fallbackTrip)
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-country trips (2)
|
||||
for i in 0..<2 {
|
||||
if let crossCountryTrip = generateCrossCountryTrip(
|
||||
games: games,
|
||||
stadiums: stadiumsById,
|
||||
teams: teamsById,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
excludeGames: i > 0 ? generatedTrips.last?.richGames.values.map { $0.game } ?? [] : []
|
||||
) {
|
||||
generatedTrips.append(crossCountryTrip)
|
||||
}
|
||||
}
|
||||
|
||||
return generatedTrips
|
||||
}
|
||||
|
||||
// MARK: - Trip Generation Helpers
|
||||
|
||||
private func generateRegionalTrip(
|
||||
private nonisolated static func generateRegionalTrip(
|
||||
games: [Game],
|
||||
region: Region,
|
||||
singleSport: Bool,
|
||||
@@ -212,7 +245,8 @@ final class SuggestedTripsGenerator {
|
||||
teams: [String: Team],
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
excludingSport: Sport? = nil
|
||||
excludingSport: Sport? = nil,
|
||||
planningEngine: TripPlanningEngine
|
||||
) -> SuggestedTrip? {
|
||||
|
||||
var filteredGames = games
|
||||
@@ -292,7 +326,7 @@ final class SuggestedTripsGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
private func buildRichGames(from games: [Game], teams: [String: Team], stadiums: [String: Stadium]) -> [String: RichGame] {
|
||||
private nonisolated static func buildRichGames(from games: [Game], teams: [String: Team], stadiums: [String: Stadium]) -> [String: RichGame] {
|
||||
var result: [String: RichGame] = [:]
|
||||
for game in games {
|
||||
guard let homeTeam = teams[game.homeTeamId],
|
||||
@@ -303,7 +337,7 @@ final class SuggestedTripsGenerator {
|
||||
return result
|
||||
}
|
||||
|
||||
private func generateCrossCountryTrip(
|
||||
private nonisolated static func generateCrossCountryTrip(
|
||||
games: [Game],
|
||||
stadiums: [String: Stadium],
|
||||
teams: [String: Team],
|
||||
@@ -463,7 +497,7 @@ final class SuggestedTripsGenerator {
|
||||
}
|
||||
|
||||
/// Builds a TripStop from a group of games at the same stadium
|
||||
private func buildTripStop(from games: [Game], stadiumId: String, stadiums: [String: Stadium], stopNumber: Int) -> TripStop? {
|
||||
private nonisolated static func buildTripStop(from games: [Game], stadiumId: String, stadiums: [String: Stadium], stopNumber: Int) -> TripStop? {
|
||||
guard !games.isEmpty else { return nil }
|
||||
|
||||
let sortedGames = games.sorted { $0.dateTime < $1.dateTime }
|
||||
@@ -488,7 +522,7 @@ final class SuggestedTripsGenerator {
|
||||
}
|
||||
|
||||
/// Builds travel segments between consecutive stops using TravelEstimator
|
||||
private func buildTravelSegments(from stops: [TripStop], stadiums: [String: Stadium]) -> [TravelSegment] {
|
||||
private nonisolated static func buildTravelSegments(from stops: [TripStop], stadiums: [String: Stadium]) -> [TravelSegment] {
|
||||
guard stops.count >= 2 else { return [] }
|
||||
|
||||
var segments: [TravelSegment] = []
|
||||
@@ -514,7 +548,7 @@ final class SuggestedTripsGenerator {
|
||||
return segments
|
||||
}
|
||||
|
||||
private func convertToTrip(option: ItineraryOption, preferences: TripPreferences) -> Trip {
|
||||
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
|
||||
@@ -543,7 +577,7 @@ final class SuggestedTripsGenerator {
|
||||
)
|
||||
}
|
||||
|
||||
private func generateTripName(from stops: [TripStop]) -> String {
|
||||
private nonisolated static func generateTripName(from stops: [TripStop]) -> String {
|
||||
let cities = stops.compactMap { $0.city }.prefix(3)
|
||||
if cities.count <= 1 {
|
||||
return cities.first ?? "Road Trip"
|
||||
@@ -558,7 +592,7 @@ final class SuggestedTripsGenerator {
|
||||
}
|
||||
|
||||
/// Builds a trip following a geographic corridor (moving consistently east or west)
|
||||
private func buildCorridorTrip(
|
||||
private nonisolated static func buildCorridorTrip(
|
||||
games: [(game: Game, lon: Double)],
|
||||
stadiums: [String: Stadium],
|
||||
direction: Direction,
|
||||
@@ -627,7 +661,7 @@ final class SuggestedTripsGenerator {
|
||||
/// Returns games in calendar order that are drivable between each stop
|
||||
/// INVARIANT: No two games on the same calendar day can be at different stadiums
|
||||
/// PREFERS: Shorter trips (fewer days) over more games
|
||||
private func buildCoastToCoastRoute(
|
||||
private nonisolated static func buildCoastToCoastRoute(
|
||||
startGames: [Game],
|
||||
middleGames: [Game],
|
||||
endGames: [Game],
|
||||
@@ -773,7 +807,7 @@ final class SuggestedTripsGenerator {
|
||||
}
|
||||
|
||||
/// Validates that no two games in the route are on the same calendar day at different stadiums
|
||||
private func validateNoSameDayConflicts(_ games: [Game], calendar: Calendar) -> Bool {
|
||||
private nonisolated static func validateNoSameDayConflicts(_ games: [Game], calendar: Calendar) -> Bool {
|
||||
var gamesByDay: [Date: String] = [:]
|
||||
for game in games {
|
||||
let day = calendar.startOfDay(for: game.dateTime)
|
||||
@@ -789,7 +823,7 @@ final class SuggestedTripsGenerator {
|
||||
}
|
||||
|
||||
/// Haversine distance in miles between two coordinates
|
||||
private func haversineDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double) -> Double {
|
||||
private nonisolated static func haversineDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double) -> Double {
|
||||
let R = 3959.0 // Earth radius in miles
|
||||
let dLat = (lat2 - lat1) * .pi / 180
|
||||
let dLon = (lon2 - lon1) * .pi / 180
|
||||
|
||||
Reference in New Issue
Block a user