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
| Enum | Values | Purpose |
|---|---|---|
TimePeriod | ALL_TIME, WEEKLY, MONTHLY, SEASONAL_INDOOR, SEASONAL_OUTDOOR | Time filtering |
VerificationLevel | SELF_REPORTED, IMAGE_RECOGNITION, WITNESS_VERIFIED, TOURNAMENT | Trust ranking |
ScoreSourceType | TOURNAMENT, PRACTICE_PUBLISHED | Score origin |
LeaderboardVisibility | PUBLIC, AUTHENTICATED_ONLY, PRIVATE | Access control |
BowType | RECURVE, COMPOUND, BAREBOW, LONGBOW, TRADITIONAL, OTHER | Equipment 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 patternunique- One entry per user/condition/perioduser- User lookupcachedAt- TTL cleanuprank- 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:
- Check local cache freshness
- Cache hit → Return immediately
- Cache miss → Fetch from Firestore
- Update cache → Return result
- 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)
| Index | Fields | Purpose |
|---|---|---|
| 1 | conditionKey, weekKey, score DESC | Weekly leaderboard |
| 2 | conditionKey, monthKey, score DESC | Monthly leaderboard |
| 3 | conditionKey, seasonKey, score DESC | Seasonal leaderboard |
| 4 | conditionKey, score DESC | All-time leaderboard |
| 5 | conditionKey, bowType, score DESC | Equipment filtered |
| 6 | userId, conditionKey | User’s entries |
| 7 | conditionKey, verificationLevel, score DESC | Verified scores |
| 8 | visibility, conditionKey, score DESC | Public entries |
Cache Strategy
TTL by Time Period
| Period | TTL | Rationale |
|---|---|---|
| ALL_TIME | 24 hours | Rarely changes |
| WEEKLY | 5 minutes | Active competition |
| MONTHLY | 5 minutes | Active competition |
| SEASONAL_INDOOR | 1 hour | Moderate activity |
| SEASONAL_OUTDOOR | 1 hour | Moderate activity |
Fallback Behavior
On Firestore error:
- Check for stale cache
- Return stale data with warning
- 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
| Component | Tests | Coverage |
|---|---|---|
| GlobalLeaderboardEntry | 15 | 100% |
| LeaderboardCondition | 12 | 100% |
| TimeKeyGenerator | 30+ | 100% |
| LeaderboardEnums | 19 | 100% |
| LeaderboardQueryService | 8 | 95% |
| Total | 84 | - |
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)
| File | Lines | Purpose |
|---|---|---|
| GlobalLeaderboardEntry.kt | 108 | Domain model |
| LeaderboardCondition.kt | 192 | Condition keys |
| LeaderboardEnums.kt | 58 | Enums |
| LeaderboardCache.kt | 212 | Room entity |
| LeaderboardCacheDao.kt | 337 | Room DAO |
| LeaderboardService.kt | 198 | KMP interface |
| LeaderboardQueryService.kt | 339 | Android service |
| FirebaseLeaderboardDataSource.kt | 386 | Firestore queries |
| firestore.indexes.json | 158 | Composite indexes |
| GlobalLeaderboardEntryTest.kt | 274 | Tests |
| LeaderboardConditionTest.kt | 294 | Tests |
| LeaderboardEnumsTest.kt | 219 | Tests |
| TimeKeyGeneratorTest.kt | 164 | Tests |
Modified Files (2)
ArcheryKmpDatabase.kt- Version 37 → 38, add LeaderboardCacheDaoInputSanitizer.kt- Fix RegexOption.DOT_MATCHES_ALL for KMP
Next Steps (Phase 2)
- LeaderboardViewModel - UI state management
- LeaderboardScreen - Compose UI
- Score Publishing - Publish practice scores to leaderboard
- iOS Service Layer - iOS-specific LeaderboardService
- Admin Console - Leaderboard moderation tools
Related Documentation
- data-validation-guard-rails - InputSanitizer fix included
- god-class-refactoring-campaign - Service extraction patterns
- firebase-overview - Firestore usage patterns