Files
tavolo/web/src/pages/history.rs
T
woggioni 93d041b004 Add chess-style Elo ratings for players
Each player's rating starts at 1500 and updates transactionally with
every finished match: a team's rating is the mean of its two members
and the standard K=32 formula decides the zero-sum delta applied to
both members of a team. Ratings are per game type in a new
player_rating table; match_player records each match's elo_delta.

- GET /api/leaderboard exposes elo and sorts by it
- GET /api/me/matches includes per-player elo deltas
- new GET /api/me/ratings returns the caller's rating per game type
- frontend: Elo column on the leaderboard, per-match delta in the
  history page, current rating in the lobby
- python -m tavolo.backfill_elo recomputes all ratings from the
  recorded match history (one-off backfill for existing matches)
2026-09-19 10:09:24 +08:00

120 lines
4.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Match history page.
use wasm_bindgen_futures::spawn_local;
use sycamore::prelude::*;
use crate::api;
use crate::components::toast::toast;
use crate::model::MatchesPage;
#[component]
pub fn HistoryPage() -> View {
let page = create_signal(Option::<MatchesPage>::None);
let error = create_signal(Option::<String>::None);
let cursor = create_signal(Option::<String>::None);
// Accumulated rows across "load more" clicks.
let rows = create_signal(Vec::<crate::model::MatchSummary>::new());
let load = move |next: Option<String>| {
spawn_local(async move {
match api::my_matches(next.as_deref()).await {
Ok(p) => {
cursor.set(p.next_cursor.clone());
rows.update(|acc| acc.extend(p.results.iter().cloned()));
page.set(Some(p));
}
Err(e) => error.set(Some(e)),
}
});
};
let load2 = load;
spawn_local(async move {
match api::my_matches(None).await {
Ok(p) => {
cursor.set(p.next_cursor.clone());
rows.set(p.results.clone());
page.set(Some(p));
}
Err(e) => error.set(Some(e)),
}
});
view! {
div(class="page") {
nav(class="top-nav") {
a(href="/") { "← Lobby" }
a(href="/leaderboard") { "Leaderboard" }
}
h1 { "My matches" }
(toast(error))
(move || match page.get_clone() {
None => view! { p(class="status") { "Loading…" } },
Some(_) if rows.get_clone().is_empty() => view! {
p(class="status") { "No matches played yet." }
},
Some(_) => {
let table_rows = rows
.get_clone()
.into_iter()
.map(|m| {
let team_a: String = m
.players
.iter()
.filter(|p| p.team == "A")
.map(|p| p.display_name.clone())
.collect::<Vec<_>>()
.join(" & ");
let team_b: String = m
.players
.iter()
.filter(|p| p.team == "B")
.map(|p| p.display_name.clone())
.collect::<Vec<_>>()
.join(" & ");
let outcome = if m.you_won { "Won" } else { "Lost" };
let elo_delta = m
.your_elo_delta
.map(|d| if d >= 0 { format!("+{d}") } else { d.to_string() })
.unwrap_or_else(|| "—".to_string());
view! {
tr {
td { (m.finished_at.replace('T', " ").chars().take(16).collect::<String>()) }
td { (team_a) }
td { (team_b) }
td { (m.team_a_score) " " (m.team_b_score) }
td { "Team " (m.winner_team) }
td(class=if m.you_won { "won" } else { "lost" }) { (outcome) }
td(class=if m.you_won { "won" } else { "lost" }) {
(elo_delta)
}
}
}
})
.collect::<Vec<_>>();
view! {
table(class="matches") {
thead {
tr {
th { "Finished" }
th { "Team A" }
th { "Team B" }
th { "Score" }
th { "Winner" }
th { "You" }
th { "Elo" }
}
}
tbody { (table_rows) }
}
(cursor.get_clone().map(|c| view! {
button(class="button", on:click=move |_| load2(Some(c.clone()))) {
"Load more"
}
}))
}
}
})
}
}
}