From 75576ce50c837a769d06fb6b482de791eac00f8f Mon Sep 17 00:00:00 2001 From: Split Date: Thu, 26 Feb 2026 12:43:15 -0500 Subject: [PATCH] v3.0.0: Complete refactor - modular structure, unified ELO, tests --- Cargo.lock | 2 +- Cargo.toml | 2 +- config.toml | 16 + examples/email_demo.rs | 135 ------ examples/simple_test.rs | 40 -- src/bin/test_glicko.rs | 20 - src/config.rs | 116 ++++++ src/db/mod.rs | 34 +- src/db/queries.rs | 121 ++++++ src/demo.rs | 287 ------------- src/elo/calculator.rs | 2 +- src/elo/integration_tests.rs | 446 ++++++++++++++++++++ src/elo/mod.rs | 3 + src/elo/tests.rs | 123 ++++++ src/glicko/calculator.rs | 219 ---------- src/glicko/doubles.rs | 155 ------- src/glicko/mod.rs | 27 -- src/glicko/rating.rs | 34 -- src/glicko/score_weight.rs | 97 ----- src/handlers/api.rs | 46 +++ src/handlers/daily.rs | 32 ++ src/handlers/home.rs | 59 +++ src/handlers/matches.rs | 48 +++ src/handlers/mod.rs | 32 ++ src/handlers/players.rs | 46 +++ src/handlers/sessions.rs | 31 ++ src/lib.rs | 9 +- src/main.rs | 16 +- src/models/mod.rs | 62 +-- src/simple_demo.rs | 188 --------- templates/base.html | 278 +++++++++++++ templates/components/nav.html | 10 + templates/email/daily_summary.html | 66 +++ templates/pages/about.html | 94 +++++ templates/pages/daily_summary.html | 46 +++ templates/pages/home.html | 51 +++ templates/pages/leaderboard.html | 30 ++ templates/pages/match_history.html | 45 ++ templates/pages/player_profile.html | 128 ++++++ templates/pages/players_list.html | 46 +++ tests/database_tests.rs | 611 ++++++++++++++++++++++++++++ tests/integration_tests.rs | 250 ------------ 42 files changed, 2562 insertions(+), 1541 deletions(-) create mode 100644 config.toml delete mode 100644 examples/email_demo.rs delete mode 100644 examples/simple_test.rs delete mode 100644 src/bin/test_glicko.rs create mode 100644 src/config.rs create mode 100644 src/db/queries.rs delete mode 100644 src/demo.rs create mode 100644 src/elo/integration_tests.rs create mode 100644 src/elo/tests.rs delete mode 100644 src/glicko/calculator.rs delete mode 100644 src/glicko/doubles.rs delete mode 100644 src/glicko/mod.rs delete mode 100644 src/glicko/rating.rs delete mode 100644 src/glicko/score_weight.rs create mode 100644 src/handlers/api.rs create mode 100644 src/handlers/daily.rs create mode 100644 src/handlers/home.rs create mode 100644 src/handlers/matches.rs create mode 100644 src/handlers/mod.rs create mode 100644 src/handlers/players.rs create mode 100644 src/handlers/sessions.rs delete mode 100644 src/simple_demo.rs create mode 100644 templates/base.html create mode 100644 templates/components/nav.html create mode 100644 templates/email/daily_summary.html create mode 100644 templates/pages/about.html create mode 100644 templates/pages/daily_summary.html create mode 100644 templates/pages/home.html create mode 100644 templates/pages/leaderboard.html create mode 100644 templates/pages/match_history.html create mode 100644 templates/pages/player_profile.html create mode 100644 templates/pages/players_list.html create mode 100644 tests/database_tests.rs delete mode 100644 tests/integration_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 2877c6e..6870206 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1431,7 +1431,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pickleball-elo" -version = "2.0.0" +version = "3.0.0" dependencies = [ "anyhow", "askama", diff --git a/Cargo.toml b/Cargo.toml index 8ae1360..ff2e807 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pickleball-elo" -version = "2.0.0" +version = "3.0.0" edition = "2021" [dependencies] diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..8decfc9 --- /dev/null +++ b/config.toml @@ -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 diff --git a/examples/email_demo.rs b/examples/email_demo.rs deleted file mode 100644 index c09bf0d..0000000 --- a/examples/email_demo.rs +++ /dev/null @@ -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); -} diff --git a/examples/simple_test.rs b/examples/simple_test.rs deleted file mode 100644 index 07b9526..0000000 --- a/examples/simple_test.rs +++ /dev/null @@ -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!"); -} diff --git a/src/bin/test_glicko.rs b/src/bin/test_glicko.rs deleted file mode 100644 index 6159fab..0000000 --- a/src/bin/test_glicko.rs +++ /dev/null @@ -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!"); -} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..56ca113 --- /dev/null +++ b/src/config.rs @@ -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, +} + +impl Config { + /// Load configuration from config.toml and environment variables + pub fn load(config_path: &str) -> Result { + // 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 + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 7fc4ec4..9337be7 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -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 { /// 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 { diff --git a/src/db/queries.rs b/src/db/queries.rs new file mode 100644 index 0000000..9262076 --- /dev/null +++ b/src/db/queries.rs @@ -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, f64)>> { + let result = sqlx::query_as::<_, (i64, String, Option, 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> { + 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> { + 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> { + 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> { + 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 { + 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 { + 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 { + 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 +} diff --git a/src/demo.rs b/src/demo.rs deleted file mode 100644 index 64e665c..0000000 --- a/src/demo.rs +++ /dev/null @@ -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, // Player indices - pub team2: Vec, - 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::() / team1_skills.len() as f64; - let team2_avg: f64 = team2_skills.iter().sum::() / 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::() < 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 { - 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 = (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 = team1_indices.iter() - .map(|&i| players[i].true_skill) - .collect(); - let team2_skills: Vec = 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::() / 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::() / 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::() / 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::() / 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 - ); - } -} diff --git a/src/elo/calculator.rs b/src/elo/calculator.rs index c430e27..1b5b0ca 100644 --- a/src/elo/calculator.rs +++ b/src/elo/calculator.rs @@ -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)) } diff --git a/src/elo/integration_tests.rs b/src/elo/integration_tests.rs new file mode 100644 index 0000000..e8deb0b --- /dev/null +++ b/src/elo/integration_tests.rs @@ -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); + } +} diff --git a/src/elo/mod.rs b/src/elo/mod.rs index dd0abfd..312ba00 100644 --- a/src/elo/mod.rs +++ b/src/elo/mod.rs @@ -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}; diff --git a/src/elo/tests.rs b/src/elo/tests.rs new file mode 100644 index 0000000..871f11e --- /dev/null +++ b/src/elo/tests.rs @@ -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); + } +} diff --git a/src/glicko/calculator.rs b/src/glicko/calculator.rs deleted file mode 100644 index 023693c..0000000 --- a/src/glicko/calculator.rs +++ /dev/null @@ -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 = 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 = 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::(); - - // 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::() - } - - /// 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); - } -} diff --git a/src/glicko/doubles.rs b/src/glicko/doubles.rs deleted file mode 100644 index 72e7d31..0000000 --- a/src/glicko/doubles.rs +++ /dev/null @@ -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); - } -} diff --git a/src/glicko/mod.rs b/src/glicko/mod.rs deleted file mode 100644 index f60e632..0000000 --- a/src/glicko/mod.rs +++ /dev/null @@ -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) -} diff --git a/src/glicko/rating.rs b/src/glicko/rating.rs deleted file mode 100644 index ecf5294..0000000 --- a/src/glicko/rating.rs +++ /dev/null @@ -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, - } - } -} diff --git a/src/glicko/score_weight.rs b/src/glicko/score_weight.rs deleted file mode 100644 index bcc3444..0000000 --- a/src/glicko/score_weight.rs +++ /dev/null @@ -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); - } -} diff --git a/src/handlers/api.rs b/src/handlers/api.rs new file mode 100644 index 0000000..a5d5b09 --- /dev/null +++ b/src/handlers/api.rs @@ -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, +) -> Json> { + Json(vec![]) +} + +pub async fn api_player_details_handler( + State(_state): State, + Path(_player_id): Path, +) -> Result, (StatusCode, String)> { + Err((StatusCode::NOT_FOUND, "Player not found".to_string())) +} + +pub async fn api_leaderboard_handler( + State(_state): State, +) -> Json> { + Json(vec![]) +} diff --git a/src/handlers/daily.rs b/src/handlers/daily.rs new file mode 100644 index 0000000..2d2f552 --- /dev/null +++ b/src/handlers/daily.rs @@ -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) -> Html { + Html("

Daily Summary

".to_string()) +} + +pub async fn daily_public_handler(State(_state): State) -> Html { + Html("

Daily Public View

".to_string()) +} + +pub async fn create_session_handler(State(_state): State) -> Html { + Html("

Create Session

".to_string()) +} + +pub async fn send_daily_email_handler( + State(_state): State, + Path(_session_id): Path, +) -> Result, (StatusCode, String)> { + Ok(Html("

Email Sent

".to_string())) +} diff --git a/src/handlers/home.rs b/src/handlers/home.rs new file mode 100644 index 0000000..2e869a3 --- /dev/null +++ b/src/handlers/home.rs @@ -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) -> Html { + 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#"Pickleball ELO +

๐Ÿ“ Pickleball ELO Tracker

+

Matches: {}

+

Players: {}

+

Sessions: {}

+ "#, + match_count, player_count, session_count + ); + + Html(html) +} + +/// Serves the about page +pub async fn about_handler() -> Html { + let html = r#" + + + About - Pickleball ELO + + +

โ“ About

+

Pickleball ELO Rating System v3.0

+ +"#; + + Html(html.to_string()) +} diff --git a/src/handlers/matches.rs b/src/handlers/matches.rs new file mode 100644 index 0000000..b8e81f2 --- /dev/null +++ b/src/handlers/matches.rs @@ -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, + pub p2: Option, + pub p3: Option, + pub p4: Option, +} + +pub async fn match_history_handler(State(_state): State) -> Html { + Html("

Match History

".to_string()) +} + +pub async fn new_match_handler(State(_state): State) -> Html { + Html("

New Match Form

".to_string()) +} + +pub async fn create_match_handler(State(_state): State) -> Html { + Html("

Match Created

".to_string()) +} + +pub async fn delete_match_handler( + State(_state): State, + Path(_match_id): Path, +) -> Result, (StatusCode, String)> { + Ok(Html("

Match Deleted

".to_string())) +} + +pub async fn balance_query_handler( + State(_state): State, + Query(_params): Query, +) -> Result, (StatusCode, String)> { + Ok(Html("

Balance Query

".to_string())) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 0000000..f45c72b --- /dev/null +++ b/src/handlers/mod.rs @@ -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 +}; diff --git a/src/handlers/players.rs b/src/handlers/players.rs new file mode 100644 index 0000000..ad44cc8 --- /dev/null +++ b/src/handlers/players.rs @@ -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) -> Html { + Html("

Players List

".to_string()) +} + +pub async fn player_profile_handler( + State(_state): State, + Path(_player_id): Path, +) -> Result, (StatusCode, String)> { + Ok(Html("

Player Profile

".to_string())) +} + +pub async fn new_player_handler() -> Html { + Html("

New Player Form

".to_string()) +} + +pub async fn create_player_handler() -> Html { + Html("

Player Created

".to_string()) +} + +pub async fn edit_player_handler( + State(_state): State, + Path(_player_id): Path, +) -> Result, (StatusCode, String)> { + Ok(Html("

Edit Player

".to_string())) +} + +pub async fn update_player_handler( + State(_state): State, + Path(_player_id): Path, +) -> Result, (StatusCode, String)> { + Ok(Html("

Player Updated

".to_string())) +} diff --git a/src/handlers/sessions.rs b/src/handlers/sessions.rs new file mode 100644 index 0000000..b118f19 --- /dev/null +++ b/src/handlers/sessions.rs @@ -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) -> Html { + Html("

Sessions List

".to_string()) +} + +pub async fn session_preview_handler( + State(_state): State, + Path(_session_id): Path, +) -> Result, (StatusCode, String)> { + Ok(Html("

Session Preview

".to_string())) +} + +pub async fn session_send_handler( + State(_state): State, + Path(_session_id): Path, +) -> Result, (StatusCode, String)> { + Ok(Html("

Session Sent

".to_string())) +} diff --git a/src/lib.rs b/src/lib.rs index 84ca9f7..09c2f0d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/main.rs b/src/main.rs index 87b3820..588cbb3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 = 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() { diff --git a/src/models/mod.rs b/src/models/mod.rs index bbf9650..d0661c5 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -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, - // === 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, } diff --git a/src/simple_demo.rs b/src/simple_demo.rs deleted file mode 100644 index 302f355..0000000 --- a/src/simple_demo.rs +++ /dev/null @@ -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 = (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 = (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 = team1_indices.iter() - .map(|&i| players[i].true_skill) - .collect(); - let team2_skills: Vec = 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::() / 2.0, team1_score, team2_score) - } else { - calculate_weighted_score(players[idx].doubles.rating, team2_indices.iter().map(|&i| players[i].doubles.rating).sum::() / 2.0, team1_score, team2_score) - }; - - let avg_opponent = crate::glicko::GlickoRating { - rating: team2_indices.iter().map(|&i| players[i].doubles.rating).sum::() / 2.0, - rd: team2_indices.iter().map(|&i| players[i].doubles.rd).sum::() / 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::() / 2.0, team2_score, team1_score) - } else { - calculate_weighted_score(players[idx].doubles.rating, team1_indices.iter().map(|&i| players[i].doubles.rating).sum::() / 2.0, team2_score, team1_score) - }; - - let avg_opponent = crate::glicko::GlickoRating { - rating: team1_indices.iter().map(|&i| players[i].doubles.rating).sum::() / 2.0, - rd: team1_indices.iter().map(|&i| players[i].doubles.rd).sum::() / 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, - ); - } -} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..4aa32ec --- /dev/null +++ b/templates/base.html @@ -0,0 +1,278 @@ + + + + + + {% block title %}Pickleball ELO Tracker{% endblock %} + + {% block extra_css %}{% endblock %} + + +
+ {% block content %}{% endblock %} +
+ + diff --git a/templates/components/nav.html b/templates/components/nav.html new file mode 100644 index 0000000..702acea --- /dev/null +++ b/templates/components/nav.html @@ -0,0 +1,10 @@ + diff --git a/templates/email/daily_summary.html b/templates/email/daily_summary.html new file mode 100644 index 0000000..6813350 --- /dev/null +++ b/templates/email/daily_summary.html @@ -0,0 +1,66 @@ + + + + + + Pickleball Results - {{ date }} + + +
+

๐Ÿ“ Pickleball Results

+

{{ date }}

+ + {% if summary.total_matches > 0 %} +
+

Matches Played: {{ summary.total_matches }}

+

Total Players: {{ summary.total_players }}

+
+ +

๐Ÿ“Š Top Performers

+ + {% if summary.top_winners %} + + + + + + + + + {% for winner in summary.top_winners %} + + + + + {% endfor %} + +
PlayerRating Change
{{ winner.name }} + +{{ winner.rating_change }} +
+ {% endif %} + + {% if summary.all_matches %} +

๐ŸŽพ All Matches

+ + {% for match in summary.all_matches %} +
+

{{ match.team1_display }} vs {{ match.team2_display }}

+

Score: {{ match.team1_score }}-{{ match.team2_score }}

+
+ {% endfor %} + {% endif %} + {% else %} +
+

No matches recorded for today.

+
+ {% endif %} + +
+ +

+ View Full Leaderboard | + View All Sessions +

+
+ + diff --git a/templates/pages/about.html b/templates/pages/about.html new file mode 100644 index 0000000..eed86ba --- /dev/null +++ b/templates/pages/about.html @@ -0,0 +1,94 @@ +{% extends "base.html" %} + +{% block title %}About - Pickleball ELO Tracker{% endblock %} + +{% block content %} +

โ“ About Pickleball ELO

+{% include "components/nav.html" %} + +
+

๐Ÿ“Š What is ELO?

+

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

+

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

+
    +
  • Your performance: Points won รท total points (not just win/loss)
  • +
  • Your opponent's skill: Playing a higher-rated opponent is worth more
  • +
  • The upset factor: Beating someone stronger gains more points
  • +
+
+ +
+

๐ŸŽพ Unified Rating (v3.0)

+

+ In version 3, we switched from separate singles/doubles ratings to a unified rating. + Both singles and doubles matches contribute to the same ELO rating, because: +

+
    +
  • One rating is simpler to understand
  • +
  • Players who excel in both formats get fairly rewarded
  • +
  • Pure ELO is transparent and bias-free
  • +
+
+ +
+

โš–๏ธ Smart Doubles Scoring

+

+ In doubles matches, we calculate your "effective opponent" to make scoring fair: +

+

+ Effective Opponent = Opp1 + Opp2 - Teammate +

+

+ This means: +

+
    +
  • Playing with a strong teammate โ†’ harder opponents count as weaker โ†’ less credit for winning
  • +
  • Playing with a weak teammate โ†’ harder opponents count as stronger โ†’ more credit for winning
  • +
+

+ This approach is fair, symmetric, and makes strategic sense. +

+
+ +
+

๐Ÿงฎ The Formula

+

+ Rating Change = K ร— (Actual Performance โˆ’ Expected Performance) +

+

Where:

+
    +
  • K = 32 (standard, adjustable for casual/competitive play)
  • +
  • Actual Performance = Your points รท Total points
  • +
  • Expected Performance = Based on the ELO formula (derived from rating difference)
  • +
+

+ The expected performance formula: E = 1 / (1 + 10^((Opp_Rating - Your_Rating) / 400)) +

+
+ +
+

๐Ÿ“ˆ Why This System Works

+
    +
  • Transparent: No hidden formulas or magic numbers. You can verify your rating changes.
  • +
  • Fair: You're rewarded for actual performance, not just wins.
  • +
  • Balanced: Winning 11-2 and 11-9 are different and rated differently.
  • +
  • Skill-based: Beating stronger players earns more; losing to them costs less.
  • +
  • Predictable: Ratings converge to true skill over time.
  • +
+
+ +
+

๐Ÿ“ Version History

+
    +
  • v3.0.0 - Unified ELO rating, modular architecture, pure ELO calculator
  • +
  • v2.0.0 - Glicko-2 system with separate singles/doubles ratings
  • +
  • v1.0.0 - Initial release with basic ELO
  • +
+
+{% endblock %} diff --git a/templates/pages/daily_summary.html b/templates/pages/daily_summary.html new file mode 100644 index 0000000..0a4e793 --- /dev/null +++ b/templates/pages/daily_summary.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} + +{% block title %}Daily Summary - Pickleball ELO Tracker{% endblock %} + +{% block content %} +

