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
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