perf: lazy hierarchical loading for game picker

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 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-12 20:19:46 -06:00
parent 9531ed1008
commit 37a1347ce3
5 changed files with 235 additions and 295 deletions

View File

@@ -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..<end])
}
// MARK: - Actions
func loadGames() async {
@@ -186,4 +116,34 @@ final class ScheduleViewModel {
await loadGames()
}
}
// MARK: - Filtering & Grouping (pre-computed, not computed properties)
/// Recomputes filtered games and groupings based on current search text
func updateFilteredGames() {
// Step 1: Filter by search text
if searchText.isEmpty {
filteredGames = games
} else {
let query = searchText.lowercased()
filteredGames = 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)
}
}
// Step 2: Pre-compute grouping by sport (done once, not per-render)
let grouped = Dictionary(grouping: filteredGames) { $0.game.sport }
gamesBySport = 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 }) }
}
}

View File

@@ -10,13 +10,17 @@ struct ScheduleListView: View {
@State private var viewModel = ScheduleViewModel()
@State private var showDatePicker = false
private var allGames: [RichGame] {
viewModel.gamesBySport.flatMap(\.games)
}
var body: some View {
Group {
if viewModel.isLoading && viewModel.displayedGames.isEmpty {
if viewModel.isLoading && allGames.isEmpty {
loadingView
} else if let errorMessage = viewModel.errorMessage {
errorView(message: errorMessage)
} else if viewModel.displayedGames.isEmpty {
} else if allGames.isEmpty {
emptyView
} else {
gamesList
@@ -96,12 +100,6 @@ struct ScheduleListView: View {
Section {
ForEach(sportGroup.games) { richGame in
GameRowView(game: richGame, showDate: true, showLocation: true)
.onAppear {
// Trigger load more when the last game appears
if richGame.id == viewModel.lastDisplayedGame?.id {
viewModel.loadMoreGames()
}
}
}
} header: {
HStack(spacing: 8) {
@@ -112,18 +110,6 @@ struct ScheduleListView: View {
}
.listRowBackground(Theme.cardBackground(colorScheme))
}
// Show loading indicator when more games are available
if viewModel.hasMoreGames {
Section {
HStack {
Spacer()
ProgressView()
Spacer()
}
.listRowBackground(Color.clear)
}
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
@@ -231,6 +217,14 @@ struct GameRowView: View {
var showDate: Bool = false
var showLocation: Bool = false
// Static formatter to avoid allocation per row (significant performance improvement)
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "EEE, MMM d"
return formatter
}()
// Cache isToday check to avoid repeated Calendar calls
private var isToday: Bool {
Calendar.current.isDateInToday(game.game.dateTime)
}
@@ -240,7 +234,7 @@ struct GameRowView: View {
// Date (when grouped by sport)
if showDate {
HStack(spacing: 6) {
Text(formattedDate)
Text(Self.dateFormatter.string(from: game.game.dateTime))
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.secondary)
@@ -300,12 +294,6 @@ struct GameRowView: View {
.padding(.vertical, 4)
.listRowBackground(isToday ? Color.orange.opacity(0.1) : nil)
}
private var formattedDate: String {
let formatter = DateFormatter()
formatter.dateFormat = "EEE, MMM d"
return formatter.string(from: game.game.dateTime)
}
}
// MARK: - Team Badge