Individual Equipment Analytics Guide

Phase: 5b Status: Complete PR: #313


Overview

Phase 5b extends the Analytics Dashboard with individual equipment performance tracking and historical trend visualization. This guide covers both iOS and Android changes from PR #313, focusing on the ArrowEquipmentSnapshot system and the 15 new EquipmentStatsDao queries.

Key Features:

  • Individual equipment performance statistics per piece (riser, limbs, sight, arrows, stabilizer)
  • ArrowEquipmentSnapshot creation during scoring (TDD implementation)
  • Time-range filtering (7d, 30d, 90d, All) on both platforms
  • Historical trend bar chart visualization
  • Equipment display on round cards
  • N+1 query optimization with Swift TaskGroup

Architecture Overview

Data Flow

Arrow Scored → ArrowEquipmentSnapshot Created → EquipmentStatsDao Queries → UI Display
     ↓                    ↓                              ↓
RoundScoringService  Equipment IDs cached         Performance/Drift stats
     ↓                    ↓                              ↓
   scoreEnd()      BowSetupEquipment lookup    IndividualEquipmentStats

Component Hierarchy

iOS:

IndividualEquipmentPerformanceView
├── IndividualEquipmentPerformanceViewModel
│   └── IndividualEquipmentAnalyticsProtocol (DI)
│       └── IndividualEquipmentAnalyticsBridge (KMP bridge)
│           └── EquipmentStatsDao (KMP data layer)
├── TimeRangeChip (filter selection)
├── StatCard (metrics display)
├── ScoreDistributionRow (score breakdown)
└── DriftStats (shot placement analysis)

Android:

IndividualEquipmentPerformanceTab
├── EquipmentStatsDao (direct access)
├── StatisticItem (metrics)
├── ScoreDistributionCard
└── UsageInfoCard

ArrowEquipmentSnapshot Architecture

Purpose

The ArrowEquipmentSnapshot entity captures which equipment was used for each arrow shot. This enables per-equipment analytics by linking arrow scores to the specific riser, limbs, sight, arrows, and stabilizer used.

Entity Definition

@Entity(
    tableName = "arrow_equipment_snapshot",
    foreignKeys = [
        ForeignKey(
            entity = ArrowScore::class,
            parentColumns = ["id"],
            childColumns = ["arrowScoreId"],
            onDelete = ForeignKey.CASCADE
        )
    ],
    indices = [
        Index("arrowScoreId"),
        Index("bowSetupId"),
        Index("riserId"),
        Index("limbsId"),
        Index("sightId"),
        Index("arrowId"),
        Index("stabilizerId")
    ]
)
data class ArrowEquipmentSnapshot(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val arrowScoreId: Long,
    val bowSetupId: Long,
    val bowSetupVersion: Int,
    val distance: String,
    val targetSize: String,
    val weatherConditions: String = "",
    val riserId: Long? = null,
    val limbsId: Long? = null,
    val sightId: Long? = null,
    val arrowId: Long? = null,
    val stabilizerId: Long? = null,
    val capturedAt: Long = System.currentTimeMillis()
)

Database Indexes

Equipment ID columns are indexed for efficient queries:

IndexColumnPurpose
idx_snapshot_riserriserIdPerformance queries by riser
idx_snapshot_limbslimbsIdPerformance queries by limbs
idx_snapshot_sightsightIdPerformance queries by sight
idx_snapshot_arrowarrowIdPerformance queries by arrow
idx_snapshot_stabilizerstabilizerIdPerformance queries by stabilizer

TDD Implementation: Snapshot Creation

Test-First Development

7 new tests were written in RoundScoringServiceTest.kt before implementation:

