CI / Build and push docker image (push) Successful in 2m18s
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.
239 lines
9.5 KiB
Rust
239 lines
9.5 KiB
Rust
//! 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"
|
||
}
|
||
}))
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|