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 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

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

View File

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

View File

@ -229,6 +229,7 @@ async fn run_server() {
.route("/daily/send", post(send_daily_summary)) .route("/daily/send", post(send_daily_summary))
.route("/api/leaderboard", get(api_leaderboard_handler)) .route("/api/leaderboard", get(api_leaderboard_handler))
.route("/api/players", get(api_players_handler)) .route("/api/players", get(api_players_handler))
.route("/about", get(about_handler))
.with_state(state); .with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); 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="/matches" class="btn">📜 History</a>
<a href="/players" class="btn">👥 Players</a> <a href="/players" class="btn">👥 Players</a>
<a href="/balance" class="btn"> Balance</a> <a href="/balance" class="btn"> Balance</a>
<a href="/daily" class="btn">📧 Daily Summary</a> <a href="/daily" class="btn">📧 Daily</a>
<a href="/matches/new" class="btn btn-success">🎾 Record Match</a> <a href="/about" class="btn"> About</a>
<a href="/matches/new" class="btn btn-success">🎾 Record</a>
</div> </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 team1: Vec<_> = participants.iter().filter(|(_, t, _)| *t == 1).collect();
let team2: Vec<_> = participants.iter().filter(|(_, t, _)| *t == 2).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(" & "); // Format player names with individual rating changes
let team2_names: String = team2.iter().map(|(n, _, _)| n.as_str()).collect::<Vec<_>>().join(" & "); 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 team1_display: String = team1.iter()
let team2_change: f64 = team2.first().map(|(_, _, c)| *c).unwrap_or(0.0); .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 { let winner_badge = if t1_score > t2_score {
("<span class='badge badge-win'>W</span>", "<span class='badge badge-loss'>L</span>") ("<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>") ("<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 { "🎾" }; let type_emoji = if match_type == "doubles" { "👥" } else { "🎾" };
match_rows.push_str(&format!(r#" match_rows.push_str(&format!(r#"
<tr> <tr>
<td>{} {}</td> <td>{} {}</td>
<td>{} {} <span class="{}">({}{})</span></td> <td>{} {}</td>
<td style="text-align:center; font-weight:bold;">{} - {}</td> <td style="text-align:center; font-weight:bold;">{} - {}</td>
<td>{} {} <span class="{}">({}{})</span></td> <td>{} {}</td>
<td>{}</td> <td>{}</td>
<td> <td>
<form method="POST" action="/matches/{}/delete" style="display:inline;" <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> </form>
</td> </td>
</tr> </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, 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)); &timestamp[..16], match_id));
} }
@ -472,7 +480,7 @@ async fn player_profile_handler(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .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()))?; .ok_or((StatusCode::NOT_FOUND, "Player not found".to_string()))?;
// Get match stats // Get match stats
@ -1321,7 +1329,7 @@ async fn create_match(
let mut team2_players = vec![match_data.team2_player1]; let mut team2_players = vec![match_data.team2_player1];
if is_doubles { if let Some(p2) = match_data.team2_player2 { team2_players.push(p2); } } 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(); let calc = EloCalculator::new();
// Calculate per-point performance for team 1 // Calculate per-point performance for team 1
@ -1928,7 +1936,7 @@ async fn session_preview_handler(
}) })
.collect(); .collect();
let doubles_list: String = top_doubles.iter().enumerate() let _doubles_list: String = top_doubles.iter().enumerate()
.map(|(i, (name, rating))| { .map(|(i, (name, rating))| {
let medal = match i { 0 => "🥇", 1 => "🥈", 2 => "🥉", _ => "" }; let medal = match i { 0 => "🥇", 1 => "🥈", 2 => "🥉", _ => "" };
format!("<div>{}{}. {} - {:.0}</div>", medal, i+1, name, rating) format!("<div>{}{}. {} - {:.0}</div>", medal, i+1, name, rating)
@ -2119,7 +2127,7 @@ async fn send_session_email(
}) })
.collect(); .collect();
let doubles_html: String = top_doubles.iter().enumerate() let _doubles_html: String = top_doubles.iter().enumerate()
.map(|(i, (name, rating))| { .map(|(i, (name, rating))| {
let medal = match i { 0 => "🥇", 1 => "🥈", 2 => "🥉", _ => "" }; let medal = match i { 0 => "🥇", 1 => "🥈", 2 => "🥉", _ => "" };
format!("<tr><td>{} {}. {}</td><td style='text-align:right;'>{:.0}</td></tr>", medal, i+1, name, rating) 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(); .collect();
let doubles_list: String = top_doubles.iter().enumerate() let _doubles_list: String = top_doubles.iter().enumerate()
.map(|(i, (name, rating))| { .map(|(i, (name, rating))| {
let medal = match i { 0 => "🥇", 1 => "🥈", 2 => "🥉", _ => "" }; let medal = match i { 0 => "🥇", 1 => "🥈", 2 => "🥉", _ => "" };
format!("<div>{}{}. {} - {:.0}</div>", medal, i+1, name, rating) format!("<div>{}{}. {} - {:.0}</div>", medal, i+1, name, rating)
}) })
.collect(); .collect();
// === ELO HISTORY CHART DATA (SINGLES) === // === ELO HISTORY CHART DATA (UNIFIED) ===
let singles_history: Vec<(i64, String, String, f64)> = sqlx::query_as( 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, r#"SELECT m.id, strftime('%H:%M', datetime(m.timestamp, '-5 hours')) as time_str,
p.name, mp.rating_after p.name, mp.rating_after
FROM matches m FROM matches m
JOIN match_participants mp ON m.id = mp.match_id JOIN match_participants mp ON m.id = mp.match_id
JOIN players p ON mp.player_id = p.id JOIN players p ON mp.player_id = p.id
WHERE date(m.timestamp) = ? AND m.match_type = 'singles' WHERE date(m.timestamp) = ?
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'
ORDER BY m.timestamp, p.name"# ORDER BY m.timestamp, p.name"#
) )
.bind(&target_date) .bind(&target_date)
@ -2418,8 +2411,7 @@ async fn daily_summary_handler(
(labels, datasets) (labels, datasets)
} }
let (singles_labels, singles_datasets) = build_chart_data(&singles_history, &colors); let (elo_labels, elo_datasets) = build_chart_data(&elo_history, &colors);
let (doubles_labels, doubles_datasets) = build_chart_data(&doubles_history, &colors);
// === PARTNER SYNERGY HEATMAP DATA === // === PARTNER SYNERGY HEATMAP DATA ===
// Calculate win rate for each partner pair (all time, for doubles) // Calculate win rate for each partner pair (all time, for doubles)
@ -2577,32 +2569,17 @@ async fn daily_summary_handler(
</table> </table>
<h2>📈 ELO Journey</h2> <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;"> <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> </div>
<script> <script>
new Chart(document.getElementById('singlesChart'), {{ new Chart(document.getElementById('eloChart'), {{
type: 'line', type: 'line',
data: {{ labels: [{}], datasets: [{}] }}, data: {{ labels: [{}], datasets: [{}] }},
options: {{ options: {{
responsive: true, responsive: true,
plugins: {{ plugins: {{
title: {{ display: true, text: 'Singles 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' }} }} }}
}}
}});
new Chart(document.getElementById('doublesChart'), {{
type: 'line',
data: {{ labels: [{}], datasets: [{}] }},
options: {{
responsive: true,
plugins: {{
title: {{ display: true, text: 'Doubles Rating', font: {{ size: 14 }} }},
legend: {{ position: 'bottom', labels: {{ boxWidth: 12, font: {{ size: 10 }} }} }} legend: {{ position: 'bottom', labels: {{ boxWidth: 12, font: {{ size: 10 }} }} }}
}}, }},
scales: {{ y: {{ title: {{ display: true, text: 'Rating' }} }} }} scales: {{ y: {{ title: {{ display: true, text: 'Rating' }} }} }}
@ -2637,7 +2614,7 @@ async fn daily_summary_handler(
</body> </body>
</html> </html>
"#, COMMON_CSS, nav_html(), target_date, matches.len(), recipients.len(), "#, 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) Html(html)
} }
@ -3165,35 +3142,21 @@ async fn daily_public_handler(
}) })
.collect(); .collect();
let doubles_list: String = top_doubles.iter().enumerate() let _doubles_list: String = top_doubles.iter().enumerate()
.map(|(i, (name, rating))| { .map(|(i, (name, rating))| {
let medal = match i { 0 => "🥇", 1 => "🥈", 2 => "🥉", _ => "" }; let medal = match i { 0 => "🥇", 1 => "🥈", 2 => "🥉", _ => "" };
format!("<div>{}{}. {} - {:.0}</div>", medal, i+1, name, rating) format!("<div>{}{}. {} - {:.0}</div>", medal, i+1, name, rating)
}) })
.collect(); .collect();
// Chart data // Chart data (unified ELO - all match types)
let singles_history: Vec<(i64, String, String, f64)> = sqlx::query_as( 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, r#"SELECT m.id, strftime('%H:%M', datetime(m.timestamp, '-5 hours')) as time_str,
p.name, mp.rating_after p.name, mp.rating_after
FROM matches m FROM matches m
JOIN match_participants mp ON m.id = mp.match_id JOIN match_participants mp ON m.id = mp.match_id
JOIN players p ON mp.player_id = p.id JOIN players p ON mp.player_id = p.id
WHERE date(m.timestamp) = ? AND m.match_type = 'singles' WHERE date(m.timestamp) = ?
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'
ORDER BY m.timestamp, p.name"# ORDER BY m.timestamp, p.name"#
) )
.bind(&target_date) .bind(&target_date)
@ -3246,8 +3209,7 @@ async fn daily_public_handler(
(labels, datasets) (labels, datasets)
} }
let (singles_labels, singles_datasets) = build_chart_data_public(&singles_history, &colors); let (elo_labels, elo_datasets) = build_chart_data_public(&elo_history, &colors);
let (doubles_labels, doubles_datasets) = build_chart_data_public(&doubles_history, &colors);
// Partner synergy heatmap // Partner synergy heatmap
let synergy_data: Vec<(String, String, i64, i64)> = sqlx::query_as( let synergy_data: Vec<(String, String, i64, i64)> = sqlx::query_as(
@ -3427,35 +3389,18 @@ async fn daily_public_handler(
</table> </table>
<h2>📈 ELO Journey</h2> <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;"> <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> </div>
<script> <script>
if ([{}].length > 0) {{ if ([{}].length > 0) {{
new Chart(document.getElementById('singlesChart'), {{ new Chart(document.getElementById('eloChart'), {{
type: 'line', type: 'line',
data: {{ labels: [{}], datasets: [{}] }}, data: {{ labels: [{}], datasets: [{}] }},
options: {{ options: {{
responsive: true, responsive: true,
plugins: {{ plugins: {{
title: {{ display: true, text: 'Singles 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' }} }} }}
}}
}});
}}
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 }} }},
legend: {{ position: 'bottom', labels: {{ boxWidth: 12, font: {{ size: 10 }} }} }} legend: {{ position: 'bottom', labels: {{ boxWidth: 12, font: {{ size: 10 }} }} }}
}}, }},
scales: {{ y: {{ title: {{ display: true, text: 'Rating' }} }} }} scales: {{ y: {{ title: {{ display: true, text: 'Rating' }} }} }}
@ -3465,15 +3410,9 @@ async fn daily_public_handler(
</script> </script>
<h2>📊 Current Leaderboard</h2> <h2>📊 Current Leaderboard</h2>
<div class="leaderboards"> <div class="leaderboard" style="max-width: 400px; margin: 0 auto;">
<div class="leaderboard"> <h3>🏓 Top Players (Unified ELO)</h3>
<h3>🎾 Singles</h3> {}
{}
</div>
<div class="leaderboard">
<h3>👥 Doubles</h3>
{}
</div>
</div> </div>
<h2>🤝 Partner Synergy</h2> <h2>🤝 Partner Synergy</h2>
@ -3502,12 +3441,149 @@ async fn daily_public_handler(
player_rating_changes.len(), player_rating_changes.len(),
matches_html, matches_html,
players_html, players_html,
singles_labels, singles_labels, singles_datasets, elo_labels, elo_labels, elo_datasets,
doubles_labels, doubles_labels, doubles_datasets,
singles_list, singles_list,
doubles_list,
heatmap_html heatmap_html
); );
Html(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)
}