Unified ELO: single rating column, recalculated all matches

- Added 'rating' column to players table
- Renamed old columns to _deprecated_*
- Created recalculate_ratings.rs tool to replay all matches
- Updated all queries and structs to use unified rating
- Match form now shows single rating per player
- API returns single rating field

Final ratings after recalculation:
- Andrew: 1538
- David: 1522
- Jacklyn: 1515
- Eliana: 1497
- Krzysztof: 1476
- Dane: 1449
This commit is contained in:
Split 2026-02-26 13:21:26 -05:00
parent a1f96b9af4
commit d605000c28
6 changed files with 263 additions and 55 deletions

View File

@ -1094,3 +1094,16 @@ Starting Pickleball ELO Tracker Server on port 3000...
Add Player: http://localhost:3000/players/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.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,202 @@
//! Recalculate all ratings from scratch using unified ELO
//!
//! This script maintains all state in memory, then writes to DB at the end.
use sqlx::sqlite::SqlitePoolOptions;
use std::collections::HashMap;
const K_FACTOR: f64 = 32.0;
const STARTING_RATING: f64 = 1500.0;
fn expected_score(player_rating: f64, opponent_rating: f64) -> f64 {
1.0 / (1.0 + 10.0_f64.powf((opponent_rating - player_rating) / 400.0))
}
fn calculate_performance(points_won: i32, points_lost: i32) -> f64 {
let total = (points_won + points_lost) as f64;
if total == 0.0 { return 0.5; }
points_won as f64 / total
}
fn calculate_new_rating(current: f64, opponent: f64, performance: f64) -> f64 {
let expected = expected_score(current, opponent);
let change = K_FACTOR * (performance - expected);
(current + change).max(1.0)
}
fn effective_opponent(opp1: f64, opp2: f64, teammate: f64) -> f64 {
opp1 + opp2 - teammate
}
#[derive(Debug)]
struct MatchUpdate {
match_id: i64,
player_id: i64,
rating_before: f64,
rating_after: f64,
rating_change: f64,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🔄 Recalculating all ratings with unified ELO...\n");
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite:pickleball.db")
.await?;
// Step 1: Get all players and initialize ratings in memory
println!("Step 1: Loading players");
let players: Vec<(i64, String)> = sqlx::query_as("SELECT id, name FROM players")
.fetch_all(&pool)
.await?;
let mut ratings: HashMap<i64, f64> = HashMap::new();
for (id, name) in &players {
ratings.insert(*id, STARTING_RATING);
println!(" {} (id={}) -> {}", name, id, STARTING_RATING);
}
// Step 2: Get all matches in chronological order
println!("\nStep 2: Loading matches");
let matches: Vec<(i64, String, i32, i32)> = sqlx::query_as(
"SELECT id, match_type, team1_score, team2_score FROM matches ORDER BY timestamp ASC"
)
.fetch_all(&pool)
.await?;
println!(" Found {} matches\n", matches.len());
// Step 3: Get all participants for all matches
let all_participants: Vec<(i64, i64, i32)> = sqlx::query_as(
"SELECT match_id, player_id, team FROM match_participants ORDER BY match_id, team"
)
.fetch_all(&pool)
.await?;
// Group participants by match
let mut match_participants: HashMap<i64, Vec<(i64, i32)>> = HashMap::new();
for (match_id, player_id, team) in all_participants {
match_participants.entry(match_id).or_insert_with(Vec::new).push((player_id, team));
}
// Step 4: Process each match
println!("Step 3: Processing matches...");
let mut all_updates: Vec<MatchUpdate> = Vec::new();
for (match_id, match_type, team1_score, team2_score) in &matches {
let participants = match match_participants.get(match_id) {
Some(p) => p,
None => {
println!(" ⚠️ Match {} has no participants", match_id);
continue;
}
};
let team1: Vec<i64> = participants.iter().filter(|(_, t)| *t == 1).map(|(id, _)| *id).collect();
let team2: Vec<i64> = participants.iter().filter(|(_, t)| *t == 2).map(|(id, _)| *id).collect();
if team1.is_empty() || team2.is_empty() {
println!(" ⚠️ Match {} missing team members", match_id);
continue;
}
let team1_perf = calculate_performance(*team1_score, *team2_score);
let team2_perf = 1.0 - team1_perf;
let is_doubles = match_type == "doubles" && team1.len() >= 2 && team2.len() >= 2;
// Calculate new ratings for team 1
let mut team1_new_ratings: Vec<(i64, f64, f64, f64)> = Vec::new();
for &player_id in &team1 {
let current = ratings[&player_id];
let opp_rating = if is_doubles && team1.len() == 2 && team2.len() == 2 {
if let Some(&teammate_id) = team1.iter().find(|&&id| id != player_id) {
effective_opponent(ratings[&team2[0]], ratings[&team2[1]], ratings[&teammate_id])
} else {
team2.iter().map(|id| ratings[id]).sum::<f64>() / team2.len() as f64
}
} else {
// Singles or incomplete doubles: just average opponent ratings
team2.iter().map(|id| ratings[id]).sum::<f64>() / team2.len() as f64
};
let new_rating = calculate_new_rating(current, opp_rating, team1_perf);
team1_new_ratings.push((player_id, current, new_rating, new_rating - current));
}
// Calculate new ratings for team 2
let mut team2_new_ratings: Vec<(i64, f64, f64, f64)> = Vec::new();
for &player_id in &team2 {
let current = ratings[&player_id];
let opp_rating = if is_doubles && team1.len() == 2 && team2.len() == 2 {
if let Some(&teammate_id) = team2.iter().find(|&&id| id != player_id) {
effective_opponent(ratings[&team1[0]], ratings[&team1[1]], ratings[&teammate_id])
} else {
team1.iter().map(|id| ratings[id]).sum::<f64>() / team1.len() as f64
}
} else {
// Singles or incomplete doubles: just average opponent ratings
team1.iter().map(|id| ratings[id]).sum::<f64>() / team1.len() as f64
};
let new_rating = calculate_new_rating(current, opp_rating, team2_perf);
team2_new_ratings.push((player_id, current, new_rating, new_rating - current));
}
// Apply updates to our in-memory ratings
for (player_id, before, after, change) in team1_new_ratings.iter().chain(team2_new_ratings.iter()) {
ratings.insert(*player_id, *after);
all_updates.push(MatchUpdate {
match_id: *match_id,
player_id: *player_id,
rating_before: *before,
rating_after: *after,
rating_change: *change,
});
}
print!(".");
}
// Step 5: Write all updates to database
println!("\n\nStep 4: Writing {} updates to database...", all_updates.len());
// Update players table
for (player_id, rating) in &ratings {
sqlx::query("UPDATE players SET rating = ? WHERE id = ?")
.bind(rating)
.bind(player_id)
.execute(&pool)
.await?;
}
// Update match_participants table
for update in &all_updates {
sqlx::query(
"UPDATE match_participants SET rating_before = ?, rating_after = ?, rating_change = ? WHERE match_id = ? AND player_id = ?"
)
.bind(update.rating_before)
.bind(update.rating_after)
.bind(update.rating_change)
.bind(update.match_id)
.bind(update.player_id)
.execute(&pool)
.await?;
}
// Step 6: Display final ratings
println!("\n✅ Final ratings:");
let mut sorted: Vec<_> = ratings.iter().collect();
sorted.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap());
let player_names: HashMap<i64, String> = players.into_iter().collect();
println!("\n{:<25} {:>10}", "Player", "Rating");
println!("{}", "-".repeat(37));
for (id, rating) in sorted {
let name = player_names.get(id).map(|s| s.as_str()).unwrap_or("Unknown");
println!("{:<25} {:>10.0}", name, rating);
}
println!("\n🎉 Recalculation complete!");
Ok(())
}

