From 37a1347ce3f6cd3b72cd451723e843682daba1f0 Mon Sep 17 00:00:00 2001 From: Trey t Date: Mon, 12 Jan 2026 20:19:46 -0600 Subject: [PATCH] perf: lazy hierarchical loading for game picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace upfront loading of all games with lazy Sport → Team → Game hierarchy. Games are now fetched per-team when expanded and cached to prevent re-fetching. Also removes pagination workaround and pre-computes groupings in ScheduleViewModel to avoid per-render work. Co-Authored-By: Claude Opus 4.5 --- SportsTime/Core/Services/DataProvider.swift | 31 ++ .../ViewModels/ScheduleViewModel.swift | 110 ++---- .../Schedule/Views/ScheduleListView.swift | 42 +-- .../ViewModels/TripCreationViewModel.swift | 30 -- .../Trip/Views/TripCreationView.swift | 317 +++++++++--------- 5 files changed, 235 insertions(+), 295 deletions(-) diff --git a/SportsTime/Core/Services/DataProvider.swift b/SportsTime/Core/Services/DataProvider.swift index c2f35a1..e308879 100644 --- a/SportsTime/Core/Services/DataProvider.swift +++ b/SportsTime/Core/Services/DataProvider.swift @@ -219,6 +219,37 @@ final class AppDataProvider: ObservableObject { } return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium) } + + /// Get all games for a specific team (home or away) - for lazy loading in game picker + func gamesForTeam(teamId: String) async throws -> [RichGame] { + guard let context = modelContext else { + throw DataProviderError.contextNotConfigured + } + + // Fetch all non-deprecated games (predicate with captured vars causes type-check timeout) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.deprecatedAt == nil }, + sortBy: [SortDescriptor(\.dateTime)] + ) + + let allCanonical: [CanonicalGame] = try context.fetch(descriptor) + + // Filter by team in Swift (fast for ~5K games) + var teamGames: [RichGame] = [] + for canonical in allCanonical { + guard canonical.homeTeamCanonicalId == teamId || canonical.awayTeamCanonicalId == teamId else { + continue + } + let game = canonical.toDomain() + guard let homeTeam = teamsById[game.homeTeamId], + let awayTeam = teamsById[game.awayTeamId], + let stadium = stadiumsById[game.stadiumId] else { + continue + } + teamGames.append(RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)) + } + return teamGames + } } // MARK: - Errors diff --git a/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift b/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift index 7be2438..e9e325f 100644 --- a/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift +++ b/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift @@ -26,88 +26,18 @@ final class ScheduleViewModel { private let dataProvider = AppDataProvider.shared - // MARK: - Pagination + // MARK: - Pre-computed Groupings (avoid computed property overhead) - private let pageSize = 50 - private(set) var displayedGames: [RichGame] = [] - private var currentPage = 0 - private var allFilteredGames: [RichGame] = [] + /// Games grouped by sport - pre-computed to avoid re-grouping on every render + private(set) var gamesBySport: [(sport: Sport, games: [RichGame])] = [] - var hasMoreGames: Bool { - displayedGames.count < allFilteredGames.count - } - - /// The last game in the displayed list, used for infinite scroll detection - var lastDisplayedGame: RichGame? { - displayedGames.last - } - - // MARK: - Computed Properties - - var filteredGames: [RichGame] { - guard !searchText.isEmpty else { return games } - - let query = searchText.lowercased() - return games.filter { game in - game.homeTeam.name.lowercased().contains(query) || - game.homeTeam.city.lowercased().contains(query) || - game.awayTeam.name.lowercased().contains(query) || - game.awayTeam.city.lowercased().contains(query) || - game.stadium.name.lowercased().contains(query) || - game.stadium.city.lowercased().contains(query) - } - } - - var gamesByDate: [(date: Date, games: [RichGame])] { - let calendar = Calendar.current - let grouped = Dictionary(grouping: filteredGames) { game in - calendar.startOfDay(for: game.game.dateTime) - } - return grouped.sorted { $0.key < $1.key }.map { ($0.key, $0.value) } - } - - var gamesBySport: [(sport: Sport, games: [RichGame])] { - let grouped = Dictionary(grouping: displayedGames) { game in - game.game.sport - } - // Sort by sport order (use allCases index for consistent ordering) - // Within each sport, games are sorted by date - return grouped - .sorted { lhs, rhs in - let lhsIndex = Sport.allCases.firstIndex(of: lhs.key) ?? 0 - let rhsIndex = Sport.allCases.firstIndex(of: rhs.key) ?? 0 - return lhsIndex < rhsIndex - } - .map { (sport: $0.key, games: $0.value.sorted { $0.game.dateTime < $1.game.dateTime }) } - } + /// All games matching current filters (before any display limiting) + private var filteredGames: [RichGame] = [] var hasFilters: Bool { selectedSports.count < Sport.supported.count || !searchText.isEmpty } - // MARK: - Pagination Actions - - /// Updates filtered games list based on current search text and resets pagination - func updateFilteredGames() { - allFilteredGames = filteredGames - loadInitialGames() - } - - /// Loads the first page of games - func loadInitialGames() { - currentPage = 0 - displayedGames = Array(allFilteredGames.prefix(pageSize)) - } - - /// Loads more games when scrolling to the bottom - func loadMoreGames() { - guard hasMoreGames else { return } - currentPage += 1 - let start = currentPage * pageSize - let end = min(start + pageSize, allFilteredGames.count) - displayedGames.append(contentsOf: allFilteredGames[start.. @Binding var selectedIds: Set - let hasMoreGames: Bool - let totalGamesCount: Int - let loadMoreGames: () -> Void @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme @State private var expandedSports: Set = [] @State private var expandedTeams: Set = [] + @State private var gamesCache: [String: [RichGame]] = [:] // teamId -> games + @State private var loadingTeams: Set = [] + @State private var selectedGamesCache: [String: RichGame] = [:] // gameId -> game (for count display) - // Group games by Sport → Team (both home and away teams so browsing shows all team games) - private var gamesBySport: [Sport: [TeamWithGames]] { - var result: [Sport: [String: TeamWithGames]] = [:] + private let dataProvider = AppDataProvider.shared - for game in games { - let sport = game.game.sport - - if result[sport] == nil { - result[sport] = [:] - } - - // Add game to home team's list - let homeTeam = game.homeTeam - if var teamData = result[sport]?[homeTeam.id] { - teamData.games.append(game) - result[sport]?[homeTeam.id] = teamData - } else { - result[sport]?[homeTeam.id] = TeamWithGames( - team: homeTeam, - sport: sport, - games: [game] - ) - } - - // Also add game to away team's list (so browsing by team shows all games) - let awayTeam = game.awayTeam - if var teamData = result[sport]?[awayTeam.id] { - teamData.games.append(game) - result[sport]?[awayTeam.id] = teamData - } else { - result[sport]?[awayTeam.id] = TeamWithGames( - team: awayTeam, - sport: sport, - games: [game] - ) - } - } - - // Convert to sorted arrays - var sortedResult: [Sport: [TeamWithGames]] = [:] - for (sport, teamsDict) in result { - sortedResult[sport] = teamsDict.values.sorted { $0.team.name < $1.team.name } - } - return sortedResult + // Get teams for a sport (from in-memory cache, no fetching needed) + private func teamsForSport(_ sport: Sport) -> [Team] { + dataProvider.teams(for: sport).sorted { $0.name < $1.name } } private var sortedSports: [Sport] { - Sport.supported.filter { gamesBySport[$0] != nil } + Sport.supported.filter { selectedSports.contains($0) } } private var selectedGamesCount: Int { @@ -1023,64 +975,47 @@ struct GamePickerSheet: View { } private func selectedCountForSport(_ sport: Sport) -> Int { - guard let teams = gamesBySport[sport] else { return 0 } - return teams.flatMap { $0.games }.filter { selectedIds.contains($0.id) }.count + let teamIds = Set(teamsForSport(sport).map { $0.id }) + return selectedGamesCache.values.filter { game in + teamIds.contains(game.homeTeam.id) || teamIds.contains(game.awayTeam.id) + }.count } - private func selectedCountForTeam(_ teamData: TeamWithGames) -> Int { - teamData.games.filter { selectedIds.contains($0.id) }.count + private func selectedCountForTeam(_ teamId: String) -> Int { + guard let games = gamesCache[teamId] else { return 0 } + return games.filter { selectedIds.contains($0.id) }.count } var body: some View { NavigationStack { ScrollView { LazyVStack(spacing: 0) { - // Selected games summary - if !selectedIds.isEmpty { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - Text("\(selectedGamesCount) game(s) selected") - .font(.subheadline) - Spacer() - } - .padding(Theme.Spacing.md) - .background(Theme.cardBackground(colorScheme)) + // Selected games summary (always visible to prevent layout jump) + HStack { + Image(systemName: selectedIds.isEmpty ? "circle" : "checkmark.circle.fill") + .foregroundStyle(selectedIds.isEmpty ? Theme.textMuted(colorScheme) : .green) + Text("\(selectedGamesCount) game(s) selected") + .font(.subheadline) + .foregroundStyle(selectedIds.isEmpty ? Theme.textMuted(colorScheme) : Theme.textPrimary(colorScheme)) + Spacer() } + .padding(Theme.Spacing.md) + .background(Theme.cardBackground(colorScheme)) // Sport sections ForEach(sortedSports) { sport in - SportSection( + LazySportSection( sport: sport, - teams: gamesBySport[sport] ?? [], + teams: teamsForSport(sport), selectedIds: $selectedIds, expandedSports: $expandedSports, expandedTeams: $expandedTeams, + gamesCache: $gamesCache, + loadingTeams: $loadingTeams, + selectedGamesCache: $selectedGamesCache, selectedCount: selectedCountForSport(sport) ) } - - // Load more indicator with infinite scroll trigger - if hasMoreGames { - Button { - loadMoreGames() - } label: { - HStack(spacing: Theme.Spacing.sm) { - ProgressView() - .tint(Theme.warmOrange) - Text("Load more games (\(games.count) of \(totalGamesCount))") - .font(.subheadline) - .foregroundStyle(Theme.textSecondary(colorScheme)) - } - .frame(maxWidth: .infinity) - .padding(Theme.Spacing.md) - .background(Theme.cardBackground(colorScheme)) - } - .buttonStyle(.plain) - .onAppear { - loadMoreGames() - } - } } } .themedBackground() @@ -1094,6 +1029,7 @@ struct GamePickerSheet: View { transaction.disablesAnimations = true withTransaction(transaction) { selectedIds.removeAll() + selectedGamesCache.removeAll() } } .foregroundStyle(.red) @@ -1110,14 +1046,17 @@ struct GamePickerSheet: View { } } -// MARK: - Sport Section +// MARK: - Lazy Sport Section (loads teams from memory, games loaded on-demand per team) -struct SportSection: View { +struct LazySportSection: View { let sport: Sport - let teams: [TeamWithGames] + let teams: [Team] @Binding var selectedIds: Set @Binding var expandedSports: Set @Binding var expandedTeams: Set + @Binding var gamesCache: [String: [RichGame]] + @Binding var loadingTeams: Set + @Binding var selectedGamesCache: [String: RichGame] let selectedCount: Int @Environment(\.colorScheme) private var colorScheme @@ -1148,7 +1087,7 @@ struct SportSection: View { .font(.headline) .foregroundStyle(Theme.textPrimary(colorScheme)) - Text("\(teams.flatMap { $0.games }.count) games") + Text("\(teams.count) teams") .font(.subheadline) .foregroundStyle(Theme.textMuted(colorScheme)) @@ -1177,11 +1116,15 @@ struct SportSection: View { // Teams list (when expanded) if isExpanded { VStack(spacing: 0) { - ForEach(teams) { teamData in - TeamSection( - teamData: teamData, + ForEach(teams) { team in + LazyTeamSection( + team: team, + sport: sport, selectedIds: $selectedIds, - expandedTeams: $expandedTeams + expandedTeams: $expandedTeams, + gamesCache: $gamesCache, + loadingTeams: $loadingTeams, + selectedGamesCache: $selectedGamesCache ) } } @@ -1194,26 +1137,45 @@ struct SportSection: View { } } -// MARK: - Team Section +// MARK: - Lazy Team Section (loads games on-demand when expanded) -struct TeamSection: View { - let teamData: TeamWithGames +struct LazyTeamSection: View { + let team: Team + let sport: Sport @Binding var selectedIds: Set @Binding var expandedTeams: Set + @Binding var gamesCache: [String: [RichGame]] + @Binding var loadingTeams: Set + @Binding var selectedGamesCache: [String: RichGame] @Environment(\.colorScheme) private var colorScheme + private let dataProvider = AppDataProvider.shared + private var isExpanded: Bool { - expandedTeams.contains(teamData.id) + expandedTeams.contains(team.id) + } + + private var isLoading: Bool { + loadingTeams.contains(team.id) + } + + private var games: [RichGame] { + gamesCache[team.id] ?? [] } private var selectedCount: Int { - teamData.games.filter { selectedIds.contains($0.id) }.count + games.filter { selectedIds.contains($0.id) }.count + } + + private var gamesCount: Int? { + gamesCache[team.id]?.count } // Group games by date private var gamesByDate: [(date: String, games: [RichGame])] { - let grouped = Dictionary(grouping: teamData.sortedGames) { game in + let sortedGames = games.sorted { $0.game.dateTime < $1.game.dateTime } + let grouped = Dictionary(grouping: sortedGames) { game in game.game.formattedDate } return grouped.sorted { $0.value.first!.game.dateTime < $1.value.first!.game.dateTime } @@ -1226,31 +1188,39 @@ struct TeamSection: View { Button { withAnimation(.easeInOut(duration: 0.2)) { if isExpanded { - expandedTeams.remove(teamData.id) + expandedTeams.remove(team.id) } else { - expandedTeams.insert(teamData.id) + expandedTeams.insert(team.id) + // Load games if not cached + if gamesCache[team.id] == nil && !loadingTeams.contains(team.id) { + loadGames() + } } } } label: { HStack(spacing: Theme.Spacing.sm) { // Team color - if let colorHex = teamData.team.primaryColor { + if let colorHex = team.primaryColor { Circle() .fill(Color(hex: colorHex)) .frame(width: 10, height: 10) } - Text("\(teamData.team.city) \(teamData.team.name)") + Text("\(team.city) \(team.name)") .font(.subheadline) .foregroundStyle(Theme.textPrimary(colorScheme)) - Text("\(teamData.games.count)") - .font(.caption) - .foregroundStyle(Theme.textMuted(colorScheme)) + if let count = gamesCount { + Text("\(count)") + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + } Spacer() - if selectedCount > 0 { + if isLoading { + ThemedSpinnerCompact(size: 14) + } else if selectedCount > 0 { Text("\(selectedCount)") .font(.caption2) .foregroundStyle(.white) @@ -1273,41 +1243,76 @@ struct TeamSection: View { // Games grouped by date (when expanded) if isExpanded { - VStack(spacing: 0) { - ForEach(gamesByDate, id: \.date) { dateGroup in - VStack(alignment: .leading, spacing: 0) { - // Date header - Text(dateGroup.date) - .font(.caption) - .foregroundStyle(Theme.warmOrange) - .padding(.horizontal, Theme.Spacing.md) - .padding(.top, Theme.Spacing.sm) - .padding(.bottom, Theme.Spacing.xs) + if isLoading { + HStack { + ThemedSpinnerCompact(size: 16) + Text("Loading games...") + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + .padding(Theme.Spacing.md) + } else if games.isEmpty { + Text("No games scheduled") + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + .padding(Theme.Spacing.md) + } else { + VStack(spacing: 0) { + ForEach(gamesByDate, id: \.date) { dateGroup in + VStack(alignment: .leading, spacing: 0) { + // Date header + Text(dateGroup.date) + .font(.caption) + .foregroundStyle(Theme.warmOrange) + .padding(.horizontal, Theme.Spacing.md) + .padding(.top, Theme.Spacing.sm) + .padding(.bottom, Theme.Spacing.xs) - // Games on this date - ForEach(dateGroup.games) { game in - GameCalendarRow( - game: game, - isSelected: selectedIds.contains(game.id), - onTap: { - // Disable implicit animation to prevent weird morphing effect - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { - if selectedIds.contains(game.id) { - selectedIds.remove(game.id) - } else { - selectedIds.insert(game.id) + // Games on this date + ForEach(dateGroup.games) { game in + GameCalendarRow( + game: game, + isSelected: selectedIds.contains(game.id), + onTap: { + // Disable implicit animation to prevent weird morphing effect + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + if selectedIds.contains(game.id) { + selectedIds.remove(game.id) + selectedGamesCache.removeValue(forKey: game.id) + } else { + selectedIds.insert(game.id) + selectedGamesCache[game.id] = game + } } } - } - ) + ) + } } } } + .padding(.leading, Theme.Spacing.md) + .background(Theme.cardBackgroundElevated(colorScheme).opacity(0.5)) + } + } + } + } + + private func loadGames() { + loadingTeams.insert(team.id) + Task { + do { + let loadedGames = try await dataProvider.gamesForTeam(teamId: team.id) + await MainActor.run { + gamesCache[team.id] = loadedGames + loadingTeams.remove(team.id) + } + } catch { + await MainActor.run { + gamesCache[team.id] = [] + loadingTeams.remove(team.id) } - .padding(.leading, Theme.Spacing.md) - .background(Theme.cardBackgroundElevated(colorScheme).opacity(0.5)) } } } @@ -1362,20 +1367,6 @@ struct GameCalendarRow: View { } } -// MARK: - Team With Games Model - -struct TeamWithGames: Identifiable { - let team: Team - let sport: Sport - var games: [RichGame] - - var id: String { team.id } - - var sortedGames: [RichGame] { - games.sorted { $0.game.dateTime < $1.game.dateTime } - } -} - // MARK: - Location Search Sheet struct LocationSearchSheet: View {