diff --git a/README.md b/README.md index 4134174..be6488e 100644 --- a/README.md +++ b/README.md @@ -1,221 +1,413 @@ -# šŸ“ Pickleball ELO Tracker v2.0 +# šŸ“ Pickleball ELO Tracker -A production-ready Glicko-2 rating system for pickleball tournaments with separate singles and doubles ratings, score-margin weighting, and email summaries. +> A powerful, modern web application for tracking pickleball player ratings using the **Glicko-2** rating system with separate rankings for singles and doubles play. + +## šŸ“‹ Table of Contents + +- [Overview](#overview) +- [Features](#features) +- [Tech Stack](#tech-stack) +- [Installation](#installation) +- [Usage](#usage) +- [API Endpoints](#api-endpoints) +- [Rating System](#rating-system) +- [License](#license) + +--- + +## šŸŽÆ Overview + +The **Pickleball ELO Tracker** is a sophisticated rating management system designed specifically for pickleball communities. It leverages the **Glicko-2 rating algorithm** to provide accurate, dynamic player ratings that account for rating uncertainty and rating volatility. The system supports both singles and doubles matches, with separate rating tracks for each format. + +Perfect for: +- šŸ“ League organizers and tournament directors +- šŸ‘„ Recreational pickleball groups and clubs +- šŸ“Š Player skill progression tracking +- āš–ļø Fair team composition and matchmaking + +--- ## ✨ Features -- **Glicko-2 Rating System**: Advanced rating algorithm with: - - Rating Deviation (RD) tracking uncertainty - - Volatility (σ) measuring consistency - - Score margin weighting (blowouts impact ratings more) - - Separate Singles & Doubles ratings per player +### šŸŽ® Player Management +- āž• Add new players with names and email addresses +- āœļø Edit player profiles and ratings +- šŸ“Š View individual player statistics and match history +- šŸ—‘ļø Delete players and associated records -- **3 Tournament Sessions**: - - Session 1: Opening Tournament (50 matches) - - Session 2: Mid-Tournament (55 matches) - - Session 3: Finals (52 matches) - - **Total: 157 matches across 20 players** +### šŸ† Match Recording +- šŸ“ Record singles and doubles matches +- šŸŽÆ Automatic rating updates using Glicko-2 algorithm +- šŸ” View complete match history with details +- šŸ—‘ļø Delete matches with automatic rating recalculation +- šŸ“ˆ Transparent rating change calculations -- **Email Integration**: Generates HTML session summaries ready for Zoho SMTP +### šŸ“‹ Leaderboards +- šŸ„‡ Separate rankings for singles and doubles +- šŸ“Š Display player ratings, RD (rating deviation), and volatility +- šŸ”„ Real-time updates after each match +- 🌐 Both HTML and JSON API endpoints -- **Web Server**: Axum-based API server on port 3000 with leaderboards +### āš™ļø Team Balancer +- šŸ¤ Suggest balanced team compositions from available players +- šŸ’” Intelligent pairing based on player ratings +- šŸ“Š Predict match outcomes with win probability +- šŸŽÆ Perfect for tournament or session planning -- **SQLite Database**: Persistent storage of players, sessions, matches, and ratings +### šŸ“§ Session Management +- šŸ“§ Create and manage pickleball sessions +- šŸ‘„ Assign players to sessions +- šŸ“Š Preview session results and standings +- šŸ’Œ Send session summary emails to participants +- šŸ“„ Email templating with detailed match statistics -## šŸš€ Quick Start +### šŸ“± User Interface +- šŸŽØ Modern, responsive web interface +- 🌈 Beautiful gradient designs and intuitive navigation +- šŸ“Š Data visualization with tables and statistics cards +- šŸ–±ļø Seamless forms for all operations + +--- + +## šŸ› ļø Tech Stack + +| Component | Technology | Version | +|-----------|-----------|---------| +| **Language** | Rust | 2021 Edition | +| **Web Framework** | Axum | 0.7 | +| **Async Runtime** | Tokio | 1.x (full features) | +| **Database** | SQLite | Via sqlx 0.7 | +| **Templating** | Askama | 0.12 | +| **Serialization** | Serde | 1.0 | +| **Email** | Lettre | 0.11 | +| **CLI** | Clap | 4.0 | +| **Logging** | Tracing | 0.1 | + +### Key Dependencies +- **tower** - Middleware and utilities +- **tower-http** - HTTP-specific middleware +- **chrono** - Date/time handling +- **anyhow** & **thiserror** - Error handling + +--- + +## šŸ“¦ Installation + +### Prerequisites +- **Rust** 1.70 or later ([Install Rust](https://www.rust-lang.org/tools/install)) +- **SQLite** 3.x (usually pre-installed on macOS/Linux) +- **Cargo** (included with Rust) + +### Clone the Repository -### Run Demo (Simulate 3 Sessions) ```bash -cd /Users/split/Projects/pickleball-elo -./pickleball-elo demo +git clone https://github.com/yourusername/pickleball-elo.git +cd pickleball-elo ``` -This will: -1. Generate 20 random players -2. Simulate 157 matches across 3 sessions -3. Calculate Glicko-2 ratings -4. Generate HTML email summary -5. Display final leaderboards +### Build from Source -### Run Web Server +#### Development Build ```bash -cd /Users/split/Projects/pickleball-elo -./pickleball-elo +cargo build ``` -Server runs on `http://localhost:3000`: -- `/` - Home page with stats -- `/leaderboard` - HTML leaderboards -- `/api/leaderboard` - JSON API - -## šŸ“Š Glicko-2 Algorithm - -### Core Improvements Over Basic ELO: -1. **Rating Deviation (RD)**: Tracks certainty. New players start at 350; drops as games are played -2. **Volatility (σ)**: Measures consistency. Upset wins increase volatility -3. **Score Weighting**: Blowouts (11-2) affect ratings more than close games (11-10) - -### Algorithm Steps: -1. Convert ratings to Glicko-2 scale (μ, φ, σ) -2. Calculate opponent impact function g(φⱼ) -3. Calculate expected outcome E(μ, μⱼ, φⱼ) -4. Compute variance v from all opponents -5. **Update volatility** using bisection algorithm -6. Update RD based on pre-period uncertainty -7. Update rating μ' = μ + φ'² Ɨ Ī£[g(φⱼ) Ɨ (sā±¼ - E)] -8. Convert back to display scale (r', RD') - -### Formula Reference: -``` -μ = (r - 1500) / 173.7178 # Internal scale -φ = RD / 173.7178 # RD in internal scale -g(φⱼ) = 1 / √(1 + 3φⱼ² / π²) # Opponent impact -E(μ, μⱼ, φⱼ) = 1 / (1 + exp(-g(φⱼ) Ɨ (μ - μⱼ))) # Expected outcome -v = 1 / Σⱼ[g(φⱼ)² Ɨ E Ɨ (1 - E)] # Variance +#### Release Build (Recommended) +```bash +cargo build --release ``` -### Score Margin Weighting: -``` -margin = |winner_score - loser_score| -margin_bonus = tanh(margin / 11 Ɨ 0.3) -s_weighted = s_base + margin_bonus Ɨ (s_base - 0.5) +The compiled binary will be located at: +- **Debug**: `target/debug/pickleball-elo` +- **Release**: `target/release/pickleball-elo` -Examples (pickleball to 11): -- 11-9 (close): margin_bonus ā‰ˆ 0.055 → s_winner ā‰ˆ 1.027 -- 11-5 (moderate): margin_bonus ā‰ˆ 0.162 → s_winner ā‰ˆ 1.081 -- 11-2 (blowout): margin_bonus ā‰ˆ 0.240 → s_winner ā‰ˆ 1.120 +### Database Setup + +The application automatically initializes the SQLite database on first run: + +```bash +# The database will be created at: +# /Users/split/Projects/pickleball-elo/pickleball.db ``` -## šŸ“ Project Structure +--- + +## šŸš€ Usage + +### Running the Server + +```bash +# Development mode (debug build) +cargo run + +# Release mode (optimized) +cargo run --release +``` + +### Default Configuration + +- **URL**: `http://localhost:3000` +- **Port**: `3000` +- **Database**: `pickleball.db` (in project root) + +### Running the Demo + +To see a live demo with sample data: + +```bash +cargo run --bin pickleball-elo -- --demo +``` + +This will populate the database with sample players and matches. + +### Web Interface + +Once the server is running, open your browser to: + +``` +http://localhost:3000 +``` + +You'll see: +- šŸ  Dashboard with latest matches and stats +- šŸ‘„ Players list with all registered players +- šŸ† Leaderboards (singles & doubles) +- šŸ“ Match history with full details +- āš™ļø Team balancer tool +- šŸ“§ Session manager for tournaments/events + +--- + +## šŸ”Œ API Endpoints + +All routes support both HTML (for web UI) and JSON (for API clients). + +### Web Routes (HTML) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/` | Dashboard/home page | +| `GET` | `/leaderboard` | Leaderboard view (singles & doubles) | +| `GET` | `/players` | List all players | +| `GET` | `/players/new` | New player form | +| `POST` | `/players/new` | Create new player | +| `GET` | `/players/:id` | Player profile and stats | +| `GET` | `/players/:id/edit` | Edit player form | +| `POST` | `/players/:id/edit` | Update player details | +| `GET` | `/matches` | Match history view | +| `GET` | `/matches/new` | New match form | +| `POST` | `/matches/new` | Record new match | +| `POST` | `/matches/:id/delete` | Delete match | +| `GET` | `/balance` | Team balancer tool | +| `GET` | `/sessions` | Sessions list | +| `GET` | `/sessions/:id/preview` | Session preview | +| `POST` | `/sessions/:id/send` | Send session email | + +### API Routes (JSON) + +| Method | Endpoint | Description | Response | +|--------|----------|-------------|----------| +| `GET` | `/api/leaderboard` | Leaderboard data | JSON array of players with ratings | +| `GET` | `/api/players` | Players list | JSON array of all players | + +### Response Examples + +**GET /api/leaderboard** +```json +[ + { + "id": 1, + "name": "Alice Chen", + "singles_rating": 1650.5, + "singles_rd": 45.2, + "singles_volatility": 0.062, + "doubles_rating": 1580.3, + "doubles_rd": 52.1, + "doubles_volatility": 0.075, + "matches_played": 24 + }, + ... +] +``` + +**GET /api/players** +```json +[ + { + "id": 1, + "name": "Alice Chen", + "singles_rating": 1650.5, + "doubles_rating": 1580.3 + }, + ... +] +``` + +--- + +## 🧮 Rating System + +### Glicko-2 Overview + +The **Glicko-2 rating system** is an advanced evolution of the Elo rating system that addresses several limitations: + +- **Rating Deviation (RD)**: Measures the uncertainty in a player's rating + - Low RD = High confidence in the rating + - High RD = More uncertainty (needs more matches to stabilize) + +- **Rating Volatility**: Measures how much a player's rating changes over time + - High volatility = Inconsistent performer + - Low volatility = Consistent performer + +- **Automatic Decay**: If a player doesn't play for extended periods, their RD increases, reflecting the decay in confidence about their true skill level + +### Separate Ratings + +This tracker maintains **two independent rating tracks** per player: + +#### šŸŽÆ Singles Ratings +- Tracks performance in 1v1 matches +- Separate rating, RD, and volatility +- Useful for evaluating individual skill + +#### šŸ‘„ Doubles Ratings +- Tracks performance in 2v2 matches +- Separate rating, RD, and volatility +- Accounts for team synergy and partner chemistry + +### How Ratings Are Calculated + +When a match is recorded: + +1. **Match Outcome** is recorded (winner and loser) +2. **Glicko-2 Algorithm** processes: + - Pre-match ratings and rating deviations + - Expected outcome probability (based on pre-match ratings) + - Actual outcome + - Time since last match +3. **New Ratings** are calculated for all participants +4. **RD and Volatility** are updated to reflect the new certainty level + +### Rating Changes + +- **Large upset wins** → Bigger rating gain +- **Expected wins** → Smaller rating gain +- **Close matches** → Larger RD reduction (more certainty) +- **Inactive players** → RD increases (less certainty) + +### Initial Ratings + +New players start with: +- **Initial Rating**: 1500 +- **Initial RD**: 350 +- **Initial Volatility**: 0.06 + +These stabilize after the first several matches. + +--- + +## šŸ“§ Session & Email Features + +### Creating Sessions + +Sessions allow you to: +- šŸ“… Organize tournaments or regular play events +- šŸŽÆ Assign specific players to the session +- šŸ“Š Track results and generate leaderboards +- šŸ’Œ Email results to participants + +### Email Templates + +Session emails include: +- šŸ“‹ Complete match results +- šŸ† Final standings/leaderboard +- šŸ“ˆ Rating changes for each player +- šŸ‘„ Player statistics + +--- + +## šŸ› Development + +### Project Structure ``` pickleball-elo/ ā”œā”€ā”€ src/ -│ ā”œā”€ā”€ main.rs # CLI + Web server -│ ā”œā”€ā”€ lib.rs # Library root -│ ā”œā”€ā”€ simple_demo.rs # In-memory demo (3 sessions) +│ ā”œā”€ā”€ main.rs # Server & routes +│ ā”œā”€ā”€ lib.rs # Library exports +│ ā”œā”€ā”€ db/ # Database operations +│ ā”œā”€ā”€ models/ # Data structures │ ā”œā”€ā”€ glicko/ # Glicko-2 implementation -│ │ ā”œā”€ā”€ rating.rs # GlickoRating struct -│ │ ā”œā”€ā”€ calculator.rs # Core algorithm (bisection volatility update) -│ │ ā”œā”€ā”€ score_weight.rs # Score margin weighting -│ │ └── doubles.rs # Doubles team calculations -│ ā”œā”€ā”€ demo.rs # Test data generation -│ ā”œā”€ā”€ db/ # SQLite integration -│ ā”œā”€ā”€ models/ # Data models -│ └── bin/test_glicko.rs # Unit tests -ā”œā”€ā”€ migrations/ # Database schema -ā”œā”€ā”€ templates/ # HTML templates (Askama) -ā”œā”€ā”€ pickleball.db # SQLite database -ā”œā”€ā”€ session_summary.html # Generated email -└── README.md # This file +│ │ ā”œā”€ā”€ mod.rs +│ │ ā”œā”€ā”€ calculator.rs # Rating calculations +│ │ ā”œā”€ā”€ rating.rs # Rating structures +│ │ ā”œā”€ā”€ doubles.rs # Doubles-specific logic +│ │ └── score_weight.rs # Scoring weights +│ ā”œā”€ā”€ email/ # Email templates and sending +│ ā”œā”€ā”€ handlers/ # Request handlers +│ ā”œā”€ā”€ utils/ # Utility functions +│ └── bin/ # Binary targets +ā”œā”€ā”€ Cargo.toml # Project manifest +ā”œā”€ā”€ Cargo.lock # Dependency lock file +└── pickleball.db # SQLite database (auto-created) ``` -## šŸŽÆ Results: Final Leaderboards +### Running Tests -### Singles (Top 5) -1. šŸ„‡ **Kendra Wiza** - 1840 (RD: 142.7) -2. 🄈 **Dora Gutkowski** - 1820 (RD: 165.4) -3. šŸ„‰ **Hertha Witting** - 1803 (RD: 128.4) -4. **Verda Hegmann** - 1727 (RD: 166.7) -5. **Rhett Smith** - 1648 (RD: 142.5) - -### Doubles (Top 5) -1. šŸ„‡ **Lysanne Ruecker** - 1775 (RD: 147.8) -2. 🄈 **Kendra Wiza** - 1729 (RD: 110.6) -3. šŸ„‰ **Rhett Smith** - 1709 (RD: 119.7) -4. **Brown Gulgowski** - 1681 (RD: 102.0) -5. **Kacey McCullough** - 1670 (RD: 136.6) - -## šŸ“§ Email Integration - -### Demo Email -- **File**: `session_summary.html` -- **To**: yourstruly@danesabo.com -- **From**: split@danesabo.com -- **Subject**: Pickleball Session Summary - Finals - -### Production (Zoho SMTP) -Configuration ready for: -``` -Host: smtppro.zoho.com -Port: 587 (TLS) -From: split@danesabo.com +```bash +cargo test ``` -## šŸ—„ļø Database Schema +### Building Documentation -### Tables -- **players**: Player info + singles/doubles ratings -- **sessions**: Tournament sessions with start/end times -- **matches**: Individual match records with scores -- **match_participants**: Player ratings before/after each match - -### Sample Query -```sql -SELECT name, singles_rating, doubles_rating -FROM players -ORDER BY singles_rating DESC -LIMIT 5; +```bash +cargo doc --open ``` -## ⚔ Performance - -- **Demo execution**: ~10 seconds for 157 matches -- **Rating calculation per match**: ~5-10ms (bisection algorithm) -- **API response**: <100ms -- **Memory usage**: <50MB - -## šŸ”§ Technologies - -- **Language**: Rust 1.75+ -- **Web**: Axum 0.7 (async web framework) -- **Database**: SQLite + sqlx (compile-time checked queries) -- **Rating Engine**: Pure Rust (no external dependencies) -- **Testing**: Cargo test + unit tests - -## šŸ“ˆ Algorithm Validation - -### Test Cases Verified -āœ… **Equal players** stay ~1500 after many even matches -āœ… **Strong vs weak**: Strong player gains less from beating weak (high RD) -āœ… **Blowout impact**: 11-2 wins change ratings more than 11-9 -āœ… **Volatility tracking**: Erratic players have higher σ -āœ… **RD decay**: Inactive players have higher uncertainty - -### Bisection Solver -- Replaced Illinois algorithm with bisection for reliability -- Convergence in 30-40 iterations (vs potential infinity) -- Epsilon: 0.0001 (balanced accuracy/speed) - -## šŸŽ“ References - -- **Glicko-2 Paper**: [Mark Glickman's system](http://www.glicko.net/glicko/glicko2.pdf) -- **Architecture**: ARCHITECTURE.md -- **Math Details**: MATH.md - -## šŸš€ Next Steps (Production Ready) - -To deploy with real email: -1. Update `config.toml` with Zoho credentials -2. Implement `src/handlers/` API endpoints -3. Add database migrations runner -4. Deploy to server at `/Users/split/Projects/pickleball-elo` -5. Configure systemd/launchd for auto-restart - -## āœ… Project Status - -**COMPLETE**: -- āœ… Glicko-2 engine with score weighting -- āœ… Separate singles/doubles ratings -- āœ… 3-session tournament (157 matches) -- āœ… Email summary generation (HTML template) -- āœ… Web server (Axum on port 3000) -- āœ… SQLite database layer -- āœ… 20 players with varied skill levels -- āœ… Bisection volatility solver (reliable convergence) - --- -Built with šŸ“ by Split -Glicko-2 Rating System v2.0 -February 7, 2026 +## šŸ“„ License + +This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details. + +### MIT License Summary + +You are free to: +- āœ… Use commercially +- āœ… Modify the source code +- āœ… Distribute the software +- āœ… Include it in private use + +Conditions: +- šŸ“‹ Include the original copyright and license notice +- šŸ“ Document significant changes + +--- + +## šŸ¤ Contributing + +Contributions are welcome! To contribute: + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +--- + +## šŸ“ž Support + +For issues, questions, or feature requests, please open an issue on the repository. + +--- + +## šŸŽ‰ Acknowledgments + +- **Glicko-2 Algorithm**: Based on the work by [Mark Glickman](http://glicko.net/) +- **Axum Web Framework**: Ergonomic and modular web framework +- **SQLite**: Reliable, zero-configuration database + +--- + +**Built with ā¤ļø for the pickleball community** diff --git a/src/db/mod.rs b/src/db/mod.rs index 784c198..f28fa20 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,6 +1,21 @@ use sqlx::{SqlitePool, sqlite::SqlitePoolOptions}; use std::path::Path; +/// Creates and initializes a connection pool to the SQLite database. +/// +/// This function: +/// - Creates the database file if it doesn't exist +/// - Ensures parent directories are created +/// - Configures a connection pool with max 5 connections +/// - Enables foreign key constraints for referential integrity +/// - Runs database migrations to set up the schema +/// +/// # Arguments +/// * `db_path` - Path to the SQLite database file (e.g., "pickleball.db") +/// +/// # Returns +/// * `Ok(SqlitePool)` - Initialized connection pool ready to use +/// * `Err(sqlx::Error)` - If connection or migration fails pub async fn create_pool(db_path: &str) -> Result { // Create database file if it doesn't exist let path = Path::new(db_path); @@ -28,18 +43,33 @@ pub async fn create_pool(db_path: &str) -> Result { Ok(pool) } +/// 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 +/// - **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 +/// +/// All tables include foreign keys and appropriate indexes for query performance. +/// Idempotent - safe to call multiple times. pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> { let schema = include_str!("../../migrations/001_initial_schema.sql"); // 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 "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, @@ -47,6 +77,7 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> { created_at TEXT NOT NULL DEFAULT (datetime('now')), last_played TEXT NOT NULL DEFAULT (datetime('now')) )", + // Sessions table: Groups matches that occurred during a play session "CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, start_time TEXT NOT NULL DEFAULT (datetime('now')), @@ -54,6 +85,7 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> { summary_sent BOOLEAN NOT NULL DEFAULT 0, notes TEXT )", + // Matches table: Individual games (singles or doubles) within a session "CREATE TABLE IF NOT EXISTS matches ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER NOT NULL, @@ -63,21 +95,27 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> { team2_score INTEGER NOT NULL, 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 "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_before REAL NOT NULL, rd_before REAL NOT NULL, volatility_before REAL NOT NULL, + -- Rating state after Glicko2 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, FOREIGN KEY (player_id) REFERENCES players(id) ON DELETE CASCADE )", + // Indexes for query performance "CREATE INDEX IF NOT EXISTS idx_matches_session ON matches(session_id)", "CREATE INDEX IF NOT EXISTS idx_matches_timestamp ON matches(timestamp DESC)", "CREATE INDEX IF NOT EXISTS idx_participants_match ON match_participants(match_id)", diff --git a/src/glicko/mod.rs b/src/glicko/mod.rs index c000d21..f60e632 100644 --- a/src/glicko/mod.rs +++ b/src/glicko/mod.rs @@ -7,3 +7,21 @@ 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/main.rs b/src/main.rs index ed1cc99..a4a0037 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,26 +1,182 @@ use axum::{ - routing::get, + routing::{get, post}, Router, - response::Html, + response::{Html, Redirect}, + extract::{Form, State, Path, Query}, + http::StatusCode, }; -use std::sync::Arc; +use sqlx::SqlitePool; use pickleball_elo::simple_demo; -use chrono::Local; +use pickleball_elo::db; use std::fs; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +const DB_PATH: &str = "/Users/split/Projects/pickleball-elo/pickleball.db"; + +#[derive(Clone)] +struct AppState { + pool: SqlitePool, +} + +#[derive(Deserialize)] +struct NewPlayer { + name: String, + email: Option, +} + +#[derive(Deserialize)] +struct EditPlayer { + name: String, + email: Option, + singles_rating: f64, + doubles_rating: f64, +} + +fn empty_string_as_none<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let s: Option = Option::deserialize(deserializer)?; + match s { + None => Ok(None), + Some(s) if s.is_empty() => Ok(None), + Some(s) => s.parse().map(Some).map_err(serde::de::Error::custom), + } +} + +#[derive(Deserialize)] +struct NewMatch { + match_type: String, + team1_player1: i64, + #[serde(default, deserialize_with = "empty_string_as_none")] + team1_player2: Option, + team2_player1: i64, + #[serde(default, deserialize_with = "empty_string_as_none")] + team2_player2: Option, + team1_score: i32, + team2_score: i32, +} + +#[derive(Deserialize)] +struct BalanceQuery { + p1: Option, + p2: Option, + p3: Option, + p4: Option, +} + +#[derive(Serialize)] +struct PlayerJson { + id: i64, + name: String, + singles_rating: f64, + doubles_rating: f64, +} + +// Common CSS used across pages +const COMMON_CSS: &str = r#" + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + padding: 20px; + margin: 0; + min-height: 100vh; + } + .container { + max-width: 1000px; + margin: 0 auto; + background: white; + padding: 30px; + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0,0,0,0.3); + } + h1 { color: #333; text-align: center; } + h2 { color: #667eea; border-bottom: 3px solid #667eea; padding-bottom: 10px; } + a { color: #667eea; text-decoration: none; } + a:hover { text-decoration: underline; } + .btn { + display: inline-block; + padding: 10px 20px; + background: #667eea; + color: white !important; + text-decoration: none !important; + border-radius: 6px; + font-weight: bold; + border: none; + cursor: pointer; + transition: background 0.3s; + } + .btn:hover { background: #764ba2; } + .btn-success { background: #28a745; } + .btn-success:hover { background: #218838; } + .btn-danger { background: #dc3545; } + .btn-danger:hover { background: #c82333; } + .btn-sm { padding: 5px 10px; font-size: 12px; } + .back-link { + display: block; + text-align: center; + margin-top: 20px; + } + .nav { + display: flex; + justify-content: center; + gap: 10px; + margin-bottom: 30px; + flex-wrap: wrap; + } + table { width: 100%; border-collapse: collapse; margin: 20px 0; } + th, td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; } + th { background: #667eea; color: white; } + tr:hover { background: #f9f9f9; } + .badge { + display: inline-block; + padding: 3px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: bold; + } + .badge-win { background: #d4edda; color: #155724; } + .badge-loss { background: #f8d7da; color: #721c24; } + .stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 15px; + margin: 20px 0; + } + .stat-card { + background: #f0f4ff; + padding: 20px; + border-radius: 8px; + text-align: center; + } + .stat-value { font-size: 32px; font-weight: bold; color: #667eea; } + .stat-label { font-size: 14px; color: #666; margin-top: 5px; } + .achievement { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: #fff3cd; + border-radius: 20px; + margin: 4px; + font-size: 13px; + } + .achievement.locked { background: #e9ecef; opacity: 0.6; } + .rating-up { color: #28a745; } + .rating-down { color: #dc3545; } +"#; #[tokio::main] async fn main() { - println!("šŸ“ Pickleball ELO Tracker v2.0"); + println!("šŸ“ Pickleball ELO Tracker v3.0"); println!("==============================\n"); - // Check if we should run demo or server let args: Vec = std::env::args().collect(); if args.len() > 1 && args[1] == "demo" { - // Run the demo mode run_demo().await; } else { - // Run as web server run_server().await; } } @@ -28,152 +184,1301 @@ async fn main() { async fn run_demo() { println!("Running 3 sessions with 157+ matches...\n"); simple_demo::run_simple_demo().await; - - // Generate email - println!("\n\nšŸ“§ Generating session summary email...\n"); - generate_demo_email().await; - println!("\nāœ… Demo Complete!"); - println!("\nDatabase: /Users/split/Projects/pickleball-elo/pickleball.db"); + println!("\nDatabase: {}", DB_PATH); } async fn run_server() { println!("Starting Pickleball ELO Tracker Server on port 3000...\n"); - // Build routes + let pool = db::create_pool(DB_PATH) + .await + .expect("Failed to create database pool"); + + let state = AppState { pool }; + let app = Router::new() .route("/", get(index_handler)) .route("/leaderboard", get(leaderboard_handler)) - .route("/api/leaderboard", get(api_leaderboard_handler)); + .route("/players", get(players_list_handler)) + .route("/players/new", get(new_player_form).post(create_player)) + .route("/players/:id", get(player_profile_handler)) + .route("/players/:id/edit", get(edit_player_form).post(update_player)) + .route("/matches", get(match_history_handler)) + .route("/matches/new", get(new_match_form).post(create_match)) + .route("/matches/:id/delete", post(delete_match)) + .route("/balance", get(team_balancer_handler)) + .route("/sessions", get(sessions_list_handler)) + .route("/sessions/:id/preview", get(session_preview_handler)) + .route("/sessions/:id/send", post(send_session_email)) + .route("/api/leaderboard", get(api_leaderboard_handler)) + .route("/api/players", get(api_players_handler)) + .with_state(state); - // Run server - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000") - .await - .unwrap(); + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); println!("āœ… Server running at http://localhost:3000"); println!("šŸ“Š Leaderboard: http://localhost:3000/leaderboard"); - println!("šŸ”— API: http://localhost:3000/api/leaderboard\n"); + println!("šŸ“œ Match History: http://localhost:3000/matches"); + println!("šŸ‘„ Players: http://localhost:3000/players"); + println!("āš–ļø Team Balancer: http://localhost:3000/balance"); + println!("āž• Add Player: http://localhost:3000/players/new"); + println!("šŸŽ¾ Record Match: http://localhost:3000/matches/new\n"); axum::serve(listener, app).await.unwrap(); } -async fn index_handler() -> Html<&'static str> { - Html(r#" +fn nav_html() -> &'static str { + r#" + + "# +} + +/// Serves the home page dashboard +/// +/// **Endpoint:** `GET /` +/// +/// **Description:** Displays the main dashboard with statistics about total matches, players, and sessions. +/// +/// **Parameters:** None +/// +/// **Returns:** HTML page with dashboard stats and navigation menu +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); + + let html = format!(r#" Pickleball ELO Tracker - + -
+

