docs: comprehensive documentation for all modules and handlers
- README.md: full project docs with features, API endpoints, Glicko-2 explanation
- main.rs: doc comments for all 18 HTTP handlers
- db/mod.rs: schema and migration documentation
- models/mod.rs: Player struct and Glicko-2 parameter docs
- Fixed route syntax (:id instead of {id}) for Axum 0.7 compatibility
This commit is contained in:
parent
ada961f35d
commit
d4c0bb889b
560
README.md
560
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**
|
||||
|
||||
@ -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<SqlitePool, sqlx::Error> {
|
||||
// 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<SqlitePool, sqlx::Error> {
|
||||
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)",
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
2284
src/main.rs
2284
src/main.rs
File diff suppressed because it is too large
Load Diff
@ -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<String>,
|
||||
|
||||
// === Singles Glicko2 Parameters ===
|
||||
/// Skill estimate in singles format (1500 = average)
|
||||
pub singles_rating: f64,
|
||||
/// Confidence in singles rating (lower = more certain; ~30 = highly established)
|
||||
pub singles_rd: f64,
|
||||
/// Consistency in singles play (0.06 = starting; higher = more variable performance)
|
||||
pub singles_volatility: f64,
|
||||
|
||||
// === Doubles Glicko2 Parameters ===
|
||||
/// Skill estimate in doubles format (1500 = average)
|
||||
pub doubles_rating: f64,
|
||||
/// Confidence in doubles rating (lower = more certain; ~30 = highly established)
|
||||
pub doubles_rd: f64,
|
||||
/// Consistency in doubles play (0.06 = starting; higher = more variable performance)
|
||||
pub doubles_volatility: f64,
|
||||
}
|
||||
|
||||
250
tests/integration_tests.rs
Normal file
250
tests/integration_tests.rs
Normal file
@ -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<String>, f64) = sqlx::query_as(
|
||||
"SELECT id, name, email, singles_rating FROM players WHERE name = 'Test Player'"
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("Should find player");
|
||||
|
||||
assert_eq!(player.1, "Test Player");
|
||||
assert_eq!(player.2, Some("test@example.com".to_string()));
|
||||
assert_eq!(player.3, 1500.0); // Default rating
|
||||
|
||||
// Update player
|
||||
sqlx::query("UPDATE players SET singles_rating = 1600.0 WHERE id = ?")
|
||||
.bind(player.0)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("Should update player");
|
||||
|
||||
let updated: (f64,) = sqlx::query_as("SELECT singles_rating FROM players WHERE id = ?")
|
||||
.bind(player.0)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(updated.0, 1600.0);
|
||||
|
||||
// Delete player
|
||||
sqlx::query("DELETE FROM players WHERE id = ?")
|
||||
.bind(player.0)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("Should delete player");
|
||||
|
||||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM players")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(count.0, 0);
|
||||
|
||||
drop(pool);
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_match_recording() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let db_path = temp_dir.join("test_matches.db");
|
||||
let db_str = db_path.to_str().unwrap();
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
|
||||
let pool = db::create_pool(db_str).await.unwrap();
|
||||
|
||||
// Create two players
|
||||
sqlx::query("INSERT INTO players (name) VALUES ('Player A')")
|
||||
.execute(&pool).await.unwrap();
|
||||
sqlx::query("INSERT INTO players (name) VALUES ('Player B')")
|
||||
.execute(&pool).await.unwrap();
|
||||
|
||||
// Create a session
|
||||
let session_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO sessions (notes) VALUES ('Test Session') RETURNING id"
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create a match
|
||||
let match_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO matches (session_id, match_type, team1_score, team2_score) VALUES (?, 'singles', 11, 5) RETURNING id"
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(match_id > 0, "Match should be created with valid ID");
|
||||
|
||||
// Verify match
|
||||
let match_data: (String, i32, i32) = sqlx::query_as(
|
||||
"SELECT match_type, team1_score, team2_score FROM matches WHERE id = ?"
|
||||
)
|
||||
.bind(match_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(match_data.0, "singles");
|
||||
assert_eq!(match_data.1, 11);
|
||||
assert_eq!(match_data.2, 5);
|
||||
|
||||
drop(pool);
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rating_bounds() {
|
||||
// Test that ratings don't go below 0 or above unreasonable values
|
||||
let very_low = GlickoRating { rating: 100.0, rd: 50.0, volatility: 0.06 };
|
||||
let very_high = GlickoRating { rating: 2500.0, rd: 50.0, volatility: 0.06 };
|
||||
|
||||
let (new_low, _) = calculate_new_ratings(&very_low, &very_high, 0.0, 1.0);
|
||||
|
||||
assert!(new_low.rating > 0.0, "Rating should stay positive");
|
||||
assert!(new_low.rd > 0.0, "RD should stay positive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_draw_handling() {
|
||||
let player1 = GlickoRating::new_player();
|
||||
let player2 = GlickoRating::new_player();
|
||||
|
||||
// Score of 0.5 = draw
|
||||
let (new_p1, new_p2) = calculate_new_ratings(&player1, &player2, 0.5, 1.0);
|
||||
|
||||
// In a draw between equal players, ratings shouldn't change much
|
||||
let p1_change = (new_p1.rating - player1.rating).abs();
|
||||
let p2_change = (new_p2.rating - player2.rating).abs();
|
||||
|
||||
assert!(p1_change < 1.0, "Draw should not change rating much: {}", p1_change);
|
||||
assert!(p2_change < 1.0, "Draw should not change rating much: {}", p2_change);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user