TestValidates
scoreEnd_CreatesEquipmentSnapshotsForAllArrowsOne snapshot per arrow
scoreEnd_SnapshotsContainCorrectEquipmentIdsCorrect equipment ID mapping
scoreEnd_SkipsSnapshotsWhenNoBowSetupGraceful skip when no setup
scoreEnd_SnapshotsAreBulkInsertedBulk insert performance
scoreEnd_SnapshotsCaptureRoundContextDistance/target size captured
scoreEnd_ScoringSucceedsEvenIfSnapshotCreationFailsGraceful degradation
recordCompletedEndAndAdvance_CreatesEquipmentSnapshotsAlternative entry point

RoundScoringService Integration

Snapshot creation is integrated into both scoring methods:

class RoundScoringService(
    private val roundDao: RoundDao,
    private val bowSetupRepository: BowSetupRepository,
    private val equipmentStatsDao: EquipmentStatsDao? = null,
    private val bowSetupDao: BowSetupDao? = null,
    private val logger: LoggingProvider = AndroidLoggingProvider()
) {
    // Equipment ID cache for performance
    private val equipmentCache = mutableMapOf<Long, Map<EquipmentType, Long>>()
 
    suspend fun scoreEnd(
        roundId: Int,
        endNumber: Int,
        arrowScores: List<Int>,
        isXRing: List<Boolean>,
        coordinates: List<DomainCoordinate> = emptyList(),
        participantId: String
    ): Boolean {
        // ... arrow insertion logic ...
 
        // Fetch inserted arrows to get IDs
        val insertedArrows = roundDao.getArrowScoresForEnd(endScore.id.toLong())
 
        // Create equipment snapshots (graceful degradation)
        createEquipmentSnapshots(insertedArrows, round, participantSetupId)
 
        return true
    }
}

Equipment Caching Strategy

Equipment IDs are cached per bow setup to avoid repeated database lookups:

private val equipmentCache = mutableMapOf<Long, Map<EquipmentType, Long>>()
 
private suspend fun getEquipmentIdsForSetup(bowSetupId: Long): Map<EquipmentType, Long> {
    // Check cache first
    equipmentCache[bowSetupId]?.let { return it }
 
    // Fetch from database
    val equipment = bowSetupDao?.getEquipmentForSetup(bowSetupId) ?: emptyList()
 
    // Build map of type -> id (take first of each type if multiples exist)
    val equipmentMap = equipment
        .groupBy { it.equipmentType }
        .mapValues { (_, items) -> items.first().equipmentId }
 
    // Cache for future use
    equipmentCache[bowSetupId] = equipmentMap
 
    return equipmentMap
}

Graceful Degradation

Snapshot creation failures do not block scoring:

private suspend fun createEquipmentSnapshots(
    arrows: List<ArrowScore>,
    round: Round,
    bowSetupId: Long?
) {
    // Skip if no bow setup or no DAO available
    if (bowSetupId == null || bowSetupId <= 0) return
    if (equipmentStatsDao == null || bowSetupDao == null) return
 
    try {
        // ... snapshot creation logic ...
    } catch (_: Exception) {
        // Graceful degradation: scoring must succeed
        // Snapshot failures are silently ignored
    }
}

EquipmentStatsDao Query Reference

Performance Queries (5)

Each equipment type has a dedicated performance stats query:

// Example: Riser Performance Stats
@Query("""
    SELECT
        aes.riserId as equipmentId,
        COUNT(*) as totalArrows,
        AVG(CAST(ars.scoreValue AS FLOAT)) as averageScore,
        SUM(CASE WHEN ars.scoreValue = 10 AND ars.isX = 1 THEN 1 ELSE 0 END) as xCount,
        SUM(CASE WHEN ars.scoreValue = 10 THEN 1 ELSE 0 END) as tenCount,
        SUM(CASE WHEN ars.scoreValue >= 9 THEN 1 ELSE 0 END) as nineOrBetterCount,
        SUM(CASE WHEN ars.scoreValue = 0 THEN 1 ELSE 0 END) as missCount,
        MIN(ars.scoredAt) as firstShotAt,
        MAX(ars.scoredAt) as lastShotAt,
        COUNT(DISTINCT es.roundId) as roundCount,
        (CAST(SUM(ars.scoreValue) AS FLOAT) / (COUNT(*) * 10.0)) * 100 as accuracy
    FROM arrow_equipment_snapshot aes
    INNER JOIN arrow_scores ars ON aes.arrowScoreId = ars.id
    INNER JOIN end_scores es ON ars.endScoreId = es.id
    WHERE aes.riserId = :riserId
    AND (:startDate IS NULL OR aes.capturedAt >= :startDate)
    GROUP BY aes.riserId
""")
suspend fun getRiserPerformanceStats(riserId: Long, startDate: Long? = null): IndividualEquipmentStats?