๐Ÿ“ง Daily Session Summaries

+{% include "components/nav.html" %} + + + +{% if sessions.is_empty() %} +
+ No sessions created yet. Create one! +
+{% else %} + + + + + + + + + + + + {% for session in sessions %} + + + + + + + + {% endfor %} + +
DateMatchesPlayersPreviewActions
{{ session.created_at | truncate(length=10) }}{{ session.match_count }}{{ session.player_count }} + Preview + + ๐Ÿ“ง Send + Delete +
+{% endif %} +{% endblock %} diff --git a/templates/pages/home.html b/templates/pages/home.html new file mode 100644 index 0000000..5b0cbba --- /dev/null +++ b/templates/pages/home.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} + +{% block title %}Home - Pickleball ELO Tracker{% endblock %} + +{% block content %} +
+

๐Ÿ“ Pickleball ELO Tracker

+

Pure ELO Rating System v3.0

+ +
+
+
{{ match_count }}
+
Matches
+
+
+
{{ player_count }}
+
Players
+
+
+
{{ session_count }}
+
Sessions
+
+
+ + {% include "components/nav.html" %} + +
+

๐Ÿ“Š How Ratings Work

+ +

One unified rating โ€” Singles and doubles matches both contribute to a single ELO rating. Everyone starts at 1500.

