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:
Trey t
2026-01-12 18:27:49 -06:00
parent aa0bc4def8
commit b14f72a0fb

View File

@@ -48,7 +48,6 @@ final class SuggestedTripsGenerator {
// MARK: - Dependencies // MARK: - Dependencies
private let dataProvider = AppDataProvider.shared private let dataProvider = AppDataProvider.shared
private let planningEngine = TripPlanningEngine()
private let loadingTextGenerator = LoadingTextGenerator.shared private let loadingTextGenerator = LoadingTextGenerator.shared
// MARK: - Grouped Trips // MARK: - Grouped Trips
@@ -119,15 +118,56 @@ final class SuggestedTripsGenerator {
} }
// Build lookups (use reduce to handle potential duplicate UUIDs gracefully) // Build lookups (use reduce to handle potential duplicate UUIDs gracefully)
let stadiumsById = dataProvider.stadiums.reduce(into: [String: Stadium]()) { $0[$1.id] = $1 } let stadiums = dataProvider.stadiums
let teamsById = dataProvider.teams.reduce(into: [String: Team]()) { $0[$1.id] = $1 } let teams = dataProvider.teams
// Move heavy computation to background task
let result = await Task.detached(priority: .userInitiated) {
Self.generateTripsInBackground(
games: games,
stadiums: stadiums,
teams: teams,
startDate: startDate,
endDate: endDate
)
}.value
suggestedTrips = result
} catch {
self.error = "Failed to generate trips: \(error.localizedDescription)"
}
isLoading = false
}
func refreshTrips() async {
await loadingTextGenerator.reset()
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] = [] var generatedTrips: [SuggestedTrip] = []
// Generate regional trips (East, Central, West) // Generate regional trips (East, Central, West)
for region in [Region.east, Region.central, Region.west] { for region in [Region.east, Region.central, Region.west] {
let regionStadiumIds = Set( let regionStadiumIds = Set(
dataProvider.stadiums stadiums
.filter { $0.region == region } .filter { $0.region == region }
.map { $0.id } .map { $0.id }
) )
@@ -144,7 +184,8 @@ final class SuggestedTripsGenerator {
stadiums: stadiumsById, stadiums: stadiumsById,
teams: teamsById, teams: teamsById,
startDate: startDate, startDate: startDate,
endDate: endDate endDate: endDate,
planningEngine: planningEngine
) { ) {
generatedTrips.append(singleSportTrip) generatedTrips.append(singleSportTrip)
} }
@@ -157,7 +198,8 @@ final class SuggestedTripsGenerator {
stadiums: stadiumsById, stadiums: stadiumsById,
teams: teamsById, teams: teamsById,
startDate: startDate, startDate: startDate,
endDate: endDate endDate: endDate,
planningEngine: planningEngine
) { ) {
generatedTrips.append(multiSportTrip) generatedTrips.append(multiSportTrip)
} else if let fallbackTrip = generateRegionalTrip( } else if let fallbackTrip = generateRegionalTrip(
@@ -169,7 +211,8 @@ final class SuggestedTripsGenerator {
teams: teamsById, teams: teamsById,
startDate: startDate, startDate: startDate,
endDate: endDate, endDate: endDate,
excludingSport: generatedTrips.last?.sports.first excludingSport: generatedTrips.last?.sports.first,
planningEngine: planningEngine
) { ) {
generatedTrips.append(fallbackTrip) generatedTrips.append(fallbackTrip)
} }
@@ -189,22 +232,12 @@ final class SuggestedTripsGenerator {
} }
} }
suggestedTrips = generatedTrips return generatedTrips
} catch {
self.error = "Failed to generate trips: \(error.localizedDescription)"
}
isLoading = false
}
func refreshTrips() async {
await loadingTextGenerator.reset()
await generateTrips()
} }
// MARK: - Trip Generation Helpers // MARK: - Trip Generation Helpers
private func generateRegionalTrip( private nonisolated static func generateRegionalTrip(
games: [Game], games: [Game],
region: Region, region: Region,
singleSport: Bool, singleSport: Bool,
@@ -212,7 +245,8 @@ final class SuggestedTripsGenerator {
teams: [String: Team], teams: [String: Team],
startDate: Date, startDate: Date,
endDate: Date, endDate: Date,
excludingSport: Sport? = nil excludingSport: Sport? = nil,
planningEngine: TripPlanningEngine
) -> SuggestedTrip? { ) -> SuggestedTrip? {
var filteredGames = games 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] = [:] var result: [String: RichGame] = [:]
for game in games { for game in games {
guard let homeTeam = teams[game.homeTeamId], guard let homeTeam = teams[game.homeTeamId],
@@ -303,7 +337,7 @@ final class SuggestedTripsGenerator {
return result return result
} }
private func generateCrossCountryTrip( private nonisolated static func generateCrossCountryTrip(
games: [Game], games: [Game],
stadiums: [String: Stadium], stadiums: [String: Stadium],
teams: [String: Team], teams: [String: Team],
@@ -463,7 +497,7 @@ final class SuggestedTripsGenerator {
} }
/// Builds a TripStop from a group of games at the same stadium /// 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 } guard !games.isEmpty else { return nil }
let sortedGames = games.sorted { $0.dateTime < $1.dateTime } let sortedGames = games.sorted { $0.dateTime < $1.dateTime }
@@ -488,7 +522,7 @@ final class SuggestedTripsGenerator {
} }
/// Builds travel segments between consecutive stops using TravelEstimator /// 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 [] } guard stops.count >= 2 else { return [] }
var segments: [TravelSegment] = [] var segments: [TravelSegment] = []
@@ -514,7 +548,7 @@ final class SuggestedTripsGenerator {
return segments 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)") print("🔍 convertToTrip: option.stops.count = \(option.stops.count)")
let tripStops = option.stops.enumerated().map { index, stop in 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) let cities = stops.compactMap { $0.city }.prefix(3)
if cities.count <= 1 { if cities.count <= 1 {
return cities.first ?? "Road Trip" return cities.first ?? "Road Trip"
@@ -558,7 +592,7 @@ final class SuggestedTripsGenerator {
} }
/// Builds a trip following a geographic corridor (moving consistently east or west) /// 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)], games: [(game: Game, lon: Double)],
stadiums: [String: Stadium], stadiums: [String: Stadium],
direction: Direction, direction: Direction,
@@ -627,7 +661,7 @@ final class SuggestedTripsGenerator {
/// Returns games in calendar order that are drivable between each stop /// 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 /// INVARIANT: No two games on the same calendar day can be at different stadiums
/// PREFERS: Shorter trips (fewer days) over more games /// PREFERS: Shorter trips (fewer days) over more games
private func buildCoastToCoastRoute( private nonisolated static func buildCoastToCoastRoute(
startGames: [Game], startGames: [Game],
middleGames: [Game], middleGames: [Game],
endGames: [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 /// 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] = [:] var gamesByDay: [Date: String] = [:]
for game in games { for game in games {
let day = calendar.startOfDay(for: game.dateTime) let day = calendar.startOfDay(for: game.dateTime)
@@ -789,7 +823,7 @@ final class SuggestedTripsGenerator {
} }
/// Haversine distance in miles between two coordinates /// 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 R = 3959.0 // Earth radius in miles
let dLat = (lat2 - lat1) * .pi / 180 let dLat = (lat2 - lat1) * .pi / 180
let dLon = (lon2 - lon1) * .pi / 180 let dLon = (lon2 - lon1) * .pi / 180