Arrows Leaderboard Feature

A fun engagement feature tracking total arrows shot globally with milestone badges. Users can compete on how many arrows they’ve shot, with time-filtered leaderboards and achievement milestones.

Overview

Engagement Hook: “You’ve shot 1,234 arrows - You’re #47 globally!”

The arrows leaderboard provides:

  • Global ranking by total arrows shot
  • Time period filters (weekly, monthly, seasonal, all-time)
  • Milestone achievement badges
  • Streak tracking (consecutive shooting days)

Data Model

UserArrowStats

Firestore Collection: user_arrow_stats/{userId}

data class UserArrowStats(
    val userId: String,
    val displayName: String,
 
    // Primary engagement metrics
    val totalArrowsShot: Int,
    val roundsCompleted: Int,
 
    // Cumulative scoring stats
    val totalScore: Int,
    val totalXCount: Int,
    val totalTenCount: Int,
    val totalNineCount: Int,
    val totalMissCount: Int,
 
    // Derived stats
    val averagePerArrow: Double,
    val xPercentage: Double,
 
    // Time period keys
    val weekKey: String,    // "2025-W52"
    val monthKey: String,   // "2025-12"
    val seasonKey: String,  // "2025-INDOOR"
 
    // Time period aggregates
    val weeklyArrows: Int,
    val monthlyArrows: Int,
    val seasonalArrows: Int,
 
    // Streak tracking
    val currentStreak: Int,   // Consecutive days
    val longestStreak: Int,
    val lastShotDate: String, // ISO date: "2025-12-29"
 
    // Ranking (client-side after query)
    val rank: Int
)

Milestone Badges

Achievement milestones for gamification:

MilestoneThresholdIconDescription
First Arrow1arrow.rightWelcome!
Getting Started100targetOn your way
Hundred Club500flame.fillBuilding momentum
Consistent Archer1,000star.fillDedication showing
Dedicated2,500medal.fillSerious archer
Committed5,000trophy.fillMajor milestone
True Archer10,000crown.fillElite status
Master Archer25,000sparklesExceptional
Legendary50,000bolt.fillLegendary archer
Elite100,000diamond.fillTop tier
enum class ArrowMilestone(
    val threshold: Int,
    val displayName: String,
    val emoji: String
)

Time Period Filters

FilterKey FormatExampleUse Case
All TimeN/A-Lifetime totals
WeeklyYYYY-WNN2025-W52Current week competition
MonthlyYYYY-MM2025-12Month challenge
SeasonalYYYY-SEASON2025-INDOORSeason tracking

Season Definition

  • Indoor: November through March
  • Outdoor: April through October
  • Year assigned to season start (Nov 2024 → 2024-INDOOR)

Service Layer

ArrowStatsService

class ArrowStatsService(
    private val dataSource: FirebaseArrowStatsDataSource
) {
    // Get global leaderboard
    suspend fun getArrowsLeaderboard(
        timePeriod: TimePeriod = TimePeriod.ALL_TIME,
        limit: Int = 50
    ): Result<List<UserArrowStats>>
 
    // Real-time leaderboard updates
    fun observeArrowsLeaderboard(
        timePeriod: TimePeriod,
        limit: Int
    ): Flow<List<UserArrowStats>>
 
    // Get user's stats
    suspend fun getUserStats(userId: String): Result<UserArrowStats?>
 
    // Get user's rank
    suspend fun getUserRank(
        userId: String,
        timePeriod: TimePeriod
    ): Result<Int?>
 
    // Update display name
    suspend fun updateDisplayName(
        userId: String,
        newDisplayName: String
    ): Result<Unit>
}

Integration with Round Publishing

Arrow stats are automatically updated when a round is published:

class RoundPublishingService {
    suspend fun publishRound(round: Round): Result<PublishedRound> {
        // ... publish round logic ...
 
        // Update arrow stats
        arrowStatsDataSource?.incrementArrowCount(
            userId = currentUserId,
            arrowCount = round.totalArrowCount,
            round = round
        )
 
        return Result.success(publishedRound)
    }
}

Stats Updated