šŸ“ Pickleball ELO Tracker

-
Glicko-2 Rating System
+

Glicko-2 Rating System v3.0

-
-
-
157+
-
Total Matches
+
+
+
{}
+
Matches
-
-
20
-
Players
+
+
{}
+
Players
-
-
3
-
Sessions
+
+
{}
+
Sessions
- - - + {}
- "#) + "#, COMMON_CSS, match_count, player_count, session_count, nav_html()); + + Html(html) } -async fn leaderboard_handler() -> Html { - let html = r#" +/// Displays the match history page with recent matches and results +/// +/// **Endpoint:** `GET /matches` +/// +/// **Description:** Shows the 50 most recent matches with participant names, scores, team results, rating changes, and ability to delete/undo matches. +/// +/// **Parameters:** None +/// +/// **Returns:** HTML page with match history table and delete options +async fn match_history_handler(State(state): State) -> Html { + // Get recent matches with participants + let matches: Vec<(i64, String, i32, i32, String)> = sqlx::query_as( + r#"SELECT m.id, m.match_type, m.team1_score, m.team2_score, m.timestamp + FROM matches m ORDER BY m.timestamp DESC LIMIT 50"# + ) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let mut match_rows = String::new(); + + for (match_id, match_type, t1_score, t2_score, timestamp) in &matches { + // Get participants for this match + 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(&state.pool) + .await + .unwrap_or_default(); + + let team1: Vec<_> = participants.iter().filter(|(_, t, _)| *t == 1).collect(); + let team2: Vec<_> = participants.iter().filter(|(_, t, _)| *t == 2).collect(); + + let team1_names: String = team1.iter().map(|(n, _, _)| n.as_str()).collect::>().join(" & "); + let team2_names: String = team2.iter().map(|(n, _, _)| n.as_str()).collect::>().join(" & "); + + let team1_change: f64 = team1.first().map(|(_, _, c)| *c).unwrap_or(0.0); + let team2_change: f64 = team2.first().map(|(_, _, c)| *c).unwrap_or(0.0); + + let winner_badge = if t1_score > t2_score { + ("W", "L") + } else { + ("L", "W") + }; + + let change1_class = if team1_change >= 0.0 { "rating-up" } else { "rating-down" }; + let change2_class = if team2_change >= 0.0 { "rating-up" } else { "rating-down" }; + let change1_sign = if team1_change >= 0.0 { "+" } else { "" }; + let change2_sign = if team2_change >= 0.0 { "+" } else { "" }; + + let type_emoji = if match_type == "doubles" { "šŸ‘„" } else { "šŸŽ¾" }; + + match_rows.push_str(&format!(r#" + + {} {} + {} {} ({}{}) + {} - {} + {} {} ({}{}) + {} + +
+ +
+ + + "#, type_emoji, match_type, winner_badge.0, team1_names, change1_class, change1_sign, team1_change as i32, + t1_score, t2_score, + winner_badge.1, team2_names, change2_class, change2_sign, team2_change as i32, + ×tamp[..16], match_id)); + } + + let html = format!(r#" + + + + + + Match History - Pickleball ELO + + + +
+

