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:
@@ -16,7 +16,7 @@ final class ProgressViewModel {
|
|||||||
// MARK: - State
|
// MARK: - State
|
||||||
|
|
||||||
var selectedSport: Sport = .mlb
|
var selectedSport: Sport = .mlb
|
||||||
var isLoading = false
|
var isLoading = true
|
||||||
var error: Error?
|
var error: Error?
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
|
|
||||||
@@ -42,10 +42,8 @@ final class ProgressViewModel {
|
|||||||
visits
|
visits
|
||||||
.filter { $0.sportEnum == selectedSport }
|
.filter { $0.sportEnum == selectedSport }
|
||||||
.compactMap { visit -> String? in
|
.compactMap { visit -> String? in
|
||||||
// Match visit's canonical stadium ID to a stadium
|
// O(1) dictionary lookup via DataProvider
|
||||||
stadiums.first { stadium in
|
dataProvider.stadium(for: visit.stadiumId)?.id
|
||||||
stadium.id == visit.stadiumId
|
|
||||||
}?.id
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -120,7 +118,7 @@ final class ProgressViewModel {
|
|||||||
.sorted { $0.visitDate > $1.visitDate }
|
.sorted { $0.visitDate > $1.visitDate }
|
||||||
.prefix(10)
|
.prefix(10)
|
||||||
.compactMap { visit -> VisitSummary? in
|
.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 {
|
let sport = visit.sportEnum else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,46 +20,19 @@ struct ProgressTabView: View {
|
|||||||
|
|
||||||
@Query private var visits: [StadiumVisit]
|
@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 {
|
var body: some View {
|
||||||
ScrollView {
|
Group {
|
||||||
VStack(spacing: Theme.Spacing.lg) {
|
if viewModel.stadiums.isEmpty {
|
||||||
// League Selector
|
ProgressView("Loading...")
|
||||||
leagueSelector
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.staggeredAnimation(index: 0)
|
} else {
|
||||||
|
scrollContent
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
.themedBackground()
|
.themedBackground()
|
||||||
.toolbar {
|
.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
|
// MARK: - League Selector
|
||||||
|
|
||||||
private var leagueSelector: some View {
|
private var leagueSelector: some View {
|
||||||
@@ -385,7 +403,7 @@ struct ProgressTabView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ForEach(viewModel.recentVisits) { visitSummary in
|
ForEach(viewModel.recentVisits) { visitSummary in
|
||||||
if let stadiumVisit = visits.first(where: { $0.id == visitSummary.id }) {
|
if let stadiumVisit = visitsById[visitSummary.id] {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
VisitDetailView(visit: stadiumVisit, stadium: visitSummary.stadium)
|
VisitDetailView(visit: stadiumVisit, stadium: visitSummary.stadium)
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
Reference in New Issue
Block a user