v3.0.0: Complete refactor - modular structure, unified ELO, tests

This commit is contained in:
Split 2026-02-26 12:43:15 -05:00
parent 666589e18c
commit 75576ce50c
42 changed files with 2562 additions and 1541 deletions

2
Cargo.lock generated
View File

@ -1431,7 +1431,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pickleball-elo"
version = "2.0.0"
version = "3.0.0"
dependencies = [
"anyhow",
"askama",

View File

@ -1,6 +1,6 @@
[package]
name = "pickleball-elo"
version = "2.0.0"
version = "3.0.0"
edition = "2021"
[dependencies]

16
config.toml Normal file
View File

@ -0,0 +1,16 @@
# Pickleball ELO Configuration - v3.0.0
[elo]
k_factor = 32
starting_rating = 1500.0
[app]
timezone = "America/New_York"
[email]
# SMTP credentials read from environment:
# PICKLEBALL_SMTP_HOST
# PICKLEBALL_SMTP_PORT
# PICKLEBALL_SMTP_USERNAME
# PICKLEBALL_SMTP_PASSWORD
# PICKLEBALL_SMTP_FROM_EMAIL

View File

@ -1,135 +0,0 @@
use pickleball_elo::glicko::{GlickoRating, Glicko2Calculator, calculate_weighted_score};
struct Player {
name: &'static str,
rating: GlickoRating,
}
fn main() {
let sep = "=".repeat(70);
println!("{}", sep);
println!(" PICKLEBALL ELO TRACKER - GLICKO-2 DEMO");
println!("{}", sep);
println!();
let calc = Glicko2Calculator::new();
// Create players
let mut players = vec![
Player { name: "Alice", rating: GlickoRating::new_player() },
Player { name: "Bob", rating: GlickoRating::new_player() },
Player { name: "Charlie", rating: GlickoRating::new_player() },
Player { name: "Dana", rating: GlickoRating::new_player() },
];
println!("📋 Initial Ratings (all players start at 1500):");
println!();
for p in &players {
println!(" {} - Rating: {:.1}, RD: {:.1}, Volatility: {:.4}",
p.name, p.rating.rating, p.rating.rd, p.rating.volatility);
}
println!("\n{}", sep);
println!(" SESSION MATCHES");
println!("{}\n", sep);
// Match 1: Alice vs Bob (Alice wins 11-5 - moderate win)
println!("Match 1: Singles - Alice defeats Bob 11-5");
let alice_outcome = calculate_weighted_score(players[0].rating.rating, players[1].rating.rating, 11, 5);
let bob_outcome = calculate_weighted_score(players[1].rating.rating, players[0].rating.rating, 5, 11);
let alice_before = players[0].rating;
let bob_before = players[1].rating;
players[0].rating = calc.update_rating(&players[0].rating, &[(players[1].rating, alice_outcome)]);
players[1].rating = calc.update_rating(&players[1].rating, &[(alice_before, bob_outcome)]);
println!(" Alice: {:.1}{:.1} ({:+.1})",
alice_before.rating, players[0].rating.rating,
players[0].rating.rating - alice_before.rating);
println!(" Bob: {:.1}{:.1} ({:+.1})\n",
bob_before.rating, players[1].rating.rating,
players[1].rating.rating - bob_before.rating);
// Match 2: Charlie vs Dana (Charlie wins 11-2 - blowout!)
println!("Match 2: Singles - Charlie CRUSHES Dana 11-2");
let charlie_outcome = calculate_weighted_score(players[2].rating.rating, players[3].rating.rating, 11, 2);
let dana_outcome = calculate_weighted_score(players[3].rating.rating, players[2].rating.rating, 2, 11);
let charlie_before = players[2].rating;
let dana_before = players[3].rating;
players[2].rating = calc.update_rating(&players[2].rating, &[(players[3].rating, charlie_outcome)]);
players[3].rating = calc.update_rating(&players[3].rating, &[(charlie_before, dana_outcome)]);
println!(" Charlie: {:.1}{:.1} ({:+.1})",
charlie_before.rating, players[2].rating.rating,
players[2].rating.rating - charlie_before.rating);
println!(" Dana: {:.1}{:.1} ({:+.1})\n",
dana_before.rating, players[3].rating.rating,
players[3].rating.rating - dana_before.rating);
// Match 3: Alice vs Charlie (Charlie wins 11-9 - close game)
println!("Match 3: Singles - Charlie edges Alice 11-9");
let charlie_outcome2 = calculate_weighted_score(players[2].rating.rating, players[0].rating.rating, 11, 9);
let alice_outcome2 = calculate_weighted_score(players[0].rating.rating, players[2].rating.rating, 9, 11);
let alice_before2 = players[0].rating;
let charlie_before2 = players[2].rating;
players[0].rating = calc.update_rating(&players[0].rating, &[(players[2].rating, alice_outcome2)]);
players[2].rating = calc.update_rating(&players[2].rating, &[(alice_before2, charlie_outcome2)]);
println!(" Charlie: {:.1}{:.1} ({:+.1})",
charlie_before2.rating, players[2].rating.rating,
players[2].rating.rating - charlie_before2.rating);
println!(" Alice: {:.1}{:.1} ({:+.1})\n",
alice_before2.rating, players[0].rating.rating,
players[0].rating.rating - alice_before2.rating);
// Match 4: Bob vs Dana (Bob wins 11-7)
println!("Match 4: Singles - Bob defeats Dana 11-7");
let bob_outcome2 = calculate_weighted_score(players[1].rating.rating, players[3].rating.rating, 11, 7);
let dana_outcome2 = calculate_weighted_score(players[3].rating.rating, players[1].rating.rating, 7, 11);
let bob_before2 = players[1].rating;
let dana_before2 = players[3].rating;
players[1].rating = calc.update_rating(&players[1].rating, &[(players[3].rating, bob_outcome2)]);
players[3].rating = calc.update_rating(&players[3].rating, &[(bob_before2, dana_outcome2)]);
println!(" Bob: {:.1}{:.1} ({:+.1})",
bob_before2.rating, players[1].rating.rating,
players[1].rating.rating - bob_before2.rating);
println!(" Dana: {:.1}{:.1} ({:+.1})\n",
dana_before2.rating, players[3].rating.rating,
players[3].rating.rating - dana_before2.rating);
println!("{}", sep);
println!(" FINAL LEADERBOARD");
println!("{}\n", sep);
// Sort by rating
players.sort_by(|a, b| b.rating.rating.partial_cmp(&a.rating.rating).unwrap());
for (i, p) in players.iter().enumerate() {
println!("{}. {} - Rating: {:.1} | RD: {:.1} | Volatility: {:.4}",
i + 1, p.name, p.rating.rating, p.rating.rd, p.rating.volatility);
}
println!("\n{}", sep);
println!(" KEY INSIGHTS");
println!("{}\n", sep);
println!("✅ Glicko-2 rating system working perfectly!");
println!("✅ Rating Deviation (RD) decreases after matches (more certainty)");
println!("✅ Score margins affect ratings:");
println!(" - Charlie's blowout (11-2): +201 points");
println!(" - Alice's moderate win (11-5): +189 points");
println!(" - Charlie's close win (11-9): +74 points");
println!("✅ Volatility tracks performance consistency");
println!("✅ Separate singles/doubles tracking ready");
println!();
println!("Ready for production deployment! 🏓");
println!("{}\n", sep);
}

View File

@ -1,40 +0,0 @@
use pickleball_elo::glicko::{GlickoRating, Glicko2Calculator, calculate_weighted_score};
fn main() {
println!("🏓 Glicko-2 Simple Test\n");
let calc = Glicko2Calculator::new();
// Test 1: Single match between equal players
println!("Test 1: Equal players, one wins 11-5");
let player = GlickoRating::new_player();
let opponent = GlickoRating::new_player();
println!(" Before: Player {:.1} (RD: {:.1})", player.rating, player.rd);
println!(" Before: Opponent {:.1} (RD: {:.1})\n", opponent.rating, opponent.rd);
let outcome = calculate_weighted_score(1.0, 11, 5);
println!(" Weighted outcome: {:.3}", outcome);
let new_rating = calc.update_rating(&player, &[(opponent, outcome)]);
println!(" After: Player {:.1} (RD: {:.1}, σ: {:.4})",
new_rating.rating, new_rating.rd, new_rating.volatility);
println!(" Change: {:+.1}\n", new_rating.rating - player.rating);
// Test 2: Close game vs blowout
println!("Test 2: Close win (11-9) vs Blowout (11-2)");
let close_outcome = calculate_weighted_score(1.0, 11, 9);
let blowout_outcome = calculate_weighted_score(1.0, 11, 2);
println!(" Close (11-9): weighted score = {:.3}", close_outcome);
println!(" Blowout (11-2): weighted score = {:.3}", blowout_outcome);
let close_new = calc.update_rating(&player, &[(opponent, close_outcome)]);
let blowout_new = calc.update_rating(&player, &[(opponent, blowout_outcome)]);
println!(" Close win rating change: {:+.1}", close_new.rating - player.rating);
println!(" Blowout win rating change: {:+.1}\n", blowout_new.rating - player.rating);
println!("✅ All tests complete!");
}

View File

@ -1,20 +0,0 @@
use pickleball_elo::glicko::{GlickoRating, Glicko2Calculator};
fn main() {
println!("Testing Glicko-2 calculator...");
let calc = Glicko2Calculator::new();
let player1 = GlickoRating::new_player();
let player2 = GlickoRating::new_player();
println!("Player 1 before: rating={}, rd={}, vol={}",
player1.rating, player1.rd, player1.volatility);
println!("Calculating update...");
let updated = calc.update_rating(&player1, &[(player2, 1.0)]);
println!("Player 1 after: rating={}, rd={}, vol={}",
updated.rating, updated.rd, updated.volatility);
println!("✅ Test complete!");
}

116
src/config.rs Normal file
View File

@ -0,0 +1,116 @@
//! Configuration module for pickleball-elo
//!
//! Loads application configuration from:
//! - config.toml (for static values)
//! - Environment variables (for sensitive values like SMTP credentials)
use serde::{Deserialize, Serialize};
use anyhow::Result;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EloConfig {
/// K-factor for ELO calculation (typically 32)
pub k_factor: f64,
/// Starting rating for new players (typically 1500.0)
pub starting_rating: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
/// Timezone for match records and daily summaries
pub timezone: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmtpConfig {
/// SMTP server hostname (from PICKLEBALL_SMTP_HOST env var)
pub host: String,
/// SMTP server port (from PICKLEBALL_SMTP_PORT env var)
pub port: u16,
/// SMTP username (from PICKLEBALL_SMTP_USERNAME env var)
pub username: String,
/// SMTP password (from PICKLEBALL_SMTP_PASSWORD env var)
pub password: String,
/// From email address (from PICKLEBALL_SMTP_FROM_EMAIL env var)
pub from_email: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub elo: EloConfig,
pub app: AppConfig,
#[serde(skip)]
pub smtp: Option<SmtpConfig>,
}
impl Config {
/// Load configuration from config.toml and environment variables
pub fn load(config_path: &str) -> Result<Self> {
// Load static config from TOML
let toml_content = std::fs::read_to_string(config_path)?;
let mut config: Config = toml::from_str(&toml_content)?;
// Load SMTP config from environment variables (if available)
if let Ok(host) = std::env::var("PICKLEBALL_SMTP_HOST") {
config.smtp = Some(SmtpConfig {
host,
port: std::env::var("PICKLEBALL_SMTP_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(587),
username: std::env::var("PICKLEBALL_SMTP_USERNAME").unwrap_or_default(),
password: std::env::var("PICKLEBALL_SMTP_PASSWORD").unwrap_or_default(),
from_email: std::env::var("PICKLEBALL_SMTP_FROM_EMAIL")
.unwrap_or_else(|_| "noreply@pickleball-elo.local".to_string()),
});
}
Ok(config)
}
/// Load configuration with defaults if file doesn't exist
pub fn load_or_default(config_path: &str) -> Self {
match Self::load(config_path) {
Ok(config) => config,
Err(_) => {
eprintln!("Warning: Could not load config from {}, using defaults", config_path);
Config::default()
}
}
}
}
impl Default for Config {
fn default() -> Self {
Self {
elo: EloConfig {
k_factor: 32.0,
starting_rating: 1500.0,
},
app: AppConfig {
timezone: "America/New_York".to_string(),
},
smtp: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.elo.k_factor, 32.0);
assert_eq!(config.elo.starting_rating, 1500.0);
assert_eq!(config.app.timezone, "America/New_York");
assert!(config.smtp.is_none());
}
#[test]
fn test_config_from_env() {
// This would require setting env vars in the test environment
// Skipped for now as we don't set them here
}
}

View File

@ -1,6 +1,8 @@
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
use std::path::Path;
pub mod queries;
/// Creates and initializes a connection pool to the SQLite database.
///
/// This function:
@ -45,8 +47,8 @@ pub async fn create_pool(db_path: &str) -> Result<SqlitePool, sqlx::Error> {
/// Runs database migrations to create tables and indexes if they don't exist.
///
/// Creates the following schema:
/// - **players**: Stores player profiles with separate singles/doubles Glicko2 ratings
/// Creates the following schema (v3.0 - Pure ELO):
/// - **players**: Stores player profiles with unified ELO rating
/// - **sessions**: Tracks play sessions with optional summaries
/// - **matches**: Individual matches within sessions (singles or doubles)
/// - **match_participants**: Records each player's performance in a match with before/after ratings
@ -59,21 +61,14 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> {
// Execute each statement
let statements = vec![
"PRAGMA foreign_keys = ON",
// Players table: Stores player profiles with separate Glicko2 ratings for singles and doubles
// Each player maintains independent rating systems since skill in singles vs doubles may differ
// Players table: Stores player profiles with unified ELO rating (v3.0)
// Single rating applies to both singles and doubles matches
"CREATE TABLE IF NOT EXISTS players (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
email TEXT,
-- Glicko2 Rating: Skill estimate (1500 = average)
singles_rating REAL NOT NULL DEFAULT 1500.0,
-- Glicko2 RD (Rating Deviation): Confidence in rating (lower = more confident; ~30 = highly confident)
singles_rd REAL NOT NULL DEFAULT 350.0,
-- Glicko2 Volatility: Unpredictability of performance (0.06 = starting volatility)
singles_volatility REAL NOT NULL DEFAULT 0.06,
doubles_rating REAL NOT NULL DEFAULT 1500.0,
doubles_rd REAL NOT NULL DEFAULT 350.0,
doubles_volatility REAL NOT NULL DEFAULT 0.06,
-- Unified ELO Rating: Skill estimate (1500 = average)
rating REAL NOT NULL DEFAULT 1500.0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_played TEXT NOT NULL DEFAULT (datetime('now'))
)",
@ -96,20 +91,16 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> {
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
)",
// Match participants table: Records each player's participation and rating changes per match
// Stores before/after ratings to allow recalculation and audit trails
// Stores before/after ratings for audit trail
"CREATE TABLE IF NOT EXISTS match_participants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
match_id INTEGER NOT NULL,
player_id INTEGER NOT NULL,
team INTEGER NOT NULL CHECK(team IN (1, 2)),
-- Rating state before match
-- Rating state before match (for audit trail)
rating_before REAL NOT NULL,
rd_before REAL NOT NULL,
volatility_before REAL NOT NULL,
-- Rating state after Glicko2 calculation
-- Rating state after ELO calculation
rating_after REAL NOT NULL,
rd_after REAL NOT NULL,
volatility_after REAL NOT NULL,
-- Net change in rating from this match
rating_change REAL NOT NULL,
FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE,
@ -121,8 +112,7 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> {
"CREATE INDEX IF NOT EXISTS idx_participants_match ON match_participants(match_id)",
"CREATE INDEX IF NOT EXISTS idx_participants_player ON match_participants(player_id)",
"CREATE INDEX IF NOT EXISTS idx_players_name ON players(name)",
"CREATE INDEX IF NOT EXISTS idx_players_singles_rating ON players(singles_rating DESC)",
"CREATE INDEX IF NOT EXISTS idx_players_doubles_rating ON players(doubles_rating DESC)",
"CREATE INDEX IF NOT EXISTS idx_players_rating ON players(rating DESC)",
];
for statement in &statements {

121
src/db/queries.rs Normal file
View File

@ -0,0 +1,121 @@
//! Database query module
//!
//! Centralizes repeated database queries to avoid duplication
//! and provide a consistent interface for data access.
use sqlx::SqlitePool;
use anyhow::Result;
/// Get a player by ID
pub async fn get_player_by_id(pool: &SqlitePool, player_id: i64) -> Result<Option<(i64, String, Option<String>, f64)>> {
let result = sqlx::query_as::<_, (i64, String, Option<String>, f64)>(
"SELECT id, name, email, rating FROM players WHERE id = ?"
)
.bind(player_id)
.fetch_optional(pool)
.await?;
Ok(result)
}
/// Get all players
pub async fn get_all_players(pool: &SqlitePool) -> Result<Vec<(i64, String, f64)>> {
let players = sqlx::query_as::<_, (i64, String, f64)>(
"SELECT id, name, rating FROM players ORDER BY rating DESC"
)
.fetch_all(pool)
.await?;
Ok(players)
}
/// Get leaderboard with ratings
pub async fn get_leaderboard(pool: &SqlitePool) -> Result<Vec<(i64, String, f64)>> {
let leaderboard = sqlx::query_as::<_, (i64, String, f64)>(
r#"SELECT
p.id,
p.name,
p.rating
FROM players p
WHERE p.id IN (SELECT DISTINCT player_id FROM match_participants)
ORDER BY p.rating DESC"#
)
.fetch_all(pool)
.await?;
Ok(leaderboard)
}
/// Get all matches for a player
pub async fn get_player_matches(pool: &SqlitePool, player_id: i64) -> Result<Vec<(i64, String, i32, i32, String)>> {
let matches = sqlx::query_as::<_, (i64, String, i32, i32, String)>(
r#"SELECT
m.id,
m.match_type,
m.team1_score,
m.team2_score,
m.timestamp
FROM matches m
JOIN match_participants mp ON m.id = mp.match_id
WHERE mp.player_id = ?
ORDER BY m.timestamp DESC
LIMIT 100"#
)
.bind(player_id)
.fetch_all(pool)
.await?;
Ok(matches)
}
/// Get all matches from today
pub async fn get_daily_matches(pool: &SqlitePool, date: &str) -> Result<Vec<(i64, String, i32, i32, String)>> {
let matches = sqlx::query_as::<_, (i64, String, i32, i32, String)>(
r#"SELECT
id,
match_type,
team1_score,
team2_score,
timestamp
FROM matches
WHERE DATE(timestamp) = ?
ORDER BY timestamp DESC"#
)
.bind(date)
.fetch_all(pool)
.await?;
Ok(matches)
}
/// Get total match count
pub async fn get_match_count(pool: &SqlitePool) -> Result<i64> {
let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM matches")
.fetch_one(pool)
.await?;
Ok(count)
}
/// Get player count
pub async fn get_player_count(pool: &SqlitePool) -> Result<i64> {
let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM players")
.fetch_one(pool)
.await?;
Ok(count)
}
/// Get session count
pub async fn get_session_count(pool: &SqlitePool) -> Result<i64> {
let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sessions")
.fetch_one(pool)
.await?;
Ok(count)
}
#[cfg(test)]
mod tests {
// Database tests would go here
}

View File

@ -1,287 +0,0 @@
// Demo module for generating test data and running simulations
use crate::glicko::{GlickoRating, Glicko2Calculator, calculate_weighted_score};
use fake::faker::name::en::Name;
use fake::Fake;
extern crate rand;
use rand::Rng;
use rand::seq::SliceRandom;
#[derive(Debug, Clone)]
pub struct Player {
pub name: String,
pub email: String,
pub singles: GlickoRating,
pub doubles: GlickoRating,
pub true_skill: f64, // Hidden skill level for simulation
}
impl Player {
pub fn new_random() -> Self {
let mut rng = rand::thread_rng();
let name: String = Name().fake();
let email = format!("{}@example.com", name.to_lowercase().replace(' ', "."));
// Random true skill between 1200-1800
let true_skill = rng.gen_range(1200.0..1800.0);
Self {
name,
email,
singles: GlickoRating::new_player(),
doubles: GlickoRating::new_player(),
true_skill,
}
}
}
#[derive(Debug)]
pub struct Match {
pub match_type: MatchType,
pub team1: Vec<usize>, // Player indices
pub team2: Vec<usize>,
pub team1_score: i32,
pub team2_score: i32,
}
#[derive(Debug, Clone, Copy)]
pub enum MatchType {
Singles,
Doubles,
}
/// Simulate a match outcome based on true skill levels
pub fn simulate_match(
team1_skills: &[f64],
team2_skills: &[f64],
) -> (i32, i32) {
let mut rng = rand::thread_rng();
// Team skill is average
let team1_avg: f64 = team1_skills.iter().sum::<f64>() / team1_skills.len() as f64;
let team2_avg: f64 = team2_skills.iter().sum::<f64>() / team2_skills.len() as f64;
// Win probability based on skill difference
let skill_diff = team1_avg - team2_avg;
let win_prob = 1.0 / (1.0 + 10_f64.powf(-skill_diff / 400.0));
// Determine winner
let team1_wins = rng.gen::<f64>() < win_prob;
// Generate score (pickleball to 11)
if team1_wins {
let margin = rng.gen_range(1..10); // 1-9 point margin
let team2_score = 11 - margin;
(11, team2_score)
} else {
let margin = rng.gen_range(1..10);
let team1_score = 11 - margin;
(team1_score, 11)
}
}
/// Generate a session of matches
pub fn generate_session(players: &mut [Player], num_matches: usize) -> Vec<Match> {
let mut rng = rand::thread_rng();
let mut matches = Vec::new();
let calc = Glicko2Calculator::new();
for _ in 0..num_matches {
// Randomly choose singles or doubles
let match_type = if rng.gen_bool(0.5) {
MatchType::Singles
} else {
MatchType::Doubles
};
let match_result = match match_type {
MatchType::Singles => {
// Pick 2 random players
let p1_idx = rng.gen_range(0..players.len());
let mut p2_idx = rng.gen_range(0..players.len());
while p2_idx == p1_idx {
p2_idx = rng.gen_range(0..players.len());
}
let (team1_score, team2_score) = simulate_match(
&[players[p1_idx].true_skill],
&[players[p2_idx].true_skill],
);
// Update ratings with performance-based weighting
let p1_outcome = calculate_weighted_score(
players[p1_idx].singles.rating,
players[p2_idx].singles.rating,
team1_score,
team2_score
);
let p2_outcome = calculate_weighted_score(
players[p2_idx].singles.rating,
players[p1_idx].singles.rating,
team2_score,
team1_score
);
players[p1_idx].singles = calc.update_rating(
&players[p1_idx].singles,
&[(players[p2_idx].singles, p1_outcome)],
);
players[p2_idx].singles = calc.update_rating(
&players[p2_idx].singles,
&[(players[p1_idx].singles, p2_outcome)],
);
Match {
match_type,
team1: vec![p1_idx],
team2: vec![p2_idx],
team1_score,
team2_score,
}
}
MatchType::Doubles => {
// Pick 4 random players
let mut indices: Vec<usize> = (0..players.len()).collect();
indices.shuffle(&mut rng);
let team1_indices = vec![indices[0], indices[1]];
let team2_indices = vec![indices[2], indices[3]];
let team1_skills: Vec<f64> = team1_indices.iter()
.map(|&i| players[i].true_skill)
.collect();
let team2_skills: Vec<f64> = team2_indices.iter()
.map(|&i| players[i].true_skill)
.collect();
let (team1_score, team2_score) = simulate_match(&team1_skills, &team2_skills);
// Update doubles ratings (simplified - each player vs team average)
for &idx in &team1_indices {
let avg_opponent_rating = team2_indices.iter().map(|&i| players[i].doubles.rating).sum::<f64>() / 2.0;
let outcome = calculate_weighted_score(
players[idx].doubles.rating,
avg_opponent_rating,
team1_score,
team2_score
);
// Simulate vs average opponent
let avg_opponent = GlickoRating {
rating: avg_opponent_rating,
rd: team2_indices.iter().map(|&i| players[i].doubles.rd).sum::<f64>() / 2.0,
volatility: 0.06,
};
players[idx].doubles = calc.update_rating(
&players[idx].doubles,
&[(avg_opponent, outcome)],
);
}
for &idx in &team2_indices {
let avg_opponent_rating = team1_indices.iter().map(|&i| players[i].doubles.rating).sum::<f64>() / 2.0;
let outcome = calculate_weighted_score(
players[idx].doubles.rating,
avg_opponent_rating,
team2_score,
team1_score
);
let avg_opponent = GlickoRating {
rating: avg_opponent_rating,
rd: team1_indices.iter().map(|&i| players[i].doubles.rd).sum::<f64>() / 2.0,
volatility: 0.06,
};
players[idx].doubles = calc.update_rating(
&players[idx].doubles,
&[(avg_opponent, outcome)],
);
}
Match {
match_type,
team1: team1_indices,
team2: team2_indices,
team1_score,
team2_score,
}
}
};
matches.push(match_result);
}
matches
}
/// Print session summary
pub fn print_summary(players: &[Player], matches: &[Match]) {
println!("\n🏓 Session Summary");
println!("==================");
println!("\n{} matches played\n", matches.len());
println!("Match Results:");
for (i, m) in matches.iter().enumerate() {
match m.match_type {
MatchType::Singles => {
let p1 = &players[m.team1[0]];
let p2 = &players[m.team2[0]];
let winner = if m.team1_score > m.team2_score { &p1.name } else { &p2.name };
println!(
" {}. Singles: {} vs {} → {} wins {}-{}",
i + 1,
p1.name,
p2.name,
winner,
m.team1_score.max(m.team2_score),
m.team1_score.min(m.team2_score)
);
}
MatchType::Doubles => {
let t1_names: Vec<&str> = m.team1.iter().map(|&i| players[i].name.as_str()).collect();
let t2_names: Vec<&str> = m.team2.iter().map(|&i| players[i].name.as_str()).collect();
let winner = if m.team1_score > m.team2_score { "Team 1" } else { "Team 2" };
println!(
" {}. Doubles: {} vs {} → {} wins {}-{}",
i + 1,
t1_names.join(" & "),
t2_names.join(" & "),
winner,
m.team1_score.max(m.team2_score),
m.team1_score.min(m.team2_score)
);
}
}
}
println!("\n📊 Singles Leaderboard:");
let mut singles_sorted = players.to_vec();
singles_sorted.sort_by(|a, b| b.singles.rating.partial_cmp(&a.singles.rating).unwrap());
for (i, p) in singles_sorted.iter().take(10).enumerate() {
println!(
" {}. {} - {:.1} (RD: {:.1}, σ: {:.3})",
i + 1,
p.name,
p.singles.rating,
p.singles.rd,
p.singles.volatility
);
}
println!("\n📊 Doubles Leaderboard:");
let mut doubles_sorted = players.to_vec();
doubles_sorted.sort_by(|a, b| b.doubles.rating.partial_cmp(&a.doubles.rating).unwrap());
for (i, p) in doubles_sorted.iter().take(10).enumerate() {
println!(
" {}. {} - {:.1} (RD: {:.1}, σ: {:.3})",
i + 1,
p.name,
p.doubles.rating,
p.doubles.rd,
p.doubles.volatility
);
}
}

View File

@ -17,7 +17,7 @@ impl EloCalculator {
/// Calculate expected score for player against opponent
/// E = 1 / (1 + 10^((R_opp - R_self)/400))
fn expected_score(&self, player_rating: f64, opponent_rating: f64) -> f64 {
pub fn expected_score(&self, player_rating: f64, opponent_rating: f64) -> f64 {
let rating_diff = opponent_rating - player_rating;
1.0 / (1.0 + 10.0_f64.powf(rating_diff / 400.0))
}

View File

@ -0,0 +1,446 @@
//! Comprehensive integration tests for the ELO rating system
//!
//! These tests verify end-to-end rating calculations for singles and doubles matches,
//! including edge cases and real-world scenarios.
#[cfg(test)]
mod tests {
use crate::elo::calculator::EloCalculator;
use crate::elo::doubles::calculate_effective_opponent_rating;
use crate::elo::rating::EloRating;
use crate::elo::score_weight::calculate_weighted_score;
// ============================================
// SINGLES MATCH TESTS
// ============================================
#[test]
fn test_singles_equal_ratings_close_win() {
// Two 1500-rated players, winner wins 11-9
let calc = EloCalculator::new();
let winner = EloRating::new_player();
let loser = EloRating::new_player();
let performance = calculate_weighted_score(winner.rating, loser.rating, 11, 9);
let new_winner = calc.update_rating(&winner, &loser, performance);
let new_loser = calc.update_rating(&loser, &winner, 1.0 - performance);
// Winner should gain a small amount (~1.6 points for 55% performance vs 50% expected)
assert!(new_winner.rating > winner.rating);
assert!(new_loser.rating < loser.rating);
// Changes should be symmetric
let winner_gain = new_winner.rating - winner.rating;
let loser_loss = loser.rating - new_loser.rating;
assert!((winner_gain - loser_loss).abs() < 0.01);
// For close game, change should be small
assert!(winner_gain < 5.0);
println!("Singles 1500 vs 1500, 11-9: Winner {} -> {} (+{:.1})",
winner.rating, new_winner.rating, winner_gain);
}
#[test]
fn test_singles_equal_ratings_blowout_win() {
// Two 1500-rated players, winner wins 11-2
let calc = EloCalculator::new();
let winner = EloRating::new_player();
let loser = EloRating::new_player();
let performance = calculate_weighted_score(winner.rating, loser.rating, 11, 2);
let new_winner = calc.update_rating(&winner, &loser, performance);
let winner_gain = new_winner.rating - winner.rating;
// Blowout should yield bigger gains than close game
assert!(winner_gain > 10.0);
assert!(winner_gain < 16.0); // Max is 16 for K=32
println!("Singles 1500 vs 1500, 11-2: Winner {} -> {} (+{:.1})",
winner.rating, new_winner.rating, winner_gain);
}
#[test]
fn test_singles_upset_win() {
// Lower rated (1400) beats higher rated (1600), close game 11-9
let calc = EloCalculator::new();
let underdog = EloRating::new_with_rating(1400.0);
let favorite = EloRating::new_with_rating(1600.0);
let performance = calculate_weighted_score(underdog.rating, favorite.rating, 11, 9);
let new_underdog = calc.update_rating(&underdog, &favorite, performance);
let new_favorite = calc.update_rating(&favorite, &underdog, 1.0 - performance);
let underdog_gain = new_underdog.rating - underdog.rating;
let favorite_loss = favorite.rating - new_favorite.rating;
// Upset should yield big gains
assert!(underdog_gain > 5.0);
println!("Singles upset 1400 vs 1600, 11-9: Underdog {} -> {} (+{:.1}), Favorite {} -> {} ({:.1})",
underdog.rating, new_underdog.rating, underdog_gain,
favorite.rating, new_favorite.rating, -favorite_loss);
}
#[test]
fn test_singles_expected_win() {
// Higher rated (1600) beats lower rated (1400), close game 11-9
let calc = EloCalculator::new();
let favorite = EloRating::new_with_rating(1600.0);
let underdog = EloRating::new_with_rating(1400.0);
let performance = calculate_weighted_score(favorite.rating, underdog.rating, 11, 9);
let new_favorite = calc.update_rating(&favorite, &underdog, performance);
let favorite_gain = new_favorite.rating - favorite.rating;
// Expected win should yield small gains (underperformed expectations)
// Expected ~64% of points, got 55%
assert!(favorite_gain < 0.0); // Actually loses rating for close expected win!
println!("Singles expected 1600 vs 1400, 11-9: Favorite {} -> {} ({:.1})",
favorite.rating, new_favorite.rating, favorite_gain);
}
#[test]
fn test_singles_expected_blowout_win() {
// Higher rated (1600) blows out lower rated (1400), 11-2
let calc = EloCalculator::new();
let favorite = EloRating::new_with_rating(1600.0);
let underdog = EloRating::new_with_rating(1400.0);
let performance = calculate_weighted_score(favorite.rating, underdog.rating, 11, 2);
let new_favorite = calc.update_rating(&favorite, &underdog, performance);
let favorite_gain = new_favorite.rating - favorite.rating;
// Blowout exceeds expectations (85% vs expected 64%), should gain
assert!(favorite_gain > 0.0);
println!("Singles expected blowout 1600 vs 1400, 11-2: Favorite {} -> {} (+{:.1})",
favorite.rating, new_favorite.rating, favorite_gain);
}
#[test]
fn test_singles_shutout() {
// Complete shutout 11-0
let calc = EloCalculator::new();
let winner = EloRating::new_player();
let loser = EloRating::new_player();
let performance = calculate_weighted_score(winner.rating, loser.rating, 11, 0);
assert!((performance - 1.0).abs() < 0.001); // 100% performance
let new_winner = calc.update_rating(&winner, &loser, performance);
// Should be max gain of K/2 = 16 (since expected is 0.5)
let gain = new_winner.rating - winner.rating;
assert!((gain - 16.0).abs() < 0.1);
println!("Singles shutout 11-0: {} -> {} (+{:.1})",
winner.rating, new_winner.rating, gain);
}
#[test]
fn test_singles_get_shutout() {
// Complete shutout loss 0-11
let calc = EloCalculator::new();
let winner = EloRating::new_player();
let loser = EloRating::new_player();
let performance = calculate_weighted_score(loser.rating, winner.rating, 0, 11);
assert!(performance.abs() < 0.001); // 0% performance
let new_loser = calc.update_rating(&loser, &winner, performance);
// Should be max loss of K/2 = 16
let loss = loser.rating - new_loser.rating;
assert!((loss - 16.0).abs() < 0.1);
println!("Singles get shutout 0-11: {} -> {} (-{:.1})",
loser.rating, new_loser.rating, loss);
}
// ============================================
// DOUBLES MATCH TESTS
// ============================================
#[test]
fn test_doubles_equal_teams() {
// All four players rated 1500
let calc = EloCalculator::new();
let player1 = EloRating::new_player(); // 1500
let teammate1 = EloRating::new_player(); // 1500
let opp1 = EloRating::new_player(); // 1500
let opp2 = EloRating::new_player(); // 1500
// Player 1's effective opponent
let eff_opp = calculate_effective_opponent_rating(opp1.rating, opp2.rating, teammate1.rating);
assert!((eff_opp - 1500.0).abs() < 0.01); // 1500+1500-1500 = 1500
// Win 11-9
let performance = calculate_weighted_score(player1.rating, eff_opp, 11, 9);
let new_player1 = calc.update_rating(&player1, &EloRating::new_with_rating(eff_opp), performance);
let gain = new_player1.rating - player1.rating;
println!("Doubles equal teams, 11-9: {} -> {} (+{:.1})",
player1.rating, new_player1.rating, gain);
}
#[test]
fn test_doubles_carried_by_strong_teammate() {
// Player (1400) with strong teammate (1600) vs two 1500s
let calc = EloCalculator::new();
let player = EloRating::new_with_rating(1400.0);
let teammate = EloRating::new_with_rating(1600.0);
let opp1 = EloRating::new_player(); // 1500
let opp2 = EloRating::new_player(); // 1500
// Player's effective opponent: 1500+1500-1600 = 1400
let eff_opp = calculate_effective_opponent_rating(opp1.rating, opp2.rating, teammate.rating);
assert!((eff_opp - 1400.0).abs() < 0.01);
// Teammate's effective opponent: 1500+1500-1400 = 1600
let teammate_eff_opp = calculate_effective_opponent_rating(opp1.rating, opp2.rating, player.rating);
assert!((teammate_eff_opp - 1600.0).abs() < 0.01);
// Win 11-9
let player_perf = calculate_weighted_score(player.rating, eff_opp, 11, 9);
let teammate_perf = calculate_weighted_score(teammate.rating, teammate_eff_opp, 11, 9);
let new_player = calc.update_rating(&player, &EloRating::new_with_rating(eff_opp), player_perf);
let new_teammate = calc.update_rating(&teammate, &EloRating::new_with_rating(teammate_eff_opp), teammate_perf);
let player_gain = new_player.rating - player.rating;
let teammate_gain = new_teammate.rating - teammate.rating;
// Player faces easier effective opponent (1400), should gain less
// Teammate faces harder effective opponent (1600), should lose rating (underperformed)
println!("Doubles carry: Player (1400) eff_opp=1400, gain={:.1}; Teammate (1600) eff_opp=1600, gain={:.1}",
player_gain, teammate_gain);
// The weaker player benefits less from wins with strong partner
assert!(player_gain < 3.0);
}
#[test]
fn test_doubles_carrying_weak_teammate() {
// Strong player (1600) with weak teammate (1400) vs two 1500s
let calc = EloCalculator::new();
let strong_player = EloRating::new_with_rating(1600.0);
let weak_teammate = EloRating::new_with_rating(1400.0);
let opp1 = EloRating::new_player(); // 1500
let opp2 = EloRating::new_player(); // 1500
// Strong player's effective opponent: 1500+1500-1400 = 1600
let eff_opp = calculate_effective_opponent_rating(opp1.rating, opp2.rating, weak_teammate.rating);
assert!((eff_opp - 1600.0).abs() < 0.01);
// Win 11-9 - strong player carrying
let performance = calculate_weighted_score(strong_player.rating, eff_opp, 11, 9);
let new_strong = calc.update_rating(&strong_player, &EloRating::new_with_rating(eff_opp), performance);
let gain = new_strong.rating - strong_player.rating;
// Strong player faces harder effective opponent, gains less for close win
// (or even loses points since 55% < expected)
println!("Doubles carrying: Strong (1600) eff_opp=1600, 11-9 win, change={:.1}", gain);
}
#[test]
fn test_doubles_all_different_ratings() {
// Realistic scenario: 1550+1450 vs 1520+1480
let calc = EloCalculator::new();
let p1 = EloRating::new_with_rating(1550.0);
let p1_teammate = EloRating::new_with_rating(1450.0);
let p2 = EloRating::new_with_rating(1520.0);
let p2_teammate = EloRating::new_with_rating(1480.0);
// P1's effective opponent: 1520+1480-1450 = 1550
let p1_eff = calculate_effective_opponent_rating(p2.rating, p2_teammate.rating, p1_teammate.rating);
// P1's teammate's effective opponent: 1520+1480-1550 = 1450
let p1t_eff = calculate_effective_opponent_rating(p2.rating, p2_teammate.rating, p1.rating);
// P2's effective opponent: 1550+1450-1480 = 1520
let p2_eff = calculate_effective_opponent_rating(p1.rating, p1_teammate.rating, p2_teammate.rating);
// P2's teammate's effective opponent: 1550+1450-1520 = 1480
let p2t_eff = calculate_effective_opponent_rating(p1.rating, p1_teammate.rating, p2.rating);
println!("Team 1 (1550+1450) vs Team 2 (1520+1480):");
println!(" P1 (1550) eff_opp: {:.0}", p1_eff);
println!(" P1 teammate (1450) eff_opp: {:.0}", p1t_eff);
println!(" P2 (1520) eff_opp: {:.0}", p2_eff);
println!(" P2 teammate (1480) eff_opp: {:.0}", p2t_eff);
// Each player's effective opponent equals their own rating!
// This is a property of balanced teams
assert!((p1_eff - p1.rating).abs() < 0.01);
assert!((p1t_eff - p1_teammate.rating).abs() < 0.01);
}
// ============================================
// K-FACTOR TESTS
// ============================================
#[test]
fn test_different_k_factors() {
let player = EloRating::new_player();
let opponent = EloRating::new_player();
let calc_k16 = EloCalculator::new_with_k_factor(16.0);
let calc_k32 = EloCalculator::new_with_k_factor(32.0);
let calc_k64 = EloCalculator::new_with_k_factor(64.0);
let new_k16 = calc_k16.update_rating(&player, &opponent, 1.0);
let new_k32 = calc_k32.update_rating(&player, &opponent, 1.0);
let new_k64 = calc_k64.update_rating(&player, &opponent, 1.0);
// Higher K = more volatile ratings
let gain_k16 = new_k16.rating - player.rating;
let gain_k32 = new_k32.rating - player.rating;
let gain_k64 = new_k64.rating - player.rating;
assert!((gain_k16 - 8.0).abs() < 0.1); // K=16, win = +8
assert!((gain_k32 - 16.0).abs() < 0.1); // K=32, win = +16
assert!((gain_k64 - 32.0).abs() < 0.1); // K=64, win = +32
println!("K-factor comparison for 1.0 performance:");
println!(" K=16: +{:.1}", gain_k16);
println!(" K=32: +{:.1}", gain_k32);
println!(" K=64: +{:.1}", gain_k64);
}
// ============================================
// EDGE CASES
// ============================================
#[test]
fn test_extreme_rating_difference() {
// 2000 vs 1000 - extreme mismatch
let calc = EloCalculator::new();
let elite = EloRating::new_with_rating(2000.0);
let beginner = EloRating::new_with_rating(1000.0);
// Elite wins as expected 11-3
let perf = calculate_weighted_score(elite.rating, beginner.rating, 11, 3);
let new_elite = calc.update_rating(&elite, &beginner, perf);
// Expected performance is ~0.99, actual is 0.786
// Should actually lose rating!
let change = new_elite.rating - elite.rating;
assert!(change < 0.0);
println!("Extreme mismatch 2000 vs 1000, 11-3: Elite change = {:.1}", change);
}
#[test]
fn test_beginner_beats_elite() {
// Major upset: 1000 beats 2000
let calc = EloCalculator::new();
let beginner = EloRating::new_with_rating(1000.0);
let elite = EloRating::new_with_rating(2000.0);
let perf = calculate_weighted_score(beginner.rating, elite.rating, 11, 9);
let new_beginner = calc.update_rating(&beginner, &elite, perf);
let gain = new_beginner.rating - beginner.rating;
// Massive upset - expected only ~1% of points, got 55%!
assert!(gain > 15.0);
println!("Major upset 1000 beats 2000, 11-9: Beginner gain = +{:.1}", gain);
}
#[test]
fn test_rating_conservation_singles() {
// In a match, total rating change should sum to approximately zero
let calc = EloCalculator::new();
let p1 = EloRating::new_with_rating(1500.0);
let p2 = EloRating::new_with_rating(1500.0);
let p1_perf = calculate_weighted_score(p1.rating, p2.rating, 11, 7);
let p2_perf = calculate_weighted_score(p2.rating, p1.rating, 7, 11);
let new_p1 = calc.update_rating(&p1, &p2, p1_perf);
let new_p2 = calc.update_rating(&p2, &p1, p2_perf);
let total_change = (new_p1.rating - p1.rating) + (new_p2.rating - p2.rating);
// Should sum to zero (rating conserved in the system)
assert!(total_change.abs() < 0.01);
println!("Rating conservation: P1 {:.1}, P2 {:.1}, sum = {:.3}",
new_p1.rating - p1.rating, new_p2.rating - p2.rating, total_change);
}
#[test]
fn test_multiple_matches_convergence() {
// After many matches, better player should have higher rating
let calc = EloCalculator::new();
let mut strong = EloRating::new_player(); // Actually wins 70% of points
let mut weak = EloRating::new_player(); // Actually wins 30% of points
// Simulate 20 matches where strong player gets ~70% of points
for _ in 0..20 {
let strong_points = 11;
let weak_points = 5; // ~70-30 split
let strong_perf = calculate_weighted_score(strong.rating, weak.rating, strong_points, weak_points);
let weak_perf = calculate_weighted_score(weak.rating, strong.rating, weak_points, strong_points);
strong = calc.update_rating(&strong, &weak, strong_perf);
weak = calc.update_rating(&weak, &strong, weak_perf);
}
// Strong player should be significantly higher rated now
assert!(strong.rating > weak.rating + 100.0);
println!("After 20 matches (70-30 split): Strong={:.0}, Weak={:.0}, Diff={:.0}",
strong.rating, weak.rating, strong.rating - weak.rating);
}
// ============================================
// SCORE WEIGHT TESTS
// ============================================
#[test]
fn test_score_weight_various_margins() {
// Compare performance scores for different margins
let scores = vec![
(11, 0, "11-0 shutout"),
(11, 1, "11-1"),
(11, 5, "11-5"),
(11, 9, "11-9 close"),
(11, 10, "11-10 tiebreak"),
];
println!("Performance scores (1500 vs 1500):");
for (won, lost, label) in scores {
let perf = calculate_weighted_score(1500.0, 1500.0, won, lost);
println!(" {}: {:.3}", label, perf);
}
}
#[test]
fn test_performance_symmetry() {
// Winner and loser performances should sum to 1.0
let winner_perf = calculate_weighted_score(1500.0, 1500.0, 11, 7);
let loser_perf = calculate_weighted_score(1500.0, 1500.0, 7, 11);
assert!((winner_perf + loser_perf - 1.0).abs() < 0.001);
println!("Performance symmetry: Winner {:.3} + Loser {:.3} = {:.3}",
winner_perf, loser_perf, winner_perf + loser_perf);
}
}

View File

@ -3,6 +3,9 @@ pub mod calculator;
pub mod doubles;
pub mod score_weight;
#[cfg(test)]
mod integration_tests;
pub use rating::EloRating;
pub use calculator::EloCalculator;
pub use doubles::{calculate_effective_opponent_rating, calculate_effective_opponent};

123
src/elo/tests.rs Normal file
View File

@ -0,0 +1,123 @@
//! Comprehensive tests for the ELO rating system
//!
//! Tests the pure ELO calculator with unified ratings for both singles and doubles
#[cfg(test)]
mod test_elo {
use crate::elo::{EloCalculator, EloRating};
#[test]
fn test_elo_expected_score_equal_ratings() {
let calc = EloCalculator::new();
let player1 = EloRating::new_player();
let player2 = EloRating::new_player();
// When both players have same rating, expected score should be 0.5
let expected = calc.expected_score(player1.rating, player2.rating);
assert!((expected - 0.5).abs() < 0.001, "Expected 0.5, got {}", expected);
}
#[test]
fn test_elo_expected_score_higher_rated() {
let calc = EloCalculator::new();
let player_high = EloRating::new_with_rating(1600.0);
let player_low = EloRating::new_with_rating(1500.0);
let expected = calc.expected_score(player_high.rating, player_low.rating);
// Higher rated player should have > 0.5 expected score
assert!(expected > 0.5, "Expected > 0.5 for higher rated, got {}", expected);
assert!((expected - 0.64).abs() < 0.02, "Expected ~0.64, got {}", expected);
}
#[test]
fn test_elo_rating_update_win_as_expected() {
let calc = EloCalculator::new();
let player = EloRating::new_player();
let opponent = EloRating::new_player();
// Win (1.0 performance) as expected (E=0.5)
// ΔR = 32 × (1.0 - 0.5) = 16
let new_rating = calc.update_rating(&player, &opponent, 1.0);
assert!((new_rating.rating - 1516.0).abs() < 0.1,
"Expected 1516, got {}", new_rating.rating);
}
#[test]
fn test_elo_rating_update_loss_as_expected() {
let calc = EloCalculator::new();
let player = EloRating::new_player();
let opponent = EloRating::new_player();
// Loss (0.0 performance) as expected (E=0.5)
// ΔR = 32 × (0.0 - 0.5) = -16
let new_rating = calc.update_rating(&player, &opponent, 0.0);
assert!((new_rating.rating - 1484.0).abs() < 0.1,
"Expected 1484, got {}", new_rating.rating);
}
#[test]
fn test_elo_rating_update_upset_win() {
let calc = EloCalculator::new();
let player_low = EloRating::new_with_rating(1400.0);
let player_high = EloRating::new_with_rating(1500.0);
// Lower rated player wins (unexpected)
let new_rating = calc.update_rating(&player_low, &player_high, 1.0);
assert!(new_rating.rating > player_low.rating + 20.0,
"Upset win should gain >20");
}
#[test]
fn test_elo_rating_update_draw() {
let calc = EloCalculator::new();
let player = EloRating::new_player();
let opponent = EloRating::new_player();
// Draw/neutral (0.5 performance, E=0.5)
let new_rating = calc.update_rating(&player, &opponent, 0.5);
assert!((new_rating.rating - player.rating).abs() < 0.1,
"Draw should result in no change");
}
#[test]
fn test_elo_rating_never_below_one() {
let calc = EloCalculator::new_with_k_factor(1000.0); // Huge K for testing
let player = EloRating::new_with_rating(10.0);
let opponent = EloRating::new_with_rating(2500.0);
// Massive loss, but should never go below 1
let new_rating = calc.update_rating(&player, &opponent, 0.0);
assert!(new_rating.rating >= 1.0,
"Rating should never drop below 1, got {}", new_rating.rating);
}
#[test]
fn test_elo_partial_score_singles_win() {
let calc = EloCalculator::new();
let player = EloRating::new_player();
let opponent = EloRating::new_player();
// Close singles win: 11-9 means 11/20 = 0.55 performance
let new_rating = calc.update_rating(&player, &opponent, 0.55);
// Should be slightly positive change
let delta = new_rating.rating - player.rating;
assert!(delta > 0.0 && delta < 5.0,
"Close win should give small positive gain");
}
#[test]
fn test_elo_blowout_win() {
let calc = EloCalculator::new();
let player = EloRating::new_player();
let opponent = EloRating::new_player();
// Blowout: 11-2 means 11/13 = 0.846 performance
let new_rating = calc.update_rating(&player, &opponent, 11.0 / 13.0);
let delta = new_rating.rating - player.rating;
// Should be much larger than close win
assert!(delta > 25.0,
"Blowout win should give large gain, got {}", delta);
}
}

View File

@ -1,219 +0,0 @@
use super::rating::GlickoRating;
use std::f64::consts::PI;
pub struct Glicko2Calculator {
tau: f64, // System volatility constraint (0.5)
epsilon: f64, // Convergence tolerance (0.000001)
}
impl Glicko2Calculator {
pub fn new() -> Self {
Self {
tau: 0.5,
epsilon: 0.0001, // Relaxed for performance
}
}
pub fn new_with_tau(tau: f64) -> Self {
Self { tau, epsilon: 0.0001 }
}
/// Update a player's rating based on match results
///
/// Arguments:
/// - player: Current rating
/// - results: Vec of (opponent_rating, weighted_outcome)
/// where weighted_outcome is from calculate_weighted_score (0.0-1.2)
pub fn update_rating(
&self,
player: &GlickoRating,
results: &[(GlickoRating, f64)],
) -> GlickoRating {
if results.is_empty() {
return *player;
}
let (mu, phi, sigma) = player.to_glicko2_scale();
// Step 1: Calculate g(φⱼ) for each opponent
let g_values: Vec<f64> = results.iter()
.map(|(opp, _)| {
let (_, phi_j, _) = opp.to_glicko2_scale();
self.g(phi_j)
})
.collect();
// Step 2: Calculate E(μ, μⱼ, φⱼ) for each opponent
let e_values: Vec<f64> = results.iter()
.zip(g_values.iter())
.map(|((opp, _), g_j)| {
let (mu_j, _, _) = opp.to_glicko2_scale();
self.e(mu, mu_j, *g_j)
})
.collect();
// Step 3: Calculate variance (v)
let v = self.calculate_variance(&g_values, &e_values);
// Step 4: Calculate rating change direction (Δ)
let delta = self.calculate_delta(&g_values, &e_values, results, v);
// Step 5: Update volatility (σ')
let sigma_prime = self.update_volatility(phi, sigma, delta, v);
// Step 6: Pre-rating period RD update (φ*)
let phi_star = (phi.powi(2) + sigma_prime.powi(2)).sqrt();
// Step 7: Update rating and RD
let phi_prime = 1.0 / (1.0 / phi_star.powi(2) + 1.0 / v).sqrt();
let mu_prime = mu + phi_prime.powi(2) * results.iter()
.zip(g_values.iter())
.zip(e_values.iter())
.map(|(((_, outcome), g_j), e_j)| g_j * (outcome - e_j))
.sum::<f64>();
// Step 8: Convert back to display scale
GlickoRating::from_glicko2_scale(mu_prime, phi_prime, sigma_prime)
}
/// g(φⱼ) = 1 / √(1 + 3φⱼ² / π²)
fn g(&self, phi_j: f64) -> f64 {
1.0 / (1.0 + 3.0 * phi_j.powi(2) / PI.powi(2)).sqrt()
}
/// E(μ, μⱼ, φⱼ) = 1 / (1 + exp(-g(φⱼ) × (μ - μⱼ)))
fn e(&self, mu: f64, mu_j: f64, g_j: f64) -> f64 {
1.0 / (1.0 + (-g_j * (mu - mu_j)).exp())
}
/// v = 1 / Σⱼ [ g(φⱼ)² × E × (1 - E) ]
fn calculate_variance(&self, g_values: &[f64], e_values: &[f64]) -> f64 {
let sum: f64 = g_values.iter()
.zip(e_values.iter())
.map(|(g_j, e_j)| g_j.powi(2) * e_j * (1.0 - e_j))
.sum();
1.0 / sum
}
/// Δ = v × Σⱼ [ g(φⱼ) × (sⱼ - E) ]
fn calculate_delta(
&self,
g_values: &[f64],
e_values: &[f64],
results: &[(GlickoRating, f64)],
v: f64,
) -> f64 {
v * g_values.iter()
.zip(e_values.iter())
.zip(results.iter())
.map(|((g_j, e_j), (_, outcome))| g_j * (outcome - e_j))
.sum::<f64>()
}
/// Update volatility using bisection algorithm (more reliable than Illinois)
fn update_volatility(&self, phi: f64, sigma: f64, delta: f64, v: f64) -> f64 {
let ln_sigma_sq = sigma.powi(2).ln();
let phi_sq = phi.powi(2);
let delta_sq = delta.powi(2);
let tau_sq = self.tau.powi(2);
// Helper function for f(x)
let compute_f = |x: f64| {
let exp_x = x.exp();
let denom = 2.0 * (phi_sq + v + exp_x).powi(2);
let numer = exp_x * (delta_sq - phi_sq - v - exp_x);
numer / denom - (x - ln_sigma_sq) / tau_sq
};
// Find initial bracket [a, b] where f(a)*f(b) < 0
let mut a = ln_sigma_sq;
let fa_init = compute_f(a);
// Find b such that f(b) has opposite sign
let mut b = if delta_sq > phi_sq + v {
(delta_sq - phi_sq - v).ln()
} else {
let mut k = 1.0;
let mut candidate = ln_sigma_sq - k * self.tau;
while compute_f(candidate) >= 0.0 && k < 10.0 {
k += 1.0;
candidate = ln_sigma_sq - k * self.tau;
}
candidate
};
let mut fa = fa_init;
let fb = compute_f(b);
// Ensure proper bracket
if fa * fb >= 0.0 {
// If still same sign, just return initial guess
return sigma;
}
// Bisection with iteration limit
let mut iterations = 0;
const MAX_ITERATIONS: usize = 50;
while (b - a).abs() > self.epsilon && iterations < MAX_ITERATIONS {
let c = (a + b) / 2.0;
let fc = compute_f(c);
if fc * fa < 0.0 {
b = c;
} else {
a = c;
fa = fc;
}
iterations += 1;
}
((a + b) / 2.0 / 2.0).exp()
}
}
impl Default for Glicko2Calculator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::glicko::score_weight::calculate_weighted_score;
#[test]
fn test_rating_unchanged_no_matches() {
let calc = Glicko2Calculator::new();
let player = GlickoRating::new_player();
let results = vec![];
let new_rating = calc.update_rating(&player, &results);
assert_eq!(new_rating, player);
}
#[test]
fn test_score_margin_impact() {
let calc = Glicko2Calculator::new();
let player = GlickoRating::new_player();
let opponent = GlickoRating::new_player();
// Close win (11-9, equal ratings)
let close_outcome = calculate_weighted_score(player.rating, opponent.rating, 11, 9);
let close_results = vec![(opponent, close_outcome)];
let close_new = calc.update_rating(&player, &close_results);
// Blowout win (11-2, equal ratings)
let blowout_outcome = calculate_weighted_score(player.rating, opponent.rating, 11, 2);
let blowout_results = vec![(opponent, blowout_outcome)];
let blowout_new = calc.update_rating(&player, &blowout_results);
// Blowout should give bigger rating boost
assert!(blowout_new.rating > close_new.rating);
println!("Close win: {} -> {}", player.rating, close_new.rating);
println!("Blowout win: {} -> {}", player.rating, blowout_new.rating);
}
}

View File

@ -1,155 +0,0 @@
use super::rating::GlickoRating;
/// Calculate team rating from two partners (average approach)
/// Returns: (team_mu, team_phi) in Glicko-2 scale
pub fn calculate_team_rating(
partner1: &GlickoRating,
partner2: &GlickoRating,
) -> (f64, f64) {
let (mu1, phi1, _) = partner1.to_glicko2_scale();
let (mu2, phi2, _) = partner2.to_glicko2_scale();
let team_mu = (mu1 + mu2) / 2.0;
let team_phi = ((phi1.powi(2) + phi2.powi(2)) / 2.0).sqrt();
(team_mu, team_phi)
}
/// Calculate effective opponent rating for a player in doubles
/// This personalizes the rating adjustment based on partner strength
///
/// Formula: Effective Opponent = Opp1_rating + Opp2_rating - Teammate_rating
///
/// This makes intuitive sense:
/// - If opponents are strong, effective opponent rating is higher
/// - If your teammate is strong, effective opponent rating is lower (teammate helped)
/// - If your teammate is weak, effective opponent rating is higher (you did more work)
///
/// Returns: The effective opponent rating (in display scale, e.g., 1400-1600)
pub fn calculate_effective_opponent_rating(
opponent1_rating: f64,
opponent2_rating: f64,
teammate_rating: f64,
) -> f64 {
opponent1_rating + opponent2_rating - teammate_rating
}
/// Calculate effective opponent as a GlickoRating struct
/// Uses the effective rating and interpolates RD/volatility
pub fn calculate_effective_opponent(
opponent1: &GlickoRating,
opponent2: &GlickoRating,
teammate: &GlickoRating,
) -> GlickoRating {
let effective_rating = calculate_effective_opponent_rating(
opponent1.rating,
opponent2.rating,
teammate.rating,
);
// For RD, use average of opponents (they're the collective threat)
let effective_rd = (opponent1.rd + opponent2.rd) / 2.0;
// For volatility, use average opponent volatility
let effective_volatility = (opponent1.volatility + opponent2.volatility) / 2.0;
GlickoRating {
rating: effective_rating,
rd: effective_rd,
volatility: effective_volatility,
}
}
/// Distribute rating change between partners based on RD
/// More uncertain (higher RD) players get more weight because they should update faster
/// This reflects the principle that ratings with higher uncertainty should be adjusted more
/// aggressively to converge to their true skill level.
pub fn distribute_rating_change(
partner1_rd: f64,
partner2_rd: f64,
team_change: f64,
) -> (f64, f64) {
// Higher RD → more uncertain → deserves more change
let weight1 = partner1_rd.powi(2);
let weight2 = partner2_rd.powi(2);
let total_weight = weight1 + weight2;
(
team_change * (weight1 / total_weight),
team_change * (weight2 / total_weight),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_team_rating() {
let p1 = GlickoRating { rating: 1600.0, rd: 200.0, volatility: 0.06 };
let p2 = GlickoRating { rating: 1400.0, rd: 200.0, volatility: 0.06 };
let (team_mu, _) = calculate_team_rating(&p1, &p2);
// Team rating should be ~1500 (average)
let team_rating = team_mu * 173.7178 + 1500.0;
assert!((team_rating - 1500.0).abs() < 1.0);
println!("Team rating: {}", team_rating);
}
#[test]
fn test_distribution() {
let (c1, c2) = distribute_rating_change(100.0, 200.0, 10.0);
// Higher RD (200) should get more change (now correct!)
assert!(c2 > c1);
// Should sum to total change
assert!((c1 + c2 - 10.0).abs() < 0.001);
println!("Distribution: {} / {} (total: {})", c1, c2, c1 + c2);
}
#[test]
fn test_effective_opponent_equal_teams() {
// Both teams equally matched
// Opp1: 1500, Opp2: 1500, Teammate: 1500
// Effective opponent = 1500 + 1500 - 1500 = 1500
let eff = calculate_effective_opponent_rating(1500.0, 1500.0, 1500.0);
assert!((eff - 1500.0).abs() < 0.001);
println!("Equal teams: {}", eff);
}
#[test]
fn test_effective_opponent_strong_teammate() {
// Strong teammates make it "easier" - lower effective opponent
// Opp1: 1500, Opp2: 1500, Teammate: 1600
// Effective opponent = 1500 + 1500 - 1600 = 1400
let eff = calculate_effective_opponent_rating(1500.0, 1500.0, 1600.0);
assert!((eff - 1400.0).abs() < 0.001);
println!("Strong teammate (1600 vs 1500/1500): effective = {}", eff);
}
#[test]
fn test_effective_opponent_weak_teammate() {
// Weak teammates make it "harder" - higher effective opponent
// Opp1: 1500, Opp2: 1500, Teammate: 1400
// Effective opponent = 1500 + 1500 - 1400 = 1600
let eff = calculate_effective_opponent_rating(1500.0, 1500.0, 1400.0);
assert!((eff - 1600.0).abs() < 0.001);
println!("Weak teammate (1400 vs 1500/1500): effective = {}", eff);
}
#[test]
fn test_effective_opponent_struct() {
let opp1 = GlickoRating { rating: 1500.0, rd: 100.0, volatility: 0.06 };
let opp2 = GlickoRating { rating: 1600.0, rd: 150.0, volatility: 0.07 };
let teammate = GlickoRating { rating: 1400.0, rd: 200.0, volatility: 0.08 };
let eff = calculate_effective_opponent(&opp1, &opp2, &teammate);
// Rating: 1500 + 1600 - 1400 = 1700
assert!((eff.rating - 1700.0).abs() < 0.001);
// RD: (100 + 150) / 2 = 125
assert!((eff.rd - 125.0).abs() < 0.001);
// Volatility: (0.06 + 0.07) / 2 = 0.065
assert!((eff.volatility - 0.065).abs() < 0.001);
println!("Effective opponent struct: {:.0} (RD: {:.0})", eff.rating, eff.rd);
}
}

View File

@ -1,27 +0,0 @@
pub mod rating;
pub mod calculator;
pub mod score_weight;
pub mod doubles;
pub use rating::GlickoRating;
pub use calculator::Glicko2Calculator;
pub use score_weight::calculate_weighted_score;
pub use doubles::{calculate_team_rating, distribute_rating_change};
/// Convenience function to calculate new ratings for a single match
/// Returns (winner_new_rating, loser_new_rating)
pub fn calculate_new_ratings(
player1: &GlickoRating,
player2: &GlickoRating,
player1_score: f64, // 1.0 = win, 0.0 = loss, 0.5 = draw
margin_multiplier: f64, // From calculate_weighted_score
) -> (GlickoRating, GlickoRating) {
let calc = Glicko2Calculator::new();
let player2_score = 1.0 - player1_score;
let new_p1 = calc.update_rating(player1, &[(*player2, player1_score * margin_multiplier.min(1.2))]);
let new_p2 = calc.update_rating(player2, &[(*player1, player2_score * margin_multiplier.min(1.2))]);
(new_p1, new_p2)
}

View File

@ -1,34 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct GlickoRating {
pub rating: f64, // Display scale (e.g., 1500)
pub rd: f64, // Rating deviation (350 for new)
pub volatility: f64, // Consistency (0.06 default)
}
impl GlickoRating {
pub fn new_player() -> Self {
Self {
rating: 1500.0,
rd: 350.0,
volatility: 0.06,
}
}
pub fn to_glicko2_scale(&self) -> (f64, f64, f64) {
// Convert to internal scale: μ, φ, σ
let mu = (self.rating - 1500.0) / 173.7178;
let phi = self.rd / 173.7178;
(mu, phi, self.volatility)
}
pub fn from_glicko2_scale(mu: f64, phi: f64, sigma: f64) -> Self {
// Convert back to display scale
Self {
rating: mu * 173.7178 + 1500.0,
rd: phi * 173.7178,
volatility: sigma,
}
}
}

View File

@ -1,97 +0,0 @@
/// Calculate performance-based score using per-point expected value
///
/// Instead of arbitrary margin bonuses, this calculates the probability of winning
/// each individual point based on rating difference, then uses the actual performance
/// (points won / total points) as the outcome.
///
/// Arguments:
/// - player_rating: The player/team's rating (display scale, e.g., 1500)
/// - opponent_rating: The opponent's rating (display scale)
/// - points_scored: Points the player/team scored in the match
/// - points_allowed: Points the opponent scored
///
/// Returns: Performance ratio (0.0-1.0) representing actual_points / total_points,
/// weighted by expected value. Higher if player overperformed expectations.
pub fn calculate_weighted_score(
player_rating: f64,
opponent_rating: f64,
points_scored: i32,
points_allowed: i32,
) -> f64 {
let total_points = (points_scored + points_allowed) as f64;
if total_points == 0.0 {
return 0.5; // No points played, assume 50/50
}
let points_scored_f64 = points_scored as f64;
// Calculate expected probability of winning a single point
// P(win point) = 1 / (1 + 10^((R_opp - R_self)/400))
// Note: We compute this for reference, but use raw performance ratio instead
let rating_diff = opponent_rating - player_rating;
let _p_win_point = 1.0 / (1.0 + 10.0_f64.powf(rating_diff / 400.0));
// Performance ratio: actual points / total points
let performance = points_scored_f64 / total_points;
// Return performance as the outcome (this feeds into Glicko-2)
// This represents: how well did you perform relative to expected?
performance
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_equal_ratings_close_game() {
// With equal ratings, expected P(point) = 0.5
// Actual: 11 points out of 20 = 0.55 performance
let s = calculate_weighted_score(1500.0, 1500.0, 11, 9);
assert!((s - 0.55).abs() < 0.001);
println!("Equal ratings, 11-9 win: {}", s);
}
#[test]
fn test_equal_ratings_blowout() {
// With equal ratings, expected P(point) = 0.5
// Actual: 11 points out of 13 = 0.846 performance
let s = calculate_weighted_score(1500.0, 1500.0, 11, 2);
assert!((s - (11.0 / 13.0)).abs() < 0.001);
println!("Equal ratings, 11-2 win: {}", s);
}
#[test]
fn test_higher_rated_player() {
// Player rated 100 points higher: P(point) ≈ 0.64
// Actual: 11/20 = 0.55 (underperformed slightly)
let s = calculate_weighted_score(1600.0, 1500.0, 11, 9);
assert!((s - 0.55).abs() < 0.001);
println!("Higher rated (1600 vs 1500), 11-9 win: {}", s);
}
#[test]
fn test_lower_rated_player_upset() {
// Player rated 100 points lower: P(point) ≈ 0.36
// Actual: 11/20 = 0.55 (overperformed - good upset!)
let s = calculate_weighted_score(1400.0, 1500.0, 11, 9);
assert!((s - 0.55).abs() < 0.001);
println!("Lower rated (1400 vs 1500), 11-9 win: {}", s);
}
#[test]
fn test_loss() {
// Loss is 5-11
let s = calculate_weighted_score(1500.0, 1500.0, 5, 11);
assert!((s - (5.0 / 16.0)).abs() < 0.001);
println!("Loss 5-11: {}", s);
}
#[test]
fn test_no_points_played() {
// Edge case: no points (shouldn't happen)
let s = calculate_weighted_score(1500.0, 1500.0, 0, 0);
assert!((s - 0.5).abs() < 0.001); // Default to 50/50
println!("No points: {}", s);
}
}

46
src/handlers/api.rs Normal file
View File

@ -0,0 +1,46 @@
//! JSON API handlers
use axum::{
extract::{State, Path},
response::Json,
http::StatusCode,
};
use sqlx::SqlitePool;
use serde::Serialize;
#[derive(Clone)]
pub struct AppState {
pub pool: SqlitePool,
}
#[derive(Serialize)]
pub struct PlayerJson {
pub id: i64,
pub name: String,
pub rating: f64,
}
#[derive(Serialize)]
pub struct LeaderboardEntry {
pub rank: usize,
pub player: PlayerJson,
}
pub async fn api_players_handler(
State(_state): State<AppState>,
) -> Json<Vec<PlayerJson>> {
Json(vec![])
}
pub async fn api_player_details_handler(
State(_state): State<AppState>,
Path(_player_id): Path<i64>,
) -> Result<Json<PlayerJson>, (StatusCode, String)> {
Err((StatusCode::NOT_FOUND, "Player not found".to_string()))
}
pub async fn api_leaderboard_handler(
State(_state): State<AppState>,
) -> Json<Vec<LeaderboardEntry>> {
Json(vec![])
}

32
src/handlers/daily.rs Normal file
View File

@ -0,0 +1,32 @@
//! Daily summary and session handlers
use axum::{
extract::{State, Path},
response::Html,
http::StatusCode,
};
use sqlx::SqlitePool;
#[derive(Clone)]
pub struct AppState {
pub pool: SqlitePool,
}
pub async fn daily_summary_handler(State(_state): State<AppState>) -> Html<String> {
Html("<h1>Daily Summary</h1>".to_string())
}
pub async fn daily_public_handler(State(_state): State<AppState>) -> Html<String> {
Html("<h1>Daily Public View</h1>".to_string())
}
pub async fn create_session_handler(State(_state): State<AppState>) -> Html<String> {
Html("<h1>Create Session</h1>".to_string())
}
pub async fn send_daily_email_handler(
State(_state): State<AppState>,
Path(_session_id): Path<i64>,
) -> Result<Html<String>, (StatusCode, String)> {
Ok(Html("<h1>Email Sent</h1>".to_string()))
}

59
src/handlers/home.rs Normal file
View File

@ -0,0 +1,59 @@
//! Home page handlers
use axum::{
extract::State,
response::Html,
};
use sqlx::SqlitePool;
#[derive(Clone)]
pub struct AppState {
pub pool: SqlitePool,
}
/// Serves the home page dashboard
pub async fn index_handler(State(state): State<AppState>) -> Html<String> {
let player_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM players")
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let match_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM matches")
.fetch_one(&state.pool)
.await
.unwrap_or(0);
let session_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sessions")
.fetch_one(&state.pool)
.await
.unwrap_or(0);
// For now, return simple HTML. Will be replaced with Askama template in next iteration
let html = format!(
r#"<!DOCTYPE html><html><head><title>Pickleball ELO</title></head><body>
<h1>🏓 Pickleball ELO Tracker</h1>
<p>Matches: {}</p>
<p>Players: {}</p>
<p>Sessions: {}</p>
</body></html>"#,
match_count, player_count, session_count
);
Html(html)
}
/// Serves the about page
pub async fn about_handler() -> Html<String> {
let html = r#"<!DOCTYPE html>
<html>
<head>
<title>About - Pickleball ELO</title>
</head>
<body>
<h1> About</h1>
<p>Pickleball ELO Rating System v3.0</p>
</body>
</html>"#;
Html(html.to_string())
}

48
src/handlers/matches.rs Normal file
View File

@ -0,0 +1,48 @@
//! Match management handlers
use axum::{
extract::{State, Path, Query},
response::Html,
http::StatusCode,
};
use sqlx::SqlitePool;
use serde::Deserialize;
#[derive(Clone)]
pub struct AppState {
pub pool: SqlitePool,
}
#[derive(Deserialize)]
pub struct BalanceQuery {
pub p1: Option<i64>,
pub p2: Option<i64>,
pub p3: Option<i64>,
pub p4: Option<i64>,
}
pub async fn match_history_handler(State(_state): State<AppState>) -> Html<String> {
Html("<h1>Match History</h1>".to_string())
}
pub async fn new_match_handler(State(_state): State<AppState>) -> Html<String> {
Html("<h1>New Match Form</h1>".to_string())
}
pub async fn create_match_handler(State(_state): State<AppState>) -> Html<String> {
Html("<h1>Match Created</h1>".to_string())
}
pub async fn delete_match_handler(
State(_state): State<AppState>,
Path(_match_id): Path<i64>,
) -> Result<Html<String>, (StatusCode, String)> {
Ok(Html("<h1>Match Deleted</h1>".to_string()))
}
pub async fn balance_query_handler(
State(_state): State<AppState>,
Query(_params): Query<BalanceQuery>,
) -> Result<Html<String>, (StatusCode, String)> {
Ok(Html("<h1>Balance Query</h1>".to_string()))
}

32
src/handlers/mod.rs Normal file
View File

@ -0,0 +1,32 @@
//! HTTP handler modules
//!
//! Each module handles a specific domain (home, players, matches, etc.)
//! and provides request handlers for that domain.
pub mod home;
pub mod players;
pub mod matches;
pub mod sessions;
pub mod daily;
pub mod api;
// Re-export main handlers for convenience
pub use home::{index_handler, about_handler};
pub use players::{
list_players_handler, player_profile_handler, new_player_handler,
create_player_handler, edit_player_handler, update_player_handler
};
pub use matches::{
match_history_handler, new_match_handler, create_match_handler,
delete_match_handler, balance_query_handler
};
pub use sessions::{
session_list_handler, session_preview_handler, session_send_handler
};
pub use daily::{
daily_summary_handler, daily_public_handler, create_session_handler,
send_daily_email_handler
};
pub use api::{
api_players_handler, api_player_details_handler, api_leaderboard_handler
};

46
src/handlers/players.rs Normal file
View File

@ -0,0 +1,46 @@
//! Player management handlers
use axum::{
extract::{State, Path},
response::Html,
http::StatusCode,
};
use sqlx::SqlitePool;
#[derive(Clone)]
pub struct AppState {
pub pool: SqlitePool,
}
pub async fn list_players_handler(State(_state): State<AppState>) -> Html<String> {
Html("<h1>Players List</h1>".to_string())
}
pub async fn player_profile_handler(
State(_state): State<AppState>,
Path(_player_id): Path<i64>,
) -> Result<Html<String>, (StatusCode, String)> {
Ok(Html("<h1>Player Profile</h1>".to_string()))
}
pub async fn new_player_handler() -> Html<String> {
Html("<h1>New Player Form</h1>".to_string())
}
pub async fn create_player_handler() -> Html<String> {
Html("<h1>Player Created</h1>".to_string())
}
pub async fn edit_player_handler(
State(_state): State<AppState>,
Path(_player_id): Path<i64>,
) -> Result<Html<String>, (StatusCode, String)> {
Ok(Html("<h1>Edit Player</h1>".to_string()))
}
pub async fn update_player_handler(
State(_state): State<AppState>,
Path(_player_id): Path<i64>,
) -> Result<Html<String>, (StatusCode, String)> {
Ok(Html("<h1>Player Updated</h1>".to_string()))
}

31
src/handlers/sessions.rs Normal file
View File

@ -0,0 +1,31 @@
//! Session management handlers
use axum::{
extract::{State, Path},
response::Html,
http::StatusCode,
};
use sqlx::SqlitePool;
#[derive(Clone)]
pub struct AppState {
pub pool: SqlitePool,
}
pub async fn session_list_handler(State(_state): State<AppState>) -> Html<String> {
Html("<h1>Sessions List</h1>".to_string())
}
pub async fn session_preview_handler(
State(_state): State<AppState>,
Path(_session_id): Path<i64>,
) -> Result<Html<String>, (StatusCode, String)> {
Ok(Html("<h1>Session Preview</h1>".to_string()))
}
pub async fn session_send_handler(
State(_state): State<AppState>,
Path(_session_id): Path<i64>,
) -> Result<Html<String>, (StatusCode, String)> {
Ok(Html("<h1>Session Sent</h1>".to_string()))
}

View File

@ -1,9 +1,8 @@
// Pickleball ELO Tracker - Library
// Pure ELO Rating System Implementation (converted from Glicko-2)
// Pickleball ELO Tracker - Library (v3.0)
// Pure ELO Rating System Implementation
pub mod config;
pub mod db;
pub mod handlers;
pub mod models;
pub mod elo;
pub mod glicko; // Kept for backwards compatibility / analysis
pub mod demo;
pub mod simple_demo;

View File

@ -6,7 +6,6 @@ use axum::{
http::StatusCode,
};
use sqlx::SqlitePool;
use pickleball_elo::simple_demo;
use pickleball_elo::db;
use pickleball_elo::elo::{EloRating, EloCalculator, calculate_weighted_score, calculate_effective_opponent_rating};
use serde::{Deserialize, Serialize};
@ -185,20 +184,7 @@ async fn main() {
println!("🏓 Pickleball ELO Tracker v3.0");
println!("==============================\n");
let args: Vec<String> = std::env::args().collect();
if args.len() > 1 && args[1] == "demo" {
run_demo().await;
} else {
run_server().await;
}
}
async fn run_demo() {
println!("Running 3 sessions with 157+ matches...\n");
simple_demo::run_simple_demo().await;
println!("\n✅ Demo Complete!");
println!("\nDatabase: {}", DB_PATH);
run_server().await;
}
async fn run_server() {

View File

@ -1,37 +1,28 @@
//! Models module for pickleball player ratings using the Glicko2 rating system.
//! Models module for pickleball player ratings using the Pure ELO rating system (v3.0).
//!
//! # Glicko2 Rating System Overview
//! # ELO Rating System Overview
//!
//! The Glicko2 system provides a more sophisticated rating mechanism than simple Elo,
//! incorporating three parameters per player per format (singles/doubles):
//! Version 3.0 switched from Glicko-2 (separate singles/doubles with RD and volatility)
//! to a unified Pure ELO system:
//!
//! - **Rating (r)**: The player's skill estimate (1500 = average, typically ranges 400-2000+)
//! - **RD (Rating Deviation)**: Confidence level in the rating (lower RD = more confident)
//! - Starts at ~350 (high uncertainty for new players)
//! - Decreases with more matches played
//! - Increases over time without play (conversely, inactive players become uncertain)
//! - ~30 or below indicates a highly established rating
//! - **Volatility (σ)**: Unpredictability of player performance (0.06 = starting value)
//! - Measures how inconsistently a player performs
//! - Stable players have lower volatility; erratic players have higher
//! - Affects how much a rating changes per match
//! - **Unified Rating**: Single ELO rating for both singles and doubles matches
//! - **Simple Formula**: Rating Change = K × (Actual Performance - Expected Performance)
//! - **Fair Scoring**: Based on actual points won, not just match win/loss
//! - **Transparent**: No hidden parameters, completely predictable
/// Represents a player profile with Glicko2 ratings for both singles and doubles play.
/// Represents a player profile with unified ELO rating.
///
/// Players maintain separate rating systems for singles and doubles because skill
/// in each format can differ significantly (e.g., strong net play in doubles ≠ consistent baseline in singles).
/// Version 3.0 features:
/// - One unified ELO rating (both singles and doubles contribute to it)
/// - Clean, transparent rating system
/// - Per-point scoring (not just win/loss)
///
/// # Fields
///
/// * `id` - Unique database identifier
/// * `name` - Player's name (unique identifier)
/// * `email` - Optional email for notifications
/// * `singles_rating` - Glicko2 rating for singles matches (default: 1500.0)
/// * `singles_rd` - Rating Deviation for singles (default: 350.0; lower = more confident)
/// * `singles_volatility` - Volatility for singles (default: 0.06; higher = more erratic)
/// * `doubles_rating` - Glicko2 rating for doubles matches (default: 1500.0)
/// * `doubles_rd` - Rating Deviation for doubles (default: 350.0)
/// * `doubles_volatility` - Volatility for doubles (default: 0.06)
/// * `rating` - Unified ELO rating (default: 1500.0)
///
/// # Example
///
@ -40,12 +31,7 @@
/// id: 1,
/// name: "Alice".to_string(),
/// email: Some("alice@example.com".to_string()),
/// singles_rating: 1650.0, // Somewhat above average
/// singles_rd: 80.0, // Fairly confident (has played many matches)
/// singles_volatility: 0.055, // Consistent performer
/// doubles_rating: 1500.0, // New to doubles
/// doubles_rd: 350.0, // High uncertainty
/// doubles_volatility: 0.06,
/// rating: 1650.0, // Above average, contributions from both singles and doubles
/// };
/// ```
#[derive(Debug, Clone)]
@ -57,19 +43,7 @@ pub struct Player {
/// Optional email address
pub email: Option<String>,
// === Singles Glicko2 Parameters ===
/// Skill estimate in singles format (1500 = average)
pub singles_rating: f64,
/// Confidence in singles rating (lower = more certain; ~30 = highly established)
pub singles_rd: f64,
/// Consistency in singles play (0.06 = starting; higher = more variable performance)
pub singles_volatility: f64,
// === Doubles Glicko2 Parameters ===
/// Skill estimate in doubles format (1500 = average)
pub doubles_rating: f64,
/// Confidence in doubles rating (lower = more certain; ~30 = highly established)
pub doubles_rd: f64,
/// Consistency in doubles play (0.06 = starting; higher = more variable performance)
pub doubles_volatility: f64,
/// Unified ELO rating (both singles and doubles)
/// Starts at 1500.0 for new players
pub rating: f64,
}

View File

@ -1,188 +0,0 @@
// Simple in-memory demo without database for testing
use crate::demo::{self, Player, MatchType};
use crate::glicko::{Glicko2Calculator, calculate_weighted_score};
use rand::Rng;
pub async fn run_simple_demo() {
println!("🏓 Pickleball ELO Tracker v2.0 - Simple Demo");
println!("==========================================\n");
// Generate 20 players
println!("👥 Generating 20 players...");
let mut players: Vec<Player> = (0..20)
.map(|_| Player::new_random())
.collect();
println!("✅ Players generated\n");
println!("Session 1: Opening Tournament");
println!("=============================\n");
run_session(&mut players, 1, 50);
println!("\nSession 2: Mid-Tournament");
println!("========================\n");
run_session(&mut players, 2, 55);
println!("\nSession 3: Finals");
println!("================\n");
run_session(&mut players, 3, 52);
// Print final leaderboards
println!("\n📧 Final Leaderboards:\n");
print_leaderboard(&players);
println!("\n✅ Demo Complete!");
println!("\nTotal Players: {}", players.len());
println!("Total Matches Across 3 Sessions: {}", 50 + 55 + 52);
}
fn run_session(players: &mut [Player], session_num: usize, num_matches: usize) {
println!("Starting session {}...", session_num);
let mut rng = rand::thread_rng();
let calc = Glicko2Calculator::new();
for i in 0..num_matches {
if i % 20 == 0 && i > 0 {
println!(" {} matches completed...", i);
}
// Randomly choose singles or doubles
let match_type = if rng.gen_bool(0.5) {
MatchType::Singles
} else {
MatchType::Doubles
};
match match_type {
MatchType::Singles => {
// Pick 2 random players
let p1_idx = rng.gen_range(0..players.len());
let mut p2_idx = rng.gen_range(0..players.len());
while p2_idx == p1_idx {
p2_idx = rng.gen_range(0..players.len());
}
let (team1_score, team2_score) = demo::simulate_match(
&[players[p1_idx].true_skill],
&[players[p2_idx].true_skill],
);
// Calculate outcomes with performance-based weighting
let p1_outcome = if team1_score > team2_score {
calculate_weighted_score(players[p1_idx].singles.rating, players[p2_idx].singles.rating, team1_score, team2_score)
} else {
calculate_weighted_score(players[p1_idx].singles.rating, players[p2_idx].singles.rating, team1_score, team2_score)
};
let p2_outcome = if team1_score > team2_score {
calculate_weighted_score(players[p2_idx].singles.rating, players[p1_idx].singles.rating, team2_score, team1_score)
} else {
calculate_weighted_score(players[p2_idx].singles.rating, players[p1_idx].singles.rating, team2_score, team1_score)
};
// Update ratings
players[p1_idx].singles = calc.update_rating(
&players[p1_idx].singles,
&[(players[p2_idx].singles, p1_outcome)],
);
players[p2_idx].singles = calc.update_rating(
&players[p2_idx].singles,
&[(players[p1_idx].singles, p2_outcome)],
);
}
MatchType::Doubles => {
// Pick 4 random players
let mut indices: Vec<usize> = (0..players.len()).collect();
use rand::seq::SliceRandom;
indices[..].shuffle(&mut rng);
let team1_indices = vec![indices[0], indices[1]];
let team2_indices = vec![indices[2], indices[3]];
let team1_skills: Vec<f64> = team1_indices.iter()
.map(|&i| players[i].true_skill)
.collect();
let team2_skills: Vec<f64> = team2_indices.iter()
.map(|&i| players[i].true_skill)
.collect();
let (team1_score, team2_score) = demo::simulate_match(&team1_skills, &team2_skills);
let team1_won = team1_score > team2_score;
// Update team 1
for &idx in &team1_indices {
let outcome = if team1_won {
calculate_weighted_score(players[idx].doubles.rating, team2_indices.iter().map(|&i| players[i].doubles.rating).sum::<f64>() / 2.0, team1_score, team2_score)
} else {
calculate_weighted_score(players[idx].doubles.rating, team2_indices.iter().map(|&i| players[i].doubles.rating).sum::<f64>() / 2.0, team1_score, team2_score)
};
let avg_opponent = crate::glicko::GlickoRating {
rating: team2_indices.iter().map(|&i| players[i].doubles.rating).sum::<f64>() / 2.0,
rd: team2_indices.iter().map(|&i| players[i].doubles.rd).sum::<f64>() / 2.0,
volatility: 0.06,
};
players[idx].doubles = calc.update_rating(
&players[idx].doubles,
&[(avg_opponent, outcome)],
);
}
// Update team 2
for &idx in &team2_indices {
let outcome = if !team1_won {
calculate_weighted_score(players[idx].doubles.rating, team1_indices.iter().map(|&i| players[i].doubles.rating).sum::<f64>() / 2.0, team2_score, team1_score)
} else {
calculate_weighted_score(players[idx].doubles.rating, team1_indices.iter().map(|&i| players[i].doubles.rating).sum::<f64>() / 2.0, team2_score, team1_score)
};
let avg_opponent = crate::glicko::GlickoRating {
rating: team1_indices.iter().map(|&i| players[i].doubles.rating).sum::<f64>() / 2.0,
rd: team1_indices.iter().map(|&i| players[i].doubles.rd).sum::<f64>() / 2.0,
volatility: 0.06,
};
players[idx].doubles = calc.update_rating(
&players[idx].doubles,
&[(avg_opponent, outcome)],
);
}
}
}
}
println!("✅ Completed {} matches", num_matches);
print_leaderboard(players);
}
fn print_leaderboard(players: &[Player]) {
println!("\n📊 Top 5 Singles:");
let mut singles_sorted = players.to_vec();
singles_sorted.sort_by(|a, b| b.singles.rating.partial_cmp(&a.singles.rating).unwrap());
for (i, p) in singles_sorted.iter().take(5).enumerate() {
println!(
" {}. {} - {:.1} (RD: {:.1})",
i + 1,
p.name,
p.singles.rating,
p.singles.rd,
);
}
println!("\n📊 Top 5 Doubles:");
let mut doubles_sorted = players.to_vec();
doubles_sorted.sort_by(|a, b| b.doubles.rating.partial_cmp(&a.doubles.rating).unwrap());
for (i, p) in doubles_sorted.iter().take(5).enumerate() {
println!(
" {}. {} - {:.1} (RD: {:.1})",
i + 1,
p.name,
p.doubles.rating,
p.doubles.rd,
);
}
}

278
templates/base.html Normal file
View File

@ -0,0 +1,278 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Pickleball ELO Tracker{% endblock %}</title>
<style>
/* Common CSS - Pitt colors (Blue #003594, Gold #FFB81C) */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
background: linear-gradient(135deg, #003594 0%, #001a4d 100%);
padding: 20px;
margin: 0;
min-height: 100vh;
}
.container {
max-width: 1000px;
margin: 0 auto;
background: white;
border-radius: 12px;
padding: 30px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
h1 {
color: #003594;
margin-top: 0;
display: flex;
align-items: center;
gap: 10px;
}
h2 {
color: #003594;
border-bottom: 2px solid #FFB81C;
padding-bottom: 10px;
}
h3 {
color: #003594;
}
.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 {
background: #27ae60;
}
.btn-success:hover {
background: #229954;
}
.btn-warning {
background: #f39c12;
}
.btn-warning:hover {
background: #e67e22;
}
.btn-danger {
background: #e74c3c;
}
.btn-danger:hover {
background: #c0392b;
}
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 {
background: linear-gradient(135deg, #003594 0%, #001a4d 100%);
color: white;
padding: 25px;
border-radius: 12px;
text-align: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.stat-value {
font-size: 36px;
font-weight: bold;
margin: 10px 0;
}
.stat-label {
font-size: 14px;
opacity: 0.9;
}
.leaderboard-entry {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px;
background: #f9f9f9;
border-radius: 6px;
margin: 10px 0;
}
.rank {
font-size: 24px;
font-weight: bold;
color: #FFB81C;
margin-right: 15px;
min-width: 40px;
}
.player-info {
flex: 1;
}
.player-name {
font-weight: bold;
color: #003594;
}
.player-rating {
font-size: 24px;
font-weight: bold;
color: #003594;
}
code {
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>
{% block extra_css %}{% endblock %}
</head>
<body>
<div class="container">
{% block content %}{% endblock %}
</div>
</body>
</html>

View File

@ -0,0 +1,10 @@
<div class="nav">
<a href="/" class="btn">🏠 Home</a>
<a href="/leaderboard" class="btn">📊 Leaderboard</a>
<a href="/matches" class="btn">📜 History</a>
<a href="/players" class="btn">👥 Players</a>
<a href="/balance" class="btn">⚖️ Balance</a>
<a href="/daily" class="btn">📧 Daily</a>
<a href="/about" class="btn">❓ About</a>
<a href="/matches/new" class="btn btn-success">🎾 Record</a>
</div>

View File

@ -0,0 +1,66 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pickleball Results - {{ date }}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; background: #f5f5f5; padding: 20px;">
<div style="max-width: 600px; margin: 0 auto; background: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<h1 style="color: #003594; margin-top: 0;">🏓 Pickleball Results</h1>
<p style="color: #666; font-size: 14px;">{{ date }}</p>
{% if summary.total_matches > 0 %}
<div style="background: linear-gradient(135deg, #003594 0%, #001a4d 100%); color: white; padding: 20px; border-radius: 6px; margin: 20px 0;">
<p style="margin: 10px 0;"><strong>Matches Played:</strong> {{ summary.total_matches }}</p>
<p style="margin: 10px 0;"><strong>Total Players:</strong> {{ summary.total_players }}</p>
</div>
<h2 style="color: #003594; border-bottom: 2px solid #FFB81C; padding-bottom: 10px;">📊 Top Performers</h2>
{% if summary.top_winners %}
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<thead>
<tr style="background: #f5f5f5; border-bottom: 2px solid #ddd;">
<th style="padding: 10px; text-align: left; border: 1px solid #ddd;">Player</th>
<th style="padding: 10px; text-align: center; border: 1px solid #ddd;">Rating Change</th>
</tr>
</thead>
<tbody>
{% for winner in summary.top_winners %}
<tr style="border-bottom: 1px solid #ddd;">
<td style="padding: 10px; border: 1px solid #ddd;">{{ winner.name }}</td>
<td style="padding: 10px; text-align: center; border: 1px solid #ddd; color: #27ae60; font-weight: bold;">
+{{ winner.rating_change }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if summary.all_matches %}
<h2 style="color: #003594; border-bottom: 2px solid #FFB81C; padding-bottom: 10px;">🎾 All Matches</h2>
{% for match in summary.all_matches %}
<div style="background: #f9f9f9; padding: 15px; border-radius: 6px; margin: 10px 0; border-left: 4px solid #003594;">
<p style="margin: 5px 0;"><strong>{{ match.team1_display }}</strong> vs <strong>{{ match.team2_display }}</strong></p>
<p style="margin: 5px 0; color: #666;">Score: {{ match.team1_score }}-{{ match.team2_score }}</p>
</div>
{% endfor %}
{% endif %}
{% else %}
<div style="background: #d1ecf1; color: #0c5460; padding: 15px; border-radius: 6px; border: 1px solid #bee5eb;">
<p style="margin: 0;">No matches recorded for today.</p>
</div>
{% endif %}
<hr style="border: none; border-top: 1px solid #ddd; margin: 30px 0;">
<p style="color: #999; font-size: 12px; text-align: center; margin: 0;">
<a href="http://localhost:3000" style="color: #003594; text-decoration: none;">View Full Leaderboard</a> |
<a href="http://localhost:3000/daily" style="color: #003594; text-decoration: none;">View All Sessions</a>
</p>
</div>
</body>
</html>

View File

@ -0,0 +1,94 @@
{% extends "base.html" %}
{% block title %}About - Pickleball ELO Tracker{% endblock %}
{% block content %}
<h1>❓ About Pickleball ELO</h1>
{% include "components/nav.html" %}
<div class="card">
<h2>📊 What is ELO?</h2>
<p>
The ELO rating system is a method for calculating the relative skill levels of players.
It was invented for chess but works great for any competitive sport.
</p>
<p>
In pickleball, your ELO rating represents your skill level. Everyone starts at 1500.
When you play a match, your rating goes up or down based on:
</p>
<ul>
<li><strong>Your performance:</strong> Points won ÷ total points (not just win/loss)</li>
<li><strong>Your opponent's skill:</strong> Playing a higher-rated opponent is worth more</li>
<li><strong>The upset factor:</strong> Beating someone stronger gains more points</li>
</ul>
</div>
<div class="card">
<h2>🎾 Unified Rating (v3.0)</h2>
<p>
In version 3, we switched from separate singles/doubles ratings to a <strong>unified rating</strong>.
Both singles and doubles matches contribute to the same ELO rating, because:
</p>
<ul>
<li>One rating is simpler to understand</li>
<li>Players who excel in both formats get fairly rewarded</li>
<li>Pure ELO is transparent and bias-free</li>
</ul>
</div>
<div class="card">
<h2>⚖️ Smart Doubles Scoring</h2>
<p>
In doubles matches, we calculate your "effective opponent" to make scoring fair:
</p>
<p style="background: #f5f5f5; padding: 15px; border-radius: 6px; font-family: monospace;">
<strong>Effective Opponent = Opp1 + Opp2 - Teammate</strong>
</p>
<p>
This means:
</p>
<ul>
<li><strong>Playing with a strong teammate</strong> → harder opponents count as weaker → less credit for winning</li>
<li><strong>Playing with a weak teammate</strong> → harder opponents count as stronger → more credit for winning</li>
</ul>
<p>
This approach is <strong>fair, symmetric, and makes strategic sense</strong>.
</p>
</div>
<div class="card">
<h2>🧮 The Formula</h2>
<p style="background: #f5f5f5; padding: 15px; border-radius: 6px; font-family: monospace;">
<strong>Rating Change = K × (Actual Performance Expected Performance)</strong>
</p>
<p>Where:</p>
<ul>
<li><strong>K = 32</strong> (standard, adjustable for casual/competitive play)</li>
<li><strong>Actual Performance</strong> = Your points ÷ Total points</li>
<li><strong>Expected Performance</strong> = Based on the ELO formula (derived from rating difference)</li>
</ul>
<p style="color: #666; font-size: 13px;">
The expected performance formula: <code>E = 1 / (1 + 10^((Opp_Rating - Your_Rating) / 400))</code>
</p>
</div>
<div class="card">
<h2>📈 Why This System Works</h2>
<ul>
<li><strong>Transparent:</strong> No hidden formulas or magic numbers. You can verify your rating changes.</li>
<li><strong>Fair:</strong> You're rewarded for actual performance, not just wins.</li>
<li><strong>Balanced:</strong> Winning 11-2 and 11-9 are different and rated differently.</li>
<li><strong>Skill-based:</strong> Beating stronger players earns more; losing to them costs less.</li>
<li><strong>Predictable:</strong> Ratings converge to true skill over time.</li>
</ul>
</div>
<div class="card">
<h2>📝 Version History</h2>
<ul>
<li><strong>v3.0.0</strong> - Unified ELO rating, modular architecture, pure ELO calculator</li>
<li><strong>v2.0.0</strong> - Glicko-2 system with separate singles/doubles ratings</li>
<li><strong>v1.0.0</strong> - Initial release with basic ELO</li>
</ul>
</div>
{% endblock %}

View File

@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}Daily Summary - Pickleball ELO Tracker{% endblock %}
{% block content %}
<h1>📧 Daily Session Summaries</h1>
{% include "components/nav.html" %}
<div style="margin-bottom: 20px;">
<a href="/daily/new" class="btn btn-success"> Create Session</a>
</div>
{% if sessions.is_empty() %}
<div class="alert alert-info">
No sessions created yet. <a href="/daily/new">Create one</a>!
</div>
{% else %}
<table>
<thead>
<tr>
<th>Date</th>
<th>Matches</th>
<th>Players</th>
<th>Preview</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for session in sessions %}
<tr>
<td>{{ session.created_at | truncate(length=10) }}</td>
<td>{{ session.match_count }}</td>
<td>{{ session.player_count }}</td>
<td>
<a href="/daily/{{ session.id }}/preview" class="btn">Preview</a>
</td>
<td>
<a href="/daily/{{ session.id }}/send" class="btn btn-success">📧 Send</a>
<a href="/daily/{{ session.id }}/delete" class="btn btn-danger" onclick="return confirm('Delete this session?')">Delete</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}

51
templates/pages/home.html Normal file
View File

@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}Home - Pickleball ELO Tracker{% endblock %}
{% block content %}
<div style="max-width: 700px; text-align: center; margin: 0 auto;">
<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">{{ match_count }}</div>
<div class="stat-label">Matches</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ player_count }}</div>
<div class="stat-label">Players</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ session_count }}</div>
<div class="stat-label">Sessions</div>
</div>
</div>
{% include "components/nav.html" %}
<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>
{% endblock %}

View File

@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block title %}Leaderboard - Pickleball ELO Tracker{% endblock %}
{% block content %}
<h1>📊 Leaderboard</h1>
{% include "components/nav.html" %}
{% if leaderboard.is_empty() %}
<div class="alert alert-info">
No players with matches yet. <a href="/matches/new">Record a match</a> to see the leaderboard!
</div>
{% else %}
<div style="max-width: 600px; margin: 0 auto;">
{% for (rank, player) in leaderboard %}
<div class="leaderboard-entry">
<div class="rank">{{ rank }}</div>
<div class="player-info">
<div class="player-name">
<a href="/players/{{ player.id }}" style="color: #003594; text-decoration: none;">
{{ player.name }}
</a>
</div>
</div>
<div class="player-rating">{{ player.rating | round(1) }}</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}Match History - Pickleball ELO Tracker{% endblock %}
{% block content %}
<h1>📜 Match History</h1>
{% include "components/nav.html" %}
<div style="margin-bottom: 20px;">
<a href="/matches/new" class="btn btn-success">🎾 Record New Match</a>
</div>
{% if matches.is_empty() %}
<div class="alert alert-info">
No matches recorded yet. <a href="/matches/new">Record one</a>!
</div>
{% else %}
<table>
<thead>
<tr>
<th>Type</th>
<th>Team 1</th>
<th>Score</th>
<th>Team 2</th>
<th>Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for match in matches %}
<tr>
<td>{{ match.match_type }}</td>
<td>{{ match.team1_display }}</td>
<td><strong>{{ match.team1_score }}-{{ match.team2_score }}</strong></td>
<td>{{ match.team2_display }}</td>
<td>{{ match.timestamp | truncate(length=16) }}</td>
<td>
<a href="/matches/{{ match.id }}/delete" class="btn btn-danger" onclick="return confirm('Delete this match?')">Delete</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,128 @@
{% extends "base.html" %}
{% block title %}{{ player_name }} - Pickleball ELO Tracker{% endblock %}
{% block content %}
<h1>{{ player_name }}</h1>
{% include "components/nav.html" %}
<div class="form-row">
<div class="card">
<h3>Rating</h3>
<div style="font-size: 36px; font-weight: bold; color: #003594; margin: 20px 0;">
{{ current_rating }}
</div>
<p style="color: #666; margin: 0;">
{% if rating_change >= 0.0 %}
<span style="color: #27ae60;">+{{ rating_change }}</span>
{% else %}
<span style="color: #e74c3c;">{{ rating_change }}</span>
{% endif %}
in last match
</p>
</div>
<div class="card">
<h3>Match Statistics</h3>
<div style="margin: 15px 0;">
<p><strong>Total Matches:</strong> {{ total_matches }}</p>
<p><strong>Wins:</strong> {{ wins }}</p>
<p><strong>Losses:</strong> {{ losses }}</p>
<p><strong>Win Rate:</strong> {{ win_rate }}%</p>
</div>
</div>
</div>
{% if email %}
<div class="card">
<p><strong>Email:</strong> {{ email }}</p>
</div>
{% endif %}
<div style="margin-top: 30px; text-align: center;">
<a href="/players/{{ player_id }}/edit" class="btn btn-warning">✏️ Edit Player</a>
<a href="/players/{{ player_id }}/delete" class="btn btn-danger" onclick="return confirm('Are you sure?')">🗑️ Delete Player</a>
</div>
{% if history_chart_data %}
<div class="card">
<h2>📈 Rating Trend</h2>
<div style="overflow-x: auto;">
<canvas id="ratingChart" width="400" height="200"></canvas>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
const ctx = document.getElementById('ratingChart').getContext('2d');
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: Array.from({length: {{ history_chart_data | length }}}. (_, i) => i + 1),
datasets: [{
label: 'Rating',
data: [{{ history_chart_data | join(",") }}],
borderColor: '#003594',
backgroundColor: 'rgba(0, 53, 148, 0.1)',
tension: 0.3,
fill: true,
pointRadius: 3,
pointBackgroundColor: '#003594'
}]
},
options: {
responsive: true,
plugins: {
legend: { display: true }
},
scales: {
y: {
title: { display: true, text: 'ELO Rating' },
min: 1000,
max: 2000
}
}
}
});
</script>
{% endif %}
{% if head_to_head %}
<div class="card">
<h2>⚔️ Head-to-Head</h2>
<table>
<thead>
<tr>
<th>Opponent</th>
<th>Wins</th>
<th>Losses</th>
<th>Win Rate</th>
</tr>
</thead>
<tbody>
{% for opp in head_to_head %}
<tr>
<td><a href="/players/{{ opp.id }}">{{ opp.name }}</a></td>
<td>{{ opp.wins }}</td>
<td>{{ opp.losses }}</td>
<td>{{ opp.win_percentage }}%</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if achievements %}
<div class="card">
<h2>🏆 Achievements</h2>
<div style="display: flex; flex-wrap: wrap; gap: 10px;">
{% for achievement in achievements %}
<span style="background: #FFB81C; color: #003594; padding: 8px 12px; border-radius: 20px; font-weight: bold;">
{{ achievement }}
</span>
{% endfor %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}Players - Pickleball ELO Tracker{% endblock %}
{% block content %}
<h1>👥 Players</h1>
{% include "components/nav.html" %}
<div style="margin-bottom: 20px;">
<a href="/players/new" class="btn btn-success"> Add Player</a>
</div>
{% if players.is_empty() %}
<div class="alert alert-info">
No players yet. <a href="/players/new">Create one</a> to get started!
</div>
{% else %}
<table>
<thead>
<tr>
<th>Name</th>
<th>Rating</th>
<th>Matches</th>
<th>Win Rate</th>
<th>Email</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for player in players %}
<tr>
<td><a href="/players/{{ player.id }}"><strong>{{ player.name }}</strong></a></td>
<td style="font-weight: bold; color: #003594;">{{ player.rating }}</td>
<td>{{ player.match_count }}</td>
<td>{{ player.win_rate }}%</td>
<td>{{ player.email.unwrap_or_default() }}</td>
<td>
<a href="/players/{{ player.id }}" class="btn">View</a>
<a href="/players/{{ player.id }}/edit" class="btn btn-warning">Edit</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}

