Complete ELO system overhaul: unified charts, individual rating changes, about page

- Charts: Single unified ELO chart instead of separate singles/doubles
- Match history: Shows individual player rating changes (not just team)
- About page: Full explanation of rating system with examples
- Nav: Added About link, shortened button text for mobile
- Cleaned up unused variables
This commit is contained in:
Split 2026-02-26 12:33:38 -05:00
parent e6cc881a36
commit 666589e18c
5 changed files with 203 additions and 114 deletions

View File

@ -1042,3 +1042,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.

View File

@ -19,7 +19,7 @@ use std::path::Path;
pub async fn create_pool(db_path: &str) -> Result<SqlitePool, sqlx::Error> {
// Create database file if it doesn't exist
let path = Path::new(db_path);
let db_exists = path.exists();
let _db_exists = path.exists();
// Ensure parent directory exists
if let Some(parent) = path.parent() {
@ -54,7 +54,7 @@ pub async fn create_pool(db_path: &str) -> Result<SqlitePool, sqlx::Error> {
/// All tables include foreign keys and appropriate indexes for query performance.
/// Idempotent - safe to call multiple times.
pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> {
let schema = include_str!("../../migrations/001_initial_schema.sql");
let _schema = include_str!("../../migrations/001_initial_schema.sql");
// Execute each statement
let statements = vec![

View File

@ -144,7 +144,7 @@ impl Glicko2Calculator {
};
let mut fa = fa_init;
let mut fb = compute_f(b);
let fb = compute_f(b);
// Ensure proper bracket
if fa * fb >= 0.0 {

View File

@ -229,6 +229,7 @@ async fn run_server() {
.route("/daily/send", post(send_daily_summary))
.route("/api/leaderboard", get(api_leaderboard_handler))
.route("/api/players", get(api_players_handler))
.route("/about", get(about_handler))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
@ -252,8 +253,9 @@ fn nav_html() -> &'static str {
<a href="/matches" class="btn">📜 History</a>
<a href="/players" class="btn">👥 Players</a>
<a href="/balance" class="btn"> Balance</a>
<a href="/daily" class="btn">📧 Daily Summary</a>
<a href="/matches/new" class="btn btn-success">🎾 Record Match</a>
<a href="/daily" class="btn">📧 Daily</a>
<a href="/about" class="btn"> About</a>
<a href="/matches/new" class="btn btn-success">🎾 Record</a>
</div>
"#
}
@ -374,11 +376,22 @@ async fn match_history_handler(State(state): State<AppState>) -> Html<String> {
let team1: Vec<_> = participants.iter().filter(|(_, t, _)| *t == 1).collect();
let team2: Vec<_> = participants.iter().filter(|(_, t, _)| *t == 2).collect();
let team1_names: String = team1.iter().map(|(n, _, _)| n.as_str()).collect::<Vec<_>>().join(" & ");
let team2_names: String = team2.iter().map(|(n, _, _)| n.as_str()).collect::<Vec<_>>().join(" & ");
// Format player names with individual rating changes
let format_player = |name: &str, change: f64| {
let change_class = if change >= 0.0 { "rating-up" } else { "rating-down" };
let change_sign = if change >= 0.0 { "+" } else { "" };
format!("{} <span class='{}' style='font-size:0.85em;'>({}{})</span>",
name, change_class, change_sign, change as i32)
};
let team1_change: f64 = team1.first().map(|(_, _, c)| *c).unwrap_or(0.0);
let team2_change: f64 = team2.first().map(|(_, _, c)| *c).unwrap_or(0.0);
let team1_display: String = team1.iter()
.map(|(n, _, c)| format_player(n, *c))
.collect::<Vec<_>>()
.join(" &amp; ");
let team2_display: String = team2.iter()
.map(|(n, _, c)| format_player(n, *c))
.collect::<Vec<_>>()
.join(" &amp; ");
let winner_badge = if t1_score > t2_score {
("<span class='badge badge-win'>W</span>", "<span class='badge badge-loss'>L</span>")
@ -386,19 +399,14 @@ async fn match_history_handler(State(state): State<AppState>) -> Html<String> {
("<span class='badge badge-loss'>L</span>", "<span class='badge badge-win'>W</span>")
};
let change1_class = if team1_change >= 0.0 { "rating-up" } else { "rating-down" };
let change2_class = if team2_change >= 0.0 { "rating-up" } else { "rating-down" };
let change1_sign = if team1_change >= 0.0 { "+" } else { "" };
let change2_sign = if team2_change >= 0.0 { "+" } else { "" };
let type_emoji = if match_type == "doubles" { "👥" } else { "🎾" };
match_rows.push_str(&format!(r#"
<tr>
<td>{} {}</td>
<td>{} {} <span class="{}">({}{})</span></td>
<td>{} {}</td>
<td style="text-align:center; font-weight:bold;">{} - {}</td>
<td>{} {} <span class="{}">({}{})</span></td>
<td>{} {}</td>
<td>{}</td>
<td>
<form method="POST" action="/matches/{}/delete" style="display:inline;"
@ -407,9 +415,9 @@ async fn match_history_handler(State(state): State<AppState>) -> Html<String> {
</form>
</td>
</tr>
"#, type_emoji, match_type, winner_badge.0, team1_names, change1_class, change1_sign, team1_change as i32,
"#, type_emoji, match_type, winner_badge.0, team1_display,
t1_score, t2_score,
winner_badge.1, team2_names, change2_class, change2_sign, team2_change as i32,
winner_badge.1, team2_display,
&timestamp[..16], match_id));
}
@ -472,7 +480,7 @@ async fn player_profile_handler(
.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, singles_rating, _singles_rd, _doubles_rating, _doubles_rd) = player
.ok_or((StatusCode::NOT_FOUND, "Player not found".to_string()))?;
// Get match stats
@ -1321,7 +1329,7 @@ async fn create_match(
let mut team2_players = vec![match_data.team2_player1];
if is_doubles { if let Some(p2) = match_data.team2_player2 { team2_players.push(p2); } }
let team1_wins = match_data.team1_score > match_data.team2_score;
let _team1_wins = match_data.team1_score > match_data.team2_score;
let calc = EloCalculator::new();
// Calculate per-point performance for team 1
@ -1928,7 +1936,7 @@ async fn session_preview_handler(
})
.collect();
let doubles_list: String = top_doubles.iter().enumerate()
let _doubles_list: String = top_doubles.iter().enumerate()
.map(|(i, (name, rating))| {
let medal = match i { 0 => "🥇", 1 => "🥈", 2 => "🥉", _ => "" };
format!("<div>{}{}. {} - {:.0}</div>", medal, i+1, name, rating)
@ -2119,7 +2127,7 @@ async fn send_session_email(
})
.collect();
let doubles_html: String = top_doubles.iter().enumerate()
let _doubles_html: String = top_doubles.iter().enumerate()
.map(|(i, (name, rating))| {
let medal = match i { 0 => "🥇", 1 => "🥈", 2 => "🥉", _ => "" };
format!("<tr><td>{} {}. {}</td><td style='text-align:right;'>{:.0}</td></tr>", medal, i+1, name, rating)
@ -2335,36 +2343,21 @@ async fn daily_summary_handler(
})
.collect();
let doubles_list: String = top_doubles.iter().enumerate()
let _doubles_list: String = top_doubles.iter().enumerate()
.map(|(i, (name, rating))| {
let medal = match i { 0 => "🥇", 1 => "🥈", 2 => "🥉", _ => "" };
format!("<div>{}{}. {} - {:.0}</div>", medal, i+1, name, rating)
})
.collect();
// === ELO HISTORY CHART DATA (SINGLES) ===
let singles_history: Vec<(i64, String, String, f64)> = sqlx::query_as(
// === ELO HISTORY CHART DATA (UNIFIED) ===
let elo_history: Vec<(i64, String, String, f64)> = sqlx::query_as(
r#"SELECT m.id, strftime('%H:%M', datetime(m.timestamp, '-5 hours')) as time_str,
p.name, mp.rating_after
FROM matches m
JOIN match_participants mp ON m.id = mp.match_id
JOIN players p ON mp.player_id = p.id
WHERE date(m.timestamp) = ? AND m.match_type = 'singles'
ORDER BY m.timestamp, p.name"#
)
.bind(&target_date)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
// === ELO HISTORY CHART DATA (DOUBLES) ===
let doubles_history: Vec<(i64, String, String, f64)> = sqlx::query_as(
r#"SELECT m.id, strftime('%H:%M', datetime(m.timestamp, '-5 hours')) as time_str,
p.name, mp.rating_after
FROM matches m
JOIN match_participants mp ON m.id = mp.match_id
JOIN players p ON mp.player_id = p.id
WHERE date(m.timestamp) = ? AND m.match_type = 'doubles'
WHERE date(m.timestamp) = ?
ORDER BY m.timestamp, p.name"#
)
.bind(&target_date)
@ -2418,8 +2411,7 @@ async fn daily_summary_handler(
(labels, datasets)
}
let (singles_labels, singles_datasets) = build_chart_data(&singles_history, &colors);
let (doubles_labels, doubles_datasets) = build_chart_data(&doubles_history, &colors);
let (elo_labels, elo_datasets) = build_chart_data(&elo_history, &colors);
// === PARTNER SYNERGY HEATMAP DATA ===
// Calculate win rate for each partner pair (all time, for doubles)
@ -2577,32 +2569,17 @@ async fn daily_summary_handler(
</table>
<h2>📈 ELO Journey</h2>
<div style="background:#fafafa;padding:15px;border-radius:8px;margin-bottom:15px;">
<canvas id="singlesChart" height="150"></canvas>
</div>
<div style="background:#fafafa;padding:15px;border-radius:8px;margin-bottom:20px;">
<canvas id="doublesChart" height="150"></canvas>
<canvas id="eloChart" height="180"></canvas>
</div>
<script>
new Chart(document.getElementById('singlesChart'), {{
new Chart(document.getElementById('eloChart'), {{
type: 'line',
data: {{ labels: [{}], datasets: [{}] }},
options: {{
responsive: true,
plugins: {{
title: {{ display: true, text: 'Singles Rating', font: {{ size: 14 }} }},
legend: {{ position: 'bottom', labels: {{ boxWidth: 12, font: {{ size: 10 }} }} }}
}},
scales: {{ y: {{ title: {{ display: true, text: 'Rating' }} }} }}
}}
}});
new Chart(document.getElementById('doublesChart'), {{
type: 'line',
data: {{ labels: [{}], datasets: [{}] }},
options: {{
responsive: true,
plugins: {{
title: {{ display: true, text: 'Doubles Rating', font: {{ size: 14 }} }},
title: {{ display: true, text: 'Unified ELO Rating', font: {{ size: 14 }} }},
legend: {{ position: 'bottom', labels: {{ boxWidth: 12, font: {{ size: 10 }} }} }}
}},
scales: {{ y: {{ title: {{ display: true, text: 'Rating' }} }} }}
@ -2637,7 +2614,7 @@ async fn daily_summary_handler(
</body>
</html>
"#, COMMON_CSS, nav_html(), target_date, matches.len(), recipients.len(),
matches_html, players_html, singles_labels, singles_datasets, doubles_labels, doubles_datasets, heatmap_html, recipients_html, target_date, matches_html, singles_list, send_button);
matches_html, players_html, elo_labels, elo_datasets, heatmap_html, recipients_html, target_date, matches_html, singles_list, send_button);
Html(html)
}
@ -3165,35 +3142,21 @@ async fn daily_public_handler(
})
.collect();
let doubles_list: String = top_doubles.iter().enumerate()
let _doubles_list: String = top_doubles.iter().enumerate()
.map(|(i, (name, rating))| {
let medal = match i { 0 => "🥇", 1 => "🥈", 2 => "🥉", _ => "" };
format!("<div>{}{}. {} - {:.0}</div>", medal, i+1, name, rating)
})
.collect();
// Chart data
let singles_history: Vec<(i64, String, String, f64)> = sqlx::query_as(
// Chart data (unified ELO - all match types)
let elo_history: Vec<(i64, String, String, f64)> = sqlx::query_as(
r#"SELECT m.id, strftime('%H:%M', datetime(m.timestamp, '-5 hours')) as time_str,
p.name, mp.rating_after
FROM matches m
JOIN match_participants mp ON m.id = mp.match_id
JOIN players p ON mp.player_id = p.id
WHERE date(m.timestamp) = ? AND m.match_type = 'singles'
ORDER BY m.timestamp, p.name"#
)
.bind(&target_date)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let doubles_history: Vec<(i64, String, String, f64)> = sqlx::query_as(
r#"SELECT m.id, strftime('%H:%M', datetime(m.timestamp, '-5 hours')) as time_str,
p.name, mp.rating_after
FROM matches m
JOIN match_participants mp ON m.id = mp.match_id
JOIN players p ON mp.player_id = p.id
WHERE date(m.timestamp) = ? AND m.match_type = 'doubles'
WHERE date(m.timestamp) = ?
ORDER BY m.timestamp, p.name"#
)
.bind(&target_date)
@ -3246,8 +3209,7 @@ async fn daily_public_handler(
(labels, datasets)
}
let (singles_labels, singles_datasets) = build_chart_data_public(&singles_history, &colors);
let (doubles_labels, doubles_datasets) = build_chart_data_public(&doubles_history, &colors);
let (elo_labels, elo_datasets) = build_chart_data_public(&elo_history, &colors);
// Partner synergy heatmap
let synergy_data: Vec<(String, String, i64, i64)> = sqlx::query_as(
@ -3427,35 +3389,18 @@ async fn daily_public_handler(
</table>
<h2>📈 ELO Journey</h2>
<div style="background:#fafafa;padding:15px;border-radius:8px;margin-bottom:15px;">
<canvas id="singlesChart" height="150"></canvas>
</div>
<div style="background:#fafafa;padding:15px;border-radius:8px;margin-bottom:20px;">
<canvas id="doublesChart" height="150"></canvas>
<canvas id="eloChart" height="180"></canvas>
</div>
<script>
if ([{}].length > 0) {{
new Chart(document.getElementById('singlesChart'), {{
new Chart(document.getElementById('eloChart'), {{
type: 'line',
data: {{ labels: [{}], datasets: [{}] }},
options: {{
responsive: true,
plugins: {{
title: {{ display: true, text: 'Singles Rating', font: {{ size: 14 }} }},
legend: {{ position: 'bottom', labels: {{ boxWidth: 12, font: {{ size: 10 }} }} }}
}},
scales: {{ y: {{ title: {{ display: true, text: 'Rating' }} }} }}
}}
}});
}}
if ([{}].length > 0) {{
new Chart(document.getElementById('doublesChart'), {{
type: 'line',
data: {{ labels: [{}], datasets: [{}] }},
options: {{
responsive: true,
plugins: {{
title: {{ display: true, text: 'Doubles Rating', font: {{ size: 14 }} }},
title: {{ display: true, text: 'Unified ELO Rating', font: {{ size: 14 }} }},
legend: {{ position: 'bottom', labels: {{ boxWidth: 12, font: {{ size: 10 }} }} }}
}},
scales: {{ y: {{ title: {{ display: true, text: 'Rating' }} }} }}
@ -3465,16 +3410,10 @@ async fn daily_public_handler(
</script>
<h2>📊 Current Leaderboard</h2>
<div class="leaderboards">
<div class="leaderboard">
<h3>🎾 Singles</h3>
<div class="leaderboard" style="max-width: 400px; margin: 0 auto;">
<h3>🏓 Top Players (Unified ELO)</h3>
{}
</div>
<div class="leaderboard">
<h3>👥 Doubles</h3>
{}
</div>
</div>
<h2>🤝 Partner Synergy</h2>
<p style="color:#666;font-size:13px;margin-bottom:10px;">Win rate when partnered together (all-time doubles)</p>
@ -3502,12 +3441,149 @@ async fn daily_public_handler(
player_rating_changes.len(),
matches_html,
players_html,
singles_labels, singles_labels, singles_datasets,
doubles_labels, doubles_labels, doubles_datasets,
elo_labels, elo_labels, elo_datasets,
singles_list,
doubles_list,
heatmap_html
);
Html(html)
}
/// About page explaining the ELO rating system
///
/// **Endpoint:** `GET /about`
async fn about_handler() -> Html<String> {
let html = format!(r#"
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>About - Pickleball ELO</title>
<style>
{}
.content {{ max-width: 800px; margin: 0 auto; line-height: 1.8; }}
.formula {{ background: #f5f5f5; padding: 15px 20px; border-radius: 8px; margin: 20px 0; font-family: monospace; overflow-x: auto; }}
.example {{ background: #e8f4f8; padding: 20px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #003594; }}
.section {{ margin-bottom: 40px; }}
h2 {{ color: #003594; border-bottom: 2px solid #FFB81C; padding-bottom: 8px; }}
h3 {{ color: #333; margin-top: 25px; }}
ul {{ margin-left: 20px; }}
li {{ margin-bottom: 8px; }}
code {{ background: #e9e9e9; padding: 2px 6px; border-radius: 4px; font-family: monospace; }}
</style>
</head>
<body>
<div class="container">
{}
<div class="content">
<h1>📊 How the Rating System Works</h1>
<p style="color: #666; font-size: 1.1em;">A unified ELO system designed for recreational pickleball</p>
<div class="section">
<h2>🎯 The Basics</h2>
<p><strong>One rating for everything.</strong> Whether you play singles or doubles, all matches contribute to a single ELO rating. Everyone starts at <strong>1500</strong>.</p>
<p>The system rewards skill and adapts to your performance. Beat higher-rated players? Big gains. Lose to lower-rated players? Bigger losses. It's that simple.</p>
</div>
<div class="section">
<h2>📈 Per-Point Scoring</h2>
<p>Unlike traditional ELO that only cares about winning or losing, our system considers <em>how</em> you played:</p>
<div class="formula">
Performance = Points Won ÷ Total Points
</div>
<p>This means:</p>
<ul>
<li>Winning <strong>11-2</strong> earns more than winning <strong>11-9</strong></li>
<li>Losing <strong>9-11</strong> costs less than losing <strong>2-11</strong></li>
<li>Close games = smaller rating swings for everyone</li>
</ul>
<div class="example">
<strong>Example:</strong> You win 11-7 against someone equally rated.<br>
Performance = 11 ÷ 18 = <strong>0.611</strong><br>
Expected (equal ratings) = <strong>0.500</strong><br>
You outperformed expectations rating goes up!
</div>
</div>
<div class="section">
<h2>👥 The Doubles Problem</h2>
<p>In doubles, you have a partner. If your partner is much stronger than you, you'll probably win more but how much credit should <em>you</em> get?</p>
<p>We solve this with the <strong>Effective Opponent</strong> formula:</p>
<div class="formula">
Effective Opponent = Opponent 1 + Opponent 2 Teammate
</div>
<p>This creates a personalized opponent rating for each player. Here's how it works:</p>
<ul>
<li><strong>Strong teammate</strong> Lower effective opponent Less credit for winning, less blame for losing</li>
<li><strong>Weak teammate</strong> Higher effective opponent More credit for winning, more blame for losing</li>
</ul>
<div class="example">
<strong>Example:</strong> You're 1500 playing with a 1600 partner against two 1550 opponents.<br>
Your effective opponent = 1550 + 1550 1600 = <strong>1500</strong><br>
Your partner's effective opponent = 1550 + 1550 1500 = <strong>1600</strong><br><br>
If you win, you gain less than your partner because their effective opponent was harder!
</div>
</div>
<div class="section">
<h2>🔢 The Math</h2>
<p>For those who want the details:</p>
<h3>Expected Performance</h3>
<div class="formula">
Expected = 1 / (1 + 10^((Opponent You) / 400))
</div>
<p>This is the standard ELO expectation formula. If you're 200 points above your opponent, you're expected to score about 76% of points.</p>
<h3>Rating Change</h3>
<div class="formula">
Δ Rating = K × (Actual Expected)
</div>
<p>We use <strong>K = 32</strong>, which is standard for casual/club play. This means:</p>
<ul>
<li>Maximum gain/loss per match: ±32 points</li>
<li>Typical swing for competitive matches: ±10-15 points</li>
<li>Close match against equal opponent: ±2-5 points</li>
</ul>
</div>
<div class="section">
<h2> Why This System?</h2>
<p>We tried Glicko-2 first (with rating deviation and volatility), but it was:</p>
<ul>
<li><strong>Confusing</strong> nobody understood what "RD 150" meant</li>
<li><strong>Opaque</strong> the math was hidden behind complexity</li>
<li><strong>Overkill</strong> designed for chess with thousands of games, not rec pickleball</li>
</ul>
<p>Pure ELO with per-point scoring is:</p>
<ul>
<li><strong>Transparent</strong> you can calculate changes by hand</li>
<li><strong>Fair</strong> accounts for margin of victory and partner strength</li>
<li><strong>Simple</strong> one number that goes up when you play well</li>
</ul>
</div>
<div class="section">
<h2>🏆 Rating Tiers</h2>
<table style="width: 100%; max-width: 400px;">
<tr><td><strong>Below 1400</strong></td><td>Developing</td></tr>
<tr><td><strong>1400-1500</strong></td><td>Intermediate</td></tr>
<tr><td><strong>1500-1600</strong></td><td>Solid</td></tr>
<tr><td><strong>1600-1700</strong></td><td>Strong</td></tr>
<tr><td><strong>1700+</strong></td><td> Rising Star</td></tr>
<tr><td><strong>1900+</strong></td><td>👑 Elite</td></tr>
</table>
</div>
<p style="text-align: center; margin-top: 40px; color: #666;">
<a href="/" class="btn"> Back to Home</a>
</p>
</div>
</div>
</body>
</html>
"#, COMMON_CSS, nav_html());
Html(html)
}