Rebuild frontend with HTMX + Tailwind + Askama templates
This commit is contained in:
parent
534d293be4
commit
1b74470fcb
427
src/main.rs
427
src/main.rs
@ -1,10 +1,11 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
response::{Html, Redirect},
|
response::{Html, Redirect, IntoResponse},
|
||||||
extract::{Form, State, Path, Query},
|
extract::{Form, State, Path, Query},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
|
use askama::Template;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use pickleball_elo::db;
|
use pickleball_elo::db;
|
||||||
use pickleball_elo::elo::{EloRating, EloCalculator, calculate_weighted_score, calculate_effective_opponent_rating};
|
use pickleball_elo::elo::{EloRating, EloCalculator, calculate_weighted_score, calculate_effective_opponent_rating};
|
||||||
@ -179,6 +180,111 @@ const COMMON_CSS: &str = r#"
|
|||||||
.rating-down { color: #dc3545; }
|
.rating-down { color: #dc3545; }
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
// ========== ASKAMA TEMPLATE STRUCTS ==========
|
||||||
|
|
||||||
|
#[derive(Template, Clone, Debug)]
|
||||||
|
#[template(path = "pages/home.html")]
|
||||||
|
struct HomeTemplate {
|
||||||
|
player_count: i64,
|
||||||
|
match_count: i64,
|
||||||
|
session_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template, Clone, Debug)]
|
||||||
|
#[template(path = "pages/leaderboard.html")]
|
||||||
|
struct LeaderboardTemplate {
|
||||||
|
leaderboard: Vec<(i32, PlayerData)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
struct PlayerData {
|
||||||
|
id: i64,
|
||||||
|
name: String,
|
||||||
|
rating: f64,
|
||||||
|
singles_rating: f64,
|
||||||
|
email: String,
|
||||||
|
rating_display: String,
|
||||||
|
has_email: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template, Clone, Debug)]
|
||||||
|
#[template(path = "pages/players.html")]
|
||||||
|
struct PlayersTemplate {
|
||||||
|
players: Vec<PlayerData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template, Clone, Debug)]
|
||||||
|
#[template(path = "pages/player.html")]
|
||||||
|
struct PlayerTemplate {
|
||||||
|
player: PlayerData,
|
||||||
|
match_count: i64,
|
||||||
|
win_rate: f64,
|
||||||
|
win_rate_display: String,
|
||||||
|
chart_labels: String,
|
||||||
|
chart_data: String,
|
||||||
|
recent_matches: Vec<MatchData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
struct MatchData {
|
||||||
|
id: i64,
|
||||||
|
match_type: String,
|
||||||
|
score: String,
|
||||||
|
rating_change: f64,
|
||||||
|
rating_change_display: String,
|
||||||
|
date: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template, Clone, Debug)]
|
||||||
|
#[template(path = "pages/matches.html")]
|
||||||
|
struct MatchesTemplate {
|
||||||
|
matches: Vec<MatchDisplay>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct MatchDisplay {
|
||||||
|
id: i64,
|
||||||
|
match_type: String,
|
||||||
|
team1_players: Vec<PlayerMatchData>,
|
||||||
|
team1_score: i32,
|
||||||
|
team2_score: i32,
|
||||||
|
team2_players: Vec<PlayerMatchData>,
|
||||||
|
match_date: String,
|
||||||
|
match_time: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct PlayerMatchData {
|
||||||
|
id: i64,
|
||||||
|
name: String,
|
||||||
|
rating_change: f64,
|
||||||
|
rating_change_display: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template, Clone, Debug)]
|
||||||
|
#[template(path = "pages/match_form.html")]
|
||||||
|
struct MatchFormTemplate {
|
||||||
|
players: Vec<PlayerData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "pages/daily.html")]
|
||||||
|
struct DailyTemplate {
|
||||||
|
daily_matches: Vec<MatchDisplay>,
|
||||||
|
leaderboard: Vec<(i32, PlayerData)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "pages/about.html")]
|
||||||
|
struct AboutTemplate;
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "pages/player_form.html")]
|
||||||
|
struct PlayerFormTemplate {
|
||||||
|
player: Option<PlayerData>,
|
||||||
|
editing: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
println!("🏓 Pickleball ELO Tracker v3.0");
|
println!("🏓 Pickleball ELO Tracker v3.0");
|
||||||
@ -255,7 +361,7 @@ fn nav_html() -> &'static str {
|
|||||||
/// **Parameters:** None
|
/// **Parameters:** None
|
||||||
///
|
///
|
||||||
/// **Returns:** HTML page with dashboard stats and navigation menu
|
/// **Returns:** HTML page with dashboard stats and navigation menu
|
||||||
async fn index_handler(State(state): State<AppState>) -> Html<String> {
|
async fn index_handler(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
let player_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM players")
|
let player_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM players")
|
||||||
.fetch_one(&state.pool).await.unwrap_or(0);
|
.fetch_one(&state.pool).await.unwrap_or(0);
|
||||||
let match_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM matches")
|
let match_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM matches")
|
||||||
@ -263,66 +369,11 @@ async fn index_handler(State(state): State<AppState>) -> Html<String> {
|
|||||||
let session_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sessions")
|
let session_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sessions")
|
||||||
.fetch_one(&state.pool).await.unwrap_or(0);
|
.fetch_one(&state.pool).await.unwrap_or(0);
|
||||||
|
|
||||||
let html = format!(r#"
|
HomeTemplate {
|
||||||
<!DOCTYPE html>
|
player_count,
|
||||||
<html>
|
match_count,
|
||||||
<head>
|
session_count,
|
||||||
<meta charset="UTF-8">
|
}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Pickleball ELO Tracker</title>
|
|
||||||
<style>{}</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container" style="max-width: 700px; text-align: center;">
|
|
||||||
<h1>🏓 Pickleball ELO Tracker</h1>
|
|
||||||
<p style="color: #666;">Pure ELO Rating System v3.0</p>
|
|
||||||
|
|
||||||
<div class="stats-grid">
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value">{}</div>
|
|
||||||
<div class="stat-label">Matches</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value">{}</div>
|
|
||||||
<div class="stat-label">Players</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value">{}</div>
|
|
||||||
<div class="stat-label">Sessions</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{}
|
|
||||||
|
|
||||||
<div style="margin-top: 40px; text-align: left; background: #f9f9f9; padding: 25px; border-radius: 12px;">
|
|
||||||
<h2 style="color: #003594; margin-top: 0;">📊 How Ratings Work</h2>
|
|
||||||
|
|
||||||
<p><strong>One unified rating</strong> — Singles and doubles matches both contribute to a single ELO rating. Everyone starts at 1500.</p>
|
|
||||||
|
|
||||||
<p><strong>Per-point scoring</strong> — Your rating change depends on your actual point performance (points won ÷ total points), not just whether you won or lost. Winning 11-2 earns more than winning 11-9.</p>
|
|
||||||
|
|
||||||
<p><strong>Smart doubles scoring</strong> — In doubles, we calculate your "effective opponent" using:<br>
|
|
||||||
<code style="background: #e9e9e9; padding: 2px 6px; border-radius: 4px;">Effective Opponent = Opp1 + Opp2 - Teammate</code></p>
|
|
||||||
|
|
||||||
<p>This means:</p>
|
|
||||||
<ul style="margin-left: 20px;">
|
|
||||||
<li><strong>Strong teammate</strong> → lower effective opponent → less credit for winning</li>
|
|
||||||
<li><strong>Weak teammate</strong> → higher effective opponent → more credit for winning</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p><strong>The formula:</strong><br>
|
|
||||||
<code style="background: #e9e9e9; padding: 4px 8px; border-radius: 4px; display: inline-block; margin-top: 5px;">
|
|
||||||
Rating Change = 32 × (Actual Performance - Expected Performance)
|
|
||||||
</code></p>
|
|
||||||
|
|
||||||
<p style="color: #666; font-size: 13px; margin-bottom: 0;">Fair, transparent, and no mysterious "volatility" numbers. Just skill vs. expectations.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"#, COMMON_CSS, match_count, player_count, session_count, nav_html());
|
|
||||||
|
|
||||||
Html(html)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Displays the match history page with recent matches and results
|
/// Displays the match history page with recent matches and results
|
||||||
@ -1526,54 +1577,31 @@ async fn create_match(
|
|||||||
/// **Parameters:** None
|
/// **Parameters:** None
|
||||||
///
|
///
|
||||||
/// **Returns:** HTML page with players table
|
/// **Returns:** HTML page with players table
|
||||||
async fn players_list_handler(State(state): State<AppState>) -> Html<String> {
|
async fn players_list_handler(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
// UNIFIED RATING: just get singles_rating as the unified rating
|
// UNIFIED RATING: use rating field (merged singles/doubles)
|
||||||
let players: Vec<(i64, String, Option<String>, f64)> = sqlx::query_as(
|
let players: Vec<(i64, String, Option<String>, f64)> = sqlx::query_as(
|
||||||
"SELECT id, name, email, singles_rating FROM players ORDER BY singles_rating DESC"
|
"SELECT id, name, email, rating FROM players ORDER BY rating DESC"
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let player_rows: String = players.iter()
|
let players = players.into_iter()
|
||||||
.map(|(id, name, _email, rating)| {
|
.map(|(id, name, email, rating)| {
|
||||||
format!(r#"<tr>
|
let has_email = email.is_some();
|
||||||
<td><a href="/players/{}">{}</a></td>
|
PlayerData {
|
||||||
<td>{:.0}</td>
|
id,
|
||||||
</tr>"#, id, name, rating)
|
name,
|
||||||
|
rating,
|
||||||
|
singles_rating: rating,
|
||||||
|
email: email.unwrap_or_default(),
|
||||||
|
rating_display: format!("{:.1}", rating),
|
||||||
|
has_email,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let html = format!(r#"
|
PlayersTemplate { players }
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>All Players - Pickleball ELO</title>
|
|
||||||
<style>{}</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>👥 All Players ({})</h1>
|
|
||||||
{}
|
|
||||||
|
|
||||||
<div style="text-align: center; margin-bottom: 20px;">
|
|
||||||
<a href="/players/new" class="btn btn-success">➕ Add New Player</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Name</th><th>Rating</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>{}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"#, COMMON_CSS, players.len(), nav_html(), player_rows);
|
|
||||||
|
|
||||||
Html(html)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Displays the top 10 ranked players in singles and doubles
|
/// Displays the top 10 ranked players in singles and doubles
|
||||||
@ -1585,58 +1613,35 @@ async fn players_list_handler(State(state): State<AppState>) -> Html<String> {
|
|||||||
/// **Parameters:** None
|
/// **Parameters:** None
|
||||||
///
|
///
|
||||||
/// **Returns:** HTML page with unified leaderboard
|
/// **Returns:** HTML page with unified leaderboard
|
||||||
async fn leaderboard_handler(State(state): State<AppState>) -> Html<String> {
|
async fn leaderboard_handler(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
// UNIFIED RATING: Single leaderboard using singles_rating as unified rating
|
// UNIFIED RATING: Single leaderboard using rating field
|
||||||
let players: Vec<(i64, String, f64)> = sqlx::query_as(
|
let players: Vec<(i64, String, f64, Option<String>)> = sqlx::query_as(
|
||||||
r#"SELECT DISTINCT p.id, p.name, p.singles_rating
|
r#"SELECT DISTINCT p.id, p.name, p.rating, p.email
|
||||||
FROM players p
|
FROM players p
|
||||||
JOIN match_participants mp ON p.id = mp.player_id
|
JOIN match_participants mp ON p.id = mp.player_id
|
||||||
ORDER BY p.singles_rating DESC LIMIT 10"#
|
ORDER BY p.rating DESC"#
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let player_rows: String = players.iter().enumerate()
|
let leaderboard = players.into_iter().enumerate()
|
||||||
.map(|(i, (id, name, rating))| {
|
.map(|(i, (id, name, rating, email))| {
|
||||||
let medal = match i { 0 => "🥇", 1 => "🥈", 2 => "🥉", _ => "" };
|
let has_email = email.is_some();
|
||||||
format!(r#"<div style="padding: 12px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between;">
|
let player_data = PlayerData {
|
||||||
<div><span style="font-size: 18px; margin-right: 8px;">{}</span><strong>{}.</strong> <a href="/players/{}">{}</a></div>
|
id,
|
||||||
<strong>{:.0}</strong>
|
name,
|
||||||
</div>"#, medal, i + 1, id, name, rating)
|
rating,
|
||||||
|
singles_rating: rating,
|
||||||
|
email: email.unwrap_or_default(),
|
||||||
|
rating_display: format!("{:.1}", rating),
|
||||||
|
has_email,
|
||||||
|
};
|
||||||
|
((i + 1) as i32, player_data)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let html = format!(r#"
|
LeaderboardTemplate { leaderboard }
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Leaderboard - Pickleball ELO</title>
|
|
||||||
<style>
|
|
||||||
{}
|
|
||||||
.leaderboard {{ background: #f9f9f9; border-radius: 8px; overflow: hidden; max-width: 500px; margin: 0 auto; }}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>🏓 Leaderboard</h1>
|
|
||||||
{}
|
|
||||||
|
|
||||||
<h2 style="text-align: center;">📊 Top Players (Unified ELO)</h2>
|
|
||||||
<div class="leaderboard">{}</div>
|
|
||||||
|
|
||||||
<p style="text-align: center; color: #666; margin-top: 20px; font-size: 14px;">
|
|
||||||
Unified rating: singles and doubles matches both contribute to one rating.<br>
|
|
||||||
<a href="/about" style="color: #003594;">Learn how ratings work →</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"#, COMMON_CSS, nav_html(), player_rows);
|
|
||||||
|
|
||||||
Html(html)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns JSON API endpoint for leaderboard data
|
/// Returns JSON API endpoint for leaderboard data
|
||||||
@ -3396,138 +3401,6 @@ async fn daily_public_handler(
|
|||||||
/// About page explaining the ELO rating system
|
/// About page explaining the ELO rating system
|
||||||
///
|
///
|
||||||
/// **Endpoint:** `GET /about`
|
/// **Endpoint:** `GET /about`
|
||||||
async fn about_handler() -> Html<String> {
|
async fn about_handler() -> impl IntoResponse {
|
||||||
let html = format!(r#"
|
AboutTemplate
|
||||||
<!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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,277 +1,73 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}Pickleball ELO Tracker{% endblock %}</title>
|
<title>{% block title %}Pickleball ELO Tracker{% endblock %}</title>
|
||||||
|
<!-- Tailwind CSS -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<!-- HTMX -->
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
<!-- Chart.js -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
|
||||||
<style>
|
<style>
|
||||||
/* Common CSS - Pitt colors (Blue #003594, Gold #FFB81C) */
|
:root {
|
||||||
body {
|
--pitt-blue: #003594;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
--pitt-gold: #FFB81C;
|
||||||
background: linear-gradient(135deg, #003594 0%, #001a4d 100%);
|
|
||||||
padding: 20px;
|
|
||||||
margin: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
}
|
||||||
.container {
|
body {
|
||||||
max-width: 1000px;
|
@apply bg-gradient-to-br from-blue-900 via-blue-800 to-blue-950;
|
||||||
margin: 0 auto;
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 30px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
}
|
||||||
h1 {
|
.pitt-primary {
|
||||||
color: #003594;
|
@apply text-blue-900;
|
||||||
margin-top: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
}
|
||||||
h2 {
|
.pitt-gold {
|
||||||
color: #003594;
|
@apply text-yellow-500;
|
||||||
border-bottom: 2px solid #FFB81C;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
}
|
}
|
||||||
h3 {
|
.btn-primary {
|
||||||
color: #003594;
|
@apply px-4 py-2 bg-blue-900 text-white rounded-lg font-bold hover:bg-blue-950 transition-all duration-200 hover:shadow-lg hover:translate-y-[-2px] inline-block;
|
||||||
}
|
|
||||||
|
|
||||||
.nav {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 20px 0;
|
|
||||||
padding: 20px;
|
|
||||||
background: #f5f5f5;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
padding: 10px 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #003594;
|
|
||||||
color: white;
|
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: bold;
|
|
||||||
transition: all 0.2s;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
.btn:hover {
|
|
||||||
background: #001a4d;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
}
|
||||||
.btn-success {
|
.btn-success {
|
||||||
background: #27ae60;
|
@apply px-4 py-2 bg-green-600 text-white rounded-lg font-bold hover:bg-green-700 transition-all;
|
||||||
}
|
|
||||||
.btn-success:hover {
|
|
||||||
background: #229954;
|
|
||||||
}
|
}
|
||||||
.btn-warning {
|
.btn-warning {
|
||||||
background: #f39c12;
|
@apply px-4 py-2 bg-yellow-500 text-white rounded-lg font-bold hover:bg-yellow-600 transition-all;
|
||||||
}
|
|
||||||
.btn-warning:hover {
|
|
||||||
background: #e67e22;
|
|
||||||
}
|
}
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background: #e74c3c;
|
@apply px-4 py-2 bg-red-600 text-white rounded-lg font-bold hover:bg-red-700 transition-all;
|
||||||
}
|
}
|
||||||
.btn-danger:hover {
|
.card {
|
||||||
background: #c0392b;
|
@apply bg-white rounded-lg shadow-lg p-6;
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
th {
|
|
||||||
background: #003594;
|
|
||||||
color: white;
|
|
||||||
padding: 12px;
|
|
||||||
text-align: left;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
td {
|
|
||||||
border-bottom: 1px solid #ddd;
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
tr:hover {
|
|
||||||
background: #f9f9f9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin: 15px 0;
|
|
||||||
}
|
|
||||||
label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
input[type="text"],
|
|
||||||
input[type="email"],
|
|
||||||
input[type="number"],
|
|
||||||
input[type="date"],
|
|
||||||
select,
|
|
||||||
textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
input[type="text"]:focus,
|
|
||||||
input[type="email"]:focus,
|
|
||||||
input[type="number"]:focus,
|
|
||||||
input[type="date"]:focus,
|
|
||||||
select:focus,
|
|
||||||
textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #003594;
|
|
||||||
box-shadow: 0 0 0 3px rgba(0, 53, 148, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.form-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert {
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin: 15px 0;
|
|
||||||
}
|
|
||||||
.alert-success {
|
|
||||||
background: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
border: 1px solid #c3e6cb;
|
|
||||||
}
|
|
||||||
.alert-error {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
border: 1px solid #f5c6cb;
|
|
||||||
}
|
|
||||||
.alert-info {
|
|
||||||
background: #d1ecf1;
|
|
||||||
color: #0c5460;
|
|
||||||
border: 1px solid #bee5eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
margin: 30px 0;
|
|
||||||
}
|
}
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: linear-gradient(135deg, #003594 0%, #001a4d 100%);
|
@apply bg-gradient-to-br from-blue-900 to-blue-950 text-white p-6 rounded-lg shadow-lg text-center;
|
||||||
color: white;
|
|
||||||
padding: 25px;
|
|
||||||
border-radius: 12px;
|
|
||||||
text-align: center;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
}
|
||||||
.stat-value {
|
.alert-info {
|
||||||
font-size: 36px;
|
@apply bg-blue-100 text-blue-900 p-4 rounded-lg border border-blue-300 mb-4;
|
||||||
font-weight: bold;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
}
|
||||||
.stat-label {
|
.alert-success {
|
||||||
font-size: 14px;
|
@apply bg-green-100 text-green-900 p-4 rounded-lg border border-green-300 mb-4;
|
||||||
opacity: 0.9;
|
}
|
||||||
|
.alert-error {
|
||||||
|
@apply bg-red-100 text-red-900 p-4 rounded-lg border border-red-300 mb-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaderboard-entry {
|
.leaderboard-entry {
|
||||||
display: flex;
|
@apply flex items-center justify-between p-4 bg-gray-50 rounded-lg mb-2 hover:bg-gray-100 transition-colors;
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 15px;
|
|
||||||
background: #f9f9f9;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
}
|
||||||
.rank {
|
.rank {
|
||||||
font-size: 24px;
|
@apply text-3xl font-bold text-yellow-500 mr-4 min-w-12;
|
||||||
font-weight: bold;
|
|
||||||
color: #FFB81C;
|
|
||||||
margin-right: 15px;
|
|
||||||
min-width: 40px;
|
|
||||||
}
|
|
||||||
.player-info {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.player-name {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #003594;
|
|
||||||
}
|
}
|
||||||
.player-rating {
|
.player-rating {
|
||||||
font-size: 24px;
|
@apply text-2xl font-bold text-blue-900;
|
||||||
font-weight: bold;
|
|
||||||
color: #003594;
|
|
||||||
}
|
}
|
||||||
|
.form-input {
|
||||||
code {
|
@apply w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-900 focus:ring-2 focus:ring-blue-200;
|
||||||
background: #f5f5f5;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: #f9f9f9;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
margin: 15px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-message {
|
|
||||||
background: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.container {
|
|
||||||
padding: 15px;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
.nav {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% block extra_css %}{% endblock %}
|
{% block extra_css %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="min-h-screen">
|
||||||
<div class="container">
|
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
55
templates/components/chart.html
Normal file
55
templates/components/chart.html
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<div class="card mt-6">
|
||||||
|
<h2 class="pitt-primary font-bold text-2xl mb-4">📈 ELO Rating History</h2>
|
||||||
|
<canvas id="ratingChart"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const chartData = {
|
||||||
|
labels: {{ labels | json }},
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'ELO Rating',
|
||||||
|
data: {{ data | json }},
|
||||||
|
borderColor: '#003594',
|
||||||
|
backgroundColor: 'rgba(0, 53, 148, 0.1)',
|
||||||
|
borderWidth: 3,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: 4,
|
||||||
|
pointBackgroundColor: '#FFB81C',
|
||||||
|
pointBorderColor: '#003594',
|
||||||
|
pointBorderWidth: 2,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = document.getElementById('ratingChart').getContext('2d');
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: chartData,
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
labels: {
|
||||||
|
font: { size: 14 },
|
||||||
|
color: '#003594'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: false,
|
||||||
|
grid: { color: 'rgba(0, 53, 148, 0.1)' },
|
||||||
|
ticks: { color: '#003594' }
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
grid: { color: 'rgba(0, 53, 148, 0.05)' },
|
||||||
|
ticks: { color: '#003594' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
26
templates/components/match_row.html
Normal file
26
templates/components/match_row.html
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<tr class="border-b hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3">{{ match_type }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{% for player in team1_players %}
|
||||||
|
<div class="font-semibold text-blue-900">
|
||||||
|
<a href="/players/{{ player.id }}" class="hover:underline">{{ player.name }}</a>
|
||||||
|
<span class="text-green-600">+{{ player.rating_change | round(1) }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-center font-bold">{{ team1_score }}-{{ team2_score }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{% for player in team2_players %}
|
||||||
|
<div class="font-semibold text-blue-900">
|
||||||
|
<a href="/players/{{ player.id }}" class="hover:underline">{{ player.name }}</a>
|
||||||
|
<span class="{% if player.rating_change >= 0 %}text-green-600{% else %}text-red-600{% endif %}">{{ player.rating_change | round(1) }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-600">{{ match_date }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<form method="post" action="/matches/{{ match_id }}/delete" style="display: inline;">
|
||||||
|
<button type="submit" class="btn-danger text-sm" onclick="return confirm('Delete this match?')">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
@ -1,10 +1,11 @@
|
|||||||
<div class="nav">
|
<nav class="flex flex-wrap gap-3 justify-center my-6 p-4 bg-gray-100 rounded-lg">
|
||||||
<a href="/" class="btn">🏠 Home</a>
|
<a href="/" class="btn-primary">🏠 Home</a>
|
||||||
<a href="/leaderboard" class="btn">📊 Leaderboard</a>
|
<a href="/leaderboard" class="btn-primary">📊 Leaderboard</a>
|
||||||
<a href="/matches" class="btn">📜 History</a>
|
<a href="/players" class="btn-primary">👥 Players</a>
|
||||||
<a href="/players" class="btn">👥 Players</a>
|
<a href="/players/new" class="btn-primary">➕ Add Player</a>
|
||||||
<a href="/balance" class="btn">⚖️ Balance</a>
|
<a href="/matches" class="btn-primary">🎯 Match History</a>
|
||||||
<a href="/daily" class="btn">📧 Daily</a>
|
<a href="/matches/new" class="btn-success">✅ Record Match</a>
|
||||||
<a href="/about" class="btn">❓ About</a>
|
<a href="/balance" class="btn-warning">⚖️ Balance Teams</a>
|
||||||
<a href="/matches/new" class="btn btn-success">🎾 Record</a>
|
<a href="/daily" class="btn-primary">📈 Daily Summary</a>
|
||||||
</div>
|
<a href="/about" class="btn-primary">ℹ️ About</a>
|
||||||
|
</nav>
|
||||||
|
|||||||
13
templates/components/player_card.html
Normal file
13
templates/components/player_card.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<div class="card">
|
||||||
|
<h3 class="pitt-primary font-bold text-xl mb-2">
|
||||||
|
<a href="/players/{{ player.id }}" class="hover:underline">{{ player.name }}</a>
|
||||||
|
</h3>
|
||||||
|
<div class="text-3xl font-bold pitt-primary mb-4">{{ player.singles_rating | round(1) }}</div>
|
||||||
|
{% if player.email %}
|
||||||
|
<p class="text-sm text-gray-600">📧 {{ player.email }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex gap-2 mt-4">
|
||||||
|
<a href="/players/{{ player.id }}" class="btn-primary text-sm">View Profile</a>
|
||||||
|
<a href="/players/{{ player.id }}/edit" class="btn-warning text-sm">Edit</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -3,92 +3,101 @@
|
|||||||
{% block title %}About - Pickleball ELO Tracker{% endblock %}
|
{% block title %}About - Pickleball ELO Tracker{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>❓ About Pickleball ELO</h1>
|
<div class="max-w-4xl mx-auto">
|
||||||
{% include "components/nav.html" %}
|
<h1 class="pitt-primary text-4xl font-bold mb-8 text-center">ℹ️ About the Rating System</h1>
|
||||||
|
|
||||||
<div class="card">
|
{% include "components/nav.html" %}
|
||||||
<h2>📊 What is ELO?</h2>
|
|
||||||
<p>
|
<div class="card">
|
||||||
The ELO rating system is a method for calculating the relative skill levels of players.
|
<h2 class="pitt-primary text-2xl font-bold mb-4">🎯 ELO Basics</h2>
|
||||||
It was invented for chess but works great for any competitive sport.
|
<p class="mb-4">
|
||||||
</p>
|
This system uses <strong>ELO rating</strong>, a proven method for measuring competitive skill. Everyone starts at <strong>1500</strong>.
|
||||||
<p>
|
</p>
|
||||||
In pickleball, your ELO rating represents your skill level. Everyone starts at 1500.
|
<p class="mb-4">
|
||||||
When you play a match, your rating goes up or down based on:
|
Unlike traditional win/loss records, ELO captures:
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul class="ml-6 mb-4 space-y-2">
|
||||||
<li><strong>Your performance:</strong> Points won ÷ total points (not just win/loss)</li>
|
<li>✅ <strong>Skill level of opponents</strong> — Beating better players earns more points</li>
|
||||||
<li><strong>Your opponent's skill:</strong> Playing a higher-rated opponent is worth more</li>
|
<li>✅ <strong>Individual performance</strong> — Point differential matters (11-2 vs 11-9)</li>
|
||||||
<li><strong>The upset factor:</strong> Beating someone stronger gains more points</li>
|
<li>✅ <strong>Consistency</strong> — Your rating reflects your true skill over time</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>🎾 Unified Rating (v3.0)</h2>
|
<h2 class="pitt-primary text-2xl font-bold mb-4">📊 Unified Rating</h2>
|
||||||
<p>
|
<p class="mb-4">
|
||||||
In version 3, we switched from separate singles/doubles ratings to a <strong>unified rating</strong>.
|
One rating covers <strong>all match types</strong> (singles and doubles). This is more realistic:
|
||||||
Both singles and doubles matches contribute to the same ELO rating, because:
|
</p>
|
||||||
</p>
|
<ul class="ml-6 mb-4 space-y-2">
|
||||||
<ul>
|
<li>🏓 <strong>Singles:</strong> Your direct 1v1 skill shows immediately</li>
|
||||||
<li>One rating is simpler to understand</li>
|
<li>👥 <strong>Doubles:</strong> Teamwork and court awareness matter just as much</li>
|
||||||
<li>Players who excel in both formats get fairly rewarded</li>
|
</ul>
|
||||||
<li>Pure ELO is transparent and bias-free</li>
|
<p class="mb-4">
|
||||||
</ul>
|
Your "ELO Rating" represents your expected performance in ANY match type. A 1700-rated player will usually beat a 1500-rated player, whether playing singles or doubles.
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
<div class="card">
|
|
||||||
<h2>⚖️ Smart Doubles Scoring</h2>
|
<div class="card">
|
||||||
<p>
|
<h2 class="pitt-primary text-2xl font-bold mb-4">🧮 How Points Change</h2>
|
||||||
In doubles matches, we calculate your "effective opponent" to make scoring fair:
|
<p class="mb-4">
|
||||||
</p>
|
The <strong>core formula</strong> is simple:
|
||||||
<p style="background: #f5f5f5; padding: 15px; border-radius: 6px; font-family: monospace;">
|
</p>
|
||||||
<strong>Effective Opponent = Opp1 + Opp2 - Teammate</strong>
|
<div class="bg-blue-50 p-4 rounded-lg mb-4 font-mono">
|
||||||
</p>
|
Rating Change = 32 × (Actual Performance - Expected Performance)
|
||||||
<p>
|
</div>
|
||||||
This means:
|
<p class="mb-4">
|
||||||
</p>
|
Where:
|
||||||
<ul>
|
</p>
|
||||||
<li><strong>Playing with a strong teammate</strong> → harder opponents count as weaker → less credit for winning</li>
|
<ul class="ml-6 mb-4 space-y-2">
|
||||||
<li><strong>Playing with a weak teammate</strong> → harder opponents count as stronger → more credit for winning</li>
|
<li><strong>Actual Performance</strong> = Points You Scored ÷ Total Points</li>
|
||||||
</ul>
|
<li><strong>Expected Performance</strong> = Calculated from rating difference</li>
|
||||||
<p>
|
</ul>
|
||||||
This approach is <strong>fair, symmetric, and makes strategic sense</strong>.
|
<p class="mb-4">
|
||||||
</p>
|
<strong>Example:</strong>
|
||||||
</div>
|
</p>
|
||||||
|
<ul class="ml-6 mb-4 space-y-2">
|
||||||
<div class="card">
|
<li>You (1600) beat them (1400) 11-5: <code class="bg-gray-100 px-2 py-1 rounded">Actual = 11/16 = 0.6875</code></li>
|
||||||
<h2>🧮 The Formula</h2>
|
<li>You were expected to win ~70% of points (rating difference)</li>
|
||||||
<p style="background: #f5f5f5; padding: 15px; border-radius: 6px; font-family: monospace;">
|
<li>You performed worse than expected → smaller gain (~+8)</li>
|
||||||
<strong>Rating Change = K × (Actual Performance − Expected Performance)</strong>
|
<li>They performed better than expected → smaller loss (~-8)</li>
|
||||||
</p>
|
</ul>
|
||||||
<p>Where:</p>
|
</div>
|
||||||
<ul>
|
|
||||||
<li><strong>K = 32</strong> (standard, adjustable for casual/competitive play)</li>
|
<div class="card">
|
||||||
<li><strong>Actual Performance</strong> = Your points ÷ Total points</li>
|
<h2 class="pitt-primary text-2xl font-bold mb-4">👥 Doubles Scoring</h2>
|
||||||
<li><strong>Expected Performance</strong> = Based on the ELO formula (derived from rating difference)</li>
|
<p class="mb-4">
|
||||||
</ul>
|
In doubles, we calculate your <strong>effective opponent</strong> rating:
|
||||||
<p style="color: #666; font-size: 13px;">
|
</p>
|
||||||
The expected performance formula: <code>E = 1 / (1 + 10^((Opp_Rating - Your_Rating) / 400))</code>
|
<div class="bg-blue-50 p-4 rounded-lg mb-4 font-mono">
|
||||||
</p>
|
Effective Opponent = (Opp1 + Opp2 - Teammate) ÷ 2
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mb-4">
|
||||||
<div class="card">
|
This accounts for <strong>teammate strength</strong>:
|
||||||
<h2>📈 Why This System Works</h2>
|
</p>
|
||||||
<ul>
|
<ul class="ml-6 mb-4 space-y-2">
|
||||||
<li><strong>Transparent:</strong> No hidden formulas or magic numbers. You can verify your rating changes.</li>
|
<li>🟢 <strong>Strong teammate:</strong> Lower effective opponent → less credit for winning</li>
|
||||||
<li><strong>Fair:</strong> You're rewarded for actual performance, not just wins.</li>
|
<li>🔴 <strong>Weak teammate:</strong> Higher effective opponent → more credit for winning</li>
|
||||||
<li><strong>Balanced:</strong> Winning 11-2 and 11-9 are different and rated differently.</li>
|
</ul>
|
||||||
<li><strong>Skill-based:</strong> Beating stronger players earns more; losing to them costs less.</li>
|
<p class="mb-4">
|
||||||
<li><strong>Predictable:</strong> Ratings converge to true skill over time.</li>
|
<strong>Why?</strong> Winning with a weaker partner takes more individual skill. Winning with a stronger partner is easier. The system recognizes this.
|
||||||
</ul>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>📝 Version History</h2>
|
<h2 class="pitt-primary text-2xl font-bold mb-4">✨ Why This System?</h2>
|
||||||
<ul>
|
<ul class="ml-6 space-y-3">
|
||||||
<li><strong>v3.0.0</strong> - Unified ELO rating, modular architecture, pure ELO calculator</li>
|
<li>🎯 <strong>Transparent:</strong> No hidden "k-factors" or magical numbers</li>
|
||||||
<li><strong>v2.0.0</strong> - Glicko-2 system with separate singles/doubles ratings</li>
|
<li>⚖️ <strong>Fair:</strong> Rewards individual skill, not just team wins</li>
|
||||||
<li><strong>v1.0.0</strong> - Initial release with basic ELO</li>
|
<li>📈 <strong>Responsive:</strong> Converges quickly to true skill level</li>
|
||||||
</ul>
|
<li>🔄 <strong>Unified:</strong> One rating works for all match types</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="pitt-primary text-2xl font-bold mb-4">🚀 Get Started</h2>
|
||||||
|
<p class="mb-4">
|
||||||
|
Ready to join? <a href="/players/new" class="pitt-primary font-bold hover:underline">Add yourself as a player</a>, then <a href="/matches/new" class="pitt-primary font-bold hover:underline">record your first match</a>. Your journey begins at 1500! 🏓
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
78
templates/pages/daily.html
Normal file
78
templates/pages/daily.html
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Daily Summary - Pickleball ELO Tracker{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-6xl mx-auto">
|
||||||
|
<h1 class="pitt-primary text-4xl font-bold mb-8 text-center">📈 Daily Summary</h1>
|
||||||
|
|
||||||
|
{% include "components/nav.html" %}
|
||||||
|
|
||||||
|
<div class="card mb-8">
|
||||||
|
<h2 class="pitt-primary text-2xl font-bold mb-4">🎯 Today's Matches</h2>
|
||||||
|
{% if daily_matches.is_empty() %}
|
||||||
|
<p class="text-gray-600">No matches recorded today.</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-blue-900 text-white">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left">Type</th>
|
||||||
|
<th class="px-4 py-3 text-left">Team 1</th>
|
||||||
|
<th class="px-4 py-3 text-center">Score</th>
|
||||||
|
<th class="px-4 py-3 text-left">Team 2</th>
|
||||||
|
<th class="px-4 py-3 text-left">Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for match in daily_matches %}
|
||||||
|
<tr class="border-b hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3 capitalize font-semibold">{{ match.match_type }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{% for player in match.team1_players %}
|
||||||
|
<div class="pitt-primary font-semibold">
|
||||||
|
<a href="/players/{{ player.id }}" class="hover:underline">{{ player.name }}</a>
|
||||||
|
<span class="{% if player.rating_change >= 0.0 %}text-green-600{% else %}text-red-600{% endif %}">{{ player.rating_change_display }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-center font-bold">{{ match.team1_score }}-{{ match.team2_score }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{% for player in match.team2_players %}
|
||||||
|
<div class="pitt-primary font-semibold">
|
||||||
|
<a href="/players/{{ player.id }}" class="hover:underline">{{ player.name }}</a>
|
||||||
|
<span class="{% if player.rating_change >= 0.0 %}text-green-600{% else %}text-red-600{% endif %}">{{ player.rating_change_display }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-600">{{ match.match_time }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="pitt-primary text-2xl font-bold mb-4">👥 Leaderboard</h2>
|
||||||
|
{% if leaderboard.is_empty() %}
|
||||||
|
<p class="text-gray-600">No players with matches yet.</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for (rank, player) in leaderboard %}
|
||||||
|
<div class="leaderboard-entry">
|
||||||
|
<div class="rank">{{ rank }}</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<a href="/players/{{ player.id }}" class="pitt-primary font-bold hover:underline">
|
||||||
|
{{ player.name }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="player-rating">{{ player.rating_display }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -3,49 +3,47 @@
|
|||||||
{% block title %}Home - Pickleball ELO Tracker{% endblock %}
|
{% block title %}Home - Pickleball ELO Tracker{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div style="max-width: 700px; text-align: center; margin: 0 auto;">
|
<div class="max-w-2xl mx-auto">
|
||||||
<h1>🏓 Pickleball ELO Tracker</h1>
|
<h1 class="pitt-primary text-4xl font-bold text-center mb-2">🏓 Pickleball ELO Tracker</h1>
|
||||||
<p style="color: #666;">Pure ELO Rating System v3.0</p>
|
<p class="text-center text-gray-600 mb-8">Pure ELO Rating System v3.0</p>
|
||||||
|
|
||||||
<div class="stats-grid">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-value">{{ match_count }}</div>
|
<div class="text-4xl font-bold mb-2">{{ match_count }}</div>
|
||||||
<div class="stat-label">Matches</div>
|
<div class="text-sm opacity-90">Matches</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-value">{{ player_count }}</div>
|
<div class="text-4xl font-bold mb-2">{{ player_count }}</div>
|
||||||
<div class="stat-label">Players</div>
|
<div class="text-sm opacity-90">Players</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-value">{{ session_count }}</div>
|
<div class="text-4xl font-bold mb-2">{{ session_count }}</div>
|
||||||
<div class="stat-label">Sessions</div>
|
<div class="text-sm opacity-90">Sessions</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include "components/nav.html" %}
|
{% include "components/nav.html" %}
|
||||||
|
|
||||||
<div style="margin-top: 40px; text-align: left; background: #f9f9f9; padding: 25px; border-radius: 12px;">
|
<div class="card mt-8">
|
||||||
<h2 style="color: #003594; margin-top: 0;">📊 How Ratings Work</h2>
|
<h2 class="pitt-primary text-2xl font-bold mb-4">📊 How Ratings Work</h2>
|
||||||
|
|
||||||
<p><strong>One unified rating</strong> — Singles and doubles matches both contribute to a single ELO rating. Everyone starts at 1500.</p>
|
<p class="mb-4"><strong>One unified rating</strong> — Singles and doubles matches both contribute to a single ELO rating. Everyone starts at 1500.</p>
|
||||||
|
|
||||||
<p><strong>Per-point scoring</strong> — Your rating change depends on your actual point performance (points won ÷ total points), not just whether you won or lost. Winning 11-2 earns more than winning 11-9.</p>
|
<p class="mb-4"><strong>Per-point scoring</strong> — Your rating change depends on your actual point performance (points won ÷ total points), not just whether you won or lost. Winning 11-2 earns more than winning 11-9.</p>
|
||||||
|
|
||||||
<p><strong>Smart doubles scoring</strong> — In doubles, we calculate your "effective opponent" using:<br>
|
<p class="mb-4"><strong>Smart doubles scoring</strong> — In doubles, we calculate your "effective opponent" using:<br>
|
||||||
<code style="background: #e9e9e9; padding: 2px 6px; border-radius: 4px;">Effective Opponent = Opp1 + Opp2 - Teammate</code></p>
|
<code class="bg-gray-100 px-2 py-1 rounded">Effective Opponent = Opp1 + Opp2 - Teammate</code></p>
|
||||||
|
|
||||||
<p>This means:</p>
|
<p class="mb-4">This means:</p>
|
||||||
<ul style="margin-left: 20px;">
|
<ul class="ml-6 mb-4 space-y-2">
|
||||||
<li><strong>Strong teammate</strong> → lower effective opponent → less credit for winning</li>
|
<li><strong>Strong teammate</strong> → lower effective opponent → less credit for winning</li>
|
||||||
<li><strong>Weak teammate</strong> → higher effective opponent → more credit for winning</li>
|
<li><strong>Weak teammate</strong> → higher effective opponent → more credit for winning</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p><strong>The formula:</strong><br>
|
<p class="mb-4"><strong>The formula:</strong><br>
|
||||||
<code style="background: #e9e9e9; padding: 4px 8px; border-radius: 4px; display: inline-block; margin-top: 5px;">
|
<code class="bg-gray-100 px-3 py-2 rounded inline-block mt-2">Rating Change = 32 × (Actual Performance - Expected Performance)</code></p>
|
||||||
Rating Change = 32 × (Actual Performance - Expected Performance)
|
|
||||||
</code></p>
|
|
||||||
|
|
||||||
<p style="color: #666; font-size: 13px; margin-bottom: 0;">Fair, transparent, and no mysterious "volatility" numbers. Just skill vs. expectations.</p>
|
<p class="text-sm text-gray-600">Fair, transparent, and no mysterious "volatility" numbers. Just skill vs. expectations.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -3,26 +3,25 @@
|
|||||||
{% block title %}Leaderboard - Pickleball ELO Tracker{% endblock %}
|
{% block title %}Leaderboard - Pickleball ELO Tracker{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>📊 Leaderboard</h1>
|
<h1 class="pitt-primary text-4xl font-bold mb-8 text-center">📊 Leaderboard</h1>
|
||||||
|
|
||||||
{% include "components/nav.html" %}
|
{% include "components/nav.html" %}
|
||||||
|
|
||||||
{% if leaderboard.is_empty() %}
|
{% if leaderboard.is_empty() %}
|
||||||
<div class="alert alert-info">
|
<div class="alert-info">
|
||||||
No players with matches yet. <a href="/matches/new">Record a match</a> to see the leaderboard!
|
No players with matches yet. <a href="/matches/new" class="font-bold hover:underline">Record a match</a> to see the leaderboard!
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div style="max-width: 600px; margin: 0 auto;">
|
<div class="max-w-2xl mx-auto">
|
||||||
{% for (rank, player) in leaderboard %}
|
{% for (rank, player) in leaderboard %}
|
||||||
<div class="leaderboard-entry">
|
<div class="leaderboard-entry">
|
||||||
<div class="rank">{{ rank }}</div>
|
<div class="rank">{{ rank }}</div>
|
||||||
<div class="player-info">
|
<div class="flex-1">
|
||||||
<div class="player-name">
|
<a href="/players/{{ player.id }}" class="pitt-primary font-bold hover:underline">
|
||||||
<a href="/players/{{ player.id }}" style="color: #003594; text-decoration: none;">
|
{{ player.name }}
|
||||||
{{ player.name }}
|
</a>
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="player-rating">{{ player.rating | round(1) }}</div>
|
<div class="player-rating">{{ player.rating_display }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
79
templates/pages/match_form.html
Normal file
79
templates/pages/match_form.html
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Record Match - Pickleball ELO Tracker{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<h1 class="pitt-primary text-4xl font-bold mb-8 text-center">✅ Record a Match</h1>
|
||||||
|
|
||||||
|
{% include "components/nav.html" %}
|
||||||
|
|
||||||
|
<form method="post" action="/matches/new" class="card">
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block font-bold pitt-primary mb-2">Match Type</label>
|
||||||
|
<select name="match_type" class="form-input" required>
|
||||||
|
<option value="">-- Select Match Type --</option>
|
||||||
|
<option value="singles">Singles</option>
|
||||||
|
<option value="doubles">Doubles</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-blue-50 p-6 rounded-lg mb-6">
|
||||||
|
<h2 class="font-bold pitt-primary mb-4">Team 1</h2>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block font-bold pitt-primary mb-2">Player 1</label>
|
||||||
|
<select name="team1_player1" class="form-input" required>
|
||||||
|
<option value="">-- Select Player --</option>
|
||||||
|
{% for player in players %}
|
||||||
|
<option value="{{ player.id }}">{{ player.name }} ({{ player.rating_display }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block font-bold pitt-primary mb-2">Player 2 (Doubles Only)</label>
|
||||||
|
<select name="team1_player2" class="form-input">
|
||||||
|
<option value="">-- Select Player (Optional) --</option>
|
||||||
|
{% for player in players %}
|
||||||
|
<option value="{{ player.id }}">{{ player.name }} ({{ player.rating_display }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block font-bold pitt-primary mb-2">Score</label>
|
||||||
|
<input type="number" name="team1_score" class="form-input" min="0" max="21" required placeholder="e.g., 11">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-red-50 p-6 rounded-lg mb-6">
|
||||||
|
<h2 class="font-bold pitt-primary mb-4">Team 2</h2>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block font-bold pitt-primary mb-2">Player 1</label>
|
||||||
|
<select name="team2_player1" class="form-input" required>
|
||||||
|
<option value="">-- Select Player --</option>
|
||||||
|
{% for player in players %}
|
||||||
|
<option value="{{ player.id }}">{{ player.name }} ({{ player.rating_display }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block font-bold pitt-primary mb-2">Player 2 (Doubles Only)</label>
|
||||||
|
<select name="team2_player2" class="form-input">
|
||||||
|
<option value="">-- Select Player (Optional) --</option>
|
||||||
|
{% for player in players %}
|
||||||
|
<option value="{{ player.id }}">{{ player.name }} ({{ player.rating_display }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block font-bold pitt-primary mb-2">Score</label>
|
||||||
|
<input type="number" name="team2_score" class="form-input" min="0" max="21" required placeholder="e.g., 9">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<button type="submit" class="btn-success flex-1">✅ Record Match</button>
|
||||||
|
<a href="/matches" class="btn-primary flex-1 text-center">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
66
templates/pages/matches.html
Normal file
66
templates/pages/matches.html
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Match History - Pickleball ELO Tracker{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="pitt-primary text-4xl font-bold mb-8 text-center">🎯 Match History</h1>
|
||||||
|
|
||||||
|
{% include "components/nav.html" %}
|
||||||
|
|
||||||
|
{% if matches.is_empty() %}
|
||||||
|
<div class="alert-info">
|
||||||
|
No matches recorded yet. <a href="/matches/new" class="font-bold hover:underline">Record the first match</a>!
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="max-w-6xl mx-auto card">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-blue-900 text-white">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left">Type</th>
|
||||||
|
<th class="px-4 py-3 text-left">Team 1</th>
|
||||||
|
<th class="px-4 py-3 text-center">Score</th>
|
||||||
|
<th class="px-4 py-3 text-left">Team 2</th>
|
||||||
|
<th class="px-4 py-3 text-left">Date</th>
|
||||||
|
<th class="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for match in matches %}
|
||||||
|
<tr class="border-b hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3 capitalize font-semibold">{{ match.match_type }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{% for player in match.team1_players %}
|
||||||
|
<div class="pitt-primary font-semibold">
|
||||||
|
<a href="/players/{{ player.id }}" class="hover:underline">{{ player.name }}</a>
|
||||||
|
<span class="{% if player.rating_change >= 0.0 %}text-green-600{% else %}text-red-600{% endif %} font-bold">
|
||||||
|
{% if player.rating_change >= 0.0 %}+{% endif %}{{ player.rating_change_display }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-center font-bold text-lg">{{ match.team1_score }}-{{ match.team2_score }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{% for player in match.team2_players %}
|
||||||
|
<div class="pitt-primary font-semibold">
|
||||||
|
<a href="/players/{{ player.id }}" class="hover:underline">{{ player.name }}</a>
|
||||||
|
<span class="{% if player.rating_change >= 0.0 %}text-green-600{% else %}text-red-600{% endif %} font-bold">
|
||||||
|
{% if player.rating_change >= 0.0 %}+{% endif %}{{ player.rating_change_display }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-600">{{ match.match_date }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<form method="post" action="/matches/{{ match.id }}/delete" style="display: inline;">
|
||||||
|
<button type="submit" class="btn-danger text-sm" onclick="return confirm('Delete this match?')">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
132
templates/pages/player.html
Normal file
132
templates/pages/player.html
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ player.name }} - Pickleball ELO Tracker{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
{% include "components/nav.html" %}
|
||||||
|
|
||||||
|
<div class="card mb-8">
|
||||||
|
<div class="flex justify-between items-start mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="pitt-primary text-4xl font-bold">{{ player.name }}</h1>
|
||||||
|
{% if player.has_email %}
|
||||||
|
<p class="text-gray-600 mt-2">📧 {{ player.email }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<a href="/players/{{ player.id }}/edit" class="btn-warning">✏️ Edit</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div class="bg-gradient-to-br from-blue-50 to-blue-100 p-6 rounded-lg">
|
||||||
|
<div class="text-sm text-gray-600 mb-1">ELO Rating</div>
|
||||||
|
<div class="text-4xl font-bold pitt-primary">{{ player.rating_display }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gradient-to-br from-green-50 to-green-100 p-6 rounded-lg">
|
||||||
|
<div class="text-sm text-gray-600 mb-1">Matches Played</div>
|
||||||
|
<div class="text-4xl font-bold text-green-700">{{ match_count }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gradient-to-br from-yellow-50 to-yellow-100 p-6 rounded-lg">
|
||||||
|
<div class="text-sm text-gray-600 mb-1">Win Rate</div>
|
||||||
|
<div class="text-4xl font-bold text-yellow-700">{{ win_rate_display }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if chart_data.is_empty() %}
|
||||||
|
<div class="alert-info">
|
||||||
|
No rating history yet. <a href="/matches/new" class="font-bold hover:underline">Record a match</a> to see the chart!
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="pitt-primary text-2xl font-bold mb-4">📈 Rating History</h2>
|
||||||
|
<canvas id="ratingChart"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const chartData = {
|
||||||
|
labels: {{ chart_labels }},
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'ELO Rating',
|
||||||
|
data: {{ chart_data }},
|
||||||
|
borderColor: '#003594',
|
||||||
|
backgroundColor: 'rgba(0, 53, 148, 0.1)',
|
||||||
|
borderWidth: 3,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: 4,
|
||||||
|
pointBackgroundColor: '#FFB81C',
|
||||||
|
pointBorderColor: '#003594',
|
||||||
|
pointBorderWidth: 2,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = document.getElementById('ratingChart').getContext('2d');
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: chartData,
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
labels: {
|
||||||
|
font: { size: 14 },
|
||||||
|
color: '#003594'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: false,
|
||||||
|
grid: { color: 'rgba(0, 53, 148, 0.1)' },
|
||||||
|
ticks: { color: '#003594' }
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
grid: { color: 'rgba(0, 53, 148, 0.05)' },
|
||||||
|
ticks: { color: '#003594' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card mt-8">
|
||||||
|
<h2 class="pitt-primary text-2xl font-bold mb-4">📋 Recent Matches</h2>
|
||||||
|
{% if recent_matches.is_empty() %}
|
||||||
|
<p class="text-gray-600">No matches yet.</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-blue-900 text-white">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left">Type</th>
|
||||||
|
<th class="px-4 py-3 text-left">Score</th>
|
||||||
|
<th class="px-4 py-3 text-left">Rating Change</th>
|
||||||
|
<th class="px-4 py-3 text-left">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for match in recent_matches %}
|
||||||
|
<tr class="border-b hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3 capitalize">{{ match.match_type }}</td>
|
||||||
|
<td class="px-4 py-3 font-semibold">{{ match.score }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="{% if match.rating_change >= 0.0 %}text-green-600 font-bold{% else %}text-red-600 font-bold{% endif %}">
|
||||||
|
{{ match.rating_change_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-600">{{ match.date }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
36
templates/pages/player_form.html
Normal file
36
templates/pages/player_form.html
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% if editing %}Edit{% else %}Add{% endif %} Player - Pickleball ELO Tracker{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<h1 class="pitt-primary text-4xl font-bold mb-8 text-center">{% if editing %}✏️ Edit Player{% else %}➕ Add Player{% endif %}</h1>
|
||||||
|
|
||||||
|
{% include "components/nav.html" %}
|
||||||
|
|
||||||
|
<form method="post" class="card">
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block font-bold pitt-primary mb-2">Player Name</label>
|
||||||
|
<input type="text" name="name" class="form-input" placeholder="e.g., Andrew" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block font-bold pitt-primary mb-2">Email (Optional)</label>
|
||||||
|
<input type="email" name="email" class="form-input" placeholder="andrew@example.com">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if editing %}
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block font-bold pitt-primary mb-2">ELO Rating</label>
|
||||||
|
<input type="number" name="singles_rating" class="form-input" step="0.1" required>
|
||||||
|
<p class="text-sm text-gray-600 mt-2">This is the unified rating for all match types.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<button type="submit" class="btn-success flex-1">{% if editing %}💾 Save Changes{% else %}✅ Add Player{% endif %}</button>
|
||||||
|
<a href="/players" class="btn-primary flex-1 text-center">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
33
templates/pages/players.html
Normal file
33
templates/pages/players.html
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Players - Pickleball ELO Tracker{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="pitt-primary text-4xl font-bold mb-8 text-center">👥 Players</h1>
|
||||||
|
|
||||||
|
{% include "components/nav.html" %}
|
||||||
|
|
||||||
|
{% if players.is_empty() %}
|
||||||
|
<div class="alert-info">
|
||||||
|
No players yet. <a href="/players/new" class="font-bold hover:underline">Add the first player</a>!
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{% for player in players %}
|
||||||
|
<div class="card">
|
||||||
|
<h3 class="pitt-primary font-bold text-xl mb-2">
|
||||||
|
<a href="/players/{{ player.id }}" class="hover:underline">{{ player.name }}</a>
|
||||||
|
</h3>
|
||||||
|
<div class="text-3xl font-bold pitt-primary mb-4">{{ player.rating_display }}</div>
|
||||||
|
{% if player.has_email %}
|
||||||
|
<p class="text-sm text-gray-600">📧 {{ player.email }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex gap-2 mt-4">
|
||||||
|
<a href="/players/{{ player.id }}" class="btn-primary text-sm">View Profile</a>
|
||||||
|
<a href="/players/{{ player.id }}/edit" class="btn-warning text-sm">Edit</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
Loading…
x
Reference in New Issue
Block a user