On each publish:

  • totalArrowsShot += round.arrowCount
  • roundsCompleted += 1
  • totalScore += round.score
  • totalXCount += round.xCount
  • weeklyArrows, monthlyArrows, seasonalArrows (if matching current period)
  • currentStreak (if consecutive day)
  • averagePerArrow, xPercentage (recalculated)

UI Components

Android: ArrowsLeaderboardScreen

@Composable
fun ArrowsLeaderboardScreen(
    timePeriod: TimePeriod,
    onTimePeriodChange: (TimePeriod) -> Unit
)

Features:

  • Time period selector (chips)
  • Scrollable leaderboard with rank, avatar, name, arrows
  • Current user highlight
  • Milestone badge display
  • Pull-to-refresh

iOS: ArrowsLeaderboardView

struct ArrowsLeaderboardView: View {
    @State var timePeriod: TimePeriod = .allTime
}

Equivalent iOS SwiftUI implementation with same features.

LeaderboardBrowserScreen

Groups leaderboard conditions by Indoor/Outdoor:

┌─────────────────────────────────────┐
│  Indoor                             │
│  ├─ 18m 40cm Indoor 5-Ring         │
│  └─ 18m 40cm Vegas 2-Ring          │
│                                     │
│  Outdoor                            │
│  ├─ 30m 80cm Standard              │
│  ├─ 50m 80cm Standard              │
│  ├─ 70m 122cm Standard             │
│  └─ 90m 122cm Standard             │
└─────────────────────────────────────┘

Firestore Indexes

{
  "indexes": [
    {
      "collectionGroup": "user_arrow_stats",
      "fields": [
        { "fieldPath": "totalArrowsShot", "order": "DESCENDING" }
      ]
    },
    {
      "collectionGroup": "user_arrow_stats",
      "fields": [
        { "fieldPath": "weekKey", "order": "ASCENDING" },
        { "fieldPath": "weeklyArrows", "order": "DESCENDING" }
      ]
    },
    {
      "collectionGroup": "user_arrow_stats",
      "fields": [
        { "fieldPath": "monthKey", "order": "ASCENDING" },
        { "fieldPath": "monthlyArrows", "order": "DESCENDING" }
      ]
    },
    {
      "collectionGroup": "user_arrow_stats",
      "fields": [
        { "fieldPath": "seasonKey", "order": "ASCENDING" },
        { "fieldPath": "seasonalArrows", "order": "DESCENDING" }
      ]
    }
  ]
}

Security Rules

match /user_arrow_stats/{userId} {
  // READ: Any authenticated user (for leaderboard display)
  allow read: if request.auth != null;
 
  // CREATE/UPDATE: Only owner can write
  allow create, update: if request.auth != null &&
                           request.auth.uid == userId;
 
  // DELETE: Only owner can delete
  allow delete: if request.auth != null &&
                   request.auth.uid == userId;
}

Display Formatting

Arrow Count Formatting

val formattedTotalArrows: String
    get() = when {
        totalArrowsShot >= 1_000_000 -> "${totalArrowsShot / 1_000_000}M+"
        totalArrowsShot >= 10_000 -> "${totalArrowsShot / 1_000}K+"
        totalArrowsShot >= 1_000 -> String.format("%.1fK", totalArrowsShot / 1000.0)
        else -> totalArrowsShot.toString()
    }
CountDisplay
500”500”
1,234”1.2K”
15,678”15K+“
1,500,000”1M+“

Ranking Display

RankDisplay
1Gold medal icon
2Silver medal icon
3Bronze medal icon
4-10Blue highlight
11+Standard row
Current userAlways highlighted

Streak Tracking

Consecutive Days

  • currentStreak: Days in current streak
  • longestStreak: All-time best
  • lastShotDate: ISO date of last activity

Update Logic

fun updateStreak(today: String, lastDate: String, currentStreak: Int): Int {
    val yesterday = today.minusDays(1)
    return when (lastDate) {
        today -> currentStreak        // Same day, no change
        yesterday -> currentStreak + 1 // Consecutive!
        else -> 1                      // Streak broken, start fresh
    }
}

Future Enhancements

  1. Weekly Challenges: Time-limited arrow shooting goals
  2. Club/Team Leaderboards: Group competitions
  3. Achievement Notifications: Push on milestone reached
  4. Historical Charts: Arrows over time visualization
  5. Social Sharing: Share milestone achievements