All Performance Queries:

QueryEquipment Type
getRiserPerformanceStats()Riser
getLimbsPerformanceStats()Limbs
getSightPerformanceStats()Sight
getArrowPerformanceStats()Arrow
getStabilizerPerformanceStats()Stabilizer

Drift Queries (5)

Shot placement drift analysis per equipment:

@Query("""
    SELECT
        aes.riserId as equipmentId,
        AVG(ars.xCoordinate - ars.targetCenterX) as xDriftAverage,
        AVG(ars.yCoordinate - ars.targetCenterY) as yDriftAverage,
        COUNT(*) as sampleCount
    FROM arrow_equipment_snapshot aes
    INNER JOIN arrow_scores ars ON aes.arrowScoreId = ars.id
    WHERE aes.riserId = :riserId
    AND ars.xCoordinate IS NOT NULL
    AND ars.yCoordinate IS NOT NULL
    AND ars.targetCenterX IS NOT NULL
    AND ars.targetCenterY IS NOT NULL
    AND (:startDate IS NULL OR aes.capturedAt >= :startDate)
    GROUP BY aes.riserId
""")
suspend fun getRiserDriftStats(riserId: Long, startDate: Long? = null): IndividualEquipmentDriftStats?

All Drift Queries:

QueryEquipment Type
getRiserDriftStats()Riser
getLimbsDriftStats()Limbs
getSightDriftStats()Sight
getArrowDriftStats()Arrow
getStabilizerDriftStats()Stabilizer

Round Navigation Queries (5)

Get rounds where specific equipment was used:

@Query("""
    SELECT DISTINCT es.roundId
    FROM arrow_equipment_snapshot aes
    INNER JOIN arrow_scores ars ON aes.arrowScoreId = ars.id
    INNER JOIN end_scores es ON ars.endScoreId = es.id
    WHERE aes.riserId = :riserId
    ORDER BY es.roundId DESC
""")
suspend fun getRoundIdsForRiser(riserId: Long): List<Int>

All Round Navigation Queries:

QueryEquipment Type
getRoundIdsForRiser()Riser
getRoundIdsForLimbs()Limbs
getRoundIdsForSight()Sight
getRoundIdsForArrow()Arrow
getRoundIdsForStabilizer()Stabilizer

Data Classes

data class IndividualEquipmentStats(
    val equipmentId: Long,
    val totalArrows: Int,
    val averageScore: Float,
    val xCount: Int,
    val tenCount: Int,
    val nineOrBetterCount: Int,
    val missCount: Int,
    val firstShotAt: Long?,
    val lastShotAt: Long?,
    val roundCount: Int,
    val accuracy: Float
)
 
data class IndividualEquipmentDriftStats(
    val equipmentId: Long,
    val xDriftAverage: Float?,
    val yDriftAverage: Float?,
    val sampleCount: Int
)

iOS Implementation

IndividualEquipmentPerformanceView

468-line SwiftUI view for displaying equipment analytics:

struct IndividualEquipmentPerformanceView: View {
    let equipmentType: EquipmentType
    let equipmentId: Int64
 
    @StateObject private var viewModel: IndividualEquipmentPerformanceViewModel
    @State private var selectedTimeRange: AnalyticsTimeRange = .all
 
