Add W-L records to player cards and leaderboard

This commit is contained in:
Split 2026-02-26 13:07:07 -05:00
parent 1b74470fcb
commit a1f96b9af4
7 changed files with 94 additions and 21 deletions

View File

@ -720,3 +720,4 @@ called `Result::unwrap()` on an `Err` value: Os { code: 48, kind: AddrInUse, mes
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Migration error: error returned from database: (code: 1) no such column: rating Migration error: error returned from database: (code: 1) no such column: rating
Migration error: error returned from database: (code: 1) no such column: rating Migration error: error returned from database: (code: 1) no such column: rating
Migration error: error returned from database: (code: 1) no such column: rating

View File

@ -1081,3 +1081,16 @@ Starting Pickleball ELO Tracker Server on port 3000...
Add Player: http://localhost:3000/players/new Add Player: http://localhost:3000/players/new
🎾 Record Match: http://localhost:3000/matches/new 🎾 Record Match: http://localhost:3000/matches/new
🏓 Pickleball ELO Tracker v3.0
==============================
Starting Pickleball ELO Tracker Server on port 3000...
✅ Server running at http://localhost:3000
📊 Leaderboard: http://localhost:3000/leaderboard
📜 Match History: http://localhost:3000/matches
👥 Players: http://localhost:3000/players
⚖️ Team Balancer: http://localhost:3000/balance
Add Player: http://localhost:3000/players/new
🎾 Record Match: http://localhost:3000/matches/new

Binary file not shown.

View File

@ -205,6 +205,8 @@ struct PlayerData {
email: String, email: String,
rating_display: String, rating_display: String,
has_email: bool, has_email: bool,
wins: i64,
losses: i64,
} }
#[derive(Template, Clone, Debug)] #[derive(Template, Clone, Debug)]
@ -1578,16 +1580,30 @@ async fn create_match(
/// ///
/// **Returns:** HTML page with players table /// **Returns:** HTML page with players table
async fn players_list_handler(State(state): State<AppState>) -> impl IntoResponse { async fn players_list_handler(State(state): State<AppState>) -> impl IntoResponse {
// UNIFIED RATING: use rating field (merged singles/doubles) // UNIFIED RATING: use rating field (merged singles/doubles) with W-L record
let players: Vec<(i64, String, Option<String>, f64)> = sqlx::query_as( let players: Vec<(i64, String, Option<String>, f64, i64, i64)> = sqlx::query_as(
"SELECT id, name, email, rating FROM players ORDER BY rating DESC" r#"SELECT
p.id, p.name, p.email, p.rating,
COALESCE(SUM(CASE WHEN
(mp.team = 1 AND m.team1_score > m.team2_score) OR
(mp.team = 2 AND m.team2_score > m.team1_score)
THEN 1 ELSE 0 END), 0) as wins,
COALESCE(SUM(CASE WHEN
(mp.team = 1 AND m.team1_score < m.team2_score) OR
(mp.team = 2 AND m.team2_score < m.team1_score)
THEN 1 ELSE 0 END), 0) as losses
FROM players p
LEFT JOIN match_participants mp ON p.id = mp.player_id
LEFT JOIN matches m ON mp.match_id = m.id
GROUP BY p.id
ORDER BY p.rating DESC"#
) )
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await .await
.unwrap_or_default(); .unwrap_or_default();
let players = players.into_iter() let players = players.into_iter()
.map(|(id, name, email, rating)| { .map(|(id, name, email, rating, wins, losses)| {
let has_email = email.is_some(); let has_email = email.is_some();
PlayerData { PlayerData {
id, id,
@ -1597,6 +1613,8 @@ async fn players_list_handler(State(state): State<AppState>) -> impl IntoRespons
email: email.unwrap_or_default(), email: email.unwrap_or_default(),
rating_display: format!("{:.1}", rating), rating_display: format!("{:.1}", rating),
has_email, has_email,
wins,
losses,
} }
}) })
.collect(); .collect();
@ -1614,11 +1632,22 @@ async fn players_list_handler(State(state): State<AppState>) -> impl IntoRespons
/// ///
/// **Returns:** HTML page with unified leaderboard /// **Returns:** HTML page with unified leaderboard
async fn leaderboard_handler(State(state): State<AppState>) -> impl IntoResponse { async fn leaderboard_handler(State(state): State<AppState>) -> impl IntoResponse {
// UNIFIED RATING: Single leaderboard using rating field // UNIFIED RATING: Single leaderboard using rating field with W-L record
let players: Vec<(i64, String, f64, Option<String>)> = sqlx::query_as( let players: Vec<(i64, String, f64, Option<String>, i64, i64)> = sqlx::query_as(
r#"SELECT DISTINCT p.id, p.name, p.rating, p.email r#"SELECT
p.id, p.name, p.rating, p.email,
COALESCE(SUM(CASE WHEN
(mp.team = 1 AND m.team1_score > m.team2_score) OR
(mp.team = 2 AND m.team2_score > m.team1_score)
THEN 1 ELSE 0 END), 0) as wins,
COALESCE(SUM(CASE WHEN
(mp.team = 1 AND m.team1_score < m.team2_score) OR
(mp.team = 2 AND m.team2_score < m.team1_score)
THEN 1 ELSE 0 END), 0) as losses
FROM players p FROM players p
JOIN match_participants mp ON p.id = mp.player_id LEFT JOIN match_participants mp ON p.id = mp.player_id
LEFT JOIN matches m ON mp.match_id = m.id
GROUP BY p.id
ORDER BY p.rating DESC"# ORDER BY p.rating DESC"#
) )
.fetch_all(&state.pool) .fetch_all(&state.pool)
@ -1626,7 +1655,7 @@ async fn leaderboard_handler(State(state): State<AppState>) -> impl IntoResponse
.unwrap_or_default(); .unwrap_or_default();
let leaderboard = players.into_iter().enumerate() let leaderboard = players.into_iter().enumerate()
.map(|(i, (id, name, rating, email))| { .map(|(i, (id, name, rating, email, wins, losses))| {
let has_email = email.is_some(); let has_email = email.is_some();
let player_data = PlayerData { let player_data = PlayerData {
id, id,
@ -1636,6 +1665,8 @@ async fn leaderboard_handler(State(state): State<AppState>) -> impl IntoResponse
email: email.unwrap_or_default(), email: email.unwrap_or_default(),
rating_display: format!("{:.1}", rating), rating_display: format!("{:.1}", rating),
has_email, has_email,
wins,
losses,
}; };
((i + 1) as i32, player_data) ((i + 1) as i32, player_data)
}) })

View File

@ -2,12 +2,23 @@
<h3 class="pitt-primary font-bold text-xl mb-2"> <h3 class="pitt-primary font-bold text-xl mb-2">
<a href="/players/{{ player.id }}" class="hover:underline">{{ player.name }}</a> <a href="/players/{{ player.id }}" class="hover:underline">{{ player.name }}</a>
</h3> </h3>
<div class="text-3xl font-bold pitt-primary mb-4">{{ player.singles_rating | round(1) }}</div> <div class="grid grid-cols-2 gap-4 mb-4">
{% if player.email %} <div>
<p class="text-sm text-gray-600">📧 {{ player.email }}</p> <div class="text-xs text-gray-500 uppercase">Rating</div>
<div class="text-2xl font-bold pitt-primary">{{ player.rating_display }}</div>
</div>
<div>
<div class="text-xs text-gray-500 uppercase">Record</div>
<div class="text-2xl font-bold">
<span class="text-green-600">{{ player.wins }}</span>-<span class="text-red-600">{{ player.losses }}</span>
</div>
</div>
</div>
{% if player.has_email %}
<p class="text-sm text-gray-600 mb-4">📧 {{ player.email }}</p>
{% endif %} {% endif %}
<div class="flex gap-2 mt-4"> <div class="flex gap-2">
<a href="/players/{{ player.id }}" class="btn-primary text-sm">View Profile</a> <a href="/players/{{ player.id }}" class="btn-primary text-sm flex-1">View Profile</a>
<a href="/players/{{ player.id }}/edit" class="btn-warning text-sm">Edit</a> <a href="/players/{{ player.id }}/edit" class="btn-warning text-sm flex-1">Edit</a>
</div> </div>
</div> </div>

View File

@ -17,11 +17,17 @@
<a href="/players/{{ player.id }}/edit" class="btn-warning">✏️ Edit</a> <a href="/players/{{ player.id }}/edit" class="btn-warning">✏️ Edit</a>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-gradient-to-br from-blue-50 to-blue-100 p-6 rounded-lg"> <div class="bg-gradient-to-br from-blue-50 to-blue-100 p-6 rounded-lg">
<div class="text-sm text-gray-600 mb-1">ELO Rating</div> <div class="text-sm text-gray-600 mb-1">ELO Rating</div>
<div class="text-4xl font-bold pitt-primary">{{ player.rating_display }}</div> <div class="text-4xl font-bold pitt-primary">{{ player.rating_display }}</div>
</div> </div>
<div class="bg-gradient-to-br from-purple-50 to-purple-100 p-6 rounded-lg">
<div class="text-sm text-gray-600 mb-1">Record</div>
<div class="text-4xl font-bold">
<span class="text-green-600">{{ player.wins }}</span><span class="text-gray-400">-</span><span class="text-red-600">{{ player.losses }}</span>
</div>
</div>
<div class="bg-gradient-to-br from-green-50 to-green-100 p-6 rounded-lg"> <div class="bg-gradient-to-br from-green-50 to-green-100 p-6 rounded-lg">
<div class="text-sm text-gray-600 mb-1">Matches Played</div> <div class="text-sm text-gray-600 mb-1">Matches Played</div>
<div class="text-4xl font-bold text-green-700">{{ match_count }}</div> <div class="text-4xl font-bold text-green-700">{{ match_count }}</div>

View File

@ -18,13 +18,24 @@
<h3 class="pitt-primary font-bold text-xl mb-2"> <h3 class="pitt-primary font-bold text-xl mb-2">
<a href="/players/{{ player.id }}" class="hover:underline">{{ player.name }}</a> <a href="/players/{{ player.id }}" class="hover:underline">{{ player.name }}</a>
</h3> </h3>
<div class="text-3xl font-bold pitt-primary mb-4">{{ player.rating_display }}</div> <div class="grid grid-cols-2 gap-4 mb-4">
<div>
<div class="text-xs text-gray-500 uppercase">Rating</div>
<div class="text-2xl font-bold pitt-primary">{{ player.rating_display }}</div>
</div>
<div>
<div class="text-xs text-gray-500 uppercase">Record</div>
<div class="text-2xl font-bold">
<span class="text-green-600">{{ player.wins }}</span>-<span class="text-red-600">{{ player.losses }}</span>
</div>
</div>
</div>
{% if player.has_email %} {% if player.has_email %}
<p class="text-sm text-gray-600">📧 {{ player.email }}</p> <p class="text-sm text-gray-600 mb-4">📧 {{ player.email }}</p>
{% endif %} {% endif %}
<div class="flex gap-2 mt-4"> <div class="flex gap-2">
<a href="/players/{{ player.id }}" class="btn-primary text-sm">View Profile</a> <a href="/players/{{ player.id }}" class="btn-primary text-sm flex-1">View Profile</a>
<a href="/players/{{ player.id }}/edit" class="btn-warning text-sm">Edit</a> <a href="/players/{{ player.id }}/edit" class="btn-warning text-sm flex-1">Edit</a>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}