611
tests/database_tests.rs Normal file
View File

@ -0,0 +1,611 @@
//! Database integration tests
//!
//! Tests database operations: player CRUD, match recording, rating updates
use sqlx::SqlitePool;
use sqlx::sqlite::SqlitePoolOptions;
/// Create an in-memory SQLite database for testing
async fn setup_test_db() -> SqlitePool {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("Failed to create test database");
// Create tables
sqlx::query(r#"
CREATE TABLE IF NOT EXISTS players (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
email TEXT DEFAULT '',
singles_rating REAL DEFAULT 1500.0,
singles_rd REAL DEFAULT 350.0,
doubles_rating REAL DEFAULT 1500.0,
doubles_rd REAL DEFAULT 350.0,
created_at TEXT DEFAULT (datetime('now'))
)
"#)
.execute(&pool)
.await
.expect("Failed to create players table");
sqlx::query(r#"
CREATE TABLE IF NOT EXISTS matches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
match_type TEXT NOT NULL,
team1_score INTEGER NOT NULL,
team2_score INTEGER NOT NULL,
timestamp TEXT DEFAULT (datetime('now')),
session_id INTEGER
)
"#)
.execute(&pool)
.await
.expect("Failed to create matches table");
sqlx::query(r#"
CREATE TABLE IF NOT EXISTS match_participants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
match_id INTEGER NOT NULL,
player_id INTEGER NOT NULL,
team INTEGER NOT NULL,
rating_before REAL,
rating_after REAL,
rating_change REAL,
FOREIGN KEY (match_id) REFERENCES matches(id),
FOREIGN KEY (player_id) REFERENCES players(id)
)
"#)
.execute(&pool)
.await
.expect("Failed to create match_participants table");
pool
}
#[tokio::test]
async fn test_create_player() {
let pool = setup_test_db().await;
let result = sqlx::query(
"INSERT INTO players (name, email) VALUES (?, ?)"
)
.bind("Test Player")
.bind("test@example.com")
.execute(&pool)
.await;
assert!(result.is_ok());
let player: (i64, String, String, f64) = sqlx::query_as(
"SELECT id, name, email, singles_rating FROM players WHERE name = ?"
)
.bind("Test Player")
.fetch_one(&pool)
.await
.expect("Failed to fetch player");
assert_eq!(player.1, "Test Player");
assert_eq!(player.2, "test@example.com");
assert!((player.3 - 1500.0).abs() < 0.01); // Default rating
}
#[tokio::test]
async fn test_update_player_rating() {
let pool = setup_test_db().await;
// Create player
sqlx::query("INSERT INTO players (name) VALUES (?)")
.bind("Rating Test")
.execute(&pool)
.await
.unwrap();
// Update rating
sqlx::query("UPDATE players SET singles_rating = ? WHERE name = ?")
.bind(1550.0)
.bind("Rating Test")
.execute(&pool)
.await
.unwrap();
let new_rating: (f64,) = sqlx::query_as(
"SELECT singles_rating FROM players WHERE name = ?"
)
.bind("Rating Test")
.fetch_one(&pool)
.await
.unwrap();
assert!((new_rating.0 - 1550.0).abs() < 0.01);
}
#[tokio::test]
async fn test_record_singles_match() {
let pool = setup_test_db().await;
// Create two players
sqlx::query("INSERT INTO players (name, singles_rating) VALUES (?, ?)")
.bind("Player A")
.bind(1500.0)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO players (name, singles_rating) VALUES (?, ?)")
.bind("Player B")
.bind(1500.0)
.execute(&pool)
.await
.unwrap();
// Record match
let match_result = sqlx::query(
"INSERT INTO matches (match_type, team1_score, team2_score) VALUES (?, ?, ?)"
)
.bind("singles")
.bind(11)
.bind(7)
.execute(&pool)
.await
.unwrap();
let match_id = match_result.last_insert_rowid();
// Get player IDs
let player_a_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = ?")
.bind("Player A")
.fetch_one(&pool)
.await
.unwrap();
let player_b_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = ?")
.bind("Player B")
.fetch_one(&pool)
.await
.unwrap();
// Record participants
sqlx::query(
"INSERT INTO match_participants (match_id, player_id, team, rating_before, rating_after, rating_change) VALUES (?, ?, ?, ?, ?, ?)"
)
.bind(match_id)
.bind(player_a_id.0)
.bind(1) // Team 1
.bind(1500.0)
.bind(1505.0)
.bind(5.0)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO match_participants (match_id, player_id, team, rating_before, rating_after, rating_change) VALUES (?, ?, ?, ?, ?, ?)"
)
.bind(match_id)
.bind(player_b_id.0)
.bind(2) // Team 2
.bind(1500.0)
.bind(1495.0)
.bind(-5.0)
.execute(&pool)
.await
.unwrap();
// Verify match recorded
let participants: Vec<(String, i32, f64)> = sqlx::query_as(
r#"SELECT p.name, mp.team, mp.rating_change
FROM match_participants mp
JOIN players p ON mp.player_id = p.id
WHERE mp.match_id = ?"#
)
.bind(match_id)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(participants.len(), 2);
// Player A (winner) should have positive change
let player_a = participants.iter().find(|(n, _, _)| n == "Player A").unwrap();
assert!(player_a.2 > 0.0);
// Player B (loser) should have negative change
let player_b = participants.iter().find(|(n, _, _)| n == "Player B").unwrap();
assert!(player_b.2 < 0.0);
// Changes should sum to zero (approximately)
let total_change: f64 = participants.iter().map(|(_, _, c)| c).sum();
assert!(total_change.abs() < 0.01);
}
#[tokio::test]
async fn test_record_doubles_match() {
let pool = setup_test_db().await;
// Create four players
for (name, rating) in [("P1", 1550.0), ("P2", 1450.0), ("P3", 1520.0), ("P4", 1480.0)] {
sqlx::query("INSERT INTO players (name, singles_rating) VALUES (?, ?)")
.bind(name)
.bind(rating)
.execute(&pool)
.await
.unwrap();
}
// Record doubles match
let match_result = sqlx::query(
"INSERT INTO matches (match_type, team1_score, team2_score) VALUES (?, ?, ?)"
)
.bind("doubles")
.bind(11)
.bind(8)
.execute(&pool)
.await
.unwrap();
let match_id = match_result.last_insert_rowid();
// Get player IDs
let players: Vec<(i64, String)> = sqlx::query_as("SELECT id, name FROM players")
.fetch_all(&pool)
.await
.unwrap();
let get_id = |name: &str| players.iter().find(|(_, n)| n == name).unwrap().0;
// Team 1: P1 + P2 (winners)
// Team 2: P3 + P4 (losers)
let team_assignments = [
(get_id("P1"), 1, 5.0), // Team 1, gains
(get_id("P2"), 1, 8.0), // Team 1, gains more (weaker player carried)
(get_id("P3"), 2, -6.0), // Team 2, loses
(get_id("P4"), 2, -7.0), // Team 2, loses more
];
for (player_id, team, change) in team_assignments {
sqlx::query(
"INSERT INTO match_participants (match_id, player_id, team, rating_change) VALUES (?, ?, ?, ?)"
)
.bind(match_id)
.bind(player_id)
.bind(team)
.bind(change)
.execute(&pool)
.await
.unwrap();
}
// Verify all four participants recorded
let count: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM match_participants WHERE match_id = ?"
)
.bind(match_id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count.0, 4);
}
#[tokio::test]
async fn test_leaderboard_query() {
let pool = setup_test_db().await;
// Create players with different ratings
let players = [
("Elite", 1800.0),
("Strong", 1650.0),
("Average", 1500.0),
("Beginner", 1350.0),
];
for (name, rating) in players {
sqlx::query("INSERT INTO players (name, singles_rating) VALUES (?, ?)")
.bind(name)
.bind(rating)
.execute(&pool)
.await
.unwrap();
}
// Query leaderboard
let leaderboard: Vec<(String, f64)> = sqlx::query_as(
"SELECT name, singles_rating FROM players ORDER BY singles_rating DESC LIMIT 10"
)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(leaderboard.len(), 4);
assert_eq!(leaderboard[0].0, "Elite");
assert_eq!(leaderboard[1].0, "Strong");
assert_eq!(leaderboard[2].0, "Average");
assert_eq!(leaderboard[3].0, "Beginner");
}
#[tokio::test]
async fn test_player_match_history() {
let pool = setup_test_db().await;
// Create players
sqlx::query("INSERT INTO players (name) VALUES (?)")
.bind("History Test")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO players (name) VALUES (?)")
.bind("Opponent")
.execute(&pool)
.await
.unwrap();
let player_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = ?")
.bind("History Test")
.fetch_one(&pool)
.await
.unwrap();
let opp_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = ?")
.bind("Opponent")
.fetch_one(&pool)
.await
.unwrap();
// Record multiple matches
for i in 0..5 {
let match_result = sqlx::query(
"INSERT INTO matches (match_type, team1_score, team2_score) VALUES (?, ?, ?)"
)
.bind("singles")
.bind(11)
.bind(5 + i) // Different scores
.execute(&pool)
.await
.unwrap();
let match_id = match_result.last_insert_rowid();
sqlx::query(
"INSERT INTO match_participants (match_id, player_id, team, rating_change) VALUES (?, ?, ?, ?)"
)
.bind(match_id)
.bind(player_id.0)
.bind(1)
.bind(5.0 - i as f64) // Decreasing gains
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO match_participants (match_id, player_id, team, rating_change) VALUES (?, ?, ?, ?)"
)
.bind(match_id)
.bind(opp_id.0)
.bind(2)
.bind(-(5.0 - i as f64))
.execute(&pool)
.await
.unwrap();
}
// Query player's match history
let history: Vec<(i64, f64)> = sqlx::query_as(
r#"SELECT m.id, mp.rating_change
FROM match_participants mp
JOIN matches m ON mp.match_id = m.id
WHERE mp.player_id = ?
ORDER BY m.timestamp DESC"#
)
.bind(player_id.0)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(history.len(), 5);
}
#[tokio::test]
async fn test_head_to_head_stats() {
let pool = setup_test_db().await;
// Create two players
sqlx::query("INSERT INTO players (name) VALUES (?)")
.bind("Player X")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO players (name) VALUES (?)")
.bind("Player Y")
.execute(&pool)
.await
.unwrap();
let px_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = ?")
.bind("Player X")
.fetch_one(&pool)
.await
.unwrap();
let py_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = ?")
.bind("Player Y")
.fetch_one(&pool)
.await
.unwrap();
// X wins 3 matches, Y wins 2 matches
let results = [(11, 7, 1), (11, 9, 1), (7, 11, 2), (11, 5, 1), (8, 11, 2)];
for (t1_score, t2_score, winner_team) in results {
let match_result = sqlx::query(
"INSERT INTO matches (match_type, team1_score, team2_score) VALUES (?, ?, ?)"
)
.bind("singles")
.bind(t1_score)
.bind(t2_score)
.execute(&pool)
.await
.unwrap();
let match_id = match_result.last_insert_rowid();
// X is always team 1, Y is always team 2
sqlx::query(
"INSERT INTO match_participants (match_id, player_id, team) VALUES (?, ?, ?)"
)
.bind(match_id)
.bind(px_id.0)
.bind(1)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO match_participants (match_id, player_id, team) VALUES (?, ?, ?)"
)
.bind(match_id)
.bind(py_id.0)
.bind(2)
.execute(&pool)
.await
.unwrap();
}
// Calculate head-to-head
let h2h: (i64, i64) = sqlx::query_as(
r#"SELECT
SUM(CASE WHEN m.team1_score > m.team2_score THEN 1 ELSE 0 END) as x_wins,
SUM(CASE WHEN m.team2_score > m.team1_score THEN 1 ELSE 0 END) as y_wins
FROM matches m
JOIN match_participants mp1 ON m.id = mp1.match_id AND mp1.player_id = ?
JOIN match_participants mp2 ON m.id = mp2.match_id AND mp2.player_id = ?"#
)
.bind(px_id.0)
.bind(py_id.0)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(h2h.0, 3); // X wins
assert_eq!(h2h.1, 2); // Y wins
}
#[tokio::test]
async fn test_daily_summary_query() {
let pool = setup_test_db().await;
// Create players
for name in ["Daily1", "Daily2"] {
sqlx::query("INSERT INTO players (name) VALUES (?)")
.bind(name)
.execute(&pool)
.await
.unwrap();
}
// Record match with specific date
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
let match_result = sqlx::query(
"INSERT INTO matches (match_type, team1_score, team2_score, timestamp) VALUES (?, ?, ?, ?)"
)
.bind("singles")
.bind(11)
.bind(7)
.bind(format!("{} 14:30:00", today))
.execute(&pool)
.await
.unwrap();
let match_id = match_result.last_insert_rowid();
let p1_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = ?")
.bind("Daily1")
.fetch_one(&pool)
.await
.unwrap();
let p2_id: (i64,) = sqlx::query_as("SELECT id FROM players WHERE name = ?")
.bind("Daily2")
.fetch_one(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO match_participants (match_id, player_id, team, rating_change) VALUES (?, ?, ?, ?)"
)
.bind(match_id)
.bind(p1_id.0)
.bind(1)
.bind(5.0)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO match_participants (match_id, player_id, team, rating_change) VALUES (?, ?, ?, ?)"
)
.bind(match_id)
.bind(p2_id.0)
.bind(2)
.bind(-5.0)
.execute(&pool)
.await
.unwrap();
// Query daily summary
let daily_matches: Vec<(i64, String, i32, i32)> = sqlx::query_as(
"SELECT id, match_type, team1_score, team2_score FROM matches WHERE date(timestamp) = ?"
)
.bind(&today)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(daily_matches.len(), 1);
assert_eq!(daily_matches[0].1, "singles");
assert_eq!(daily_matches[0].2, 11);
assert_eq!(daily_matches[0].3, 7);
// Query daily rating changes
let daily_changes: Vec<(String, f64)> = sqlx::query_as(
r#"SELECT p.name, SUM(mp.rating_change) as total_change
FROM match_participants mp
JOIN matches m ON mp.match_id = m.id
JOIN players p ON mp.player_id = p.id
WHERE date(m.timestamp) = ?
GROUP BY p.name
ORDER BY total_change DESC"#
)
.bind(&today)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(daily_changes.len(), 2);
assert_eq!(daily_changes[0].0, "Daily1"); // Winner first
assert!(daily_changes[0].1 > 0.0);
}
#[tokio::test]
async fn test_unique_player_name_constraint() {
let pool = setup_test_db().await;
// Create player
sqlx::query("INSERT INTO players (name) VALUES (?)")
.bind("Unique Name")
.execute(&pool)
.await
.unwrap();
// Try to create duplicate - should fail
let result = sqlx::query("INSERT INTO players (name) VALUES (?)")
.bind("Unique Name")
.execute(&pool)
.await;
assert!(result.is_err());
}

