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
-
Leaderboard screen:
mobile/app/(drawer)/leaderboard.tsx→mobile/src/screens/LeaderboardScreen.tsx, deep linkscout://leaderboard, drawer row "Leaderboard" (crown icon) in the YOU section after Community.- A pill rail picks the scope: Overall first, then every campaign with at least one patch, in campaign-gallery order.
- The podium shows #2 · #1 · #3 (tallest in the middle) with crowns, then ranked rows.
- Rows 4–10 have a brass rank numeral; ranks past 10 are grey.
- The caption reads "{n} collectors · every patch counts, GPS or imported", or "· patches in {Campaign}" on a campaign board.
- You: if you're in the list, your row is highlighted and reads "You". If you rank below the top 50, a pinned footer shows your real rank. If you have nothing in scope, the footer says how to get on. Admin and test accounts get no footer (see below).
- States: page-loader skeleton, error with Try again, an empty board ("Nobody's on this board yet", with a button to the campaign), and pull-to-refresh.
-
Rank pill on bylines:
RankPill(mobile/src/components/community/RankPill.tsx:16) sits between the handle and the Visited badge on:- post cards (
PostCard.tsx:52) - the post detail byline (
PostScreen.tsx:443) - comments (
CommentCard.tsx:75)
It is decorative: tapping it does nothing.
- post cards (
How it works
- Ranking (
backend/src/leaderboard/leaderboard.service.ts:104):- One Prisma
groupByoveruser_patchesper scope counts rows per user and takesmax(collected_at). - A campaign scope keeps only patches in that campaign's non-
adminOnlycollections (: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.
- One Prisma
- Who never ranks: every internal profile (
:110), by the sameinternalProfileWhere(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. - 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_atis the photo's own date, so for imports "first" means first there.
- 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. - 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}andviewerExcluded(:94).
- Takes the global top 50, then removes anyone the viewer blocked or who blocked
the viewer (
- Bylines: the community services read
overallTopRanks()(:99, ranks 1–10) from the same cache and stampauthorRankon every post and comment DTO (community-posts.service.ts:119,533,699). Official posts always getnull. The read goes throughtopRanks()(: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
backend/src/leaderboard/leaderboard.service.ts: ranking, cache, per-viewer board, top-10 mapbackend/src/leaderboard/leaderboard.types.ts: constants and DTO typesbackend/src/leaderboard/leaderboard.controller.ts:10: the endpointbackend/src/community-posts/community-posts.service.ts:79:authorMapsreads the top-10 map (viatopRanks,:93)mobile/src/screens/LeaderboardScreen.tsx: view-model + layoutmobile/src/screens/leaderboard/buildLeaderboardData.ts:27: every screen branch, puremobile/src/components/leaderboard/*: podium, row, pinned footer, skeletonmobile/src/components/community/rankMarker.ts:11+RankPill.tsx: the byline pillmobile/src/theme/colors.ts:colors.rank.{gold,silver,bronze}(+ glows)- Design:
mobile/research/leaderboard-lab.html(screen variant A, marker variant 2)
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
- Up to 60 s stale, on the screen and on bylines. The screen refetches on focus only
once its copy is older than that (
LeaderboardScreen.tsx:47). - An admin opening the board sees it with no "you're not on this board yet" footer
(
viewerExcluded), rather than being told to unlock their first patch. - Blocks hide rows without renumbering, so a viewer can see #4 then #6. The podium is keyed by rank too: a blocked #2 leaves the silver slot empty ("—") rather than promoting #3. The caption's collector count is global.
- Fewer than three people leaves dimmed "—" podium slots, the normal case for a new campaign board.
- Ranks are per process: each backend process computes its own cache. With one API process today, that means one board.
What this feature does NOT do
- There is no friends leaderboard and no friends list. The board is global. Nothing compares you with people you know unless they happen to be on it.
- There are no state leaderboards. Only Overall and per campaign.
- You cannot open anyone's profile from it. Rows and pills are not tappable, and there's still no in-app profile browsing.
- There is no opt-out. Every non-internal account with at least one patch appears, under its handle.
- Admins and test accounts never appear, including the owner's own account.
- Ranking earns nothing. No reward, badge, achievement, discount or unlock depends on your rank.
- It is not real-time; see the 60 s cache above.
- It does not make rarity a statistic. Patch rarity is still Scout's own editorial judgement about a place (patch-rarity). The leaderboard counts patches per person and says nothing about how many people hold a given patch.
- No rank pill on patch photos or in direct messages. Only community posts, the post detail byline and comments show it.
- GPS and imported unlocks are not separated. Both count the same.
Tests that cover it
backend/src/leaderboard/leaderboard.service.spec.ts: order and tie-break, internal exclusion andviewerExcluded, block filtering without renumbering, cap-before-filter,meoutside the top 50, campaign scoping, unknown campaign → 404, cache window and per-scope cache, one computation for concurrent calls, failures not cached, top-10 map.backend/src/leaderboard/leaderboard.db.spec.ts(real Postgres): distinct campaign count ignoringadminOnlycollections;is_admin,ADMIN_EMAILSand Maestro exclusion; relative Overall order.backend/src/leaderboard/leaderboard.controller.spec.ts: scope parameter handling.backend/src/community-posts/community-posts.service.spec.ts(authorRank): posts, official posts, comments, and a ranking failure leaving the feed intact.mobile/src/screens/leaderboard/__tests__/buildLeaderboardData.test.ts: every branch.mobile/src/components/community/__tests__/rankMarker.test.ts: the marker for each rank.mobile/screen-tests/leaderboard.test.tsx: podium, rows, caption, empty, pinned, in list, admin viewer, scope switch, error, and a blocked #2 leaving the silver slot empty.mobile/screen-tests/community.test.tsx: pills on ranked authors only.
Open questions
- State leaderboards were requested and deferred (2026-09-27).
- Whether a pill tap should open the leaderboard (decided "no" for now).