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:
parent
e6cc881a36
commit
666589e18c
@ -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
|
||||||
|
|
||||||
|
|||||||
BIN
pickleball-elo
BIN
pickleball-elo
Binary file not shown.
@ -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![
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
296
src/main.rs
296
src/main.rs
@ -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(" & ");
|
||||||
|
let team2_display: String = team2.iter()
|
||||||
|
.map(|(n, _, c)| format_player(n, *c))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" & ");
|
||||||
|
|
||||||
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,
|
||||||
×tamp[..16], match_id));
|
×tamp[..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,16 +3410,10 @@ 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>
|
||||||
<div class="leaderboard">
|
|
||||||
<h3>👥 Doubles</h3>
|
|
||||||
{}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>🤝 Partner Synergy</h2>
|
<h2>🤝 Partner Synergy</h2>
|
||||||
<p style="color:#666;font-size:13px;margin-bottom:10px;">Win rate when partnered together (all-time doubles)</p>
|
<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(),
|
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)
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user