View File

@ -30,8 +30,7 @@ struct EditPlayer {
name: String,
#[serde(default, deserialize_with = "empty_string_as_none_string")]
email: Option<String>,
singles_rating: f64,
doubles_rating: f64,
rating: f64,
}
fn empty_string_as_none<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
@ -83,8 +82,7 @@ struct BalanceQuery {
struct PlayerJson {
id: i64,
name: String,
singles_rating: f64,
doubles_rating: f64,
rating: f64,
}
// Common CSS used across pages - Pitt colors (Blue #003594, Gold #FFB81C)
@ -201,7 +199,6 @@ struct PlayerData {
id: i64,
name: String,
rating: f64,
singles_rating: f64,
email: String,
rating_display: String,
has_email: bool,
@ -511,15 +508,15 @@ async fn player_profile_handler(
Path(player_id): Path<i64>,
) -> Result<Html<String>, (StatusCode, String)> {
// Get player info
let player: Option<(i64, String, Option<String>, f64, f64, f64, f64)> = sqlx::query_as(
"SELECT id, name, email, singles_rating, singles_rd, doubles_rating, doubles_rd FROM players WHERE id = ?"
let player: Option<(i64, String, Option<String>, f64)> = sqlx::query_as(
"SELECT id, name, email, rating FROM players WHERE id = ?"
)
.bind(player_id)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let (_id, name, email, singles_rating, _singles_rd, _doubles_rating, _doubles_rd) = player
let (_id, name, email, rating) = player
.ok_or((StatusCode::NOT_FOUND, "Player not found".to_string()))?;
// Get match stats
@ -611,8 +608,8 @@ async fn player_profile_handler(
if wins >= 1 { achievements.push("✨ First Win"); }
if wins >= 10 { achievements.push("🔥 10 Wins"); }
if win_rate >= 60 && total_matches >= 5 { achievements.push("💪 60% Win Rate"); }
if singles_rating >= 1700.0 { achievements.push("⭐ Rising Star (1700+)"); }
if singles_rating >= 1900.0 { achievements.push("👑 Elite (1900+)"); }
if rating >= 1700.0 { achievements.push("⭐ Rising Star (1700+)"); }
if rating >= 1900.0 { achievements.push("👑 Elite (1900+)"); }
let achievements_html: String = achievements.iter()
.map(|a| format!(r#"<span class="achievement">{}</span>"#, a))
@ -794,7 +791,7 @@ async fn player_profile_handler(
</html>
"#, name, COMMON_CSS, nav_html(), name,
email.as_deref().unwrap_or("No email"), player_id,
singles_rating, total_matches, wins, losses, win_rate,
rating, total_matches, wins, losses, win_rate,
achievements_html, h2h_rows, partners_rows, recent_rows, chart_data, chart_data);
Ok(Html(html))
@ -814,7 +811,7 @@ async fn team_balancer_handler(
Query(params): Query<BalanceQuery>,
) -> Html<String> {
let players: Vec<(i64, String, f64)> = sqlx::query_as(
"SELECT id, name, singles_rating FROM players ORDER BY name"
"SELECT id, name, rating FROM players ORDER BY name"
)
.fetch_all(&state.pool)
.await
@ -983,9 +980,9 @@ async fn delete_match(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Revert each player's rating (UNIFIED: always use singles_rating)
// Revert each player's rating (UNIFIED: always use rating)
for (player_id, _match_type, rating_before, _rating_after) in &participants {
sqlx::query("UPDATE players SET singles_rating = ? WHERE id = ?")
sqlx::query("UPDATE players SET rating = ? WHERE id = ?")
.bind(rating_before)
.bind(player_id)
.execute(&state.pool)
@ -1096,15 +1093,15 @@ async fn edit_player_form(
State(state): State<AppState>,
Path(player_id): Path<i64>,
) -> Result<Html<String>, (StatusCode, String)> {
let player: Option<(i64, String, Option<String>, f64, f64)> = sqlx::query_as(
"SELECT id, name, email, singles_rating, doubles_rating FROM players WHERE id = ?"
let player: Option<(i64, String, Option<String>, f64)> = sqlx::query_as(
"SELECT id, name, email, rating FROM players WHERE id = ?"
)
.bind(player_id)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let (id, name, email, singles_rating, doubles_rating) = player
let (id, name, email, rating) = player
.ok_or((StatusCode::NOT_FOUND, "Player not found".to_string()))?;
let email_value = email.unwrap_or_default();
@ -1138,9 +1135,8 @@ async fn edit_player_form(
<div style="margin-bottom: 20px;">
<label style="display: block; font-weight: bold; margin-bottom: 8px;">ELO Rating</label>
<input type="number" name="singles_rating" step="0.1" value="{:.1}"
<input type="number" name="rating" step="0.1" value="{:.1}"
style="width: 100%; padding: 12px; border: 2px solid #ddd; border-radius: 8px; box-sizing: border-box;">
<input type="hidden" name="doubles_rating" value="{:.1}">
<p style="color: #666; font-size: 12px; margin-top: 5px;">Unified rating for all match types</p>
</div>
@ -1151,7 +1147,7 @@ async fn edit_player_form(
</div>
</body>
</html>
"#, name, COMMON_CSS, nav_html(), id, name, email_value, singles_rating, doubles_rating, id);
"#, name, COMMON_CSS, nav_html(), id, name, email_value, rating, id);
Ok(Html(html))
}
@ -1167,8 +1163,7 @@ async fn edit_player_form(
/// **Form Fields:**
/// - `name` (required): Updated player name
/// - `email` (optional): Updated email address
/// - `singles_rating` (required): Updated singles rating value
/// - `doubles_rating` (required): Updated doubles rating value
/// - `rating` (required): Updated unified ELO rating value
///
/// **Returns:** Redirect to player profile on success, or error response on failure
async fn update_player(
@ -1179,12 +1174,12 @@ async fn update_player(
let email = player.email.filter(|e| !e.is_empty());
sqlx::query(
"UPDATE players SET name = ?, email = ?, singles_rating = ?, doubles_rating = ? WHERE id = ?"
"UPDATE players SET name = ?, email = ?, rating = ? WHERE id = ?"
)
.bind(&player.name)
.bind(&email)
.bind(player.singles_rating)
.bind(player.doubles_rating)
.bind(player.rating)
.bind(player_id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)))?;
@ -1202,16 +1197,16 @@ async fn update_player(
///
/// **Returns:** HTML form page with player dropdown lists
async fn new_match_form(State(state): State<AppState>) -> Html<String> {
let players: Vec<(i64, String, f64, f64)> = sqlx::query_as(
"SELECT id, name, singles_rating, doubles_rating FROM players ORDER BY name"
let players: Vec<(i64, String, f64)> = sqlx::query_as(
"SELECT id, name, rating FROM players ORDER BY name"
)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let player_options: String = players.iter()
.map(|(id, name, sr, dr)| {
format!(r#"<option value="{}">{} (S:{:.0} / D:{:.0})</option>"#, id, name, sr, dr)
.map(|(id, name, rating)| {
format!(r#"<option value="{}">{} ({:.0})</option>"#, id, name, rating)
})
.collect();
@ -1359,7 +1354,7 @@ async fn create_match(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create match: {}", e)))?;
let is_doubles = match_data.match_type == "doubles";
// UNIFIED RATING: Always use singles_rating as the unified rating column
// UNIFIED RATING: Always use rating as the unified rating column
let rating_col = "singles";
let mut team1_players = vec![match_data.team1_player1];
@ -1609,9 +1604,8 @@ async fn players_list_handler(State(state): State<AppState>) -> impl IntoRespons
id,
name,
rating,
singles_rating: rating,
email: email.unwrap_or_default(),
rating_display: format!("{:.1}", rating),
rating_display: format!("{:.0}", rating),
has_email,
wins,
losses,
@ -1661,9 +1655,8 @@ async fn leaderboard_handler(State(state): State<AppState>) -> impl IntoResponse
id,
name,
rating,
singles_rating: rating,
email: email.unwrap_or_default(),
rating_display: format!("{:.1}", rating),
rating_display: format!("{:.0}", rating),
has_email,
wins,
losses,
@ -1687,10 +1680,10 @@ async fn leaderboard_handler(State(state): State<AppState>) -> impl IntoResponse
async fn api_leaderboard_handler(State(state): State<AppState>) -> axum::Json<serde_json::Value> {
// UNIFIED RATING: Single leaderboard
let players: Vec<(String, f64)> = sqlx::query_as(
r#"SELECT DISTINCT p.name, p.singles_rating
r#"SELECT DISTINCT p.name, p.rating
FROM players p
JOIN match_participants mp ON p.id = mp.player_id
ORDER BY p.singles_rating DESC LIMIT 10"#
ORDER BY p.rating DESC LIMIT 10"#
)
.fetch_all(&state.pool)
.await
@ -1711,17 +1704,17 @@ async fn api_leaderboard_handler(State(state): State<AppState>) -> axum::Json<se
///
/// **Parameters:** None
///
/// **Returns:** JSON array of player objects with id, name, singles_rating, and doubles_rating
/// **Returns:** JSON array of player objects with id, name, and rating
async fn api_players_handler(State(state): State<AppState>) -> axum::Json<Vec<PlayerJson>> {
let players: Vec<(i64, String, f64, f64)> = sqlx::query_as(
"SELECT id, name, singles_rating, doubles_rating FROM players ORDER BY name"
let players: Vec<(i64, String, f64)> = sqlx::query_as(
"SELECT id, name, rating FROM players ORDER BY name"
)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
axum::Json(players.into_iter().map(|(id, name, sr, dr)| PlayerJson {
id, name, singles_rating: sr, doubles_rating: dr,
axum::Json(players.into_iter().map(|(id, name, rating)| PlayerJson {
id, name, rating,
}).collect())
}
@ -1928,14 +1921,14 @@ async fn session_preview_handler(
// Get top players for this session
let top_singles: Vec<(String, f64)> = sqlx::query_as(
"SELECT name, singles_rating FROM players ORDER BY singles_rating DESC LIMIT 5"
"SELECT name, rating FROM players ORDER BY rating DESC LIMIT 5"
)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let top_doubles: Vec<(String, f64)> = sqlx::query_as(
"SELECT name, singles_rating FROM players ORDER BY singles_rating DESC LIMIT 5"
"SELECT name, rating FROM players ORDER BY rating DESC LIMIT 5"
)
.fetch_all(&state.pool)
.await
@ -2128,14 +2121,14 @@ async fn send_session_email(
// Get leaderboard data for email
let top_singles: Vec<(String, f64)> = sqlx::query_as(
"SELECT name, singles_rating FROM players ORDER BY singles_rating DESC LIMIT 5"
"SELECT name, rating FROM players ORDER BY rating DESC LIMIT 5"
)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let top_doubles: Vec<(String, f64)> = sqlx::query_as(
"SELECT name, singles_rating FROM players ORDER BY singles_rating DESC LIMIT 5"
"SELECT name, rating FROM players ORDER BY rating DESC LIMIT 5"
)
.fetch_all(&state.pool)
.await
@ -2339,20 +2332,20 @@ async fn daily_summary_handler(
// Get top 5 for leaderboard preview
let top_singles: Vec<(String, f64)> = sqlx::query_as(
r#"SELECT DISTINCT p.name, p.singles_rating
r#"SELECT DISTINCT p.name, p.rating
FROM players p
JOIN match_participants mp ON p.id = mp.player_id
ORDER BY p.singles_rating DESC LIMIT 5"#
ORDER BY p.rating DESC LIMIT 5"#
)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let top_doubles: Vec<(String, f64)> = sqlx::query_as(
r#"SELECT DISTINCT p.name, p.singles_rating
r#"SELECT DISTINCT p.name, p.rating
FROM players p
JOIN match_participants mp ON p.id = mp.player_id
ORDER BY p.singles_rating DESC LIMIT 5"#
ORDER BY p.rating DESC LIMIT 5"#
)
.fetch_all(&state.pool)
.await
@ -2827,10 +2820,10 @@ async fn send_daily_summary(
// Get unified leaderboard data
let top_players: Vec<(String, f64)> = sqlx::query_as(
r#"SELECT DISTINCT p.name, p.singles_rating
r#"SELECT DISTINCT p.name, p.rating
FROM players p
JOIN match_participants mp ON p.id = mp.player_id
ORDER BY p.singles_rating DESC LIMIT 5"#
ORDER BY p.rating DESC LIMIT 5"#
)
.fetch_all(&state.pool)
.await
@ -3096,20 +3089,20 @@ async fn daily_public_handler(
// Leaderboards
let top_singles: Vec<(String, f64)> = sqlx::query_as(
r#"SELECT DISTINCT p.name, p.singles_rating
r#"SELECT DISTINCT p.name, p.rating
FROM players p
JOIN match_participants mp ON p.id = mp.player_id
ORDER BY p.singles_rating DESC LIMIT 5"#
ORDER BY p.rating DESC LIMIT 5"#
)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let top_doubles: Vec<(String, f64)> = sqlx::query_as(
r#"SELECT DISTINCT p.name, p.singles_rating
r#"SELECT DISTINCT p.name, p.rating
FROM players p
JOIN match_participants mp ON p.id = mp.player_id
ORDER BY p.singles_rating DESC LIMIT 5"#
ORDER BY p.rating DESC LIMIT 5"#
)
.fetch_all(&state.pool)
.await