Skip to content

feat: leaderboard refonte — period tabs + 5 sort keys + aggregate metrics#58

Merged
Calixteair merged 2 commits into
mainfrom
feat/leaderboard-refonte
May 13, 2026
Merged

feat: leaderboard refonte — period tabs + 5 sort keys + aggregate metrics#58
Calixteair merged 2 commits into
mainfrom
feat/leaderboard-refonte

Conversation

@Calixteair
Copy link
Copy Markdown
Owner

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.

  • New `GET /api/leaderboard/{domain}?period=daily|weekly|all-time&sort=score|originality|time|count|wins`
  • Three period tabs in the UI: Today / 7 days / All time
  • Five sortable columns on >= sm: wins / games / avg time / originality / score
  • Per-entry aggregate metrics: gamesCount, wins, bestScore, avgScore, bestOriginality, avgOriginality, avgTimeSeconds, lastFinishedAt

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:

  • daily: games tied to today's daily grid; aggregate degenerates to one row per game.
  • weekly: sliding 7 days (`finished_at >= now() - 7d`), aggregated per `user_id`. Anonymous excluded.
  • all-time: no time filter, aggregated per `user_id`. Anonymous excluded.

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`)

  • Three pill tabs above the list. Selected tab triggers a refetch.
  • 5 sortable column headers (>= sm). Click toggles sort key; descending by default per backend contract.
  • Mobile keeps the 3-column layout (rank | player + subline | score). The subline carries games + originality + avg time inline so 360px stays usable.
  • Time format: `s` under a minute, `m:ss` otherwise; null avgTime → em-dash.

Tests

  • 3 new Rust unit tests covering time clamping, score tiebreakers, time sort with mixed wins/losses.
  • Type-checking the new `LeaderboardPage` shape catches any drift on the consumer side.

Checklist

  • Conventional commit titles
  • No mention of AI / Claude / Anthropic / Copilot
  • `cargo fmt --check` passes
  • `cargo clippy --all-targets -- -D warnings` passes
  • `cargo test --workspace` passes (35 server tests, +3)
  • `pnpm --dir web lint && typecheck && test` passes
  • `pnpm --dir web build` succeeds (9 pages)
  • Mobile-first respected (3-col layout at 360px, no horizontal scroll)
  • No new `unsafe`, no `unwrap()` in non-test code
  • Anonymous players excluded from weekly + all-time
  • Contract changes in `contracts/openapi.yaml` (LeaderboardEntry shape + new endpoint)

Test plan

  • `/leaderboard` loads daily by default
  • Click "7 jours" → list switches; same UI, anonymous gone
  • Click "Depuis le début" → all-time, sorted by best score
  • Click "Réussies" column header on all-time → re-sorts by wins desc
  • Click "Temps moy." → players without a win sink to the bottom
  • Resize to 360px → 3-col layout, secondary metrics on the subline
  • Old front (if any cached client) hitting /today still works (legacy alias)

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.
@Calixteair Calixteair merged commit 4b5978e into main May 13, 2026
7 checks passed
@Calixteair Calixteair deleted the feat/leaderboard-refonte branch May 13, 2026 01:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant