Global Leaderboard Phase 1: Foundation

Date: December 27, 2025 Session Type: Feature Development Scope: Core leaderboard infrastructure (domain models, caching, data layer) PR: #409 - feat: Add global leaderboard Phase 1 foundation

Overview

Phase 1 implements the foundation for a global leaderboard system, enabling archers to compare scores across conditions (distance/target/scoring), time periods, and equipment categories. This phase focuses on the backend infrastructure; UI will follow in Phase 2.

Architecture

┌─────────────────────────────────────────────────────────────┐
│                     ViewModel (Phase 2)                     │
└─────────────────────────────┬───────────────────────────────┘
                              │
┌─────────────────────────────▼───────────────────────────────┐
│                  LeaderboardQueryService                    │
│           (Cache-first strategy with TTL)                   │
└───────────┬─────────────────────────────────┬───────────────┘
            │                                 │
┌───────────▼───────────┐         ┌───────────▼───────────────┐
│ LeaderboardCacheDao   │         │ FirebaseLeaderboardData   │
│     (Room/KMP)        │         │      Source               │
└───────────────────────┘         └───────────────────────────┘

Components Built

Domain Models (Shared KMP)

Location: shared/domain/src/commonMain/kotlin/com/archeryapprentice/domain/models/leaderboard/

GlobalLeaderboardEntry.kt

Denormalized leaderboard entry for efficient Firestore queries:

data class GlobalLeaderboardEntry(
    val id: String,
    val userId: String,
    val displayName: String,
 
    // Score data
    val score: Int,
    val xCount: Int,
    val tenCount: Int,
    val arrowCount: Int,
    val averagePerArrow: Double,
    val maxPossibleScore: Int,
 
    // Condition key for comparison
    val conditionKey: String,
    val distance: Distance,
    val targetSize: TargetSize,
    val scoringSystem: ScoringSystem,
 
    // Verification
    val verificationLevel: VerificationLevel,
 
    // Equipment (optional)
    val bowType: BowType?,
 
    // Time period keys
    val weekKey: String,
    val monthKey: String,
    val seasonKey: String,
 
    // Ranking
    val rank: Int,
    val trend: ScoreTrend
)

Features:

  • Score percentage calculation
  • Visibility rules (public/authenticated/private)
  • Tie-breaking: score → xCount → tenCount

LeaderboardCondition.kt

Composite key for condition-based leaderboard filtering:

data class LeaderboardCondition(
    val key: String,
    val distance: Distance,
    val targetSize: TargetSize,
    val scoringSystem: ScoringSystem
) {
    val displayName: String  // "18m / 40cm / Indoor 5-Ring"
    val shortDisplayName: String  // "18m 40cm"
 
    companion object {
        fun generateKey(distance, targetSize, scoringSystem): String
        fun fromKey(key: String): LeaderboardCondition?
        fun getPopularConditions(): List<LeaderboardCondition>
    }
}

Key Format: {DISTANCE}_{TARGET_SIZE}_{SCORING_SYSTEM} Example: EIGHTEEN_METERS_FORTY_CM_INDOOR_5_RING


TimeKeyGenerator (object)

UTC-based time key generation for global consistency:

object TimeKeyGenerator {
    fun generateWeekKey(timestampMillis: Long): String   // "2025-W52"
    fun generateMonthKey(timestampMillis: Long): String  // "2025-12"
    fun generateSeasonKey(timestampMillis: Long): String // "2025-INDOOR"
 
    fun currentWeekKey(): String
    fun currentMonthKey(): String
    fun currentSeasonKey(): String
}

Season Logic:

  • Indoor: November through March
  • Outdoor: April through October
  • Year assigned to season start (e.g., Nov 2024 → 2024-INDOOR)

LeaderboardEnums.kt

EnumValuesPurpose
TimePeriodALL_TIME, WEEKLY, MONTHLY, SEASONAL_INDOOR, SEASONAL_OUTDOORTime filtering
VerificationLevelSELF_REPORTED, IMAGE_RECOGNITION, WITNESS_VERIFIED, TOURNAMENTTrust ranking
ScoreSourceTypeTOURNAMENT, PRACTICE_PUBLISHEDScore origin
LeaderboardVisibilityPUBLIC, AUTHENTICATED_ONLY, PRIVATEAccess control
BowTypeRECURVE, COMPOUND, BAREBOW, LONGBOW, TRADITIONAL, OTHEREquipment filter

