From b14f72a0fbe6a3e0e2454580a1cb785feb39cbda Mon Sep 17 00:00:00 2001 From: Trey t Date: Mon, 12 Jan 2026 18:27:49 -0600 Subject: [PATCH] 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 --- .../Services/SuggestedTripsGenerator.swift | 194 ++++++++++-------- 1 file changed, 114 insertions(+), 80 deletions(-) diff --git a/SportsTime/Core/Services/SuggestedTripsGenerator.swift b/SportsTime/Core/Services/SuggestedTripsGenerator.swift index 545b352..6d72d0b 100644 --- a/SportsTime/Core/Services/SuggestedTripsGenerator.swift +++ b/SportsTime/Core/Services/SuggestedTripsGenerator.swift @@ -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