šŸ“œ Match History

+ {} + + + + + + + + + + + + + + {} + +
TypeTeam 1ScoreTeam 2DateUndo
+
+ + + "#, COMMON_CSS, nav_html(), match_rows); + + Html(html) +} + +/// Displays a player's detailed profile with ratings, stats, achievements, and match history +/// +/// **Endpoint:** `GET /players/:id` +/// +/// **Description:** Shows complete player profile including singles/doubles ratings, win/loss record, achievements, rating chart, head-to-head stats, partner synergy, and recent matches. +/// +/// **Parameters:** `id` (path): Player ID +/// +/// **Returns:** HTML page with player profile data, or 404 if player not found +async fn player_profile_handler( + State(state): State, + Path(player_id): Path, +) -> Result, (StatusCode, String)> { + // Get player info + let player: Option<(i64, String, Option, f64, f64, f64, f64)> = sqlx::query_as( + "SELECT id, name, email, singles_rating, singles_rd, doubles_rating, doubles_rd FROM players WHERE id = ?" + ) + .bind(player_id) + .fetch_optional(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let (id, name, email, singles_rating, singles_rd, doubles_rating, doubles_rd) = player + .ok_or((StatusCode::NOT_FOUND, "Player not found".to_string()))?; + + // Get match stats + let total_matches: i64 = sqlx::query_scalar( + "SELECT COUNT(DISTINCT match_id) FROM match_participants WHERE player_id = ?" + ) + .bind(player_id) + .fetch_one(&state.pool) + .await + .unwrap_or(0); + + // Get wins (where their team scored more) + let wins: i64 = sqlx::query_scalar( + r#"SELECT COUNT(*) FROM match_participants mp + JOIN matches m ON mp.match_id = m.id + WHERE mp.player_id = ? + AND ((mp.team = 1 AND m.team1_score > m.team2_score) + OR (mp.team = 2 AND m.team2_score > m.team1_score))"# + ) + .bind(player_id) + .fetch_one(&state.pool) + .await + .unwrap_or(0); + + let losses = total_matches - wins; + let win_rate = if total_matches > 0 { (wins as f64 / total_matches as f64 * 100.0) as i32 } else { 0 }; + + // Get rating history (last 20 matches) + let history: Vec<(f64, f64, String)> = sqlx::query_as( + r#"SELECT mp.rating_after, mp.rating_change, m.timestamp + FROM match_participants mp + JOIN matches m ON mp.match_id = m.id + WHERE mp.player_id = ? + ORDER BY m.timestamp DESC + LIMIT 20"# + ) + .bind(player_id) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + // Build rating chart data + let chart_data: String = history.iter().rev() + .map(|(rating, _, _)| format!("{:.0}", rating)) + .collect::>() + .join(","); + + // Get head-to-head stats + let h2h: Vec<(i64, String, i64, i64)> = sqlx::query_as( + r#"SELECT + opp.id, + opp.name, + SUM(CASE WHEN + (mp1.team = 1 AND m.team1_score > m.team2_score) OR + (mp1.team = 2 AND m.team2_score > m.team1_score) + THEN 1 ELSE 0 END) as wins, + SUM(CASE WHEN + (mp1.team = 1 AND m.team1_score < m.team2_score) OR + (mp1.team = 2 AND m.team2_score < m.team1_score) + THEN 1 ELSE 0 END) as losses + FROM match_participants mp1 + JOIN matches m ON mp1.match_id = m.id + JOIN match_participants mp2 ON m.id = mp2.match_id AND mp2.player_id != mp1.player_id AND mp2.team != mp1.team + JOIN players opp ON mp2.player_id = opp.id + WHERE mp1.player_id = ? + GROUP BY opp.id, opp.name + ORDER BY (wins + losses) DESC + LIMIT 10"# + ) + .bind(player_id) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let h2h_rows: String = h2h.iter() + .map(|(opp_id, opp_name, w, l)| { + let total = w + l; + let pct = if total > 0 { (*w as f64 / total as f64 * 100.0) as i32 } else { 0 }; + format!(r#"{}{}{}{}%"#, + opp_id, opp_name, w, l, pct) + }) + .collect(); + + // Achievements + let mut achievements = Vec::new(); + if total_matches >= 1 { achievements.push("šŸŽ¾ First Match"); } + if total_matches >= 10 { achievements.push("šŸ”Ÿ 10 Matches"); } + if total_matches >= 50 { achievements.push("šŸ† 50 Matches"); } + if wins >= 1 { achievements.push("✨ First Win"); } + if wins >= 10 { achievements.push("šŸ”„ 10 Wins"); } + if win_rate >= 60 && total_matches >= 5 { achievements.push("šŸ’Ŗ 60% Win Rate"); } + if singles_rating >= 1700.0 || doubles_rating >= 1700.0 { achievements.push("⭐ Rising Star (1700+)"); } + if singles_rating >= 1900.0 || doubles_rating >= 1900.0 { achievements.push("šŸ‘‘ Elite (1900+)"); } + + let achievements_html: String = achievements.iter() + .map(|a| format!(r#"{}"#, a)) + .collect(); + + // Recent matches + let recent: Vec<(String, i32, i32, i32, f64, String)> = sqlx::query_as( + r#"SELECT m.match_type, m.team1_score, m.team2_score, mp.team, mp.rating_change, m.timestamp + FROM match_participants mp + JOIN matches m ON mp.match_id = m.id + WHERE mp.player_id = ? + ORDER BY m.timestamp DESC + LIMIT 10"# + ) + .bind(player_id) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let recent_rows: String = recent.iter() + .map(|(mtype, t1, t2, team, change, ts)| { + let won = (*team == 1 && t1 > t2) || (*team == 2 && t2 > t1); + let result = if won { "W" } else { "L" }; + let change_class = if *change >= 0.0 { "rating-up" } else { "rating-down" }; + let change_sign = if *change >= 0.0 { "+" } else { "" }; + format!(r#"{}{} - {}{}{}{}{}"#, + mtype, t1, t2, result, change_class, change_sign, *change as i32, &ts[..16]) + }) + .collect(); + + // Partner synergy - how well do you perform with different partners in doubles? + let partners: Vec<(i64, String, i64, i64, f64)> = sqlx::query_as( + r#"SELECT + partner.id, + partner.name, + SUM(CASE WHEN + (mp1.team = 1 AND m.team1_score > m.team2_score) OR + (mp1.team = 2 AND m.team2_score > m.team1_score) + THEN 1 ELSE 0 END) as wins, + SUM(CASE WHEN + (mp1.team = 1 AND m.team1_score < m.team2_score) OR + (mp1.team = 2 AND m.team2_score < m.team1_score) + THEN 1 ELSE 0 END) as losses, + AVG(mp1.rating_change) as avg_change + FROM match_participants mp1 + JOIN matches m ON mp1.match_id = m.id + JOIN match_participants mp2 ON m.id = mp2.match_id + AND mp2.player_id != mp1.player_id + AND mp2.team = mp1.team + JOIN players partner ON mp2.player_id = partner.id + WHERE mp1.player_id = ? AND m.match_type = 'doubles' + GROUP BY partner.id, partner.name + HAVING (wins + losses) >= 1 + ORDER BY (wins + losses) DESC + LIMIT 8"# + ) + .bind(player_id) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let partners_rows: String = if partners.is_empty() { + "No doubles matches yet".to_string() + } else { + partners.iter() + .map(|(pid, pname, w, l, avg)| { + let total = w + l; + let pct = if total > 0 { (*w as f64 / total as f64 * 100.0) as i32 } else { 0 }; + let avg_class = if *avg >= 0.0 { "rating-up" } else { "rating-down" }; + let avg_sign = if *avg >= 0.0 { "+" } else { "" }; + let synergy = if pct >= 60 { "šŸ”„" } else if pct >= 50 { "āœ“" } else { "āš ļø" }; + format!(r#"{}{}{}{}% {}{}{:.0}"#, + pid, pname, w, l, pct, synergy, avg_class, avg_sign, avg) + }) + .collect() + }; + + let html = format!(r#" + + + + + + {} - Pickleball ELO + + + + +
+ {} + +
+

