Paginate the leaderboard from materialized player ratings
CI / Build and push docker image (push) Successful in 1m19s
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:
+18
-7
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user