Seasonal Transitions

The leaderboard system supports time-filtered rankings with automatic seasonal transitions. Archery follows a natural Indoor/Outdoor seasonal pattern that the app tracks and handles automatically.

Season Definitions

SeasonPeriodKey FormatExample
IndoorNov 1 - Mar 31YYYY-INDOOR2025-INDOOR
OutdoorApr 1 - Oct 31YYYY-OUTDOOR2025-OUTDOOR

Year Assignment

The year in the key refers to when the season starts:

Date RangeSeason Key
Nov 1, 2025 - Mar 31, 20262025-INDOOR
Apr 1, 2025 - Oct 31, 20252025-OUTDOOR
Nov 1, 2024 - Mar 31, 20252024-INDOOR

SeasonInfo Model

Location: shared/domain/src/commonMain/kotlin/.../leaderboard/SeasonInfo.kt

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
) {
    companion object {
        fun fromTimestamp(timestampMillis: Long): SeasonInfo
        fun getCurrentSeason(): SeasonInfo
        fun hasSeasonChanged(lastKnownSeasonKey: String): Boolean
    }
 
    val displayName: String           // "2025 Indoor Season"
    val shortDisplayName: String      // "Indoor 2025"
    fun isCurrentlyActive(): Boolean
    val nextBoundaryTimestamp: Long   // When season ends
}

TimeKeyGenerator Enhancements

Location: shared/domain/src/commonMain/kotlin/.../leaderboard/LeaderboardCondition.kt

object TimeKeyGenerator {
    // Season key generation
    fun generateSeasonKey(timestampMillis: Long): String
    fun currentSeasonKey(): String
    fun isIndoorSeason(timestamp: Long): Boolean
 
    // Season transitions
    fun getPreviousSeasonKey(seasonKey: String): String?
    fun hasSeasonChanged(lastKey: String, currentTimestamp: Long): Boolean
 
    // Season boundaries
    fun getSeasonStartTimestamp(seasonKey: String): Long
    fun getSeasonEndTimestamp(seasonKey: String): Long
    fun getNextSeasonBoundaryTimestamp(timestamp: Long): Long
}

Season Key Algorithm

fun generateSeasonKey(timestampMillis: Long): String {
    val utcDate = Instant.fromEpochMilliseconds(timestampMillis)
        .toLocalDateTime(TimeZone.UTC).date
 
    val season = if (utcDate.monthNumber in 4..10) "OUTDOOR" else "INDOOR"
 
    // Indoor season spans year boundary, use year of season start
    val year = if (season == "INDOOR" && utcDate.monthNumber <= 3) {
        utcDate.year - 1
    } else {
        utcDate.year
    }
 
    return "$year-$season"
}

Transition Detection

SeasonTransitionService

Location: app/src/main/java/.../services/SeasonTransitionService.kt

class SeasonTransitionService(
    private val leaderboardCacheDao: LeaderboardCacheDao,
    private val maintenancePreferences: MaintenancePreferences,
    private val clock: () -> Long
) {
    suspend fun checkAndPerformSeasonTransition(): SeasonTransitionResult
}

Transition Flow

App Opens
    │
    ▼
┌───────────────────────────┐
│ Get last known season key │
│ from MaintenancePrefs     │
└─────────────┬─────────────┘
              │
              ▼
┌───────────────────────────┐
│ Generate current season   │
│ key from timestamp        │
└─────────────┬─────────────┘
              │
              ▼
       ┌──────┴──────┐
       │ Keys match? │
       └──────┬──────┘
         No   │   Yes
              │    │
    ┌─────────┘    └──────┐
    ▼                     ▼
┌───────────────┐   ┌───────────────┐
│ TRANSITION    │   │ No action     │
│ - Mark cache  │   │ needed        │
│   stale       │   └───────────────┘
│ - Update prefs│
│ - Log event   │
└───────────────┘

SeasonTransitionResult

sealed class SeasonTransitionResult {
    object NoTransitionNeeded : SeasonTransitionResult()
 
    data class Initialized(seasonKey: String) : SeasonTransitionResult()
 
    data class TransitionCompleted(
        val previousSeasonKey: String,
        val newSeasonKey: String,
        val cacheEntriesInvalidated: Int
    ) : SeasonTransitionResult()
 