    var body: some View {
        ScrollView {
            VStack(spacing: 20) {
                if viewModel.isLoading {
                    loadingView
                } else if let errorMessage = viewModel.errorMessage {
                    errorView(errorMessage)
                } else if viewModel.hasData {
                    contentView
                } else {
                    emptyView
                }
            }
            .padding()
        }
        .task {
            await viewModel.loadPerformanceStats(
                equipmentType: equipmentType,
                equipmentId: equipmentId,
                timeRange: selectedTimeRange
            )
        }
    }
}

IndividualEquipmentAnalyticsBridge

122-line bridge adapting KMP EquipmentStatsDao to Swift protocol:

class IndividualEquipmentAnalyticsBridge: IndividualEquipmentAnalyticsProtocol {
    private let equipmentStatsDao: EquipmentStatsDao
 
    func getPerformanceStats(
        equipmentType: EquipmentType,
        equipmentId: Int64,
        startDate: Int64?
    ) async throws -> IndividualEquipmentStatsModel? {
        let stats: IndividualEquipmentStats?
 
        switch equipmentType {
        case .riser:
            stats = try await equipmentStatsDao.getRiserPerformanceStats(
                riserId: equipmentId,
                startDate: startDate?.asKotlinLong()
            )
        case .limbs:
            stats = try await equipmentStatsDao.getLimbsPerformanceStats(
                limbsId: equipmentId,
                startDate: startDate?.asKotlinLong()
            )
        // ... other equipment types
        }
 
        return stats.map { IndividualEquipmentStatsModel(from: $0) }
    }
}

Parallel Data Loading

Swift TaskGroup used to load stats, drift, and rounds concurrently:

func loadPerformanceStats(
    equipmentType: EquipmentType,
    equipmentId: Int64,
    timeRange: AnalyticsTimeRange
) async {
    isLoading = true
    errorMessage = nil
 
    do {
        let startDate = timeRange.startTimestamp()
 
        // Load stats in parallel
        async let statsTask = repository.getPerformanceStats(
            equipmentType: equipmentType,
            equipmentId: equipmentId,
            startDate: startDate
        )
        async let driftTask = repository.getDriftStats(
            equipmentType: equipmentType,
            equipmentId: equipmentId,
            startDate: startDate
        )
        async let roundsTask = repository.getRoundIds(
            equipmentType: equipmentType,
            equipmentId: equipmentId
        )
 
        let (statsResult, driftResult, roundsResult) = try await (
            statsTask,
            driftTask,
            roundsTask
        )
 
        stats = statsResult
        driftStats = driftResult
        roundIds = roundsResult
 
        isLoading = false
    } catch {
        errorMessage = "Failed to load performance data: \(error.localizedDescription)"
        isLoading = false
    }
}

iOS AnalyticsTab Integration

MainTabView Wiring

AnalyticsTabView is now integrated into MainTabView with DI injection:

// DependencyContainer.swift
class DependencyContainer {
    static let shared = DependencyContainer()
 
    var analyticsRepositoryBridge: AnalyticsRepositoryBridge?
    var equipmentAnalyticsBridge: IndividualEquipmentAnalyticsBridge?
 
    func configure(with database: ArcheryKmpDatabase) {
        analyticsRepositoryBridge = AnalyticsRepositoryBridge(
            roundDao: database.roundDao()
        )
        equipmentAnalyticsBridge = IndividualEquipmentAnalyticsBridge(
            equipmentStatsDao: database.equipmentStatsDao()
        )
    }
}
 
// MainTabView.swift
struct MainTabView: View {
    var body: some View {
        TabView {
            // ... other tabs ...
 
            if let repository = DependencyContainer.shared.analyticsRepositoryBridge {
                AnalyticsTabView(repository: repository)
                    .tabItem {
                        Label("Analytics", systemImage: "chart.bar.xaxis")
                    }
            } else {
                // Fallback for test environment
                Text("Analytics unavailable")
                    .tabItem {
                        Label("Analytics", systemImage: "chart.bar.xaxis")
                    }
            }
        }
    }
}