Database Layer (Shared KMP)

Location: shared/database/src/commonMain/kotlin/com/archeryapprentice/database/

LeaderboardCache.kt

Room entity for TTL-based caching:

@Entity(tableName = "leaderboard_cache")
data class LeaderboardCache(
    val conditionKey: String,
    val timePeriodKey: String,
    val userId: String,
    val displayName: String,
    val score: Int,
    val xCount: Int,
    val tenCount: Int,
    val rank: Int,
    val bowType: String?,
    val cachedAt: Long,
    val isStale: Boolean
)

Indexes (5 total):

  • condition_period - Primary query pattern
  • unique - One entry per user/condition/period
  • user - User lookup
  • cachedAt - TTL cleanup
  • rank - Top N queries

LeaderboardCacheDao.kt

DAO with optimized queries:

@Dao
interface LeaderboardCacheDao {
    // Fresh cache check
    @Query("SELECT COUNT(*) FROM leaderboard_cache WHERE conditionKey = :conditionKey AND timePeriodKey = :timePeriodKey AND cachedAt > :minTimestamp")
    suspend fun hasFreshCache(conditionKey: String, timePeriodKey: String, minTimestamp: Long): Int
 
    // Cached leaderboard with pagination
    @Query("SELECT * FROM leaderboard_cache WHERE conditionKey = :conditionKey AND timePeriodKey = :timePeriodKey ORDER BY rank LIMIT :limit OFFSET :offset")
    suspend fun getCachedLeaderboard(...): List<LeaderboardCache>
 
    // Bow type filtered query
    @Query("SELECT * FROM ... WHERE bowType = :bowType ...")
    suspend fun getCachedLeaderboardByBowType(...): List<LeaderboardCache>
 
    // Cache cleanup
    @Query("DELETE FROM leaderboard_cache WHERE cachedAt < :threshold")
    suspend fun cleanupExpired(threshold: Long)
}

Service Layer (Android)

Location: app/src/main/java/com/archeryapprentice/domain/services/LeaderboardQueryService.kt

Cache-first strategy with Firestore fallback:

class LeaderboardQueryService(
    private val firebaseDataSource: FirebaseLeaderboardDataSource,
    private val leaderboardCacheDao: LeaderboardCacheDao,
    private val clock: () -> Long = { System.currentTimeMillis() }
) : LeaderboardService {
 
    companion object {
        const val TTL_ALL_TIME = 24 * 60 * 60 * 1000L    // 24 hours
        const val TTL_WEEKLY_MONTHLY = 5 * 60 * 1000L    // 5 minutes
        const val TTL_SEASONAL = 60 * 60 * 1000L         // 1 hour
    }
 
    override fun getLeaderboard(
        conditionKey: String,
        timePeriod: TimePeriod,
        limit: Int,
        offset: Int,
        bowType: BowType?,
        minVerificationLevel: VerificationLevel?
    ): Flow<List<GlobalLeaderboardEntry>>
}

Query Flow:

  1. Check local cache freshness
  2. Cache hit → Return immediately
  3. Cache miss → Fetch from Firestore
  4. Update cache → Return result
  5. On error → Fall back to stale cache

Data Layer (Android)

Location: app/src/main/java/com/archeryapprentice/data/datasource/remote/FirebaseLeaderboardDataSource.kt

Firestore queries with composite indexes:

class FirebaseLeaderboardDataSource(
    private val firestore: FirebaseFirestore
) {
    suspend fun getLeaderboard(
        conditionKey: String,
        timePeriod: TimePeriod,
        limit: Int,
        bowType: BowType?,
        minVerificationLevel: VerificationLevel?
    ): Result<List<GlobalLeaderboardEntry>>
 
    fun observeLeaderboard(
        conditionKey: String,
        timePeriod: TimePeriod,
        limit: Int
    ): Flow<List<GlobalLeaderboardEntry>>
 
    suspend fun getUserEntry(
        userId: String,
        conditionKey: String,
        timePeriod: TimePeriod
    ): Result<GlobalLeaderboardEntry?>
}

Firestore Schema

Collection: leaderboards