+ +

Per-point scoring โ€” 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.

+ +

Smart doubles scoring โ€” In doubles, we calculate your "effective opponent" using:
+ Effective Opponent = Opp1 + Opp2 - Teammate

+ +

This means:

+
    +
  • Strong teammate โ†’ lower effective opponent โ†’ less credit for winning
  • +
  • Weak teammate โ†’ higher effective opponent โ†’ more credit for winning
  • +
+ +

The formula:
+ + Rating Change = 32 ร— (Actual Performance - Expected Performance) +

+ +

Fair, transparent, and no mysterious "volatility" numbers. Just skill vs. expectations.

+
+
+{% endblock %} diff --git a/templates/pages/leaderboard.html b/templates/pages/leaderboard.html new file mode 100644 index 0000000..0efcbb6 --- /dev/null +++ b/templates/pages/leaderboard.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} + +{% block title %}Leaderboard - Pickleball ELO Tracker{% endblock %} + +{% block content %} +

๐Ÿ“Š Leaderboard

+{% include "components/nav.html" %} + +{% if leaderboard.is_empty() %} +
+ No players with matches yet. Record a match to see the leaderboard! +
+{% else %} +
+ {% for (rank, player) in leaderboard %} +
+
{{ rank }}
+ +
{{ player.rating | round(1) }}
+
+ {% endfor %} +
+{% endif %} +{% endblock %} diff --git a/templates/pages/match_history.html b/templates/pages/match_history.html new file mode 100644 index 0000000..54ca819 --- /dev/null +++ b/templates/pages/match_history.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} + +{% block title %}Match History - Pickleball ELO Tracker{% endblock %} + +{% block content %} +

