Phase 4: Advanced Leaderboard Features
Date: December 30, 2025 Session Type: Feature Development Scope: Seasonal transitions, user settings, auto-publish tournament scores PR: #415 - feat: Add Phase 4 advanced leaderboard features Stats: ~40 files, comprehensive test coverage Review: Agent 3 - 9/10 APPROVED
Overview
Phase 4 implements advanced features for the leaderboard system using a “lazy maintenance” pattern that avoids Cloud Functions for infrastructure cost savings:
- Seasonal Transitions: Detect and handle Indoor/Outdoor season changes
- User Settings: Personalized leaderboard preferences with offline-first sync
- Auto-Publish: Automatically publish tournament scores to global leaderboard
Architecture
┌─────────────────────────────────────────────────────────────┐
│ App Startup │
│ LeaderboardMaintenanceService │
└─────────────────────────────┬───────────────────────────────┘
│ (if >24h since last run)
┌───────────────────┼───────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Season │ │ Week/Month │ │ Cache │
│ Transition │ │ Key Update │ │ Cleanup │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ MaintenancePreferences │
│ (SharedPreferences for state tracking) │
└─────────────────────────────────────────────────────────────┘
Seasonal Transitions
Season Definitions
| Season | Months | Key Format | Example |
|---|---|---|---|
| Indoor | Nov 1 - Mar 31 | YYYY-INDOOR | 2025-INDOOR |
| Outdoor | Apr 1 - Oct 31 | YYYY-OUTDOOR | 2025-OUTDOOR |
Year in key refers to season START:
- Nov 2025 → Mar 2026 =
2025-INDOOR - Apr 2025 → Oct 2025 =
2025-OUTDOOR
SeasonInfo Model (KMP)
data class SeasonInfo(
val currentSeasonKey: String, // "2025-INDOOR"
val previousSeasonKey: String?, // "2025-OUTDOOR"
val seasonStartTimestamp: Long, // Nov 1, 2025 00:00 UTC
val seasonEndTimestamp: Long, // Mar 31, 2026 23:59 UTC
val isIndoor: Boolean,
val seasonYear: Int // 2025
) {
val displayName: String // "2025 Indoor Season"
val shortDisplayName: String // "Indoor 2025"
fun isCurrentlyActive(): Boolean
}SeasonTransitionService
Detects season changes on app open:
class SeasonTransitionService(
private val leaderboardCacheDao: LeaderboardCacheDao,
private val maintenancePreferences: MaintenancePreferences,
private val clock: () -> Long
) {
suspend fun checkAndPerformSeasonTransition(): SeasonTransitionResult
}Transition Actions:
- Mark all seasonal cache entries as stale
- Update last known season key in preferences
- Record transition timestamp
Lazy Maintenance Pattern
Why No Cloud Functions?
- Cost: Cloud Functions add infrastructure costs
- Complexity: Requires separate deployment and monitoring
- Latency: Cold starts can be slow
Lazy Maintenance Approach
The first user to open the app after a maintenance threshold triggers tasks:
class LeaderboardMaintenanceService(
private val seasonTransitionService: SeasonTransitionService,
private val leaderboardCacheDao: LeaderboardCacheDao,
private val maintenancePreferences: MaintenancePreferences
) {
companion object {
const val MAINTENANCE_INTERVAL_MS = 24 * 60 * 60 * 1000L // 24 hours
const val CACHE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000L // 7 days
}
suspend fun runMaintenanceIfNeeded(): MaintenanceResult
}Maintenance Tasks
| Task | Trigger | Action |
|---|---|---|
| Season transition | Season boundary crossed | Mark seasonal cache stale |
| Week key update | Week changed | Mark old week cache stale |
| Month key update | Month changed | Mark old month cache stale |
| Cache cleanup | >7 days old | Delete expired entries |
MaintenanceResult
sealed class MaintenanceResult {
data class NotNeeded(timeSinceLastRun: Long, nextRunIn: Long)
data class Completed(actions: List<MaintenanceAction>)
data class Error(exception: Exception, actionsCompletedBeforeError: List<MaintenanceAction>)
}
sealed class MaintenanceAction {
data class SeasonTransition(from: String, to: String, cacheInvalidated: Int)
data class SeasonInitialized(seasonKey: String)
data class WeekKeyUpdated(from: String?, to: String)
data class MonthKeyUpdated(from: String?, to: String)
data class CacheCleanup(entriesDeleted: Int)
data class Error(message: String)
}User Leaderboard Settings
LeaderboardSettings Model (KMP)
data class LeaderboardSettings(
val userId: String,
val defaultBowType: BowType?, // Default filter
val defaultTimePeriod: TimePeriod, // Default: ALL_TIME
val showOwnScores: Boolean, // Highlight own scores
val notifyRankChanges: Boolean, // Rank change notifications
val notifyPersonalBests: Boolean, // PB notifications
val createdAt: Long,
val updatedAt: Long
)Storage: Firestore at users/{userId}/settings/leaderboard
LeaderboardSettingsService
Offline-first with Firestore sync:
class LeaderboardSettingsService(
private val firestore: FirebaseFirestore,
private val localPreferences: SharedPreferences,
private val authProvider: AuthProvider
) {
// Load from local first, sync from Firestore in background
suspend fun getSettings(): LeaderboardSettings
// Save to local immediately, sync to Firestore async
suspend fun updateSettings(settings: LeaderboardSettings): Result<Unit>
// Observe settings changes
fun observeSettings(): Flow<LeaderboardSettings>
}UI Components
Android: LeaderboardSettingsScreen.kt
@Composable
fun LeaderboardSettingsScreen(
onNavigateBack: () -> Unit
)iOS: LeaderboardSettingsView.swift
struct LeaderboardSettingsView: View {
@StateObject var viewModel: LeaderboardSettingsViewModel
}Auto-Publish Tournament Scores
TournamentScorePublisher
Automatically publishes scores when tournament ends:
class TournamentScorePublisher(
private val firebasePublishingDataSource: FirebasePublishingDataSource,
private val firestore: FirebaseFirestore
) {
suspend fun publishTournamentScores(tournamentId: TournamentId): Result<Int>
}Publish Flow
Tournament Ends
│
▼
┌───────────────────────────────────┐
│ Get tournament metadata │
│ (distance, target, scoring) │
└───────────────────────┬───────────┘
│
▼
┌───────────────────────────────────┐
│ Get all participants │
│ Filter eligible: │
│ - Has Firebase UID (not guest) │
│ - Has score > 0 │
│ - Status = COMPLETED or ACTIVE │
└───────────────────────┬───────────┘
│
▼
┌───────────────────────────────────┐
│ Create GlobalLeaderboardEntry │
│ for each with: │
│ - verificationLevel = TOURNAMENT │
│ - sourceType = TOURNAMENT │
│ - Correct time period keys │
└───────────────────────┬───────────┘
│
▼
┌───────────────────────────────────┐
│ Batch publish to leaderboards │
└───────────────────────────────────┘
Integration Point
// In TournamentLifecycleRepositoryImpl
suspend fun endTournament(tournamentId: TournamentId): Result<Unit> {
// ... end tournament logic ...
// Auto-publish scores
tournamentScorePublisher?.publishTournamentScores(tournamentId)
return Result.success(Unit)
}Eligibility Criteria
| Criterion | Rule |
|---|---|
| User ID | Must have Firebase UID (not guest_*) |
| Score | Must be > 0 |
| Arrows | Must have arrowsShot > 0 |
| Status | COMPLETED or ACTIVE (not DROPPED/DISQUALIFIED) |
TimeKeyGenerator Enhancements
New methods for season handling:
object TimeKeyGenerator {
// Existing
fun generateSeasonKey(timestampMillis: Long): String
fun currentSeasonKey(): String
// New in Phase 4
fun getPreviousSeasonKey(seasonKey: String): String?
fun getNextSeasonBoundaryTimestamp(timestamp: Long): Long
fun hasSeasonChanged(lastKey: String, currentTimestamp: Long): Boolean
fun getSeasonStartTimestamp(seasonKey: String): Long
fun getSeasonEndTimestamp(seasonKey: String): Long
fun isIndoorSeason(timestamp: Long): Boolean
}Testing
Test Coverage
| Component | Tests | Coverage |
|---|---|---|
| TimeKeyGenerator (new methods) | 33 | 100% |
| SeasonInfo | 12 | 100% |
| MaintenancePreferences | 18 | 100% |
| SeasonTransitionService | 15 | 95% |
| LeaderboardMaintenanceService | 12 | 92% |
| LeaderboardSettings | 8 | 100% |
| LeaderboardSettingsService | 10 | 90% |
| TournamentScorePublisher | 8 | 88% |
| Total | 116 | - |
Key Test Scenarios
- Season boundary detection (Oct 31 → Nov 1, Mar 31 → Apr 1)
- Maintenance interval enforcement (skip if <24h)
- Settings offline-first behavior
- Tournament publish eligibility filtering
- Guest participant exclusion
Design Decisions
1. Lazy Maintenance Over Cloud Functions
Rationale:
- Eliminates Cloud Functions infrastructure costs
- Simpler deployment (no separate function deployment)
- Good enough for leaderboard use case (not mission-critical timing)
Trade-off: First user after boundary bears slight latency cost
2. Offline-First Settings
Rationale:
- Settings should work without network
- User sees immediate feedback on changes
- Firestore sync happens in background
3. Tournament Auto-Publish on End
Rationale:
- Users don’t need to manually publish tournament scores
- Verification level = TOURNAMENT (highest trust)
- Maintains tournament integrity
Commits
feat(phase4): Add season utilities foundation- TimeKeyGenerator enhancements, SeasonInfofeat(phase4): Add SeasonTransitionService- Season change detectionfeat(phase4): Add LeaderboardMaintenanceService- Lazy maintenance orchestrationfeat(phase4): Add LeaderboardSettings model and services- User preferencesfeat(phase4): Add LeaderboardSettingsScreen Android UI- Compose settingsfeat(phase4): Add LeaderboardSettingsView iOS UI- SwiftUI parityfeat(phase4): Add TournamentScorePublisher- Auto-publish on tournament endfix(ios): Replace JVM-only String.format with KMP-compatible formattingfix: Address Copilot review comments on test naming and accuracy
Related Documentation
- leaderboard-phase1-foundation - Phase 1 foundation
- verification-system - Phase 3 verification
- arrows-leaderboard - Phase 3 arrows leaderboard
- seasonal-transitions - Detailed season handling