Analytics Dashboard Implementation Guide
Phase: 5a Status: Complete PR: #312
Overview
Phase 5a implements the Analytics Dashboard foundation for iOS, providing summary statistics and recent round history. This guide covers both iOS and Android changes that were part of PR #312.
iOS Implementation
Architecture
AnalyticsTabView
├── AnalyticsHubViewModel (state management)
│ └── AnalyticsRepositoryProtocol (DI)
│ └── AnalyticsRepositoryBridge (KMP bridge)
│ └── RoundDao (KMP data layer)
├── StatCard (summary metrics)
└── RecentRoundRow (round list items)
Key Files
| Component | File Path | Lines |
|---|---|---|
| ViewModel | ViewModels/AnalyticsHubViewModel.swift | 153 |
| View | Views/AnalyticsTabView.swift | 324 |
| Repository Bridge | DI/AnalyticsRepositoryBridge.swift | 31 |
| Protocol | DI/Protocols.swift | +4 |
| Tests | AnalyticsHubViewModelTests.swift | 678 |
AnalyticsHubViewModel
ViewModel managing analytics state with TDD approach (28 tests written first).
Published Properties:
@MainActor
class AnalyticsHubViewModel: ObservableObject {
@Published private(set) var isLoading: Bool = true
@Published private(set) var errorMessage: String?
@Published private(set) var totalRounds: Int = 0
@Published private(set) var totalArrowsShot: Int = 0
@Published private(set) var averageScore: Double = 0.0
@Published private(set) var recentRounds: [Round] = []
@Published private(set) var isEmpty: Bool = true
@Published private(set) var emptyStateMessage: String?
}Key Methods:
/// Load analytics data from repository
func loadAnalytics() async {
isLoading = true
errorMessage = nil
do {
// Fetch recent completed rounds
let rounds = try await repository.getRecentCompletedRounds(limit: 10)
recentRounds = rounds
totalRounds = rounds.count
// Aggregate statistics from all rounds
var totalArrows = 0
var totalScore = 0
for round in rounds {
if let stats = try await repository.getRoundStatistics(roundId: round.id) {
totalArrows += Int(stats.totalArrows)
totalScore += Int(stats.totalScore)
}
}
totalArrowsShot = totalArrows
averageScore = totalArrows > 0 ? Double(totalScore) / Double(totalArrows) : 0.0
isEmpty = rounds.isEmpty
isLoading = false
} catch {
errorMessage = "Failed to load analytics: \(error.localizedDescription)"
isLoading = false
isEmpty = true
}
}
/// Refresh with data preservation on error
func refresh() async {
// Store current data in case refresh fails
let previousRounds = recentRounds
let previousTotalRounds = totalRounds
// ... preserve all state
do {
// Attempt refresh
// ...
} catch {
// Restore previous data on error
recentRounds = previousRounds
totalRounds = previousTotalRounds
// ...
errorMessage = "Failed to refresh analytics: \(error.localizedDescription)"
}
}Design Patterns:
- @MainActor Class - Thread-safe ViewModel for SwiftUI
- Refresh Data Preservation - Previous data restored on error
- Empty State Handling - Distinct empty vs error states
- Protocol-Based DI - Testable with mock repositories
AnalyticsTabView
SwiftUI view displaying summary statistics and recent rounds.
View States:
@ViewBuilder
private var contentView: some View {
if viewModel.isLoading {
loadingView
} else if let errorMessage = viewModel.errorMessage {
errorView(message: errorMessage)
} else if viewModel.isEmpty {
emptyStateView
} else {
analyticsContentView
}
}StatCard Component:
private struct StatCard: View {
let title: String
let value: String
let icon: String
let color: Color
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: icon)
.foregroundColor(color)
Spacer()
}
Text(value)
.font(.title2)
.fontWeight(.bold)
Text(title)
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
}
}Summary Cards:
| Card | Icon | Color |
|---|---|---|
| Total Rounds | target | Blue |
| Arrows Shot | arrow.up.right | Green |
| Average Score | chart.line.uptrend.xyaxis | Orange |
| Recent Activity | clock.arrow.circlepath | Purple |
KMP Enum Comparison Pattern:
private var statusText: String {
switch round.status.name {
case RoundStatus.completed.name:
return "Completed"
case RoundStatus.inProgress.name:
return "In Progress"
case RoundStatus.paused.name:
return "Paused"
case RoundStatus.planned.name:
return "Planned"
default:
return "Unknown"
}
}AnalyticsRepositoryBridge
Thin bridge adapting KMP RoundDao to Swift protocol.
class AnalyticsRepositoryBridge: AnalyticsRepositoryProtocol {
private let roundDao: RoundDao
init(roundDao: RoundDao) {
self.roundDao = roundDao
}
func getRecentCompletedRounds(limit: Int32) async throws -> [Round] {
return try await roundDao.getRecentCompletedRounds(limit: limit)
}
func getRoundStatistics(roundId: Int32) async throws -> RoundStatisticsData? {
return try await roundDao.getRoundStatistics(roundId: roundId)
}
}Protocol Definition:
protocol AnalyticsRepositoryProtocol {
func getRecentCompletedRounds(limit: Int32) async throws -> [Round]
func getRoundStatistics(roundId: Int32) async throws -> RoundStatisticsData?
}TDD Test Coverage
28 tests written before implementation:
Test Categories:
| Category | Tests | Description |
|---|---|---|
| Initial State | 5 | Default values, loading state |
| Load Success | 8 | Statistics aggregation, empty state |
| Load Error | 4 | Error handling, state preservation |
| Refresh Success | 6 | Data update, empty handling |
| Refresh Error | 5 | Data preservation on failure |
Mock Repository Pattern:
class MockAnalyticsRepository: AnalyticsRepositoryProtocol {
var roundsToReturn: [Round] = []
var statisticsToReturn: [Int32: RoundStatisticsData] = [:]
var shouldThrowError: Bool = false
var errorToThrow: Error = NSError(domain: "test", code: 1)
// Call tracking
var getRecentRoundsCallCount = 0
var getStatisticsCallCount = 0
func getRecentCompletedRounds(limit: Int32) async throws -> [Round] {
getRecentRoundsCallCount += 1
if shouldThrowError { throw errorToThrow }
return roundsToReturn
}
func getRoundStatistics(roundId: Int32) async throws -> RoundStatisticsData? {
getStatisticsCallCount += 1
if shouldThrowError { throw errorToThrow }
return statisticsToReturn[roundId]
}
}Android Implementation
RoundAnalyticsViewModel Updates
loadPerformanceStats Implementation:
Previously a stub, now fully implemented:
fun loadPerformanceStats(bowSetupId: Long) {
viewModelScope.launch {
try {
_isLoading.value = true
_errorMessage.value = null
val stats = roundRepository.getEquipmentPerformanceStats(bowSetupId)
_performanceStats.value = stats
if (stats == null) {
logger.w("RoundAnalyticsViewModel", "No performance stats found for bow setup $bowSetupId")
}
} catch (e: Exception) {
logger.e("RoundAnalyticsViewModel", "Failed to load performance stats", e)
_errorMessage.value = "Failed to load equipment performance: ${e.message}"
_performanceStats.value = null
} finally {
_isLoading.value = false
}
}
}Equipment Drift Statistics
New data class for shot placement drift analysis:
data class EquipmentDriftStats(
val bowSetupId: Long,
val xDriftAverage: Float, // Positive = right bias
val yDriftAverage: Float, // Positive = down bias
val arrowCount: Int
)SQL Query (RoundDao):
SELECT
ars.bowSetupId AS bowSetupId,
AVG(ars.xCoordinate) AS xDriftAverage,
AVG(ars.yCoordinate) AS yDriftAverage,
COUNT(*) AS arrowCount
FROM arrow_scores ars
JOIN end_scores es ON ars.endScoreId = es.id
JOIN rounds r ON es.roundId = r.id
WHERE ars.bowSetupId = :bowSetupId
AND ars.xCoordinate IS NOT NULL
AND ars.yCoordinate IS NOT NULL
AND (:startDate IS NULL OR r.createdAt >= :startDate)
GROUP BY ars.bowSetupIdDrift Interpretation:
| Drift | Meaning | Suggested Action |
|---|---|---|
| Positive X | Shots right of center | Adjust sight left |
| Negative X | Shots left of center | Adjust sight right |
| Positive Y | Shots below center | Adjust sight up |
| Negative Y | Shots above center | Adjust sight down |
RoundStatisticsService Fix
Hardcoded Max Score Bug:
// BEFORE (Bug)
val maxPossibleScore = round.numEnds * round.numArrows * 10 // Hardcoded 10!
// AFTER (Fixed)
val maxScorePerArrow = round.scoringSystem.maxScore
val maxPossibleScore = round.numEnds * round.numArrows * maxScorePerArrowImpact: Statistics accuracy now correct for all scoring systems (Indoor 10-ring, Field 5-ring, etc.)
Time-Range Filtering Fixes
EquipmentComparisonScreen:
// BEFORE (Bug)
val stats = roundRepo.getEquipmentPerformanceStats(setupId)
// AFTER (Fixed)
val stats = roundRepo.getEquipmentPerformanceStatsInRange(setupId, startMillis)Test Coverage Summary
iOS Tests (28 new)
| Test Class | Tests |
|---|---|
| AnalyticsHubViewModelTests | 28 |
Android Tests (9 new)
| Test Class | Tests |
|---|---|
| RoundDaoTimeRangeFilteringTest | 9 |
Total New Tests: 37
Code Review Findings
iOS Known Issues (Documented for Future)
- AnalyticsTabView not wired into MainTabView - Requires MainTabView update
- N+1 Query Problem - Statistics loop fetches individually (acceptable for MVP)
- Code Duplication - loadAnalytics/refresh share logic (could extract)
Android Known Issues
- SQL GROUP BY - May need review for edge cases
- Missing Drift Stats Time Filtering Test - Not yet covered
- Silent Error Handling - Some catch blocks return null silently
Patterns Established
1. TDD in iOS ViewModels
Write tests first, implement to pass:
// 1. Write failing test
func testLoadAnalytics_withRounds_setsStatistics() async {
mockRepository.roundsToReturn = [testRound1, testRound2]
mockRepository.statisticsToReturn = [1: stats1, 2: stats2]
await viewModel.loadAnalytics()
XCTAssertEqual(viewModel.totalRounds, 2)
XCTAssertEqual(viewModel.totalArrowsShot, 24)
}
// 2. Implement to pass
func loadAnalytics() async {
// Implementation...
}2. Refresh Data Preservation
Preserve previous data on refresh failure:
func refresh() async {
let previousData = currentData // Capture
do {
// Attempt refresh
} catch {
currentData = previousData // Restore on error
errorMessage = "Refresh failed"
}
}3. Equipment Drift Analysis
SQL aggregate for shot placement bias:
SELECT AVG(xCoordinate), AVG(yCoordinate) FROM arrow_scores
WHERE xCoordinate IS NOT NULL AND yCoordinate IS NOT NULLKnown Issues (Addressed in Phase 5b)
The following issues from Phase 5a were addressed in Phase 5b:
- AnalyticsTabView not wired into MainTabView - Now integrated with DI injection
- N+1 Query Problem - Optimized with Swift TaskGroup parallel loading
Related Documentation
- Phase 5b Individual Equipment Analytics - Equipment-level analytics
- KMP iOS Patterns - Integration patterns
- Visual Scoring Guide - Shot coordinate storage
- Equipment Analytics - Android analytics reference
Last Updated: 2025-11-30 Status: Phase 5a complete (see Phase 5b for continuation)