diff --git a/logs/pickleball.log b/logs/pickleball.log index 1fff33d..ad1980b 100644 --- a/logs/pickleball.log +++ b/logs/pickleball.log @@ -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 + diff --git a/pickleball-elo b/pickleball-elo index 9100563..6879c3b 100755 Binary files a/pickleball-elo and b/pickleball-elo differ diff --git a/pickleball.db b/pickleball.db index ef61a38..a08b650 100644 Binary files a/pickleball.db and b/pickleball.db differ diff --git a/pickleball.db.backup-pre-unified-20260226-131223 b/pickleball.db.backup-pre-unified-20260226-131223 new file mode 100644 index 0000000..ef61a38 Binary files /dev/null and b/pickleball.db.backup-pre-unified-20260226-131223 differ diff --git a/src/bin/recalculate_ratings.rs b/src/bin/recalculate_ratings.rs new file mode 100644 index 0000000..f8ce459 --- /dev/null +++ b/src/bin/recalculate_ratings.rs @@ -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> { + 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 = 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> = 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 = 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 = participants.iter().filter(|(_, t)| *t == 1).map(|(id, _)| *id).collect(); + let team2: Vec = 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::() / team2.len() as f64 + } + } else { + // Singles or incomplete doubles: just average opponent ratings + team2.iter().map(|id| ratings[id]).sum::() / 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::() / team1.len() as f64 + } + } else { + // Singles or incomplete doubles: just average opponent ratings + team1.iter().map(|id| ratings[id]).sum::() / 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 = 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(()) +} diff --git a/src/main.rs b/src/main.rs index 8a6ef77..585becf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,8 +30,7 @@ struct EditPlayer { name: String, #[serde(default, deserialize_with = "empty_string_as_none_string")] email: Option, - singles_rating: f64, - doubles_rating: f64, + rating: f64, } fn empty_string_as_none<'de, D>(deserializer: D) -> Result, 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, ) -> Result, (StatusCode, String)> { // Get player info - let player: Option<(i64, String, Option, 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, 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#"{}"#, a)) @@ -794,7 +791,7 @@ async fn player_profile_handler( "#, 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, ) -> Html { 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, Path(player_id): Path, ) -> Result, (StatusCode, String)> { - let player: Option<(i64, String, Option, f64, f64)> = sqlx::query_as( - "SELECT id, name, email, singles_rating, doubles_rating FROM players WHERE id = ?" + let player: Option<(i64, String, Option, 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(
- -

Unified rating for all match types

@@ -1151,7 +1147,7 @@ async fn edit_player_form( - "#, 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) -> Html { - 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#""#, id, name, sr, dr) + .map(|(id, name, rating)| { + format!(r#""#, 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) -> 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) -> 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) -> impl IntoResponse async fn api_leaderboard_handler(State(state): State) -> axum::Json { // 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) -> axum::Json) -> axum::Json> { - 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