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
|
// 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
|
||||||
|
|||||||
Reference in New Issue
Block a user