๐Ÿ“œ Match History

+{% include "components/nav.html" %} + + + +{% if matches.is_empty() %} +
+ No matches recorded yet. Record one! +
+{% else %} + + + + + + + + + + + + + {% for match in matches %} + + + + + + + + + {% endfor %} + +
TypeTeam 1ScoreTeam 2DateActions
{{ match.match_type }}{{ match.team1_display }}{{ match.team1_score }}-{{ match.team2_score }}{{ match.team2_display }}{{ match.timestamp | truncate(length=16) }} + Delete +
+{% endif %} +{% endblock %} diff --git a/templates/pages/player_profile.html b/templates/pages/player_profile.html new file mode 100644 index 0000000..5495444 --- /dev/null +++ b/templates/pages/player_profile.html @@ -0,0 +1,128 @@ +{% extends "base.html" %} + +{% block title %}{{ player_name }} - Pickleball ELO Tracker{% endblock %} + +{% block content %} +

{{ player_name }}

+{% include "components/nav.html" %} + +
+
+

Rating

+
+ {{ current_rating }} +
+

+ {% if rating_change >= 0.0 %} + +{{ rating_change }} + {% else %} + {{ rating_change }} + {% endif %} + in last match +

+
+ +
+

Match Statistics

+
+

Total Matches: {{ total_matches }}

