| 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.php — xpRanking($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.php — rewardXpToAdmin() 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
- All-time leaderboards come from Redis ZSETs. Key pattern:
{scope}:lb:xp. Never query MySQL for all-time rankings.
- 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.
- Per-user rows are written by MetricsService.
buildTimeSeriesRows() produces two rows per timescale × location: aggregate (user_id=0) and per-user (user_id>0).
- ZSET scores are maintained by RedisMetricsCollector. ZINCRBY for create/update, negative ZINCRBY for delete. Runs inside the Redis pipeline after MySQL commit.
- 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.
- 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.
- 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.
- Route uses
auth:sanctum — not auth:api. Use actingAs($user) in tests (no guard argument).
rewardXpToAdmin() must update both MySQL (users.xp) and Redis ({g}:lb:xp ZSET + {u:ID}:stats hash).
- Rank is 1-indexed in API response.
ZREVRANK returns 0-indexed; getCurrentUserRank() adds 1.
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);
if (isset($params['bucket_date'])) {
$query->where('bucket_date', $params['bucket_date']);
}
$query->where('xp', '>', 0)
->orderByDesc('xp')
->orderBy('user_id')
->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())
RedisKeys::xpRanking(RedisKeys::country($id))
RedisKeys::xpRanking(RedisKeys::state($id))
RedisKeys::xpRanking(RedisKeys::city($id))
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
Leaderboard.vue calls FETCH_LEADERBOARD() on mount
LeaderboardFilters.vue emits change with { timeFilter, locationType, locationId }
Leaderboard.vue calls FETCH_LEADERBOARD() with emitted params
- Pagination calls
FETCH_LEADERBOARD() with ...currentFilters + page
- Country list loaded via
FETCH_COUNTRIES() from /api/v1/locations
- State list loaded via
FETCH_STATES(countryId) from /api/v1/locations/country/{id}
- City list loaded via
FETCH_CITIES(stateId) from /api/v1/locations/state/{id}