Files
tavolo/web/src/pages/history.rs
T
woggioni 3cf7e2e257
CI / Build and push docker image (push) Successful in 2m18s
Show match finish times in the browser timezone
The history table truncated finished_at, which the API serializes in
UTC, so the Finished column always showed UTC wall time. Parse the
timestamp with the browser's Date and render its local components,
keeping the existing YYYY-MM-DD HH:MM layout.
2026-09-28 04:26:07 +08:00

239 lines
9.5 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.
//!
//! Rendering is game-generic: teams, scores and the winner are read from
//! each match's game-specific `result` object, degrading gracefully when
//! a game reports a different shape.
use std::collections::BTreeSet;
use wasm_bindgen_futures::spawn_local;
use sycamore::prelude::*;
use crate::api;
use crate::components::toast::toast;
use crate::model::{GameTypeInfo, MatchSummary, MatchesPage};
/// Distinct team labels in seat order; players without a team render in
/// a shared "Players" column.
fn team_columns(m: &MatchSummary) -> Vec<Option<String>> {
let mut teams = BTreeSet::new();
for p in &m.players {
teams.insert(p.team.clone());
}
let mut ordered: Vec<Option<String>> = teams.into_iter().collect();
// Seated teams first (in first-seat order), then the team-less column.
ordered.sort_by_key(|t| match t {
Some(_) => (0, String::new()),
None => (1, String::new()),
});
ordered
}
fn team_names(m: &MatchSummary, team: &Option<String>) -> String {
m.players
.iter()
.filter(|p| &p.team == team)
.map(|p| p.display_name.clone())
.collect::<Vec<_>>()
.join(" & ")
}
fn team_header(team: &Option<String>) -> String {
match team {
Some(t) => format!("Team {t}"),
None => "Players".to_string(),
}
}
/// "11 – 7" when the result carries both team scores, "—" otherwise.
fn score_text(m: &MatchSummary) -> String {
match (m.result_i64("team_a_score"), m.result_i64("team_b_score")) {
(Some(a), Some(b)) => format!("{a} – {b}"),
_ => "—".to_string(),
}
}
/// "Team A" from `winner_team`, a plain `winner`, or "—".
fn winner_text(m: &MatchSummary) -> String {
if let Some(team) = m.result_str("winner_team") {
return format!("Team {team}");
}
m.result_str("winner").unwrap_or_else(|| "—".to_string())
}
fn game_name(game_types: &[GameTypeInfo], game_type: &str) -> String {
game_types
.iter()
.find(|g| g.id == game_type)
.map(|g| g.name.clone())
.unwrap_or_else(|| game_type.to_string())
}
/// The match's finish instant in the browser's local timezone, as
/// `YYYY-MM-DD HH:MM`. Falls back to the raw UTC prefix when the browser
/// cannot parse the timestamp.
fn local_finished(iso: &str) -> String {
let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_str(iso));
if date.get_time().is_nan() {
return iso.replace('T', " ").chars().take(16).collect();
}
crate::model::fmt_datetime_parts(
date.get_full_year(),
date.get_month(),
date.get_date(),
date.get_hours(),
date.get_minutes(),
)
}
#[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 game_types = create_signal(Vec::<GameTypeInfo>::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)),
}
});
};
spawn_local(async move {
if let Ok(types) = api::game_types().await {
game_types.set(types);
}
});
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 {
(crate::components::nav::app_nav("/history", || view! {}))
div(class="container page-shell py-4") {
h1(class="h3 mb-4") { "My matches" }
(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(_) if rows.get_clone().is_empty() => view! {
p(class="text-secondary text-center p-5") { "No matches played yet." }
},
Some(_) => {
let names = game_types.get_clone();
let table_rows = rows
.get_clone()
.into_iter()
.map(|m| {
let finished = local_finished(&m.finished_at);
let game = game_name(&names, &m.game_type);
let mut cells: Vec<View> = team_columns(&m)
.iter()
.map(|t| team_names(&m, t))
.map(|c| view! { td { (c) } })
.collect();
let score = score_text(&m);
let winner = winner_text(&m);
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());
let cls = if m.you_won {
"text-success fw-semibold"
} else {
"text-secondary"
};
let mut row_cells = vec![
view! { td { (finished) } },
view! { td { (game) } },
];
row_cells.append(&mut cells);
row_cells.push(view! { td { (score) } });
row_cells.push(view! { td { (winner) } });
row_cells.push(view! { td(class=cls) { (outcome) } });
row_cells.push(view! { td(class=cls) { (elo_delta) } });
view! { tr { (row_cells) } }
})
.collect::<Vec<_>>();
// Team columns are per-row (matches may mix games), so
// the header shows the union across the loaded rows.
let mut header_teams = BTreeSet::new();
for m in rows.get_clone().iter() {
for t in team_columns(m) {
header_teams.insert(t);
}
}
let mut header_teams: Vec<Option<String>> =
header_teams.into_iter().collect();
header_teams.sort_by_key(|t| match t {
Some(_) => (0, String::new()),
None => (1, String::new()),
});
let header_cells: Vec<View> = header_teams
.iter()
.map(team_header)
.map(|h| view! { th { (h) } })
.collect();
view! {
div(class="table-responsive") {
table(class="table table-striped table-hover align-middle") {
thead {
tr {
th { "Finished" }
th { "Game" }
(header_cells)
th { "Score" }
th { "Winner" }
th { "You" }
th { "Elo" }
}
}
tbody { (table_rows) }
}
}
(cursor.get_clone().map(|c| view! {
button(
class="btn btn-outline-light",
on:click=move |_| load2(Some(c.clone())),
) {
"Load more"
}
}))
}
}
})
}
}
}
}