Refactor: unified rating (use singles_rating for all matches, update leaderboard)

This commit is contained in:
Split 2026-02-26 12:15:00 -05:00
parent 2533b589a0
commit 189ea1b037

View File

@ -916,10 +916,9 @@ async fn delete_match(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Revert each player's rating // Revert each player's rating (UNIFIED: always use singles_rating)
for (player_id, match_type, rating_before, _rating_after) in &participants { for (player_id, _match_type, rating_before, _rating_after) in &participants {
let rating_col = if match_type == "doubles" { "doubles_rating" } else { "singles_rating" }; sqlx::query("UPDATE players SET singles_rating = ? WHERE id = ?")
sqlx::query(&format!("UPDATE players SET {} = ? WHERE id = ?", rating_col))
.bind(rating_before) .bind(rating_before)
.bind(player_id) .bind(player_id)
.execute(&state.pool) .execute(&state.pool)
@ -1298,7 +1297,8 @@ async fn create_match(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create match: {}", e)))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create match: {}", e)))?;
let is_doubles = match_data.match_type == "doubles"; let is_doubles = match_data.match_type == "doubles";
let rating_col = if is_doubles { "doubles" } else { "singles" }; // UNIFIED RATING: Always use singles_rating as the unified rating column
let rating_col = "singles";
let mut team1_players = vec![match_data.team1_player1]; let mut team1_players = vec![match_data.team1_player1];
if is_doubles { if let Some(p2) = match_data.team1_player2 { team1_players.push(p2); } } if is_doubles { if let Some(p2) = match_data.team1_player2 { team1_players.push(p2); } }
@ -1575,10 +1575,10 @@ async fn players_list_handler(State(state): State<AppState>) -> Html<String> {
/// ///
/// **Parameters:** None /// **Parameters:** None
/// ///
/// **Returns:** HTML page with dual leaderboards /// **Returns:** HTML page with unified leaderboard
async fn leaderboard_handler(State(state): State<AppState>) -> Html<String> { async fn leaderboard_handler(State(state): State<AppState>) -> Html<String> {
// Only include players who have played at least one match // UNIFIED RATING: Single leaderboard using singles_rating as unified rating
let singles: Vec<(i64, String, f64)> = sqlx::query_as( let players: Vec<(i64, String, f64)> = sqlx::query_as(
r#"SELECT DISTINCT p.id, p.name, p.singles_rating r#"SELECT DISTINCT p.id, p.name, p.singles_rating
FROM players p FROM players p
JOIN match_participants mp ON p.id = mp.player_id JOIN match_participants mp ON p.id = mp.player_id
@ -1588,27 +1588,7 @@ async fn leaderboard_handler(State(state): State<AppState>) -> Html<String> {
.await .await
.unwrap_or_default(); .unwrap_or_default();
let doubles: Vec<(i64, String, f64)> = sqlx::query_as( let player_rows: String = players.iter().enumerate()
r#"SELECT DISTINCT p.id, p.name, p.doubles_rating
FROM players p
JOIN match_participants mp ON p.id = mp.player_id
ORDER BY p.doubles_rating DESC LIMIT 10"#
)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let singles_rows: String = singles.iter().enumerate()
.map(|(i, (id, name, rating))| {
let medal = match i { 0 => "🥇", 1 => "🥈", 2 => "🥉", _ => "" };
format!(r#"<div style="padding: 12px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between;">
<div><span style="font-size: 18px; margin-right: 8px;">{}</span><strong>{}.</strong> <a href="/players/{}">{}</a></div>
<strong>{:.0}</strong>
</div>"#, medal, i + 1, id, name, rating)
})
.collect();
let doubles_rows: String = doubles.iter().enumerate()
.map(|(i, (id, name, rating))| { .map(|(i, (id, name, rating))| {
let medal = match i { 0 => "🥇", 1 => "🥈", 2 => "🥉", _ => "" }; let medal = match i { 0 => "🥇", 1 => "🥈", 2 => "🥉", _ => "" };
format!(r#"<div style="padding: 12px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between;"> format!(r#"<div style="padding: 12px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between;">
@ -1627,9 +1607,7 @@ async fn leaderboard_handler(State(state): State<AppState>) -> Html<String> {
<title>Leaderboard - Pickleball ELO</title> <title>Leaderboard - Pickleball ELO</title>
<style> <style>
{} {}
.leaderboards {{ display: grid; grid-template-columns: 1fr 1fr; gap: 30px; }} .leaderboard {{ background: #f9f9f9; border-radius: 8px; overflow: hidden; max-width: 500px; margin: 0 auto; }}
@media (max-width: 768px) {{ .leaderboards {{ grid-template-columns: 1fr; }} }}
.leaderboard {{ background: #f9f9f9; border-radius: 8px; overflow: hidden; }}
</style> </style>
</head> </head>
<body> <body>
@ -1637,20 +1615,17 @@ async fn leaderboard_handler(State(state): State<AppState>) -> Html<String> {
<h1>🏓 Leaderboard</h1> <h1>🏓 Leaderboard</h1>
{} {}
<div class="leaderboards"> <h2 style="text-align: center;">📊 Top Players (Unified ELO)</h2>
<div>
<h2>📊 Top Singles</h2>
<div class="leaderboard">{}</div> <div class="leaderboard">{}</div>
</div>
<div> <p style="text-align: center; color: #666; margin-top: 20px; font-size: 14px;">
<h2>📊 Top Doubles</h2> Unified rating: singles and doubles matches both contribute to one rating.<br>
<div class="leaderboard">{}</div> <a href="/about" style="color: #003594;">Learn how ratings work </a>
</div> </p>
</div>
</div> </div>
</body> </body>
</html> </html>
"#, COMMON_CSS, nav_html(), singles_rows, doubles_rows); "#, COMMON_CSS, nav_html(), player_rows);
Html(html) Html(html)
} }
@ -1659,15 +1634,15 @@ async fn leaderboard_handler(State(state): State<AppState>) -> Html<String> {
/// ///
/// **Endpoint:** `GET /api/leaderboard` /// **Endpoint:** `GET /api/leaderboard`
/// ///
/// **Description:** Provides leaderboard data as JSON for external applications, including top 10 in singles and doubles with ratings and uncertainty (RD). /// **Description:** Provides unified leaderboard data as JSON for external applications.
/// ///
/// **Parameters:** None /// **Parameters:** None
/// ///
/// **Returns:** JSON object with `singles` and `doubles` arrays, each containing rank, name, rating, and RD /// **Returns:** JSON object with `players` array containing rank, name, and rating
async fn api_leaderboard_handler(State(state): State<AppState>) -> axum::Json<serde_json::Value> { async fn api_leaderboard_handler(State(state): State<AppState>) -> axum::Json<serde_json::Value> {
// Only include players who have played at least one match // UNIFIED RATING: Single leaderboard
let singles: Vec<(String, f64, f64)> = sqlx::query_as( let players: Vec<(String, f64)> = sqlx::query_as(
r#"SELECT DISTINCT p.name, p.singles_rating, p.singles_rd r#"SELECT DISTINCT p.name, p.singles_rating
FROM players p FROM players p
JOIN match_participants mp ON p.id = mp.player_id JOIN match_participants mp ON p.id = mp.player_id
ORDER BY p.singles_rating DESC LIMIT 10"# ORDER BY p.singles_rating DESC LIMIT 10"#
@ -1676,22 +1651,9 @@ async fn api_leaderboard_handler(State(state): State<AppState>) -> axum::Json<se
.await .await
.unwrap_or_default(); .unwrap_or_default();
let doubles: Vec<(String, f64, f64)> = sqlx::query_as(
r#"SELECT DISTINCT p.name, p.doubles_rating, p.doubles_rd
FROM players p
JOIN match_participants mp ON p.id = mp.player_id
ORDER BY p.doubles_rating DESC LIMIT 10"#
)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
axum::Json(serde_json::json!({ axum::Json(serde_json::json!({
"singles": singles.iter().enumerate().map(|(i, (n, r, rd))| { "players": players.iter().enumerate().map(|(i, (n, r))| {
serde_json::json!({"rank": i+1, "name": n, "rating": *r as i32, "rd": *rd as i32}) serde_json::json!({"rank": i+1, "name": n, "rating": *r as i32})
}).collect::<Vec<_>>(),
"doubles": doubles.iter().enumerate().map(|(i, (n, r, rd))| {
serde_json::json!({"rank": i+1, "name": n, "rating": *r as i32, "rd": *rd as i32})
}).collect::<Vec<_>>(), }).collect::<Vec<_>>(),
})) }))
} }