Paginate the leaderboard from materialized player ratings
CI / Build and push docker image (push) Successful in 1m19s

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 16:42:47 +08:00
parent ae7ab5ce60
commit 1ab748a34f
16 changed files with 818 additions and 167 deletions
+18 -7
View File
@@ -175,13 +175,24 @@ pub async fn my_ratings() -> Result<RatingsPage, String> {
resp.json().await.map_err(|e| e.to_string())
}
/// Fetch the leaderboard for one game type: Elo, wins, matches and points
/// are all scoped to that game.
pub async fn leaderboard(game_type: &str) -> Result<LeaderboardPage, String> {
let resp = Request::get(&format!("/api/leaderboard?game_type={game_type}"))
.send()
.await
.map_err(|e| e.to_string())?;
/// Fetch one page of the leaderboard for a game. With `focus_me` the
/// server returns the page holding the caller's own ranking (page 1 for
/// anonymous or unrated callers).
pub async fn leaderboard(
game_type: &str,
page: Option<u32>,
focus_me: bool,
) -> Result<LeaderboardPage, String> {
let mut url = format!(
"/api/leaderboard?game_type={game_type}&limit={LEADERBOARD_PAGE_SIZE}"
);
if focus_me {
url.push_str("&focus=me");
}
if let Some(page) = page {
url.push_str(&format!("&page={page}"));
}
let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?;
check(&resp)?;
resp.json().await.map_err(|e| e.to_string())
}
+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",
+231 -62
View File
@@ -1,14 +1,102 @@
//! Per-game leaderboard page.
//!
//! Elo ratings are stored per game type, so the board is scoped to the
//! game chosen in the dropdown: Elo, wins, matches and points all come
//! from that game's matches only.
//! Elo ratings and the leaderboard aggregates live in one
//! `player_rating` row per player and game, so the board is
//! page-number paginated: the first load lands on the page holding the
//! signed-in player's own ranking (the server resolves it with
//! `focus=me`) and a footer pager changes pages.
use wasm_bindgen_futures::spawn_local;
use sycamore::prelude::*;
use crate::api;
use crate::components::toast::toast;
use crate::model::{fallback_game_types, fmt_points, LeaderboardPage};
use crate::model::{
fallback_game_types, fmt_points, LeaderboardPage, LEADERBOARD_PAGE_SIZE,
};
/// Page numbers to show in the pager: the first and last page, the
/// current page and its neighbours, with `None` marking an ellipsis.
fn page_window(page: u32, pages: u32) -> Vec<Option<u32>> {
if pages <= 1 {
return Vec::new();
}
let mut window = vec![Some(1)];
let start = page.saturating_sub(2).max(2);
let end = (page + 2).min(pages.saturating_sub(1));
if start > 2 {
window.push(None);
}
for number in start..=end {
window.push(Some(number));
}
if end + 1 < pages {
window.push(None);
}
window.push(Some(pages));
window
}
/// The footer pager: first/previous, page numbers, next/last. Rendered
/// only when the board has more than one page.
fn pager(
page: &LeaderboardPage,
busy: bool,
load: impl Fn(Option<u32>, bool) + Copy + 'static,
) -> View {
let current = page.page;
let pages = page.pages;
if pages <= 1 {
return view! {};
}
let button = move |label: &'static str, target: u32, enabled: bool| {
view! {
li(class=if enabled { "page-item" } else { "page-item disabled" }) {
button(
class="page-link",
disabled=!enabled || busy,
on:click=move |_| if enabled && !busy {
load(Some(target), false);
},
) { (label) }
}
}
};
let items = page_window(current, pages)
.into_iter()
.map(|entry| match entry {
None => view! {
li(class="page-item disabled") {
span(class="page-link") { "…" }
}
},
Some(number) => {
let active = number == current;
view! {
li(class=if active { "page-item active" } else { "page-item" }) {
button(
class="page-link",
disabled=active || busy,
on:click=move |_| if !active && !busy {
load(Some(number), false);
},
) { (number) }
}
}
}
})
.collect::<Vec<_>>();
view! {
nav(aria-label="Leaderboard pages") {
ul(class="pagination justify-content-center mt-4") {
(button("«", 1, current > 1))
(button("‹", current.saturating_sub(1).max(1), current > 1))
(items)
(button("›", (current + 1).min(pages), current < pages))
(button("»", pages, current < pages))
}
}
}
}
#[component]
pub fn LeaderboardPage() -> View {
@@ -16,26 +104,33 @@ pub fn LeaderboardPage() -> View {
let error = create_signal(Option::<String>::None);
let game_types = create_signal(fallback_game_types());
let selected_game = create_signal("scopone_scientifico".to_string());
// A leaderboard request is in flight; the pager is disabled meanwhile.
let loading = create_signal(false);
// Reload whenever the selection changes; a response for a game the
// user has already switched away from is dropped.
create_effect(move || {
let load = move |target: Option<u32>, focus_me: bool| {
let game_type = selected_game.get_clone();
page.set(None);
loading.set(true);
spawn_local(async move {
match api::leaderboard(&game_type).await {
Ok(p) => {
if selected_game.get_clone() == game_type {
page.set(Some(p));
}
}
Err(e) => {
if selected_game.get_clone() == game_type {
error.set(Some(e));
}
}
let response = api::leaderboard(&game_type, target, focus_me).await;
// A response for a game the user has already switched away
// from is dropped (and must not clear the new game's spinner).
if selected_game.get_clone() != game_type {
return;
}
loading.set(false);
match response {
Ok(p) => page.set(Some(p)),
Err(e) => error.set(Some(e)),
}
});
};
// Reload whenever the selection changes; the first load focuses the
// viewer's own page so they see their name right away.
create_effect(move || {
let _game = selected_game.get_clone();
page.set(None);
load(None, true);
});
spawn_local(async move {
@@ -80,53 +175,86 @@ pub fn LeaderboardPage() -> View {
}
}
(toast(error))
(move || match page.get_clone() {
None => view! {
div(class="d-flex justify-content-center p-5") {
div(class="spinner-border text-warning", role="status") {
span(class="visually-hidden") { "Loading…" }
}
}
},
Some(p) if p.results.is_empty() => view! {
p(class="text-secondary text-center p-5") {
"No ratings yet — finish a match to appear here."
}
},
Some(p) => {
let rows = p
.results
.iter()
.cloned()
.enumerate()
.map(|(i, e)| {
view! {
tr {
td { (i + 1) }
td { (e.display_name.clone()) }
td { (e.elo) }
td { (e.wins) }
td { (e.matches) }
td { (fmt_points(e.points)) }
}
(move || {
let busy = loading.get();
match page.get_clone() {
None => view! {
div(class="d-flex justify-content-center p-5") {
div(class="spinner-border text-warning", role="status") {
span(class="visually-hidden") { "Loading…" }
}
})
.collect::<Vec<_>>();
view! {
div(class="table-responsive") {
table(class="table table-striped table-hover align-middle") {
thead {
tr {
th { "#" }
th { "Player" }
th { "Elo" }
th { "Wins" }
th { "Matches" }
th { "Points" }
}
},
Some(p) if p.results.is_empty() => view! {
p(class="text-secondary text-center p-5") {
"No ratings yet — finish a match to appear here."
}
},
Some(p) => {
let rows = p
.results
.iter()
.cloned()
.enumerate()
.map(|(i, e)| {
let rank = (p.page.saturating_sub(1))
* LEADERBOARD_PAGE_SIZE
+ i as u32
+ 1;
let is_me = p.your_rank == Some(rank);
let cls = if is_me {
"table-active fw-semibold"
} else {
""
};
view! {
tr(class=cls) {
td { (rank) }
td {
(e.display_name.clone())
(is_me.then(|| view! {
span(
class="badge text-bg-warning ms-2"
) {
"you"
}
}))
}
td { (e.elo) }
td { (e.wins) }
td { (e.matches) }
td { (fmt_points(e.points)) }
}
}
tbody { (rows) }
})
.collect::<Vec<_>>();
let pager_view = pager(&p, busy, load);
let rated = if p.total == 1 {
"1 player rated".to_string()
} else {
format!("{} players rated", p.total)
};
view! {
p(class="text-secondary small mb-2") { (rated) }
div(class="table-responsive") {
table(
class="table table-striped table-hover \
align-middle"
) {
thead {
tr {
th { "#" }
th { "Player" }
th { "Elo" }
th { "Wins" }
th { "Matches" }
th { "Points" }
}
}
tbody { (rows) }
}
}
(pager_view)
}
}
}
@@ -135,3 +263,44 @@ pub fn LeaderboardPage() -> View {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn page_window_small_boards() {
// Boards with a single page (or none) have no pager at all.
assert_eq!(Vec::<Option<u32>>::new(), page_window(1, 0));
assert_eq!(Vec::<Option<u32>>::new(), page_window(1, 1));
assert_eq!(vec![Some(1), Some(2)], page_window(1, 2));
assert_eq!(vec![Some(1), Some(2), Some(3)], page_window(1, 3));
assert_eq!(vec![Some(1), Some(2), Some(3)], page_window(3, 3));
}
#[test]
fn page_window_ellipsizes_long_boards() {
assert_eq!(
vec![Some(1), Some(2), Some(3), None, Some(10)],
page_window(1, 10)
);
assert_eq!(
vec![
Some(1),
None,
Some(3),
Some(4),
Some(5),
Some(6),
Some(7),
None,
Some(10),
],
page_window(5, 10)
);
assert_eq!(
vec![Some(1), None, Some(8), Some(9), Some(10)],
page_window(10, 10)
);
}
}