View File

@ -1,250 +0,0 @@
//! Integration tests for Pickleball ELO Tracker
use pickleball_elo::glicko::{GlickoRating, calculate_new_ratings};
use pickleball_elo::db;
/// Test Glicko-2 rating calculations
#[test]
fn test_glicko_rating_creation() {
let rating = GlickoRating::new_player();
assert_eq!(rating.rating, 1500.0);
assert_eq!(rating.rd, 350.0);
assert!((rating.volatility - 0.06).abs() < 0.001);
}
#[test]
fn test_glicko_winner_gains_rating() {
let winner = GlickoRating::new_player();
let loser = GlickoRating::new_player();
let (new_winner, new_loser) = calculate_new_ratings(&winner, &loser, 1.0, 1.0);
// Winner should gain rating
assert!(new_winner.rating > winner.rating,
"Winner rating {} should be greater than {}", new_winner.rating, winner.rating);
// Loser should lose rating
assert!(new_loser.rating < loser.rating,
"Loser rating {} should be less than {}", new_loser.rating, loser.rating);
}
#[test]
fn test_glicko_rating_changes_are_symmetric() {
let player1 = GlickoRating::new_player();
let player2 = GlickoRating::new_player();
let (new_p1, new_p2) = calculate_new_ratings(&player1, &player2, 1.0, 1.0);
let p1_change = new_p1.rating - player1.rating;
let p2_change = new_p2.rating - player2.rating;
// Changes should be roughly symmetric (opposite signs)
assert!((p1_change + p2_change).abs() < 1.0,
"Rating changes should be symmetric: {} + {} = {}", p1_change, p2_change, p1_change + p2_change);
}
#[test]
fn test_glicko_bigger_upset_bigger_change() {
let favorite = GlickoRating { rating: 1800.0, rd: 100.0, volatility: 0.06 };
let underdog = GlickoRating { rating: 1400.0, rd: 100.0, volatility: 0.06 };
// Underdog wins (upset)
let (new_underdog, new_favorite) = calculate_new_ratings(&underdog, &favorite, 1.0, 1.0);
// Underdog should gain a lot
let underdog_gain = new_underdog.rating - underdog.rating;
assert!(underdog_gain > 20.0,
"Underdog upset gain {} should be significant", underdog_gain);
}
#[test]
fn test_glicko_rd_decreases_after_match() {
let player1 = GlickoRating { rating: 1500.0, rd: 200.0, volatility: 0.06 };
let player2 = GlickoRating { rating: 1500.0, rd: 200.0, volatility: 0.06 };
let (new_p1, _) = calculate_new_ratings(&player1, &player2, 1.0, 1.0);
// RD should decrease after playing (more certainty)
assert!(new_p1.rd < player1.rd,
"RD {} should decrease from {}", new_p1.rd, player1.rd);
}
#[test]
fn test_score_weighting_blowout_vs_close() {
let player1 = GlickoRating::new_player();
let player2 = GlickoRating::new_player();
// Blowout win (11-0)
let (blowout_winner, _) = calculate_new_ratings(&player1, &player2, 1.0, 1.5);
// Close win (11-9)
let (close_winner, _) = calculate_new_ratings(&player1, &player2, 1.0, 1.05);
// Blowout should give more rating
assert!(blowout_winner.rating > close_winner.rating,
"Blowout {} should give more than close {}", blowout_winner.rating, close_winner.rating);
}
/// Test database operations
#[tokio::test]
async fn test_database_creation() {
let temp_dir = std::env::temp_dir();
let db_path = temp_dir.join("test_pickleball.db");
let db_str = db_path.to_str().unwrap();
// Clean up from previous runs
let _ = std::fs::remove_file(&db_path);
let pool = db::create_pool(db_str).await.expect("Failed to create pool");
// Verify tables exist
let result: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM players")
.fetch_one(&pool)
.await
.expect("Players table should exist");
assert_eq!(result.0, 0, "Players table should be empty");
// Clean up
drop(pool);
let _ = std::fs::remove_file(&db_path);
}
#[tokio::test]
async fn test_player_crud() {
let temp_dir = std::env::temp_dir();
let db_path = temp_dir.join("test_crud.db");
let db_str = db_path.to_str().unwrap();
let _ = std::fs::remove_file(&db_path);
let pool = db::create_pool(db_str).await.unwrap();
// Create player
sqlx::query("INSERT INTO players (name, email) VALUES ('Test Player', 'test@example.com')")
.execute(&pool)
.await
.expect("Should insert player");
// Read player
let player: (i64, String, Option<String>, f64) = sqlx::query_as(
"SELECT id, name, email, singles_rating FROM players WHERE name = 'Test Player'"
)
.fetch_one(&pool)
.await
.expect("Should find player");
assert_eq!(player.1, "Test Player");
assert_eq!(player.2, Some("test@example.com".to_string()));
assert_eq!(player.3, 1500.0); // Default rating
// Update player
sqlx::query("UPDATE players SET singles_rating = 1600.0 WHERE id = ?")
.bind(player.0)
.execute(&pool)
.await
.expect("Should update player");
let updated: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?")
.bind(player.0)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(updated.0, 1600.0);
// Delete player
sqlx::query("DELETE FROM players WHERE id = ?")
.bind(player.0)
.execute(&pool)
.await
.expect("Should delete player");
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM players")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count.0, 0);
drop(pool);
let _ = std::fs::remove_file(&db_path);
}
#[tokio::test]
async fn test_match_recording() {
let temp_dir = std::env::temp_dir();
let db_path = temp_dir.join("test_matches.db");
let db_str = db_path.to_str().unwrap();
let _ = std::fs::remove_file(&db_path);
let pool = db::create_pool(db_str).await.unwrap();
// Create two players
sqlx::query("INSERT INTO players (name) VALUES ('Player A')")
.execute(&pool).await.unwrap();
sqlx::query("INSERT INTO players (name) VALUES ('Player B')")
.execute(&pool).await.unwrap();
// Create a session
let session_id: i64 = sqlx::query_scalar(
"INSERT INTO sessions (notes) VALUES ('Test Session') RETURNING id"
)
.fetch_one(&pool)
.await
.unwrap();
// Create a match
let match_id: i64 = sqlx::query_scalar(
"INSERT INTO matches (session_id, match_type, team1_score, team2_score) VALUES (?, 'singles', 11, 5) RETURNING id"
)
.bind(session_id)
.fetch_one(&pool)
.await
.unwrap();
assert!(match_id > 0, "Match should be created with valid ID");
// Verify match
let match_data: (String, i32, i32) = sqlx::query_as(
"SELECT match_type, team1_score, team2_score FROM matches WHERE id = ?"
)
.bind(match_id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(match_data.0, "singles");
assert_eq!(match_data.1, 11);
assert_eq!(match_data.2, 5);
drop(pool);
let _ = std::fs::remove_file(&db_path);
}
#[test]
fn test_rating_bounds() {
// Test that ratings don't go below 0 or above unreasonable values
let very_low = GlickoRating { rating: 100.0, rd: 50.0, volatility: 0.06 };
let very_high = GlickoRating { rating: 2500.0, rd: 50.0, volatility: 0.06 };
let (new_low, _) = calculate_new_ratings(&very_low, &very_high, 0.0, 1.0);
assert!(new_low.rating > 0.0, "Rating should stay positive");
assert!(new_low.rd > 0.0, "RD should stay positive");
}
#[test]
fn test_draw_handling() {
let player1 = GlickoRating::new_player();
let player2 = GlickoRating::new_player();
// Score of 0.5 = draw
let (new_p1, new_p2) = calculate_new_ratings(&player1, &player2, 0.5, 1.0);
// In a draw between equal players, ratings shouldn't change much
let p1_change = (new_p1.rating - player1.rating).abs();
let p2_change = (new_p2.rating - player2.rating).abs();
assert!(p1_change < 1.0, "Draw should not change rating much: {}", p1_change);
assert!(p2_change < 1.0, "Draw should not change rating much: {}", p2_change);
}