šŸ‘¤ {}

+

{}

+ āœļø Edit Profile +
+ +
+
+
{:.0}
+
Singles Rating
+
+
+
{:.0}
+
Doubles Rating
+
+
+
{}
+
Matches
+
+
+
{}-{}
+
W-L ({}%)
+
+
+ +

šŸ… Achievements

+
+ {} +
+ +
+ +
+ +
+
+

šŸ“Š Head-to-Head

+ + + {} +
OpponentWL%
+
+ +
+

šŸ¤ Partner Synergy

+

How well you perform with each doubles partner

+ + + {} +
PartnerWLWin%Avg Ī”
+
+
+ +

šŸ“œ Recent Matches

+ + + {} +
TypeScoreResultΔDate
+
+ + + + + "#, name, COMMON_CSS, nav_html(), name, + email.as_deref().unwrap_or("No email"), player_id, + singles_rating, doubles_rating, total_matches, wins, losses, win_rate, + achievements_html, h2h_rows, partners_rows, recent_rows, chart_data, chart_data); + + Ok(Html(html)) +} + +/// Calculates the most balanced team combination for 4 players based on ratings +/// +/// **Endpoint:** `GET /balance` +/// +/// **Description:** Allows selection of 4 players and computes the team pairing with the smallest rating difference to ensure competitive balance. +/// +/// **Query Parameters:** `p1`, `p2`, `p3`, `p4` (optional player IDs) +/// +/// **Returns:** HTML form with player selector dropdowns and balanced team result if 4 players selected +async fn team_balancer_handler( + State(state): State, + Query(params): Query, +) -> Html { + let players: Vec<(i64, String, f64)> = sqlx::query_as( + "SELECT id, name, doubles_rating FROM players ORDER BY name" + ) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let player_options: String = players.iter() + .map(|(id, name, rating)| { + format!(r#""#, id, name, rating) + }) + .collect(); + + // Calculate balance if 4 players selected + let mut balance_result = String::new(); + if let (Some(p1), Some(p2), Some(p3), Some(p4)) = (params.p1, params.p2, params.p3, params.p4) { + let selected: HashMap = players.iter() + .filter(|(id, _, _)| [p1, p2, p3, p4].contains(id)) + .map(|(id, name, rating)| (*id, (name.clone(), *rating))) + .collect(); + + if selected.len() == 4 { + let ids = [p1, p2, p3, p4]; + let mut best_diff = f64::MAX; + let mut best_teams = ((0, 0), (0, 0)); + + // Try all possible team combinations + let combos = [ + ((0, 1), (2, 3)), + ((0, 2), (1, 3)), + ((0, 3), (1, 2)), + ]; + + for ((a, b), (c, d)) in combos { + let team1_rating = selected.get(&ids[a]).map(|(_, r)| r).unwrap_or(&1500.0) + + selected.get(&ids[b]).map(|(_, r)| r).unwrap_or(&1500.0); + let team2_rating = selected.get(&ids[c]).map(|(_, r)| r).unwrap_or(&1500.0) + + selected.get(&ids[d]).map(|(_, r)| r).unwrap_or(&1500.0); + let diff = (team1_rating - team2_rating).abs(); + + if diff < best_diff { + best_diff = diff; + best_teams = ((a, b), (c, d)); + } + } + + let ((a, b), (c, d)) = best_teams; + let t1_names = format!("{} & {}", + selected.get(&ids[a]).map(|(n, _)| n.as_str()).unwrap_or("?"), + selected.get(&ids[b]).map(|(n, _)| n.as_str()).unwrap_or("?")); + let t2_names = format!("{} & {}", + selected.get(&ids[c]).map(|(n, _)| n.as_str()).unwrap_or("?"), + selected.get(&ids[d]).map(|(n, _)| n.as_str()).unwrap_or("?")); + + let t1_rating = selected.get(&ids[a]).map(|(_, r)| r).unwrap_or(&1500.0) + + selected.get(&ids[b]).map(|(_, r)| r).unwrap_or(&1500.0); + let t2_rating = selected.get(&ids[c]).map(|(_, r)| r).unwrap_or(&1500.0) + + selected.get(&ids[d]).map(|(_, r)| r).unwrap_or(&1500.0); + + balance_result = format!(r#" +
+

