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

@@ -114,11 +114,8 @@ struct TripCreationView: View {
}
.sheet(isPresented: $showGamePicker) {
GamePickerSheet(
games: viewModel.displayedAvailableGames,
selectedIds: $viewModel.mustSeeGameIds,
hasMoreGames: viewModel.hasMoreAvailableGames,
totalGamesCount: viewModel.availableGames.count,
loadMoreGames: { viewModel.loadMoreAvailableGames() }
selectedSports: viewModel.selectedSports,
selectedIds: $viewModel.mustSeeGameIds
)
}
.sheet(isPresented: $showCityInput) {
@@ -455,12 +452,6 @@ struct TripCreationView: View {
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
.onAppear {
// Ensure pagination is initialized when view appears
if viewModel.displayedAvailableGames.isEmpty && !viewModel.availableGames.isEmpty {
viewModel.loadInitialAvailableGames()
}
}
Spacer()
@@ -954,68 +945,29 @@ extension TripCreationViewModel.ViewState {
}
}
// MARK: - Game Picker Sheet (Calendar view: Sport Team Date)
// MARK: - Game Picker Sheet (Calendar view: Sport Team Date with lazy loading)
struct GamePickerSheet: View {
let games: [RichGame]
let selectedSports: Set<Sport>
@Binding var selectedIds: Set<String>
let hasMoreGames: Bool
let totalGamesCount: Int
let loadMoreGames: () -> Void
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@State private var expandedSports: Set<Sport> = []
@State private var expandedTeams: Set<String> = []
@State private var gamesCache: [String: [RichGame]] = [:] // teamId -> games
@State private var loadingTeams: Set<String> = []
@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<String>
@Binding var expandedSports: Set<Sport>
@Binding var expandedTeams: Set<String>
@Binding var gamesCache: [String: [RichGame]]
@Binding var loadingTeams: Set<String>
@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<String>
@Binding var expandedTeams: Set<String>
@Binding var gamesCache: [String: [RichGame]]
@Binding var loadingTeams: Set<String>
@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 {