Paginate the leaderboard from materialized player ratings

Store each player's leaderboard totals (wins, accumulated points and the
latest display name) on player_rating alongside the Elo rating, updated
in the same transaction as the match, so the board is a single indexed
table query instead of aggregating every participation on each request.

GET /api/leaderboard is now page-number paginated (page/limit) and
returns the page, page count and total, plus the caller's own rank and
page when authenticated; focus=me returns the caller's page directly.
The migration adds the columns, backfills them from the match history
(inserting rows for players from pre-Elo matches at the initial rating)
and replaces the index. backfill_elo rebuilds the aggregates too.

The web page opens on the page containing the signed-in player, marks
their row and adds a numbered footer pager.
This commit is contained in:
2026-10-09 08:35:03 +00:00
parent bf613eb1c4
commit 551c4c9c28
16 changed files with 818 additions and 167 deletions
+49
View File
@@ -466,6 +466,10 @@ fn default_elo() -> i32 {
1500
}
fn default_page() -> u32 {
1
}
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct LeaderboardEntry {
@@ -512,10 +516,30 @@ pub struct RatingsPage {
pub results: Vec<PlayerRating>,
}
/// Page size requested by the leaderboard page. Also used to turn a rank
/// into the row number on a page (and back).
pub const LEADERBOARD_PAGE_SIZE: u32 = 20;
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct LeaderboardPage {
#[serde(default)]
pub results: Vec<LeaderboardEntry>,
/// 1-based page actually returned (clamped to the last page).
#[serde(default = "default_page")]
pub page: u32,
/// Total number of pages; 0 when the board has no rated players.
#[serde(default)]
pub pages: u32,
/// Total number of rated players.
#[serde(default)]
pub total: u32,
/// The viewer's own rank, when authenticated and rated for this game.
#[serde(default)]
pub your_rank: Option<u32>,
/// The page holding the viewer's row.
#[serde(default)]
pub your_page: Option<u32>,
}
/// A card game the platform can host (GET /api/game-types).
@@ -1113,6 +1137,31 @@ mod tests {
assert_eq!(1500, e.elo);
}
#[test]
fn leaderboard_page_parses_pagination_fields() {
let p: LeaderboardPage = serde_json::from_value(json!({
"results": [],
"page": 3,
"pages": 12,
"total": 234,
"your_rank": 47,
"your_page": 3,
}))
.unwrap();
assert_eq!(3, p.page);
assert_eq!(12, p.pages);
assert_eq!(234, p.total);
assert_eq!(Some(47), p.your_rank);
assert_eq!(Some(3), p.your_page);
// The pagination fields are optional for older payloads.
let legacy: LeaderboardPage =
serde_json::from_value(json!({"results": []})).unwrap();
assert_eq!(1, legacy.page);
assert_eq!(0, legacy.pages);
assert_eq!(None, legacy.your_rank);
assert_eq!(None, legacy.your_page);
}
fn scopone_game_type() -> GameTypeInfo {
serde_json::from_value(json!({
"id": "scopone_scientifico",