leaderboard-system

star 130

LeaderboardController, Redis sorted sets for all-time XP rankings, per-user metrics rows for time-filtered rankings, rewardXpToAdmin, and leaderboard privacy.

OpenLitterMap By OpenLitterMap schedule Updated 3/20/2026

name: leaderboard-system description: LeaderboardController, Redis sorted sets for all-time XP rankings, per-user metrics rows for time-filtered rankings, rewardXpToAdmin, and leaderboard privacy.

Leaderboard System

Rankings by XP across time and location scopes. Two backends: Redis ZSETs for all-time, MySQL per-user metrics rows for time-filtered.

Key Files

  • app/Http/Controllers/Leaderboard/LeaderboardController.php — API controller (GET /api/leaderboard)
  • app/Services/Redis/RedisKeys.phpxpRanking($scope) returns {scope}:lb:xp
  • app/Services/Redis/RedisMetricsCollector.php — ZINCRBY in pipeline for create/update/delete
  • app/Services/Metrics/MetricsService.php — Builds per-user rows (user_id > 0) alongside aggregates
  • app/Helpers/helpers.phprewardXpToAdmin() increments ZSET + user stats hash
  • tests/Feature/Leaderboard/LeaderboardTest.php — 21 tests covering all paths (global, country, state, city scopes, privacy, pagination, Redis ZSET pruning)

Invariants

  1. All-time leaderboards come from Redis ZSETs. Key pattern: {scope}:lb:xp. Never query MySQL for all-time rankings.
  2. Time-filtered leaderboards come from MySQL. Query metrics table WHERE user_id > 0 ORDER BY xp DESC, user_id ASC. Secondary sort by user_id ensures deterministic pagination when XP is tied.
  3. Per-user rows are written by MetricsService. buildTimeSeriesRows() produces two rows per timescale × location: aggregate (user_id=0) and per-user (user_id>0).
  4. ZSET scores are maintained by RedisMetricsCollector. ZINCRBY for create/update, negative ZINCRBY for delete. Runs inside the Redis pipeline after MySQL commit.
  5. Zero-XP pruning on delete. After decrementing a ZSET score, ZREMRANGEBYSCORE {scope}:lb:xp -inf 0 removes members with score ≤ 0. Without this, deleted users remain as ghost entries.
  6. Daily queries MUST include bucket_date. For today/yesterday (timescale=1), the query includes WHERE bucket_date = ?. Without it, all daily rows for the month are returned. Index idx_leaderboard includes bucket_date for this.
  7. Privacy is enforced in formatUserData(). Respects show_name, show_username, and team pivot show_name_leaderboards/show_username_leaderboards. The pivot values correctly override the global user flags — a user who shows their name globally but opted out on a specific team will be hidden on that team's leaderboard entries.
  8. Route uses auth:sanctum — not auth:api. Use actingAs($user) in tests (no guard argument).
  9. rewardXpToAdmin() must update both MySQL (users.xp) and Redis ({g}:lb:xp ZSET + {u:ID}:stats hash).
  10. Rank is 1-indexed in API response. ZREVRANK returns 0-indexed; getCurrentUserRank() adds 1.
  11. RewardLittercoin uses cluster-compatible Redis keys. All Littercoin Redis operations use RedisKeys pattern with hash tags for cluster compatibility. The job wraps Redis commands in a try-catch to prevent failures from blocking the queue.

Patterns

Redis all-time query

$key = RedisKeys::xpRanking($scope);
$results = Redis::zRevRange($key, $start, $end, ['WITHSCORES' => true]);
$total = (int) Redis::zCard($key);
$rank = Redis::zRevRank($key, (string) $userId);

Time-filtered query

$query = DB::table('metrics')
    ->where('timescale', $timescale)
    ->where('location_type', $enumType->value)
    ->where('location_id', $locationId)
    ->where('user_id', '>', 0)
    ->where('year', $year)
    ->where('month', $month);

// REQUIRED for daily queries (timescale=1) — omitting returns all daily rows for the month
if (isset($params['bucket_date'])) {
    $query->where('bucket_date', $params['bucket_date']);
}

$query->where('xp', '>', 0)
    ->orderByDesc('xp')
    ->orderBy('user_id')  // deterministic tie-breaking
    ->offset($start)
    ->limit(100)
    ->select('user_id', 'xp')
    ->get();

Time filter mapping

