diff --git a/web/src/pages/history.rs b/web/src/pages/history.rs index d72f755..3915143 100644 --- a/web/src/pages/history.rs +++ b/web/src/pages/history.rs @@ -12,29 +12,50 @@ 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(); +/// The match's teams, each as a `(label, names)` pair, in a readable +/// order: briscola chiamata's roles first (Chiamante, Socio, Difensori), +/// then other team labels alphabetically (A, B, ...), with team-less +/// players last. +fn team_groups(m: &MatchSummary) -> Vec<(String, String)> { + let mut labels: BTreeSet> = BTreeSet::new(); for p in &m.players { - teams.insert(p.team.clone()); + labels.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()), - }); + let mut ordered: Vec> = labels.into_iter().collect(); + ordered.sort_by_key(team_sort_key); ordered + .into_iter() + .map(|label| (team_header(&label), team_names(m, &label))) + .collect() +} + +/// Sort key for team labels: chiamata roles in play order, other labels +/// alphabetically, team-less players last. +fn team_sort_key(label: &Option) -> (u8, String) { + match label.as_deref() { + Some("Chiamante") => (0, String::new()), + Some("Socio") => (1, String::new()), + Some("Difensori") => (2, String::new()), + Some(other) => (0, other.to_string()), + None => (9, String::new()), + } } fn team_names(m: &MatchSummary, team: &Option) -> String { - m.players + let names = m + .players .iter() .filter(|p| &p.team == team) .map(|p| p.display_name.clone()) - .collect::>() - .join(" & ") + .collect::>(); + match names.len() { + 0 => String::new(), + 1 => names[0].clone(), + _ => { + let (last, rest) = names.split_last().expect("non-empty"); + format!("{} & {}", rest.join(", "), last) + } + } } fn team_header(team: &Option) -> String { @@ -175,10 +196,18 @@ pub fn HistoryPage() -> View { .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) } }) + let teams: Vec = team_groups(&m) + .into_iter() + .map(|(label, names)| { + view! { + div(class="match-team") { + span(class="text-secondary") { + (label) ": " + } + (names) + } + } + }) .collect(); let score = score_text(&m); let winner = winner_text(&m); @@ -198,37 +227,18 @@ pub fn HistoryPage() -> View { } else { "text-secondary" }; - let mut row_cells = vec![ + let row_cells = vec![ view! { td { (finished) } }, view! { td { (game) } }, + view! { td { (teams) } }, + view! { td { (score) } }, + view! { td { (winner) } }, + view! { td(class=cls) { (outcome) } }, + view! { td(class=cls) { (elo_delta) } }, ]; - 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") { @@ -236,7 +246,7 @@ pub fn HistoryPage() -> View { tr { th { "Finished" } th { "Game" } - (header_cells) + th { "Teams" } th { "Score" } th { "Winner" } th { "You" } @@ -261,3 +271,96 @@ pub fn HistoryPage() -> View { } } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn match_summary(players: serde_json::Value) -> MatchSummary { + serde_json::from_value(json!({ + "id": "m1", + "started_at": "2026-01-01T00:00:00+00:00", + "finished_at": "2026-01-01T01:00:00+00:00", + "players": players, + })) + .unwrap() + } + + #[test] + fn team_groups_order_chiamata_roles() { + let m = match_summary(json!([ + {"user_sub": "a", "display_name": "Alice", "seat": 0, + "team": "Difensori", "won": false}, + {"user_sub": "b", "display_name": "Bob", "seat": 1, + "team": "Chiamante", "won": true}, + {"user_sub": "c", "display_name": "Carol", "seat": 2, + "team": "Difensori", "won": false}, + {"user_sub": "d", "display_name": "Dave", "seat": 3, + "team": "Socio", "won": true}, + {"user_sub": "e", "display_name": "Erin", "seat": 4, + "team": "Difensori", "won": false}, + ])); + assert_eq!( + vec![ + ("Chiamante".to_string(), "Bob".to_string()), + ("Socio".to_string(), "Dave".to_string()), + ( + "Difensori".to_string(), + "Alice, Carol & Erin".to_string() + ), + ], + team_groups(&m) + ); + } + + #[test] + fn team_groups_keep_a_and_b_in_order() { + let m = match_summary(json!([ + {"user_sub": "a", "display_name": "Alice", "seat": 0, + "team": "A", "won": true}, + {"user_sub": "b", "display_name": "Bob", "seat": 1, + "team": "B", "won": false}, + ])); + assert_eq!( + vec![ + ("Team A".to_string(), "Alice".to_string()), + ("Team B".to_string(), "Bob".to_string()), + ], + team_groups(&m) + ); + } + + #[test] + fn team_groups_put_teamless_players_last() { + let m = match_summary(json!([ + {"user_sub": "a", "display_name": "Alice", "seat": 0, + "team": null, "won": false}, + {"user_sub": "b", "display_name": "Bob", "seat": 1, + "team": "A", "won": true}, + ])); + assert_eq!( + vec![ + ("Team A".to_string(), "Bob".to_string()), + ("Players".to_string(), "Alice".to_string()), + ], + team_groups(&m) + ); + } + + #[test] + fn team_names_join_three_names_readably() { + let m = match_summary(json!([ + {"user_sub": "a", "display_name": "Alice", "seat": 0, + "team": "A", "won": true}, + {"user_sub": "b", "display_name": "Bob", "seat": 1, + "team": "A", "won": true}, + {"user_sub": "c", "display_name": "Carol", "seat": 2, + "team": "A", "won": true}, + ])); + assert_eq!( + "Alice, Bob & Carol", + team_names(&m, &Some("A".to_string())) + ); + } +} diff --git a/web/style.css b/web/style.css index 2413ffd..6a85df5 100644 --- a/web/style.css +++ b/web/style.css @@ -871,3 +871,11 @@ body { max-width: 100%; } } + +/* ---------- match history ---------- */ + +/* One line per team inside the "Teams" cell of the history table. */ +.match-team { + font-size: 0.85rem; + line-height: 1.4; +}