//! 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> { let mut teams = BTreeSet::new(); for p in &m.players { teams.insert(p.team.clone()); } let mut ordered: Vec> = 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 { m.players .iter() .filter(|p| &p.team == team) .map(|p| p.display_name.clone()) .collect::>() .join(" & ") } fn team_header(team: &Option) -> 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::::None); let error = create_signal(Option::::None); let cursor = create_signal(Option::::None); // Accumulated rows across "load more" clicks. let rows = create_signal(Vec::::new()); let game_types = create_signal(Vec::::new()); let load = move |next: Option| { 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 = 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::>(); // 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> = 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 = 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" } })) } } }) } } } }