Filter Timescale Year Month bucket_date
today 1 current current today
yesterday 1 yesterday yesterday yesterday
this-month 3 current current
last-month 3 prev prev
this-year 4 current 0
last-year 4 prev 0

Scope resolution

RedisKeys::xpRanking(RedisKeys::global())        // {g}:lb:xp
RedisKeys::xpRanking(RedisKeys::country($id))    // {c:$id}:lb:xp
RedisKeys::xpRanking(RedisKeys::state($id))      // {s:$id}:lb:xp
RedisKeys::xpRanking(RedisKeys::city($id))       // {ci:$id}:lb:xp

Common Mistakes

  • Using auth:api guard in tests. Leaderboard route uses auth:sanctum. Use actingAs($user) with no guard.
  • Forgetting the chk_user_location constraint was dropped. Migration 2026_02_24_150115 drops this constraint. Per-user rows now exist at all location scopes.
  • Querying MySQL for all-time rankings. All-time uses Redis. Only time-filtered queries hit MySQL.
  • Not including xp > 0 in time-filtered queries. Users with 0 XP should not appear on leaderboards.
  • Directly modifying ZSET scores outside RedisMetricsCollector. Only rewardXpToAdmin() is allowed to bypass the pipeline (admin-only XP).
  • Using raw Redis key strings in RewardLittercoin. Always use RedisKeys::* helpers with hash tags for Redis Cluster compatibility. Bare key strings like "user:{$id}:littercoin" break in cluster mode.
  • Expecting the team leaderboard to ignore pivot privacy flags. show_name_leaderboards/show_username_leaderboards on the team_user pivot row override the global show_name/show_username flags for that team's leaderboard. Both must be checked in formatUserData().
  • Omitting bucket_date for daily queries. Without WHERE bucket_date = ?, daily (timescale=1) queries return ALL daily rows for the month, not just one day.
  • Not pruning zero-XP members from ZSETs. After delete, ZREMRANGEBYSCORE must run to remove ≤ 0 scores. Without it, ghost entries persist in Redis.
  • Missing orderBy('user_id') for tie-breaking. Without secondary sort, tied-XP users get non-deterministic pagination order.
  • Expecting Redis::zScore() to return null for missing members. PHP Redis returns false, not null. Use assertFalse() in tests.

Frontend

Pinia Store

File Purpose
resources/js/stores/leaderboard/index.js State: leaderboard, currentPage, hasNextPage, total, currentUserRank, loading, error, currentFilters, countries, states, cities
resources/js/stores/leaderboard/requests.js FETCH_LEADERBOARD() (unified), FETCH_COUNTRIES(), FETCH_STATES(countryId), FETCH_CITIES(stateId), backward-compat wrappers

FETCH_LEADERBOARD({ timeFilter, locationType, locationId, page }) is the single entry point. Sets loading/error, stores total/currentUserRank from response.

Vue Components

File Purpose
Leaderboard.vue Page wrapper — dark gradient bg, auth gate, stats bar (rank/total), filters, list, pagination
LeaderboardFilters.vue Time pills (desktop) / select (mobile) + cascading location selectors (type → country → state → city). Emits change event.
LeaderboardList.vue Dark glass user cards — medal, flag, name, xp. Props: leaders array only.

Design

Matches Locations page dark glass theme (bg-gradient-to-br from-slate-900 via-blue-900 to-emerald-900). Cards use bg-white/5 border border-white/10 rounded-xl. Active time pill: bg-emerald-500/20 text-emerald-400.

Data Flow

  1. Leaderboard.vue calls FETCH_LEADERBOARD() on mount
  2. LeaderboardFilters.vue emits change with { timeFilter, locationType, locationId }
  3. Leaderboard.vue calls FETCH_LEADERBOARD() with emitted params
  4. Pagination calls FETCH_LEADERBOARD() with ...currentFilters + page
  5. Country list loaded via FETCH_COUNTRIES() from /api/v1/locations
  6. State list loaded via FETCH_STATES(countryId) from /api/v1/locations/country/{id}
  7. City list loaded via FETCH_CITIES(stateId) from /api/v1/locations/state/{id}
Install via CLI
npx skills add https://github.com/OpenLitterMap/openlittermap-web --skill leaderboard-system
Repository Details
star Stars 130
call_split Forks 41
navigation Branch main
article Path SKILL.md
More from Creator
OpenLitterMap
OpenLitterMap Explore all skills →