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;
+}