Scout — Full Product Context → feature documentation

Leaderboard

A global leaderboard of who has unlocked the most patches, overall and per campaign, on its own screen at scout://leaderboard. The overall top 3 wear a gold, silver or bronze crown pill next to their name on community posts and comments; #4–10 get a #7-style pill.

Summary

A global board of who has unlocked the most patches. There is one Overall board and one board per campaign, on a single screen at scout://leaderboard reached from the drawer. The Overall top 10 also show up across the community: #1–3 wear a gold, silver or bronze crown pill next to their handle on posts and comments, and #4–10 wear a plain #7-style pill.

It is global. Scout has no friends list, and there is no friends board.

Status

Built, not yet released (2026-09-27). It ships with the next app build, and the backend has to deploy first, because the screen calls /api/leaderboard. Update this line when the store build is live. There is no feature flag; it is unconditionally on, in the same way as community. The drawer row shows whenever there is a session, guests included (mobile/src/components/navigation/drawerSections.ts:272).

User-facing surfaces

How it works

  1. Ranking (backend/src/leaderboard/leaderboard.service.ts:104):
    • One Prisma groupBy over user_patches per scope counts rows per user and takes max(collected_at).
    • A campaign scope keeps only patches in that campaign's non-adminOnly collections (:122). (user_id, patch_id) is unique, so the count is a distinct-patch count even when a patch sits in two of the campaign's collections.
  2. Who never ranks: every internal profile (:110), by the same internalProfileWhere(parseAdminEmails(config)) definition that analytics and the admin Users list use. That covers admins (the owner's own account included, by decision), Maestro accounts, @example.com, saxal28+tag@ and the legacy list. Guests and suspended accounts do rank.
  3. Order (rankRows, :26):
    • Most patches first.
    • Ties go to whoever reached that count first (the older latest-unlock), then to user id. Every rank is unique, so there are exactly three crowns.
    • An imported patch's collected_at is the photo's own date, so for imports "first" means first there.
  4. Cache: each scope's ranking is reused for LEADERBOARD_CACHE_MS (60 s, leaderboard.types.ts:3). A new unlock can take up to a minute to show. Concurrent requests at expiry share one computation (inFlight, :50), and a failed computation is never cached.
  5. Per-viewer board (boardFor, :74):
    • Takes the global top 50, then removes anyone the viewer blocked or who blocked the viewer (:81). Nobody is renumbered and #51 is never promoted.
    • Adds the viewer's own {rank, count} and viewerExcluded (:94).
  6. Bylines: the community services read overallTopRanks() (:99, ranks 1–10) from the same cache and stamp authorRank on every post and comment DTO (community-posts.service.ts:119,533,699). Official posts always get null. The read goes through topRanks() (:93), which degrades to no pills if ranking fails, so a decoration can never take down a feed.

Data model

Nothing new is stored and there's no schema change. It reads user_patches, patch_collections, collections.campaign_id / admin_only, and profiles. user_patches.user_id has a SQL-level FK to profiles(id) ON DELETE CASCADE, so every counted row has a profile.

API surface

Method Path Auth Returns
GET /api/leaderboard JwtAuthGuard (guests OK) Overall board
GET /api/leaderboard?campaign=<id> same That campaign's board. Unknown id → 404; a zero-patch campaign → empty board

Response: { scope, total, entries: [{ rank, userId, username, count }] (≤50), me: { rank, count } | null, viewerExcluded }. Community post and comment DTOs carry authorRank: number | null.

Key files

Configuration and flags

No flag. Three constants in backend/src/leaderboard/leaderboard.types.ts: LEADERBOARD_CACHE_MS (60 000), LEADERBOARD_LIMIT (50), BYLINE_RANK_LIMIT (10). ADMIN_EMAILS also removes accounts from the board.

Edge cases and known limits

What this feature does NOT do

Tests that cover it

Open questions