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

ComponentFile PathLines
ViewModelViewModels/AnalyticsHubViewModel.swift153
ViewViews/AnalyticsTabView.swift324
Repository BridgeDI/AnalyticsRepositoryBridge.swift31
ProtocolDI/Protocols.swift+4
TestsAnalyticsHubViewModelTests.swift678

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:

  1. @MainActor Class - Thread-safe ViewModel for SwiftUI
  2. Refresh Data Preservation - Previous data restored on error
  3. Empty State Handling - Distinct empty vs error states
  4. 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:

CardIconColor
Total RoundstargetBlue
Arrows Shotarrow.up.rightGreen
Average Scorechart.line.uptrend.xyaxisOrange
Recent Activityclock.arrow.circlepathPurple

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:

CategoryTestsDescription
Initial State5Default values, loading state
Load Success8Statistics aggregation, empty state
Load Error4Error handling, state preservation
Refresh Success6Data update, empty handling
Refresh Error5Data 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.bowSetupId

Drift Interpretation:

DriftMeaningSuggested Action
Positive XShots right of centerAdjust sight left
Negative XShots left of centerAdjust sight right
Positive YShots below centerAdjust sight up
Negative YShots above centerAdjust 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 * maxScorePerArrow

Impact: 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 ClassTests
AnalyticsHubViewModelTests28

Android Tests (9 new)

Test ClassTests
RoundDaoTimeRangeFilteringTest9

Total New Tests: 37


Code Review Findings

iOS Known Issues (Documented for Future)

  1. AnalyticsTabView not wired into MainTabView - Requires MainTabView update
  2. N+1 Query Problem - Statistics loop fetches individually (acceptable for MVP)
  3. Code Duplication - loadAnalytics/refresh share logic (could extract)

Android Known Issues

  1. SQL GROUP BY - May need review for edge cases
  2. Missing Drift Stats Time Filtering Test - Not yet covered
  3. 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 NULL

Known Issues (Addressed in Phase 5b)

The following issues from Phase 5a were addressed in Phase 5b:

  1. AnalyticsTabView not wired into MainTabView - Now integrated with DI injection
  2. N+1 Query Problem - Optimized with Swift TaskGroup parallel loading


Last Updated: 2025-11-30 Status: Phase 5a complete (see Phase 5b for continuation)