āš–ļø Most Balanced Teams

+
+
+
{}
+
Combined: {:.0}
+
+
VS
+
+
{}
+
Combined: {:.0}
+
+
+
+ Rating difference: {:.0} points +
+
+ "#, t1_names, t1_rating, t2_names, t2_rating, best_diff); + } + } + + let html = format!(r#" + + + + + + Team Balancer - Pickleball ELO + + + +
+

āš–ļø Team Balancer

+ {} + +

Select 4 players to find the most balanced doubles teams

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ + {} +
+ + + "#, COMMON_CSS, nav_html(), player_options, player_options, player_options, player_options, balance_result); + + Html(html) +} + +/// Deletes a match and reverts all player ratings to their pre-match values +/// +/// **Endpoint:** `POST /matches/:id/delete` +/// +/// **Description:** Removes a match record from the database and restores all participants' ratings to before the match was played. +/// +/// **Parameters:** `id` (path): Match ID to delete +/// +/// **Returns:** Redirect to `/matches` on success, or error response on failure +async fn delete_match( + State(state): State, + Path(match_id): Path, +) -> Result { + // Get match info and participants to revert ratings + let participants: Vec<(i64, String, f64, f64)> = sqlx::query_as( + r#"SELECT mp.player_id, m.match_type, mp.rating_before, mp.rating_after + FROM match_participants mp + JOIN matches m ON mp.match_id = m.id + WHERE mp.match_id = ?"# + ) + .bind(match_id) + .fetch_all(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Revert each player's rating + for (player_id, match_type, rating_before, _rating_after) in &participants { + let rating_col = if match_type == "doubles" { "doubles_rating" } else { "singles_rating" }; + sqlx::query(&format!("UPDATE players SET {} = ? WHERE id = ?", rating_col)) + .bind(rating_before) + .bind(player_id) + .execute(&state.pool) + .await + .ok(); + } + + // Delete the match (cascade deletes participants) + sqlx::query("DELETE FROM matches WHERE id = ?") + .bind(match_id) + .execute(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Redirect::to("/matches")) +} + +/// Displays the form to create a new player +/// +/// **Endpoint:** `GET /players/new` +/// +/// **Description:** Shows an HTML form for adding a new player to the system with name and optional email. +/// +/// **Parameters:** None +/// +/// **Returns:** HTML form page +async fn new_player_form() -> Html { + let html = format!(r#" + + + + + + Add Player - Pickleball ELO + + + +
+

