feat: leaderboard refonte — period tabs + 5 sort keys + aggregate metrics#58
Merged
Conversation
New GET /api/leaderboard/{domain}?period=daily|weekly|all-time&sort=score|originality|time|count|wins
One handler, three periods, five sort keys. Response shape is the same
across periods so the frontend can render a single component:
- daily: one row per game (gamesCount = 1)
- weekly: sliding 7 days, aggregated per user_id, anonymous excluded
- all-time: no time filter, aggregated per user_id, anonymous excluded
Metrics on every entry:
- gamesCount / wins
- bestScore / avgScore
- bestOriginality / avgOriginality
- avgTimeSeconds (null when zero wins — sinks to the bottom on time sort)
- lastFinishedAt
Aggregation runs in-memory: the per-period working set is under 10k rows
even at v1 scale, and replacing it with raw SQL GROUP BY + window funcs
through SeaORM costs more than it saves.
Time clamp: finish_time_seconds drops rows where finished - started is
negative or > 24h, so clock drift / replays don't leak crazy numbers to
the UI.
Sort tiebreakers:
- score DESC: originality DESC then recency DESC
- originality DESC: score DESC
- time ASC : zero-wins sink last
- count DESC: wins DESC
- wins DESC: bestScore DESC
Legacy GET /api/leaderboard/{domain}/today kept as a deprecated alias
that calls list() with period=daily, sort=score. Lets the front
migrate without a coordinated break.
contracts: LeaderboardEntry shape replaced with the aggregate version,
new /api/leaderboard/{domain} entry, old /today flagged deprecated.
3 new unit tests cover time clamping, score tiebreakers, and time sort
with mixed wins/losses.
LeaderboardView consumes the unified /api/leaderboard/{domain} endpoint:
- Three pill tabs (Today / 7 days / All time) above the list. Selected
tab triggers a refetch with the matching period.
- 5 sortable column headers on >= sm (wins, games, avg time, originality,
score). Click toggles the sort key (descending by default per the
backend contract).
- Mobile keeps the 3-column layout (rank | player + subline | score).
The subline shows games + originality + avg time inline so players on
360px don't lose the extra metrics.
- Per-row metrics: gamesCount, wins, avgTimeSeconds (formatted m:ss
or s), bestOriginality, bestScore.
Time format: 's' under 1 minute, 'm:ss' otherwise; null avgTime renders
as an em-dash so the column never reads '0' for a player with no win.
i18n: 9 new keys (3 periods, 5 columns, label) for fr + en.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
PR B of the phase 2.5 split (#57 was A, fame_score ingest). Replaces the daily-only leaderboard with a periodised, multi-sort, multi-metric one.
Why
The phase 2 leaderboard only ranked the current day, by score, with originality as a tiebreaker. Users couldn't compare effort over time, surface their best runs, or sort by speed. With #57 landing real fame scores, the originality sort actually has signal now — exposing it as a first-class key was the next step.
How
Backend (`server/src/routes/leaderboard.rs`)
One handler, three periods, five sort keys. Response shape is the same across periods so the frontend can render a single component:
Aggregation runs in-memory (per-period working set < 10k rows even at v1 scale; raw SQL GROUP BY + window funcs through SeaORM costs more than it saves).
`finish_time_seconds` clamps negative / > 24h durations to None so clock drift / replays don't leak crazy numbers to the UI.
Sort tiebreakers documented in the module doc-comment.
Legacy `GET /api/leaderboard/{domain}/today` kept as a deprecated alias that calls `list()` with `period=daily, sort=score` — lets the frontend migrate cleanly.
Frontend (`web/src/lib/components/LeaderboardView.svelte`)
Tests
Checklist
Test plan