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:
@@ -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 }) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user