+

Wins: {{ wins }}

+

Losses: {{ losses }}

+

Win Rate: {{ win_rate }}%

+
+
+
+ +{% if email %} +
+

Email: {{ email }}

+
+{% endif %} + + + +{% if history_chart_data %} +
+

๐Ÿ“ˆ Rating Trend

+
+ +
+
+ + + +{% endif %} + +{% if head_to_head %} +
+

โš”๏ธ Head-to-Head

+ + + + + + + + + + + {% for opp in head_to_head %} + + + + + + + {% endfor %} + +
OpponentWinsLossesWin Rate
{{ opp.name }}{{ opp.wins }}{{ opp.losses }}{{ opp.win_percentage }}%
+
+{% endif %} + +{% if achievements %} +
+

๐Ÿ† Achievements

+
+ {% for achievement in achievements %} + + {{ achievement }} + + {% endfor %} +
+
+{% endif %} +{% endblock %} diff --git a/templates/pages/players_list.html b/templates/pages/players_list.html new file mode 100644 index 0000000..0992df2 --- /dev/null +++ b/templates/pages/players_list.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} + +{% block title %}Players - Pickleball ELO Tracker{% endblock %} + +{% block content %} +

๐Ÿ‘ฅ Players

+{% include "components/nav.html" %} + + + +{% if players.is_empty() %} +
+ No players yet. Create one to get started! +
+{% else %} + + + + + + + + + + + + + {% for player in players %} + + + + + + + + + {% endfor %} + +
NameRatingMatchesWin RateEmailActions
{{ player.name }}{{ player.rating }}{{ player.match_count }}{{ player.win_rate }}%{{ player.email.unwrap_or_default() }} + View + Edit +
+{% endif %} +{% endblock %} diff --git a/tests/database_tests.rs b/tests/database_tests.rs new file mode 100644 index 0000000..2447474 --- /dev/null +++ b/tests/database_tests.rs @@ -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()); +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs deleted file mode 100644 index 53577fa..0000000 --- a/tests/integration_tests.rs +++ /dev/null @@ -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, 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); -}