leaderboards/
  {conditionKey}/
    entries/
      {entryId}: GlobalLeaderboardEntry

Composite Indexes (8 new)

IndexFieldsPurpose
1conditionKey, weekKey, score DESCWeekly leaderboard
2conditionKey, monthKey, score DESCMonthly leaderboard
3conditionKey, seasonKey, score DESCSeasonal leaderboard
4conditionKey, score DESCAll-time leaderboard
5conditionKey, bowType, score DESCEquipment filtered
6userId, conditionKeyUser’s entries
7conditionKey, verificationLevel, score DESCVerified scores
8visibility, conditionKey, score DESCPublic entries

Cache Strategy

TTL by Time Period

PeriodTTLRationale
ALL_TIME24 hoursRarely changes
WEEKLY5 minutesActive competition
MONTHLY5 minutesActive competition
SEASONAL_INDOOR1 hourModerate activity
SEASONAL_OUTDOOR1 hourModerate activity

Fallback Behavior

On Firestore error:

  1. Check for stale cache
  2. Return stale data with warning
  3. User sees last-known state

Design Decisions

1. UTC-Based Time Keys

All time keys use UTC for global consistency:

  • Users in different timezones see same weekly/monthly leaderboards
  • Simplifies week boundary calculations
  • Matches Firestore server timestamps

2. Denormalized Firestore Documents

Each entry contains all display data:

  • Single read per leaderboard query
  • No joins needed
  • Trade-off: Data duplication (acceptable for read-heavy workload)

3. TTL-Based Invalidation (Not Real-Time)

  • Simpler implementation than real-time sync
  • Reduces Firestore costs
  • Good enough for leaderboard use case
  • Real-time available via observeLeaderboard() when needed

4. Clock Injection for Testability

class LeaderboardQueryService(
    private val clock: () -> Long = { System.currentTimeMillis() }
)

Enables deterministic testing of TTL logic.

5. Phase 1 Android-Only

iOS implementation deferred to future phase:

  • Domain models are KMP (shared)
  • Database layer is KMP (shared)
  • Service layer is Android-specific
  • iOS will use same domain/database, different service

Testing

Test Coverage

ComponentTestsCoverage
GlobalLeaderboardEntry15100%
LeaderboardCondition12100%
TimeKeyGenerator30+100%
LeaderboardEnums19100%
LeaderboardQueryService895%
Total84-

Key Test Cases

TimeKeyGenerator:

  • Week boundaries (Sunday → Monday)
  • Month boundaries (Dec 31 → Jan 1)
  • Season boundaries (March → April, October → November)
  • Year rollover for indoor season

LeaderboardCondition:

  • Key generation for all distance/target/scoring combos
  • Key parsing with validation
  • Popular conditions list

LeaderboardQueryService:

  • Cache hit returns cached data
  • Cache miss fetches from Firestore
  • Stale cache fallback on error
  • TTL per time period

Copilot Review

GitHub Copilot review identified 11 issues:

  • 4 fixed: Test naming, accuracy improvements
  • 7 documented as intentional: Design decisions

Files Changed

New Files (15)

FileLinesPurpose
GlobalLeaderboardEntry.kt108Domain model
LeaderboardCondition.kt192Condition keys
LeaderboardEnums.kt58Enums
LeaderboardCache.kt212Room entity
LeaderboardCacheDao.kt337Room DAO
LeaderboardService.kt198KMP interface
LeaderboardQueryService.kt339Android service
FirebaseLeaderboardDataSource.kt386Firestore queries
firestore.indexes.json158Composite indexes
GlobalLeaderboardEntryTest.kt274Tests
LeaderboardConditionTest.kt294Tests
LeaderboardEnumsTest.kt219Tests
TimeKeyGeneratorTest.kt164Tests

Modified Files (2)

  • ArcheryKmpDatabase.kt - Version 37 → 38, add LeaderboardCacheDao
  • InputSanitizer.kt - Fix RegexOption.DOT_MATCHES_ALL for KMP

Next Steps (Phase 2)

  1. LeaderboardViewModel - UI state management
  2. LeaderboardScreen - Compose UI
  3. Score Publishing - Publish practice scores to leaderboard
  4. iOS Service Layer - iOS-specific LeaderboardService
  5. Admin Console - Leaderboard moderation tools