--- 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 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 ```php $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 ```php $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 ```php 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}`