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:
| Index | Column | Purpose |
|---|---|---|
idx_snapshot_riser | riserId | Performance queries by riser |
idx_snapshot_limbs | limbsId | Performance queries by limbs |
idx_snapshot_sight | sightId | Performance queries by sight |
idx_snapshot_arrow | arrowId | Performance queries by arrow |
idx_snapshot_stabilizer | stabilizerId | Performance queries by stabilizer |
TDD Implementation: Snapshot Creation
Test-First Development
7 new tests were written in RoundScoringServiceTest.kt before implementation:
| Test | Validates |
|---|---|
scoreEnd_CreatesEquipmentSnapshotsForAllArrows | One snapshot per arrow |
scoreEnd_SnapshotsContainCorrectEquipmentIds | Correct equipment ID mapping |
scoreEnd_SkipsSnapshotsWhenNoBowSetup | Graceful skip when no setup |
scoreEnd_SnapshotsAreBulkInserted | Bulk insert performance |
scoreEnd_SnapshotsCaptureRoundContext | Distance/target size captured |
scoreEnd_ScoringSucceedsEvenIfSnapshotCreationFails | Graceful degradation |
recordCompletedEndAndAdvance_CreatesEquipmentSnapshots | Alternative 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:
| Query | Equipment 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:
| Query | Equipment 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:
| Query | Equipment 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:
| Screen | File | Lines Added |
|---|---|---|
| RiserDetailScreen | RiserDetailScreen.kt | +11 |
| LimbsDetailScreen | LimbsDetailScreen.kt | +11 |
| SightDetailScreen | SightDetailScreen.kt | +11 |
| ArrowDetailScreen | ArrowDetailScreen.kt | +11 |
| StabilizerDetailScreen | StabilizerDetailScreen.kt | +11 |
| RestDetailScreen | RestDetailScreen.kt | +11 |
| PlungerDetailScreen | PlungerDetailScreen.kt | +11 |
| BowStringDetailScreen | BowStringDetailScreen.kt | +11 |
| WeightDetailScreen | WeightDetailScreen.kt | +11 |
| AccessoryDetailScreen | AccessoryDetailScreen.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):
| Test | Purpose |
|---|---|
scoreEnd_CreatesEquipmentSnapshotsForAllArrows | Snapshot count verification |
scoreEnd_SnapshotsContainCorrectEquipmentIds | Equipment ID mapping |
scoreEnd_SkipsSnapshotsWhenNoBowSetup | Graceful skip |
scoreEnd_SnapshotsAreBulkInserted | Bulk insert |
scoreEnd_SnapshotsCaptureRoundContext | Context capture |
scoreEnd_ScoringSucceedsEvenIfSnapshotCreationFails | Graceful degradation |
recordCompletedEndAndAdvance_CreatesEquipmentSnapshots | Alternative path |
RoundDisplayServiceTest.kt (5 new tests):
| Test | Purpose |
|---|---|
getRoundsWithEquipment_returnsEquipmentNames | Equipment name lookup |
getRoundsWithEquipment_handlesNullSetup | Null handling |
getRoundsWithEquipment_respectsLimit | Pagination |
getRoundsWithEquipment_ordersDescending | Sort order |
getRoundsWithEquipment_filtersCompleted | Status filtering |
LandingPageTest.kt (140 lines):
| Test | Purpose |
|---|---|
analyticsCard_displays | Card visibility |
analyticsCard_navigates | Navigation |
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
| Metric | Value |
|---|---|
| Total Additions | 3,199 |
| Total Deletions | 66 |
| Files Changed | 35 |
| New iOS Files | 3 |
| New Android Files | 1 |
| New Tests | 12 |
Key Files by Size
| File | Lines | Purpose |
|---|---|---|
AnalyticsHubViewModelTests.swift | 543 | iOS test coverage |
IndividualEquipmentPerformanceView.swift | 468 | iOS equipment view |
IndividualEquipmentPerformanceTab.kt | 421 | Android equipment tab |
EquipmentStatsDao.kt | 470 | 15 new queries |
RoundScoringServiceTest.kt | 257 additions | TDD 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"Related Documentation
- Phase 5c Equipment Comparison - Setup comparison feature
- Phase 5a Analytics Dashboard - Foundation and architecture
- Visual Scoring Guide - Shot coordinate storage
- DAOs Reference - Database access patterns
- Unit Testing Guide - TDD patterns
Last Updated: 2025-11-30 Status: Phase 5b complete (see Phase 5c for continuation)