Time-Range Filtering

Matches Android’s Timeframe enum:

enum AnalyticsTimeRange: CaseIterable {
    case sevenDays
    case thirtyDays
    case ninetyDays
    case all
 
    var label: String {
        switch self {
        case .sevenDays: return "7 Days"
        case .thirtyDays: return "30 Days"
        case .ninetyDays: return "90 Days"
        case .all: return "All Time"
        }
    }
 
    func startTimestamp() -> Int64? {
        let now = Date()
        switch self {
        case .sevenDays:
            return Int64(Calendar.current.date(
                byAdding: .day,
                value: -7,
                to: now
            )!.timeIntervalSince1970 * 1000)
        case .thirtyDays:
            return Int64(Calendar.current.date(
                byAdding: .day,
                value: -30,
                to: now
            )!.timeIntervalSince1970 * 1000)
        case .ninetyDays:
            return Int64(Calendar.current.date(
                byAdding: .day,
                value: -90,
                to: now
            )!.timeIntervalSince1970 * 1000)
        case .all:
            return nil
        }
    }
}

Historical Trend Chart

TrendChartView displays bar chart with normalized heights:

private struct TrendChartView: View {
    let trends: [PerformanceTrend]
 
    private var maxScore: Double {
        trends.map { $0.averageScore }.max() ?? 1
    }
 
    private var minScore: Double {
        trends.map { $0.averageScore }.min() ?? 0
    }
 
    private var scoreRange: Double {
        max(maxScore - minScore, 1)
    }
 
    var body: some View {
        GeometryReader { geometry in
            HStack(alignment: .bottom, spacing: 4) {
                ForEach(reversedTrends.indices, id: \.self) { index in
                    let trend = reversedTrends[index]
                    let normalizedHeight = (trend.averageScore - minScore) / scoreRange
                    let barHeight = max(10, normalizedHeight * availableHeight)
 
                    VStack(spacing: 4) {
                        RoundedRectangle(cornerRadius: 4)
                            .fill(Color.accentColor.gradient)
                            .frame(height: barHeight)
 
                        Text(formatDate(trend.date))
                            .font(.system(size: 8))
                    }
                }
            }
        }
    }
}

Android Implementation

IndividualEquipmentPerformanceTab

421-line Compose component added to all equipment detail screens:

@Composable
fun IndividualEquipmentPerformanceTab(
    equipmentType: EquipmentType,
    equipmentId: Long,
    navController: NavHostController,
    modifier: Modifier = Modifier
) {
    val context = LocalContext.current
    val database = getDatabase(context)
    val coroutineScope = rememberCoroutineScope()
 
    var stats by remember { mutableStateOf<IndividualEquipmentStats?>(null) }
    var isLoading by remember { mutableStateOf(true) }
 
    LaunchedEffect(equipmentId, equipmentType) {
        isLoading = true
        coroutineScope.launch {
            try {
                // Load from EquipmentStatsDao
                stats = when (equipmentType) {
                    EquipmentType.RISER -> database.equipmentStatsDao()
                        .getRiserPerformanceStats(equipmentId)
                    EquipmentType.LIMBS -> database.equipmentStatsDao()
                        .getLimbsPerformanceStats(equipmentId)
                    // ... other types
                }
            } finally {
                isLoading = false
            }
        }
    }
 
    // UI rendering...
}

Equipment Detail Screen Integration

Performance tab added to all 10 equipment detail screens:

ScreenFileLines Added
RiserDetailScreenRiserDetailScreen.kt+11
LimbsDetailScreenLimbsDetailScreen.kt+11
SightDetailScreenSightDetailScreen.kt+11
ArrowDetailScreenArrowDetailScreen.kt+11
StabilizerDetailScreenStabilizerDetailScreen.kt+11
RestDetailScreenRestDetailScreen.kt+11
PlungerDetailScreenPlungerDetailScreen.kt+11
BowStringDetailScreenBowStringDetailScreen.kt+11
WeightDetailScreenWeightDetailScreen.kt+11
AccessoryDetailScreenAccessoryDetailScreen.kt+11

