perf: optimize Progress tab with O(1) lookups and loading state

Replace O(n) linear searches with dictionary lookups in ProgressViewModel
and ProgressTabView. Add loading spinner while data loads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-09 17:38:59 -06:00
parent 213d2bae53
commit 1c57c47041
2 changed files with 61 additions and 45 deletions

View File

@@ -16,7 +16,7 @@ final class ProgressViewModel {
// MARK: - State
var selectedSport: Sport = .mlb
var isLoading = false
var isLoading = true
var error: Error?
var errorMessage: String?
@@ -42,10 +42,8 @@ final class ProgressViewModel {
visits
.filter { $0.sportEnum == selectedSport }
.compactMap { visit -> String? in
// Match visit's canonical stadium ID to a stadium
stadiums.first { stadium in
stadium.id == visit.stadiumId
}?.id
// O(1) dictionary lookup via DataProvider
dataProvider.stadium(for: visit.stadiumId)?.id
}
)
@@ -120,7 +118,7 @@ final class ProgressViewModel {
.sorted { $0.visitDate > $1.visitDate }
.prefix(10)
.compactMap { visit -> VisitSummary? in
guard let stadium = stadiums.first(where: { $0.id == visit.stadiumId }),
guard let stadium = dataProvider.stadium(for: visit.stadiumId),
let sport = visit.sportEnum else {
return nil
}

View File

@@ -20,46 +20,19 @@ struct ProgressTabView: View {
@Query private var visits: [StadiumVisit]
/// O(1) lookup for visits by ID (built from @Query results)
private var visitsById: [UUID: StadiumVisit] {
Dictionary(uniqueKeysWithValues: visits.map { ($0.id, $0) })
}
var body: some View {
ScrollView {
VStack(spacing: Theme.Spacing.lg) {
// League Selector
leagueSelector
.staggeredAnimation(index: 0)
// Progress Summary Card
progressSummaryCard
.staggeredAnimation(index: 1)
// Map View
ProgressMapView(
stadiums: viewModel.sportStadiums,
visitStatus: viewModel.stadiumVisitStatus,
selectedStadium: $selectedStadium
)
.frame(height: 300)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
.staggeredAnimation(index: 2)
// Stadium Lists
stadiumListsSection
.staggeredAnimation(index: 3)
// Achievements Teaser
achievementsSection
.staggeredAnimation(index: 4)
// Recent Visits
if !viewModel.recentVisits.isEmpty {
recentVisitsSection
.staggeredAnimation(index: 5)
}
Group {
if viewModel.stadiums.isEmpty {
ProgressView("Loading...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
scrollContent
}
.padding(Theme.Spacing.md)
}
.themedBackground()
.toolbar {
@@ -126,6 +99,51 @@ struct ProgressTabView: View {
}
}
// MARK: - Scroll Content
private var scrollContent: some View {
ScrollView {
VStack(spacing: Theme.Spacing.lg) {
// League Selector
leagueSelector
.staggeredAnimation(index: 0)
// Progress Summary Card
progressSummaryCard
.staggeredAnimation(index: 1)
// Map View
ProgressMapView(
stadiums: viewModel.sportStadiums,
visitStatus: viewModel.stadiumVisitStatus,
selectedStadium: $selectedStadium
)
.frame(height: 300)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
.staggeredAnimation(index: 2)
// Stadium Lists
stadiumListsSection
.staggeredAnimation(index: 3)
// Achievements Teaser
achievementsSection
.staggeredAnimation(index: 4)
// Recent Visits
if !viewModel.recentVisits.isEmpty {
recentVisitsSection
.staggeredAnimation(index: 5)
}
}
.padding(Theme.Spacing.md)
}
}
// MARK: - League Selector
private var leagueSelector: some View {
@@ -385,7 +403,7 @@ struct ProgressTabView: View {
}
ForEach(viewModel.recentVisits) { visitSummary in
if let stadiumVisit = visits.first(where: { $0.id == visitSummary.id }) {
if let stadiumVisit = visitsById[visitSummary.id] {
NavigationLink {
VisitDetailView(visit: stadiumVisit, stadium: visitSummary.stadium)
} label: {