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,77 +118,21 @@ 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
var generatedTrips: [SuggestedTrip] = [] // Move heavy computation to background task
let result = await Task.detached(priority: .userInitiated) {
// Generate regional trips (East, Central, West) Self.generateTripsInBackground(
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(
games: games, games: games,
stadiums: stadiumsById, stadiums: stadiums,
teams: teamsById, teams: teams,
startDate: startDate, startDate: startDate,
endDate: endDate, endDate: endDate
excludeGames: i > 0 ? generatedTrips.last?.richGames.values.map { $0.game } ?? [] : [] )
) { }.value
generatedTrips.append(crossCountryTrip)
}
}
suggestedTrips = generatedTrips suggestedTrips = result
} catch { } catch {
self.error = "Failed to generate trips: \(error.localizedDescription)" self.error = "Failed to generate trips: \(error.localizedDescription)"
} }
@@ -202,9 +145,99 @@ final class SuggestedTripsGenerator {
await generateTrips() 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 // 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