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
| Season | Period | Key Format | Example |
|---|---|---|---|
| Indoor | Nov 1 - Mar 31 | YYYY-INDOOR | 2025-INDOOR |
| Outdoor | Apr 1 - Oct 31 | YYYY-OUTDOOR | 2025-OUTDOOR |
Year Assignment
The year in the key refers to when the season starts:
| Date Range | Season Key |
|---|---|
| Nov 1, 2025 - Mar 31, 2026 | 2025-INDOOR |
| Apr 1, 2025 - Oct 31, 2025 | 2025-OUTDOOR |
| Nov 1, 2024 - Mar 31, 2025 | 2024-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:
| Approach | Pros | Cons |
|---|---|---|
| Cloud Functions | Precise timing | Infrastructure cost, cold starts |
| Lazy Maintenance | Free, simple | First 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
| Task | When | Action |
|---|---|---|
| Season transition | Season boundary crossed | Mark seasonal cache stale |
| Week key update | New week started | Mark old week cache stale |
| Month key update | New month started | Mark old month cache stale |
| Cache cleanup | Always | Delete entries > 7 days old |
Cache Invalidation
Stale vs Delete
| Action | Use Case |
|---|---|
| Mark stale | Time period changed - data may still be useful as fallback |
| Delete | Old 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): IntMaintenancePreferences
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
| Scenario | Test |
|---|---|
| Oct 31 → Nov 1 | Transition to INDOOR |
| Mar 31 → Apr 1 | Transition to OUTDOOR |
| Same season | No transition |
| First app launch | Initialize 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)Related Documentation
- 2025-12-30-phase4-advanced-features - Phase 4 session notes
- leaderboard-settings - User preferences
- leaderboard-phase1-foundation - Phase 1 foundation