Analytics Card on Landing Page

Quick action card added to LandingPage:

// LandingPage.kt
QuickActionCard(
    title = "Analytics",
    description = "View your performance statistics",
    icon = Icons.Default.Analytics,
    onClick = { navController.navigate("analytics") }
)

Equipment on Round Cards

Round cards now show bow setup name:

// HistoricalRoundsScreen.kt
@Composable
fun RoundCard(round: Round, bowSetupName: String?) {
    Card {
        Column {
            Text(round.roundName)
            bowSetupName?.let {
                Row {
                    Icon(Icons.Default.Target, "Equipment")
                    Text(it, color = MaterialTheme.colorScheme.primary)
                }
            }
            // ... rest of card
        }
    }
}

Test Coverage

Android Tests

RoundScoringServiceTest.kt (7 new tests):

TestPurpose
scoreEnd_CreatesEquipmentSnapshotsForAllArrowsSnapshot count verification
scoreEnd_SnapshotsContainCorrectEquipmentIdsEquipment ID mapping
scoreEnd_SkipsSnapshotsWhenNoBowSetupGraceful skip
scoreEnd_SnapshotsAreBulkInsertedBulk insert
scoreEnd_SnapshotsCaptureRoundContextContext capture
scoreEnd_ScoringSucceedsEvenIfSnapshotCreationFailsGraceful degradation
recordCompletedEndAndAdvance_CreatesEquipmentSnapshotsAlternative path

RoundDisplayServiceTest.kt (5 new tests):

TestPurpose
getRoundsWithEquipment_returnsEquipmentNamesEquipment name lookup
getRoundsWithEquipment_handlesNullSetupNull handling
getRoundsWithEquipment_respectsLimitPagination
getRoundsWithEquipment_ordersDescendingSort order
getRoundsWithEquipment_filtersCompletedStatus filtering

LandingPageTest.kt (140 lines):

TestPurpose
analyticsCard_displaysCard visibility
analyticsCard_navigatesNavigation

iOS Tests

AnalyticsHubViewModelTests.swift (extended):

543 lines total, testing:

  • Time range filtering
  • Performance trend loading
  • Empty state per time range
  • Parallel data loading
  • Error handling with data preservation

Code Metrics

PR #313 Summary

MetricValue
Total Additions3,199
Total Deletions66
Files Changed35
New iOS Files3
New Android Files1
New Tests12

Key Files by Size

FileLinesPurpose
AnalyticsHubViewModelTests.swift543iOS test coverage
IndividualEquipmentPerformanceView.swift468iOS equipment view
IndividualEquipmentPerformanceTab.kt421Android equipment tab
EquipmentStatsDao.kt47015 new queries
RoundScoringServiceTest.kt257 additionsTDD tests

Performance Considerations

Equipment ID Caching

The equipmentCache in RoundScoringService prevents repeated database lookups:

  • Without cache: O(n) DB queries per end (one per equipment type check)
  • With cache: O(1) after first end for same bow setup

Bulk Snapshot Insertion

All snapshots for an end are inserted in a single batch:

// Bulk insert all snapshots
equipmentStatsDao.insertEquipmentSnapshots(snapshots)

N+1 Query Optimization (iOS)

Swift TaskGroup parallelizes statistics, drift, and round queries:

async let statsTask = repository.getPerformanceStats(...)
async let driftTask = repository.getDriftStats(...)
async let roundsTask = repository.getRoundIds(...)
 
let (stats, drift, rounds) = try await (statsTask, driftTask, roundsTask)

Before optimization: 3 sequential network calls After optimization: 3 parallel calls, single await


Codecov Exclusions

Pure Compose UI files excluded from coverage requirements:

# .codecov.yml
ignore:
  - "**/ui/equipment/components/IndividualEquipmentPerformanceTab.kt"


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