    data class Error(exception: Exception) : SeasonTransitionResult()
}

Lazy Maintenance Pattern

Why Lazy?

Instead of using Cloud Functions triggered on schedule:

ApproachProsCons
Cloud FunctionsPrecise timingInfrastructure cost, cold starts
Lazy MaintenanceFree, simpleFirst user bears latency

Decision: Use lazy maintenance - first user to open app after season boundary triggers transition.

LeaderboardMaintenanceService

Location: app/src/main/java/.../services/LeaderboardMaintenanceService.kt

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  // 24h
        const val CACHE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000L      // 7 days
    }
 
    suspend fun runMaintenanceIfNeeded(): MaintenanceResult
    suspend fun forceRunMaintenance(): MaintenanceResult
}

Maintenance Tasks

TaskWhenAction
Season transitionSeason boundary crossedMark seasonal cache stale
Week key updateNew week startedMark old week cache stale
Month key updateNew month startedMark old month cache stale
Cache cleanupAlwaysDelete entries > 7 days old

Cache Invalidation

Stale vs Delete

ActionUse Case
Mark staleTime period changed - data may still be useful as fallback
DeleteOld data - no longer useful, save space

Cache Table Schema

CREATE TABLE leaderboard_cache (
    id INTEGER PRIMARY KEY,
    conditionKey TEXT,
    timePeriodKey TEXT,  -- "2025-INDOOR", "2025-W52", "2025-12"
    userId TEXT,
    score INTEGER,
    rank INTEGER,
    cachedAt INTEGER,
    isStale INTEGER DEFAULT 0
);

Invalidation Queries

// Mark all seasonal cache as stale
@Query("UPDATE leaderboard_cache SET isStale = 1 WHERE timePeriodKey LIKE '%-INDOOR' OR timePeriodKey LIKE '%-OUTDOOR'")
suspend fun markAllSeasonalCacheStale(): Int
 
// Mark specific period as stale
@Query("UPDATE leaderboard_cache SET isStale = 1 WHERE timePeriodKey = :timePeriodKey")
suspend fun markLeaderboardStale(conditionKey: String, timePeriodKey: String)
 
// Delete old cache
@Query("DELETE FROM leaderboard_cache WHERE cachedAt < :threshold")
suspend fun deleteExpiredCache(threshold: Long): Int

MaintenancePreferences

Location: app/src/main/java/.../preferences/MaintenancePreferences.kt

Tracks maintenance state in SharedPreferences:

class MaintenancePreferences(context: Context) {
    // Last maintenance run
    fun getLastMaintenanceTimestamp(): Long
    fun setLastMaintenanceTimestamp(timestamp: Long)
 
    // Season tracking
    fun getLastKnownSeasonKey(): String?
    fun setLastKnownSeasonKey(key: String)
    fun getLastSeasonTransition(): Long
    fun setLastSeasonTransition(timestamp: Long)
 
    // Week/Month tracking
    fun getLastKnownWeekKey(): String?
    fun setLastKnownWeekKey(key: String)
    fun getLastKnownMonthKey(): String?
    fun setLastKnownMonthKey(key: String)
}

Integration

App Startup

// In Application.onCreate() or MainActivity
lifecycleScope.launch {
    val result = leaderboardMaintenanceService.runMaintenanceIfNeeded()
    when (result) {
        is MaintenanceResult.Completed -> {
            result.actions.forEach { action ->
                Log.d("Maintenance", "Performed: $action")
            }
        }
        is MaintenanceResult.NotNeeded -> {
            Log.d("Maintenance", "Skipped - next run in ${result.nextRunIn}ms")
        }
        is MaintenanceResult.Error -> {
            Log.e("Maintenance", "Failed", result.exception)
        }
    }
}

Testing

Test Scenarios

ScenarioTest
Oct 31 → Nov 1Transition to INDOOR
Mar 31 → Apr 1Transition to OUTDOOR
Same seasonNo transition
First app launchInitialize season key
Year boundary (Dec → Jan)Correct year in key

Clock Injection

All services accept injectable clock for deterministic testing:

class SeasonTransitionService(
    private val clock: () -> Long = { Clock.System.now().toEpochMilliseconds() }
)
 
// In tests
val fixedClock = { 1735689600000L }  // Nov 1, 2025
val service = SeasonTransitionService(clock = fixedClock)