Give the match history table fixed columns
CI / Build and push docker image (push) Successful in 1m6s

Rows rendered only their own match's team labels while the header was
the union across all loaded matches. Scopone and briscola have two team
labels and briscola chiamata has three, so a mixed history produced a
five-column header above rows with two or three team cells - every cell
after them (score, winner, you, elo) landed under the wrong header, and
the columns changed with the set of game types loaded.

Replace the variable team columns with a single "Teams" column: the
table is now Finished | Game | Teams | Score | Winner | You | Elo for
every game. The cell lists each team or role on its own line, ordered
Chiamante/Socio/Difensori for chiamata and alphabetically (Team A,
Team B, ...) otherwise, with team-less players last; names read
"Bob, Carol & Dave".
This commit is contained in:
2026-10-09 09:18:46 +08:00
parent 0f9fb9b6bb
commit 4ba5ae1434
2 changed files with 155 additions and 44 deletions
+147 -44
View File
@@ -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<Option<String>> {
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<Option<String>> = BTreeSet::new();
for p in &m.players {
teams.insert(p.team.clone());
labels.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()),
});
let mut ordered: Vec<Option<String>> = 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<String>) -> (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>) -> String {
m.players
let names = m
.players
.iter()
.filter(|p| &p.team == team)
.map(|p| p.display_name.clone())
.collect::<Vec<_>>()
.join(" & ")
.collect::<Vec<_>>();
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>) -> 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<View> = team_columns(&m)
.iter()
.map(|t| team_names(&m, t))
.map(|c| view! { td { (c) } })
let teams: Vec<View> = 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::<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") {
@@ -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()))
);
}
}
+8
View File
@@ -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;
}