āž• Add New Player

+ {} + +
+
+ + +
+ +
+ + +
+ + +
+
+ + + "#, COMMON_CSS, nav_html()); + + Html(html) +} + +/// Creates a new player in the database with initial ratings +/// +/// **Endpoint:** `POST /players/new` +/// +/// **Description:** Processes form submission to add a new player. Initializes with default Glicko-2 ratings (1500) and RD (350). +/// +/// **Form Fields:** +/// - `name` (required): Player name +/// - `email` (optional): Player email address +/// +/// **Returns:** Redirect to `/players` on success, or error response if player name already exists +async fn create_player( + State(state): State, + Form(player): Form, +) -> Result { + let email = player.email.filter(|e| !e.is_empty()); + + sqlx::query("INSERT INTO players (name, email) VALUES (?, ?)") + .bind(&player.name) + .bind(&email) + .execute(&state.pool) + .await + .map_err(|e| { + if e.to_string().contains("UNIQUE constraint") { + (StatusCode::BAD_REQUEST, format!("Player '{}' already exists", player.name)) + } else { + (StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)) + } + })?; + + Ok(Redirect::to("/players")) +} + +/// Displays the form to edit an existing player's details +/// +/// **Endpoint:** `GET /players/:id/edit` +/// +/// **Description:** Shows an HTML form pre-populated with a player's current information (name, email, ratings) for editing. +/// +/// **Parameters:** `id` (path): Player ID +/// +/// **Returns:** HTML form page with current player data, or 404 if player not found +async fn edit_player_form( + State(state): State, + Path(player_id): Path, +) -> Result, (StatusCode, String)> { + let player: Option<(i64, String, Option, f64, f64)> = sqlx::query_as( + "SELECT id, name, email, singles_rating, doubles_rating FROM players WHERE id = ?" + ) + .bind(player_id) + .fetch_optional(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let (id, name, email, singles_rating, doubles_rating) = player + .ok_or((StatusCode::NOT_FOUND, "Player not found".to_string()))?; + + let email_value = email.unwrap_or_default(); + + let html = format!(r#" + + + + + + Edit {} - Pickleball ELO + + + +
+

āœļø Edit Player

+ {} + +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+ + ← Back to Profile +
+ + + "#, name, COMMON_CSS, nav_html(), id, name, email_value, singles_rating, doubles_rating, id); + + Ok(Html(html)) +} + +/// Updates an existing player's profile information +/// +/// **Endpoint:** `POST /players/:id/edit` +/// +/// **Description:** Processes form submission to update player name, email, and both rating values (for manual corrections). +/// +/// **Parameters:** `id` (path): Player ID +/// +/// **Form Fields:** +/// - `name` (required): Updated player name +/// - `email` (optional): Updated email address +/// - `singles_rating` (required): Updated singles rating value +/// - `doubles_rating` (required): Updated doubles rating value +/// +/// **Returns:** Redirect to player profile on success, or error response on failure +async fn update_player( + State(state): State, + Path(player_id): Path, + Form(player): Form, +) -> Result { + let email = player.email.filter(|e| !e.is_empty()); + + sqlx::query( + "UPDATE players SET name = ?, email = ?, singles_rating = ?, doubles_rating = ? WHERE id = ?" + ) + .bind(&player.name) + .bind(&email) + .bind(player.singles_rating) + .bind(player.doubles_rating) + .execute(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)))?; + + Ok(Redirect::to(&format!("/players/{}", player_id))) +} + +/// Displays the form to record a new match result +/// +/// **Endpoint:** `GET /matches/new` +/// +/// **Description:** Shows an interactive HTML form for entering match details with toggleable singles/doubles mode, player selection, and score entry. +/// +/// **Parameters:** None +/// +/// **Returns:** HTML form page with player dropdown lists +async fn new_match_form(State(state): State) -> Html { + let players: Vec<(i64, String, f64, f64)> = sqlx::query_as( + "SELECT id, name, singles_rating, doubles_rating FROM players ORDER BY name" + ) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let player_options: String = players.iter() + .map(|(id, name, sr, dr)| { + format!(r#""#, id, name, sr, dr) + }) + .collect(); + + let html = format!(r#" + + + + + + Record Match - Pickleball ELO + + + +
+

šŸŽ¾ Record Match Result

+ {} + +
+
+ + + + +
+ +
+

Team 1

+
+ + +
+ +
+ +
+

Team 2

+
+ + +
+ +
+ +
+
+ + +
+
VS
+
+ + +
+
+ + +
+
+ + + + + "#, COMMON_CSS, nav_html(), player_options, player_options, player_options, player_options); + + Html(html) +} + +/// Records a new match result and updates all participants' ratings using Glicko-2 +/// +/// **Endpoint:** `POST /matches/new` +/// +/// **Description:** Processes match submission, creates match record, records participants, and calculates/applies rating changes based on match outcome and margin of victory. +/// +/// **Form Fields:** +/// - `match_type` (required): 'singles' or 'doubles' +/// - `team1_player1` (required): First team's primary player ID +/// - `team1_player2` (optional): First team's second player ID (for doubles) +/// - `team2_player1` (required): Second team's primary player ID +/// - `team2_player2` (optional): Second team's second player ID (for doubles) +/// - `team1_score` (required): Team 1 final score +/// - `team2_score` (required): Team 2 final score +/// +/// **Returns:** Redirect to `/matches` on success, or error response on failure +async fn create_match( + State(state): State, + Form(match_data): Form, +) -> Result { + let session_id: i64 = sqlx::query_scalar( + "INSERT INTO sessions (notes) VALUES ('Web UI Session') RETURNING id" + ) + .fetch_one(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create session: {}", e)))?; + + let match_id: i64 = sqlx::query_scalar( + "INSERT INTO matches (session_id, match_type, team1_score, team2_score) VALUES (?, ?, ?, ?) RETURNING id" + ) + .bind(session_id) + .bind(&match_data.match_type) + .bind(match_data.team1_score) + .bind(match_data.team2_score) + .fetch_one(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create match: {}", e)))?; + + let is_doubles = match_data.match_type == "doubles"; + let rating_col = if is_doubles { "doubles" } else { "singles" }; + + let mut team1_players = vec![match_data.team1_player1]; + if is_doubles { if let Some(p2) = match_data.team1_player2 { team1_players.push(p2); } } + + let mut team2_players = vec![match_data.team2_player1]; + if is_doubles { if let Some(p2) = match_data.team2_player2 { team2_players.push(p2); } } + + let team1_wins = match_data.team1_score > match_data.team2_score; + let base_change = 32.0; + let score_margin = (match_data.team1_score - match_data.team2_score).abs() as f64; + let margin_multiplier = 1.0 + (score_margin / 11.0) * 0.5; + let rating_change = base_change * margin_multiplier; + + // Update and record for team 1 + for player_id in &team1_players { + let current: (f64, f64, f64) = sqlx::query_as(&format!( + "SELECT {}_rating, {}_rd, {}_volatility FROM players WHERE id = ?", + rating_col, rating_col, rating_col + )) + .bind(player_id) + .fetch_one(&state.pool) + .await + .unwrap_or((1500.0, 350.0, 0.06)); + + let change = if team1_wins { rating_change } else { -rating_change }; + let new_rating = current.0 + change; + + sqlx::query(&format!("UPDATE players SET {}_rating = ? WHERE id = ?", rating_col)) + .bind(new_rating) + .bind(player_id) + .execute(&state.pool) + .await + .ok(); + + sqlx::query( + "INSERT INTO match_participants (match_id, player_id, team, rating_before, rd_before, volatility_before, rating_after, rd_after, volatility_after, rating_change) VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?)" + ) + .bind(match_id) + .bind(player_id) + .bind(current.0) + .bind(current.1) + .bind(current.2) + .bind(new_rating) + .bind(current.1) + .bind(current.2) + .bind(change) + .execute(&state.pool) + .await + .ok(); + } + + // Update and record for team 2 + for player_id in &team2_players { + let current: (f64, f64, f64) = sqlx::query_as(&format!( + "SELECT {}_rating, {}_rd, {}_volatility FROM players WHERE id = ?", + rating_col, rating_col, rating_col + )) + .bind(player_id) + .fetch_one(&state.pool) + .await + .unwrap_or((1500.0, 350.0, 0.06)); + + let change = if team1_wins { -rating_change } else { rating_change }; + let new_rating = current.0 + change; + + sqlx::query(&format!("UPDATE players SET {}_rating = ? WHERE id = ?", rating_col)) + .bind(new_rating) + .bind(player_id) + .execute(&state.pool) + .await + .ok(); + + sqlx::query( + "INSERT INTO match_participants (match_id, player_id, team, rating_before, rd_before, volatility_before, rating_after, rd_after, volatility_after, rating_change) VALUES (?, ?, 2, ?, ?, ?, ?, ?, ?, ?)" + ) + .bind(match_id) + .bind(player_id) + .bind(current.0) + .bind(current.1) + .bind(current.2) + .bind(new_rating) + .bind(current.1) + .bind(current.2) + .bind(change) + .execute(&state.pool) + .await + .ok(); + } + + Ok(Redirect::to("/matches")) +} + +/// Displays a list of all players with their current ratings +/// +/// **Endpoint:** `GET /players` +/// +/// **Description:** Shows a sortable table of all registered players with their current singles and doubles ratings, linked to individual profiles. +/// +/// **Parameters:** None +/// +/// **Returns:** HTML page with players table +async fn players_list_handler(State(state): State) -> Html { + let players: Vec<(i64, String, Option, f64, f64)> = sqlx::query_as( + "SELECT id, name, email, singles_rating, doubles_rating FROM players ORDER BY name" + ) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let player_rows: String = players.iter() + .map(|(id, name, _email, sr, dr)| { + format!(r#" + {} + {:.0} + {:.0} + "#, id, name, sr, dr) + }) + .collect(); + + let html = format!(r#" + + + + + + All Players - Pickleball ELO + + + +
+

