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:

  1. Seasonal Transitions: Detect and handle Indoor/Outdoor season changes
  2. User Settings: Personalized leaderboard preferences with offline-first sync
  3. 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

SeasonMonthsKey FormatExample
IndoorNov 1 - Mar 31YYYY-INDOOR2025-INDOOR
OutdoorApr 1 - Oct 31YYYY-OUTDOOR2025-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:

  1. Mark all seasonal cache entries as stale
  2. Update last known season key in preferences
  3. 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

TaskTriggerAction
Season transitionSeason boundary crossedMark seasonal cache stale
Week key updateWeek changedMark old week cache stale
Month key updateMonth changedMark old month cache stale
Cache cleanup>7 days oldDelete 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

CriterionRule
User IDMust have Firebase UID (not guest_*)
ScoreMust be > 0
ArrowsMust have arrowsShot > 0
StatusCOMPLETED 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

ComponentTestsCoverage
TimeKeyGenerator (new methods)33100%
SeasonInfo12100%
MaintenancePreferences18100%
SeasonTransitionService1595%
LeaderboardMaintenanceService1292%
LeaderboardSettings8100%
LeaderboardSettingsService1090%
TournamentScorePublisher888%
Total116-

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

  1. feat(phase4): Add season utilities foundation - TimeKeyGenerator enhancements, SeasonInfo
  2. feat(phase4): Add SeasonTransitionService - Season change detection
  3. feat(phase4): Add LeaderboardMaintenanceService - Lazy maintenance orchestration
  4. feat(phase4): Add LeaderboardSettings model and services - User preferences
  5. feat(phase4): Add LeaderboardSettingsScreen Android UI - Compose settings
  6. feat(phase4): Add LeaderboardSettingsView iOS UI - SwiftUI parity
  7. feat(phase4): Add TournamentScorePublisher - Auto-publish on tournament end
  8. fix(ios): Replace JVM-only String.format with KMP-compatible formatting
  9. fix: Address Copilot review comments on test naming and accuracy