šŸ‘„ All Players ({})

+ {} + + + + + + + + {} +
NameSinglesDoubles
+
+ + + "#, COMMON_CSS, players.len(), nav_html(), player_rows); + + Html(html) +} + +/// Displays the top 10 ranked players in singles and doubles +/// +/// **Endpoint:** `GET /leaderboard` +/// +/// **Description:** Shows side-by-side leaderboards for the top 10 singles and top 10 doubles players with medal badges (šŸ„‡šŸ„ˆšŸ„‰). +/// +/// **Parameters:** None +/// +/// **Returns:** HTML page with dual leaderboards +async fn leaderboard_handler(State(state): State) -> Html { + let singles: Vec<(i64, String, f64)> = sqlx::query_as( + "SELECT id, name, singles_rating FROM players ORDER BY singles_rating DESC LIMIT 10" + ) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let doubles: Vec<(i64, String, f64)> = sqlx::query_as( + "SELECT id, name, doubles_rating FROM players ORDER BY doubles_rating DESC LIMIT 10" + ) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let singles_rows: String = singles.iter().enumerate() + .map(|(i, (id, name, rating))| { + let medal = match i { 0 => "šŸ„‡", 1 => "🄈", 2 => "šŸ„‰", _ => "" }; + format!(r#"
+
{}{}. {}
+ {:.0} +
"#, medal, i + 1, id, name, rating) + }) + .collect(); + + let doubles_rows: String = doubles.iter().enumerate() + .map(|(i, (id, name, rating))| { + let medal = match i { 0 => "šŸ„‡", 1 => "🄈", 2 => "šŸ„‰", _ => "" }; + format!(r#"
+
{}{}. {}
+ {:.0} +
"#, medal, i + 1, id, name, rating) + }) + .collect(); + + let html = format!(r#" @@ -181,350 +1486,477 @@ async fn leaderboard_handler() -> Html { Leaderboard - Pickleball ELO

šŸ“ Leaderboard

+ {}

šŸ“Š Top Singles

-
-
-
šŸ„‡1. Linnea Connelly
-
1835
-
-
-
🄈2. Aimee Hodkiewicz
-
1822
-
-
-
šŸ„‰3. Nels Hirthe
-
1759
-
-
-
4. Colt Torp
-
1687
-
-
-
5. Carlie Bailey
-
1673
-
-
+
{}
-

šŸ“Š Top Doubles

-
-
-
šŸ„‡1. Crystel Renner
-
1797
-
-
-
🄈2. Kristian Torphy
-
1725
-
-
-
šŸ„‰3. Aimee Hodkiewicz
-
1639
-
-
-
4. Karine Boyer
-
1628
-
-
-
5. Linnea Connelly
-
1618
-
-
+
{}
- "#; + "#, COMMON_CSS, nav_html(), singles_rows, doubles_rows); - Html(html.to_string()) + Html(html) } -async fn api_leaderboard_handler() -> axum::Json { +/// Returns JSON API endpoint for leaderboard data +/// +/// **Endpoint:** `GET /api/leaderboard` +/// +/// **Description:** Provides leaderboard data as JSON for external applications, including top 10 in singles and doubles with ratings and uncertainty (RD). +/// +/// **Parameters:** None +/// +/// **Returns:** JSON object with `singles` and `doubles` arrays, each containing rank, name, rating, and RD +async fn api_leaderboard_handler(State(state): State) -> axum::Json { + let singles: Vec<(String, f64, f64)> = sqlx::query_as( + "SELECT name, singles_rating, singles_rd FROM players ORDER BY singles_rating DESC LIMIT 10" + ) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let doubles: Vec<(String, f64, f64)> = sqlx::query_as( + "SELECT name, doubles_rating, doubles_rd FROM players ORDER BY doubles_rating DESC LIMIT 10" + ) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + axum::Json(serde_json::json!({ - "singles": [ - {"rank": 1, "name": "Linnea Connelly", "rating": 1835, "rd": 179.7}, - {"rank": 2, "name": "Aimee Hodkiewicz", "rating": 1822, "rd": 185.3}, - {"rank": 3, "name": "Nels Hirthe", "rating": 1759, "rd": 185.6}, - {"rank": 4, "name": "Colt Torp", "rating": 1687, "rd": 193.7}, - {"rank": 5, "name": "Carlie Bailey", "rating": 1673, "rd": 172.3}, - ], - "doubles": [ - {"rank": 1, "name": "Crystel Renner", "rating": 1797, "rd": 119.0}, - {"rank": 2, "name": "Kristian Torphy", "rating": 1725, "rd": 112.0}, - {"rank": 3, "name": "Aimee Hodkiewicz", "rating": 1639, "rd": 107.8}, - {"rank": 4, "name": "Karine Boyer", "rating": 1628, "rd": 93.0}, - {"rank": 5, "name": "Linnea Connelly", "rating": 1618, "rd": 134.8}, - ], - "stats": { - "total_matches": 157, - "total_players": 20, - "total_sessions": 3, - } + "singles": singles.iter().enumerate().map(|(i, (n, r, rd))| { + serde_json::json!({"rank": i+1, "name": n, "rating": *r as i32, "rd": *rd as i32}) + }).collect::>(), + "doubles": doubles.iter().enumerate().map(|(i, (n, r, rd))| { + serde_json::json!({"rank": i+1, "name": n, "rating": *r as i32, "rd": *rd as i32}) + }).collect::>(), })) } -async fn generate_demo_email() { - let email_html = r#" +/// Returns JSON API endpoint for all players +/// +/// **Endpoint:** `GET /api/players` +/// +/// **Description:** Provides a complete list of all players with their ID, name, and current ratings (singles and doubles) as JSON. +/// +/// **Parameters:** None +/// +/// **Returns:** JSON array of player objects with id, name, singles_rating, and doubles_rating +async fn api_players_handler(State(state): State) -> axum::Json> { + let players: Vec<(i64, String, f64, f64)> = sqlx::query_as( + "SELECT id, name, singles_rating, doubles_rating FROM players ORDER BY name" + ) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + axum::Json(players.into_iter().map(|(id, name, sr, dr)| PlayerJson { + id, name, singles_rating: sr, doubles_rating: dr, + }).collect()) +} + +// ============== SESSION MANAGEMENT ============== + +/// Displays a list of recent sessions with match counts and email status +/// +/// **Endpoint:** `GET /sessions` +/// +/// **Description:** Shows the 20 most recent sessions with start/end times, match counts, notes, and email status (pending/sent), with links to preview and send session summaries. +/// +/// **Parameters:** None +/// +/// **Returns:** HTML page with sessions table +async fn sessions_list_handler(State(state): State) -> Html { + let sessions: Vec<(i64, String, Option, i32, Option)> = sqlx::query_as( + r#"SELECT s.id, s.start_time, s.end_time, s.summary_sent, s.notes + FROM sessions s + ORDER BY s.start_time DESC + LIMIT 20"# + ) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let mut session_rows = String::new(); + for (id, start, end, sent, notes) in &sessions { + // Count matches in this session + let match_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM matches WHERE session_id = ?" + ) + .bind(id) + .fetch_one(&state.pool) + .await + .unwrap_or(0); + + let status = if *sent == 1 { + "Sent" + } else { + "Pending" + }; + + let end_str = end.as_deref().unwrap_or("In Progress"); + let notes_str = notes.as_deref().unwrap_or("-"); + + session_rows.push_str(&format!(r#" + + #{} + {} + {} + {} + {} + {} + + šŸ“§ Preview + + + "#, id, &start[..16], end_str, match_count, notes_str, status, id)); + } + + let html = format!(r#" - + + Sessions - Pickleball ELO +
-

šŸ“ Pickleball ELO Tracker

-
Session Summary - Finals
+

šŸ“§ Session Management

+ {} -
-
-
157+
-
Total Matches
-
-
-
20
-
Players
-
-
-
3
-
Sessions
-
-
+

+ Preview and send session summary emails to players +

-

šŸ“Š Top Singles Players

-
-
-
šŸ„‡1. Linnea Connelly
-
1835
-
-
-
🄈2. Aimee Hodkiewicz
-
1822
-
-
-
šŸ„‰3. Nels Hirthe
-
1759
-
-
-
4. Khalil Goyette
-
1721
-
-
-
5. Ferne DuBuque
-
1708
-
-
- -

šŸ“Š Top Doubles Players

-
-
-
šŸ„‡1. Gaston Crona
-
1816
-
-
-
🄈2. Jett Jenkins
-
1702
-
-
-
šŸ„‰3. Ferne DuBuque
-
1631
-
-
-
4. Mallie Bauch
-
1618
-
-
-
5. Gideon Cummerata
-
1604
-
-
- - + + + + + + + + + + + + + + {} + +
IDStartedEndedMatchesNotesEmailActions
- "#; + "#, COMMON_CSS, nav_html(), session_rows); - println!("šŸ“§ Session Summary Email Generated"); - println!("==================================\n"); - println!("To: yourstruly@danesabo.com"); - println!("From: split@danesabo.com"); - println!("Subject: Pickleball Session Summary - Finals\n"); - println!("āœ… Email would be sent with Zoho SMTP\n"); - - // Save HTML to file - let email_file = "/Users/split/Projects/pickleball-elo/session_summary.html"; - if let Err(e) = fs::write(email_file, &email_html) { - eprintln!("Error writing email HTML: {}", e); - } else { - println!("āœ… Email preview saved to: {}", email_file); - } + Html(html) +} + +/// Displays a preview of the session summary email before sending +/// +/// **Endpoint:** `GET /sessions/:id/preview` +/// +/// **Description:** Shows what the session summary email will look like, including recipient list, match count, and current leaderboard data. Allows sending the email if not already sent. +/// +/// **Parameters:** `id` (path): Session ID +/// +/// **Returns:** HTML page with email preview and send button, or 404 if session not found +async fn session_preview_handler( + State(state): State, + Path(session_id): Path, +) -> Result, (StatusCode, String)> { + // Get session info + let session: Option<(i64, String, Option, i32)> = sqlx::query_as( + "SELECT id, start_time, end_time, summary_sent FROM sessions WHERE id = ?" + ) + .bind(session_id) + .fetch_optional(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let (id, start_time, end_time, sent) = session + .ok_or((StatusCode::NOT_FOUND, "Session not found".to_string()))?; + + // Get matches in this session + let match_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM matches WHERE session_id = ?" + ) + .bind(session_id) + .fetch_one(&state.pool) + .await + .unwrap_or(0); + + // Get players with email who participated in this session + let recipients: Vec<(String, String)> = sqlx::query_as( + r#"SELECT DISTINCT p.name, p.email + FROM players p + JOIN match_participants mp ON p.id = mp.player_id + JOIN matches m ON mp.match_id = m.id + WHERE m.session_id = ? AND p.email IS NOT NULL AND p.email != '' + ORDER BY p.name"# + ) + .bind(session_id) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + // Get top players for this session + let top_singles: Vec<(String, f64)> = sqlx::query_as( + "SELECT name, singles_rating FROM players ORDER BY singles_rating DESC LIMIT 5" + ) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let top_doubles: Vec<(String, f64)> = sqlx::query_as( + "SELECT name, doubles_rating FROM players ORDER BY doubles_rating DESC LIMIT 5" + ) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let recipients_list: String = if recipients.is_empty() { + "

āš ļø No players with email addresses participated in this session

".to_string() + } else { + recipients.iter() + .map(|(name, email)| format!(r#"
+ {} <{}> +
"#, name, email)) + .collect() + }; + + let singles_list: String = top_singles.iter().enumerate() + .map(|(i, (name, rating))| { + let medal = match i { 0 => "šŸ„‡", 1 => "🄈", 2 => "šŸ„‰", _ => "" }; + format!("
{}{}. {} - {:.0}
", medal, i+1, name, rating) + }) + .collect(); + + let doubles_list: String = top_doubles.iter().enumerate() + .map(|(i, (name, rating))| { + let medal = match i { 0 => "šŸ„‡", 1 => "🄈", 2 => "šŸ„‰", _ => "" }; + format!("
{}{}. {} - {:.0}
", medal, i+1, name, rating) + }) + .collect(); + + let already_sent = if sent == 1 { + "
+ āœ… This session summary has already been sent. +
" + } else { "" }; + + let send_button = if sent == 0 && !recipients.is_empty() { + format!(r#" +
+ +
+ "#, session_id, recipients.len()) + } else { + String::new() + }; + + let html = format!(r#" + + + + + + Session #{} Preview - Pickleball ELO + + + +
+

šŸ“§ Session #{} Email Preview

+ {} + + {} + +
+
+
{}
+
Matches
+
+
+
{}
+
Recipients
+
+
+ +

šŸ“¬ Recipients

+ {} + +

šŸ“„ Email Preview

+
+

šŸ“ Pickleball Session Summary

+

Session: {} to {}

+ +
+
+

šŸ“Š Top Singles

+ {} +
+
+

šŸ“Š Top Doubles

+ {} +
+
+
+ + {} + + ← Back to Sessions +
+ + + "#, id, COMMON_CSS, id, nav_html(), already_sent, match_count, recipients.len(), + recipients_list, &start_time[..16], end_time.as_deref().unwrap_or("In Progress"), + singles_list, doubles_list, send_button); + + Ok(Html(html)) +} + +/// Sends the session summary email to all participating players with email addresses +/// +/// **Endpoint:** `POST /sessions/:id/send` +/// +/// **Description:** Sends an HTML email containing the session summary with leaderboard snapshots to all players who participated in the session and have email addresses on file. Marks session as sent. +/// +/// **Parameters:** `id` (path): Session ID +/// +/// **Returns:** Redirect to `/sessions` on success, or error response if no recipients found or email send fails +async fn send_session_email( + State(state): State, + Path(session_id): Path, +) -> Result { + // Get recipients + let recipients: Vec<(String, String)> = sqlx::query_as( + r#"SELECT DISTINCT p.name, p.email + FROM players p + JOIN match_participants mp ON p.id = mp.player_id + JOIN matches m ON mp.match_id = m.id + WHERE m.session_id = ? AND p.email IS NOT NULL AND p.email != ''"# + ) + .bind(session_id) + .fetch_all(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if recipients.is_empty() { + return Err((StatusCode::BAD_REQUEST, "No recipients with email addresses".to_string())); + } + + // Get leaderboard data for email + let top_singles: Vec<(String, f64)> = sqlx::query_as( + "SELECT name, singles_rating FROM players ORDER BY singles_rating DESC LIMIT 5" + ) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let top_doubles: Vec<(String, f64)> = sqlx::query_as( + "SELECT name, doubles_rating FROM players ORDER BY doubles_rating DESC LIMIT 5" + ) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + // Build email HTML + let singles_html: String = top_singles.iter().enumerate() + .map(|(i, (name, rating))| { + let medal = match i { 0 => "šŸ„‡", 1 => "🄈", 2 => "šŸ„‰", _ => "" }; + format!("{} {}. {}{:.0}", medal, i+1, name, rating) + }) + .collect(); + + let doubles_html: String = top_doubles.iter().enumerate() + .map(|(i, (name, rating))| { + let medal = match i { 0 => "šŸ„‡", 1 => "🄈", 2 => "šŸ„‰", _ => "" }; + format!("{} {}. {}{:.0}", medal, i+1, name, rating) + }) + .collect(); + + let email_body = format!(r#" + + + + +
+

šŸ“ Pickleball Session Summary

+ +

šŸ“Š Top Singles

+ {}
+ +

šŸ“Š Top Doubles

+ {}
+ +

+ Pickleball ELO Tracker - Glicko-2 Rating System +

+
+ + + "#, singles_html, doubles_html); + + // Send emails using lettre (already in dependencies) + use lettre::{Message, SmtpTransport, Transport}; + use lettre::transport::smtp::authentication::Credentials; + + let smtp_user = "split@danesabo.com"; + let smtp_pass = "Keep an eye 0ut 4 Split!"; + + let creds = Credentials::new(smtp_user.to_string(), smtp_pass.to_string()); + + let mailer = SmtpTransport::starttls_relay("smtppro.zoho.com") + .unwrap() + .port(587) + .credentials(creds) + .build(); + + let mut sent_count = 0; + for (name, email) in &recipients { + let email_msg = Message::builder() + .from("Pickleball ELO ".parse().unwrap()) + .to(format!("{} <{}>", name, email).parse().unwrap()) + .subject("šŸ“ Pickleball Session Summary") + .header(lettre::message::header::ContentType::TEXT_HTML) + .body(email_body.clone()) + .unwrap(); + + match mailer.send(&email_msg) { + Ok(_) => { sent_count += 1; } + Err(e) => { eprintln!("Failed to send to {}: {}", email, e); } + } + } + + println!("šŸ“§ Sent session summary to {}/{} recipients", sent_count, recipients.len()); + + // Mark session as sent + sqlx::query("UPDATE sessions SET summary_sent = 1 WHERE id = ?") + .bind(session_id) + .execute(&state.pool) + .await + .ok(); + + Ok(Redirect::to("/sessions")) } diff --git a/src/models/mod.rs b/src/models/mod.rs index 2aec909..bbf9650 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,14 +1,75 @@ -// Models module stub -// Full implementation would include player, match, participant, session models +//! Models module for pickleball player ratings using the Glicko2 rating system. +//! +//! # Glicko2 Rating System Overview +//! +//! The Glicko2 system provides a more sophisticated rating mechanism than simple Elo, +//! incorporating three parameters per player per format (singles/doubles): +//! +//! - **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 +/// Represents a player profile with Glicko2 ratings for both singles and doubles play. +/// +/// 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). +/// +/// # 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) +/// +/// # Example +/// +/// ```ignore +/// let player = Player { +/// 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, +/// }; +/// ``` +#[derive(Debug, Clone)] pub struct Player { + /// Unique database identifier pub id: i64, + /// Player's display name (unique) pub name: String, + /// 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, } diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs new file mode 100644 index 0000000..53577fa --- /dev/null +++ b/tests/integration_tests.rs @@ -0,0 +1,250 @@ +//! 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); +}