jashdoshi77 commited on
Commit
3e6f1d3
·
1 Parent(s): 10cc4da

Add analytics, confidence meter, enhanced H2H, daily MVP refresh

Browse files
claude.md ADDED
@@ -0,0 +1,377 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NBA Sage - Complete Codebase Context for AI Assistants
2
+
3
+ > **Purpose**: This document provides comprehensive context for AI assistants to understand the NBA Sage prediction system. Read this entire document before making any code modifications.
4
+
5
+ ---
6
+
7
+ ## 🏗️ Project Architecture Overview
8
+
9
+ ```
10
+ NBA ML/
11
+ ├── server.py # Main production server (Flask + React)
12
+ ├── api/api.py # Development API server
13
+
14
+ ├── src/ # Core Python modules
15
+ │ ├── prediction_pipeline.py # Main prediction orchestrator
16
+ │ ├── feature_engineering.py # ELO + feature generation
17
+ │ ├── data_collector.py # Historical NBA API data
18
+ │ ├── live_data_collector.py # Real-time game data
19
+ │ ├── injury_collector.py # Player injury tracking
20
+ │ ├── prediction_tracker.py # ChromaDB prediction storage
21
+ │ ├── auto_trainer.py # Automated training scheduler
22
+ │ ├── continuous_learner.py # Incremental model updates
23
+ │ ├── preprocessing.py # Data preprocessing
24
+ │ ├── config.py # Global configuration
25
+ │ └── models/
26
+ │ ├── game_predictor.py # XGBoost+LightGBM ensemble
27
+ │ ├── mvp_predictor.py # MVP prediction model
28
+ │ └── championship_predictor.py
29
+
30
+ ├── web/ # React Frontend
31
+ │ └── src/
32
+ │ ├── App.jsx # Main app with sidebar navigation
33
+ │ ├── pages/ # LiveGames, Predictions, MVP, etc.
34
+ │ ├── api.js # API client
35
+ │ └── index.css # Comprehensive CSS design system
36
+
37
+ ├── data/
38
+ │ ├── api_data/ # Cached NBA API responses (parquet)
39
+ │ ├── processed/ # Processed datasets (joblib)
40
+ │ └── raw/ # Raw game data
41
+
42
+ └── models/
43
+ └── game_predictor.joblib # Trained ML model
44
+ ```
45
+
46
+ ---
47
+
48
+ ## 🔄 Data Flow
49
+
50
+ ```
51
+ NBA API ──► Data Collectors ──► Feature Engineering ──► ML Training
52
+
53
+
54
+ Live API ──► Live Collector ──► Prediction Pipeline ──► Flask API ──► React UI
55
+
56
+
57
+ Prediction Tracker (ChromaDB)
58
+ ```
59
+
60
+ ---
61
+
62
+ ## 📦 Core Components Deep Dive
63
+
64
+ ### 1. `server.py` - Production Server (39KB, 929 lines)
65
+
66
+ **Critical for Hugging Face deployment. Combines Flask API + React static serving.**
67
+
68
+ **Key Sections:**
69
+ - **Cache Configuration (lines 30-40)**: In-memory caching for rosters, predictions, live games
70
+ - **Startup Cache Warming (lines 140-225)**: `warm_starter_cache()` fetches all 30 team rosters on startup
71
+ - **Background Scheduler (lines 340-370)**: APScheduler jobs for ELO updates, retraining, prediction sync
72
+ - **API Endpoints (lines 400-860)**: All REST endpoints for frontend
73
+
74
+ **Important Functions:**
75
+ ```python
76
+ warm_starter_cache() # Fetches real NBA API data for all teams
77
+ startup_cache_warming() # Runs synchronously on server start
78
+ auto_retrain_model() # Smart retraining after all daily games complete
79
+ sync_prediction_results() # Updates prediction correctness from final scores
80
+ update_elo_ratings() # Daily ELO recalculation
81
+ ```
82
+
83
+ **Endpoints:**
84
+ - `GET /api/live-games` - Today's games with predictions
85
+ - `GET /api/roster/<team>` - Team's projected starting 5
86
+ - `GET /api/accuracy` - Model accuracy statistics
87
+ - `GET /api/mvp` - MVP race standings
88
+ - `GET /api/championship` - Championship odds
89
+
90
+ ---
91
+
92
+ ### 2. `prediction_pipeline.py` - Prediction Orchestrator (41KB, 765 lines)
93
+
94
+ **The heart of the system. Orchestrates all predictions.**
95
+
96
+ **Key Properties:**
97
+ ```python
98
+ self.live_collector # LiveDataCollector instance
99
+ self.injury_collector # InjuryCollector instance
100
+ self.feature_gen # FeatureGenerator instance
101
+ self.tracker # PredictionTracker (ChromaDB)
102
+ self._game_model # Lazy-loaded GamePredictor
103
+ ```
104
+
105
+ **Important Methods:**
106
+
107
+ | Method | Purpose |
108
+ |--------|---------|
109
+ | `predict_game(home, away)` | Generate single game prediction |
110
+ | `get_upcoming_games(days)` | Fetch future NBA schedule |
111
+ | `get_mvp_race()` | Calculate MVP standings from live stats |
112
+ | `get_championship_odds()` | Calculate championship probabilities |
113
+ | `get_team_roster(team)` | Fast fallback roster data |
114
+
115
+ **⚠️ CRITICAL: Prediction Algorithm (lines 349-504)**
116
+
117
+ The `predict_game()` method uses a **formula-based approach**, NOT the trained ML model:
118
+
119
+ ```python
120
+ # Weights in predict_game():
121
+ home_talent = (
122
+ 0.40 * home_win_pct + # Current season record
123
+ 0.30 * home_form + # Last 10 games
124
+ 0.20 * home_elo_strength + # Historical ELO
125
+ 0.10 * 0.5 # Baseline
126
+ )
127
+ # Plus: +3.5% home court, -2% per injury point
128
+ # Uses Log5 formula for head-to-head probability
129
+ ```
130
+
131
+ The trained `GamePredictor` model exists but is NOT called for live predictions.
132
+
133
+ ---
134
+
135
+ ### 3. `feature_engineering.py` - Feature Generation (29KB, 696 lines)
136
+
137
+ **Contains ELO system and all feature generation logic.**
138
+
139
+ **Classes:**
140
+
141
+ | Class | Purpose | Key Methods |
142
+ |-------|---------|-------------|
143
+ | `ELOCalculator` | ELO rating system | `update_ratings()`, `calculate_game_features()` |
144
+ | `EraNormalizer` | Z-score normalization across seasons | `fit_season()`, `transform()` |
145
+ | `StatLoader` | Load all stat types | `get_team_season_stats()`, `get_team_top_players_stats()` |
146
+ | `FeatureGenerator` | Main feature orchestrator | `generate_game_features()`, `generate_features_for_dataset()` |
147
+
148
+ **ELO Configuration:**
149
+ ```python
150
+ initial_rating = 1500
151
+ k_factor = 20
152
+ home_advantage = 100 # ELO points for home court
153
+ regression_factor = 0.25 # Season regression to mean
154
+ ```
155
+
156
+ **Feature Types Generated:**
157
+ - ELO features (team_elo, opponent_elo, elo_diff, elo_expected_win)
158
+ - Rolling averages (5, 10, 20 game windows)
159
+ - Rest days, back-to-back detection
160
+ - Season record features
161
+ - Head-to-head history
162
+
163
+ ---
164
+
165
+ ### 4. `data_collector.py` - Historical Data (27KB, 650 lines)
166
+
167
+ **Collects comprehensive NBA data from official API.**
168
+
169
+ **Classes:**
170
+ | Class | Data Collected |
171
+ |-------|---------------|
172
+ | `GameDataCollector` | Game results per season |
173
+ | `TeamDataCollector` | Team stats (basic, advanced, clutch, hustle, defense) |
174
+ | `PlayerDataCollector` | Player stats |
175
+ | `CacheManager` | Parquet file caching |
176
+
177
+ **Key Features:**
178
+ - Exponential backoff retry for rate limiting
179
+ - Per-season parquet caching
180
+ - Checkpoint system for resumable collection
181
+
182
+ ---
183
+
184
+ ### 5. `live_data_collector.py` - Real-Time Data (9KB, 236 lines)
185
+
186
+ **Uses `nba_api.live` endpoints for real-time game data.**
187
+
188
+ **Key Methods:**
189
+ ```python
190
+ get_live_scoreboard() # Today's games with live scores
191
+ get_game_boxscore(id) # Detailed box score
192
+ get_games_by_status() # Filter: NOT_STARTED, IN_PROGRESS, FINAL
193
+ ```
194
+
195
+ **Data Fields Returned:**
196
+ - game_id, game_code
197
+ - home_team, away_team (tricodes)
198
+ - home_score, away_score
199
+ - period, clock
200
+ - status
201
+
202
+ ---
203
+
204
+ ### 6. `prediction_tracker.py` - Persistence (20KB, 508 lines)
205
+
206
+ **Stores predictions and tracks accuracy using ChromaDB Cloud.**
207
+
208
+ **Features:**
209
+ - ChromaDB Cloud integration (with local JSON fallback)
210
+ - Prediction storage before games start
211
+ - Result updating after games complete
212
+ - Comprehensive accuracy statistics
213
+
214
+ **Key Methods:**
215
+ ```python
216
+ save_prediction(game_id, prediction) # Store pre-game prediction
217
+ update_result(game_id, winner, scores) # Update with final result
218
+ get_accuracy_stats() # Overall, by confidence, by team
219
+ get_pending_predictions() # Awaiting results
220
+ ```
221
+
222
+ ---
223
+
224
+ ### 7. `models/game_predictor.py` - ML Model (12KB, 332 lines)
225
+
226
+ **XGBoost + LightGBM ensemble classifier.**
227
+
228
+ **Architecture:**
229
+ ```
230
+ Input Features ──┬──► XGBoost ──┐
231
+ │ │──► Weighted Average ──► Win Probability
232
+ └──► LightGBM ─┘
233
+ (50/50 weight)
234
+ ```
235
+
236
+ **Key Methods:**
237
+ ```python
238
+ train(X_train, y_train, X_val, y_val) # Train both models
239
+ predict_proba(X) # Get [loss_prob, win_prob]
240
+ predict_with_confidence(X) # Detailed prediction info
241
+ explain_prediction(X) # Feature importance for prediction
242
+ save() / load() # Persist to models/game_predictor.joblib
243
+ ```
244
+
245
+ **⚠️ NOTE: Model exists but `predict_game()` doesn't use it!**
246
+
247
+ ---
248
+
249
+ ### 8. `auto_trainer.py` & `continuous_learner.py` - Auto Training
250
+
251
+ **AutoTrainer** (Singleton scheduler):
252
+ - Runs background loop checking for tasks
253
+ - Ingests completed games every hour
254
+ - Smart retraining: only after ALL daily games complete
255
+ - If new accuracy < old accuracy, reverts model
256
+
257
+ **ContinuousLearner** (Update workflow):
258
+ ```
259
+ ingest_completed_games() ──► update_features() ──► retrain_model()
260
+ ```
261
+
262
+ ---
263
+
264
+ ## 🗄️ Database & Storage
265
+
266
+ ### ChromaDB Cloud
267
+ - **Purpose**: Persistent prediction storage
268
+ - **Credentials**: Set via environment variables (`CHROMA_TENANT`, `CHROMA_DATABASE`, `CHROMA_API_KEY`)
269
+ - **Fallback**: `data/processed/predictions_local.json`
270
+
271
+ ### Parquet Files
272
+ - `data/api_data/*.parquet` - Cached API responses
273
+ - `data/api_data/all_games_summary.parquet` - Consolidated game history (41K+ games)
274
+
275
+ ### Joblib Files
276
+ - `models/game_predictor.joblib` - Trained ML model
277
+ - `data/processed/game_dataset.joblib` - Processed training data
278
+
279
+ ---
280
+
281
+ ## 🌐 Frontend Architecture
282
+
283
+ **React + Vite with custom CSS design system.**
284
+
285
+ **Pages:**
286
+ | Page | File | Purpose |
287
+ |------|------|---------|
288
+ | Live Games | `LiveGames.jsx` | Today's games, live scores, predictions |
289
+ | Predictions | `Predictions.jsx` | Upcoming games with predictions |
290
+ | Head to Head | `HeadToHead.jsx` | Compare two teams |
291
+ | Accuracy | `Accuracy.jsx` | Model performance stats |
292
+ | MVP Race | `MvpRace.jsx` | Current MVP standings |
293
+ | Championship | `Championship.jsx` | Championship odds |
294
+
295
+ **Key Frontend Components:**
296
+ - `TeamLogo.jsx` - Official NBA team logos
297
+ - `api.js` - API client with base URL handling
298
+ - `index.css` - Complete design system (27KB)
299
+
300
+ ---
301
+
302
+ ## 🔧 Configuration (`src/config.py`)
303
+
304
+ **Critical Settings:**
305
+ ```python
306
+ # NBA Teams mapping (team_id -> tricode)
307
+ NBA_TEAMS = {1610612737: "ATL", 1610612738: "BOS", ...}
308
+
309
+ # Data paths
310
+ API_CACHE_DIR = Path("data/api_data")
311
+ PROCESSED_DATA_DIR = Path("data/processed")
312
+ MODELS_DIR = Path("models")
313
+
314
+ # Feature engineering
315
+ FEATURE_CONFIG = {
316
+ "rolling_windows": [5, 10, 20],
317
+ "min_games_for_features": 5
318
+ }
319
+
320
+ # ELO system
321
+ ELO_CONFIG = {
322
+ "initial_rating": 1500,
323
+ "k_factor": 20,
324
+ "home_advantage": 100
325
+ }
326
+ ```
327
+
328
+ ---
329
+
330
+ ## ⚠️ Known Issues & Technical Debt
331
+
332
+ 1. **ML Model Not Used**: `predict_game()` uses formula, not trained `GamePredictor`
333
+ 2. **Season Hardcoding**: Some places use `2025-26` explicitly
334
+ 3. **Fallback Data**: Pipeline has hardcoded rosters as backup
335
+ 4. **Function Order**: `warm_starter_cache()` must be defined before scheduler calls it
336
+
337
+ ---
338
+
339
+ ## 🚀 Deployment Notes
340
+
341
+ **Hugging Face Spaces:**
342
+ - Uses persistent `/data` directory for storage
343
+ - Dockerfile copies `models/` and `data/api_data/`
344
+ - Git LFS for large files (`.joblib`, `.parquet`)
345
+ - Port 7860 for HF Spaces
346
+
347
+ **Environment Variables:**
348
+ ```
349
+ CHROMA_TENANT, CHROMA_DATABASE, CHROMA_API_KEY # ChromaDB
350
+ NBA_ML_DATA_DIR, NBA_ML_MODELS_DIR # Override paths
351
+ ```
352
+
353
+ ---
354
+
355
+ ## 📋 Quick Reference: Common Tasks
356
+
357
+ **Add new API endpoint:**
358
+ 1. Add route in `server.py` (production) AND `api/api.py` (development)
359
+ 2. Add frontend call in `web/src/api.js`
360
+ 3. Create/update page component in `web/src/pages/`
361
+
362
+ **Modify prediction algorithm:**
363
+ 1. Edit `PredictionPipeline.predict_game()` in `prediction_pipeline.py`
364
+ 2. Consider blending with `GamePredictor` model
365
+
366
+ **Update ML model:**
367
+ 1. Retrain via `ContinuousLearner.retrain_model()`
368
+ 2. Or trigger via `POST /api/admin/retrain`
369
+
370
+ **Add new feature:**
371
+ 1. Add to `FeatureGenerator` in `feature_engineering.py`
372
+ 2. Update preprocessing pipeline
373
+ 3. Retrain model
374
+
375
+ ---
376
+
377
+ *Last updated: January 2026*
explain.md ADDED
@@ -0,0 +1,445 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NBA Sage - Technical Explanation
2
+
3
+ > **An AI-powered NBA game prediction system with real-time data, machine learning, and a modern web interface.**
4
+
5
+ ---
6
+
7
+ ## 🎯 What Does This Project Do?
8
+
9
+ NBA Sage is a full-stack application that:
10
+
11
+ 1. **Predicts NBA game outcomes** before they happen
12
+ 2. **Shows live scores** with real-time updates
13
+ 3. **Tracks prediction accuracy** over time
14
+ 4. **Calculates MVP race standings** based on current stats
15
+ 5. **Estimates championship odds** for all 30 teams
16
+
17
+ ---
18
+
19
+ ## 🏆 Key Features
20
+
21
+ | Feature | Description |
22
+ |---------|-------------|
23
+ | **Live Game Dashboard** | Real-time scores, game status, win probabilities |
24
+ | **Win Predictions** | Probability % for each team to win |
25
+ | **Starting 5 Lineups** | Projected starters with PPG stats from NBA API |
26
+ | **MVP Race** | Top 10 MVP candidates with scores |
27
+ | **Championship Odds** | All 30 teams ranked by title probability |
28
+ | **Model Accuracy** | Track how well predictions perform over time |
29
+
30
+ ---
31
+
32
+ ## 🛠️ Technology Stack
33
+
34
+ ### Backend (Python)
35
+ | Technology | Purpose |
36
+ |------------|---------|
37
+ | **Flask** | REST API framework |
38
+ | **nba_api** | Official NBA data (stats.nba.com) |
39
+ | **XGBoost + LightGBM** | Machine learning ensemble model |
40
+ | **APScheduler** | Background job scheduling |
41
+ | **ChromaDB Cloud** | Persistent prediction storage |
42
+ | **Pandas/NumPy** | Data processing |
43
+
44
+ ### Frontend (React)
45
+ | Technology | Purpose |
46
+ |------------|---------|
47
+ | **React 18** | UI framework |
48
+ | **Vite** | Build tool & dev server |
49
+ | **Custom CSS** | Modern design system |
50
+
51
+ ### Infrastructure
52
+ | Technology | Purpose |
53
+ |------------|---------|
54
+ | **Docker** | Container deployment |
55
+ | **Hugging Face Spaces** | Cloud hosting |
56
+ | **Git LFS** | Large file versioning |
57
+
58
+ ---
59
+
60
+ ## 🔬 How Predictions Work
61
+
62
+ ### The Prediction Algorithm
63
+
64
+ Predictions are made using a **multi-factor formula**:
65
+
66
+ ```
67
+ Win Probability = Log5 Formula of:
68
+ ├── 40% - Current Season Record (Win %)
69
+ ├── 30% - Recent Form (Last 10 games performance)
70
+ ├── 20% - ELO Rating (Historical team strength)
71
+ └── 10% - Baseline
72
+
73
+ Adjustments Applied:
74
+ ├── +3.5% for Home Court Advantage
75
+ └── -2% per Injury Impact Point
76
+ ```
77
+
78
+ ### ELO Rating System
79
+
80
+ ELO is a chess-inspired rating system adapted for NBA:
81
+
82
+ - **Starting rating**: 1500 (average team)
83
+ - **K-factor**: 20 (how much ratings change per game)
84
+ - **Home advantage**: +100 ELO points equivalent
85
+ - **Season regression**: Ratings regress 25% to mean each season
86
+
87
+ **How it works:**
88
+ - Win against better team → Big ELO gain
89
+ - Win against weaker team → Small ELO gain
90
+ - Lose against better team → Small ELO loss
91
+ - Lose against weaker team → Big ELO loss
92
+
93
+ ---
94
+
95
+ ## 📊 Data Sources
96
+
97
+ ### Real-Time Data
98
+ - **NBA Live API** (`nba_api.live`)
99
+ - Live scores updated every 30 seconds
100
+ - Game status (scheduled, in progress, final)
101
+ - Box scores and player stats
102
+
103
+ ### Historical Data
104
+ - **NBA Stats API** (`nba_api.stats`)
105
+ - 23 years of game data (2003-2026)
106
+ - Team statistics (basic, advanced, clutch, hustle)
107
+ - Player statistics
108
+ - Current season stats for predictions
109
+
110
+ ### Data Storage
111
+ - **Parquet files**: Cached API responses (~140 files)
112
+ - **ChromaDB Cloud**: Prediction history and accuracy tracking
113
+ - **Joblib files**: Trained ML model and processed datasets
114
+
115
+ ---
116
+
117
+ ## 🧠 Machine Learning Components
118
+
119
+ ### Trained Model: XGBoost + LightGBM Ensemble
120
+
121
+ Two gradient boosting models trained on 41,000+ historical games:
122
+
123
+ ```
124
+ Game Features ──┬──► XGBoost (50%) ──┐
125
+ │ │──► Ensemble Prediction
126
+ └──► LightGBM (50%) ─┘
127
+ ```
128
+
129
+ **Features Used:**
130
+ - ELO ratings and differentials
131
+ - Rolling averages (5, 10, 20 game windows)
132
+ - Rest days and back-to-back games
133
+ - Home/away status
134
+ - Season record statistics
135
+
136
+ ### Training Pipeline
137
+
138
+ ```
139
+ Data Collection ──► Feature Engineering ──► Model Training ──► Evaluation
140
+ │ │ │
141
+ ▼ ▼ ▼
142
+ NBA API Data ELO Calculation XGBoost+LightGBM
143
+ Era Normalization
144
+ Rolling Windows
145
+ ```
146
+
147
+ ### Auto-Training System
148
+
149
+ The system automatically retrains itself:
150
+
151
+ 1. **Ingests completed games** every hour
152
+ 2. **Waits for all daily games** to complete
153
+ 3. **Compares new model accuracy** to existing
154
+ 4. **Only updates if improved** (prevents regression)
155
+
156
+ ---
157
+
158
+ ## 🌐 System Architecture
159
+
160
+ ```
161
+ ┌─────────────────────────────────────────────────────────────────┐
162
+ │ React Frontend │
163
+ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ��
164
+ │ │LiveGames │ │Predictions│ │MVP Race │ │ Accuracy │ │
165
+ │ └────▲─────┘ └────▲─────┘ └────▲─────┘ └────▲─────┘ │
166
+ └───────┼────────────┼────────────┼────────────┼──────────────────┘
167
+ │ │ │ │
168
+ └────────────┴─────┬──────┴────────────┘
169
+ │ REST API
170
+
171
+ ┌─────────────────────────────────────────────────────────────────┐
172
+ │ Flask Server │
173
+ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
174
+ │ │ Endpoints │ │ Caching │ │ Scheduler │ │
175
+ │ │ /api/live │ │ In-Memory │ │ APScheduler │ │
176
+ │ │ /api/roster │ │ 1-hour rosters│ │ Auto-retrain │ │
177
+ │ └────────┬───────┘ └────────────────┘ └────────────────┘ │
178
+ │ │ │
179
+ │ ▼ │
180
+ │ ┌────────────────────────────────────────────────────────┐ │
181
+ │ │ Prediction Pipeline │ │
182
+ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
183
+ │ │ │Live Collector│ │Feature Gen │ │ ELO System │ │ │
184
+ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
185
+ │ └────────────────────────────────────────────────────────┘ │
186
+ └─────────────────────────────────────────────────────────────────┘
187
+
188
+
189
+ ┌─────────────────────────────────────────────────────────────────┐
190
+ │ External Services │
191
+ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
192
+ │ │ NBA API │ │ ChromaDB │ │ Hugging Face│ │
193
+ │ │ stats.nba │ │ Cloud │ │ Spaces │ │
194
+ │ └─────────────┘ └─────────────┘ └─────────────┘ │
195
+ └─────────────────────────────────────────────────────────────────┘
196
+ ```
197
+
198
+ ---
199
+
200
+ ## 📁 Project Structure
201
+
202
+ ```
203
+ NBA ML/
204
+ ├── server.py # Production server (Hugging Face)
205
+ ├── api/api.py # Development server
206
+
207
+ ├── src/ # Core logic
208
+ │ ├── prediction_pipeline.py # Main orchestrator
209
+ │ ├── feature_engineering.py # ELO + features
210
+ │ ├── data_collector.py # Historical data
211
+ │ ├── live_data_collector.py # Real-time data
212
+ │ ├── prediction_tracker.py # Accuracy tracking
213
+ │ └── models/
214
+ │ └── game_predictor.py # ML model
215
+
216
+ ├── web/ # React frontend
217
+ │ └── src/
218
+ │ ├── App.jsx
219
+ │ ├── pages/ # UI pages
220
+ │ └── index.css # Design system
221
+
222
+ ├── data/
223
+ │ └── api_data/ # 140+ parquet files
224
+
225
+ └── models/
226
+ └── game_predictor.joblib # Trained model (9.6KB)
227
+ ```
228
+
229
+ ---
230
+
231
+ ## 🚀 Deployment
232
+
233
+ ### Local Development
234
+ ```bash
235
+ # Backend
236
+ python api/api.py # Runs on localhost:8000
237
+
238
+ # Frontend
239
+ cd web && npm run dev # Runs on localhost:5173
240
+ ```
241
+
242
+ ### Production (Hugging Face Spaces)
243
+ ```bash
244
+ # Docker container
245
+ python server.py # Serves both API + React on port 7860
246
+ ```
247
+
248
+ ---
249
+
250
+ ## 📈 Performance & Accuracy
251
+
252
+ ### Prediction Accuracy
253
+ - **Overall**: Tracked via ChromaDB Cloud
254
+ - **By Confidence**: High/Medium/Low confidence splits
255
+ - **By Team**: Per-team prediction accuracy
256
+
257
+ ### Speed Optimizations
258
+ - **In-memory caching**: Roster data cached for 1 hour
259
+ - **Startup warming**: All 30 teams pre-loaded on server start
260
+ - **Background refresh**: Cache updated every 2 hours
261
+
262
+ ---
263
+
264
+ ## 🔮 Future Improvements
265
+
266
+ 1. **Integrate ML model** into live predictions (currently formula-based)
267
+ 2. **Add player-level features** (injuries, rest days per player)
268
+ 3. **Implement spread predictions** (margin of victory)
269
+ 4. **Add playoff predictions** with series outcomes
270
+
271
+ ---
272
+
273
+ ## 📊 Stats at a Glance
274
+
275
+ | Metric | Value |
276
+ |--------|-------|
277
+ | Historical games | 41,000+ |
278
+ | Seasons covered | 23 (2003-2026) |
279
+ | Teams tracked | 30 |
280
+ | ML model type | XGBoost + LightGBM |
281
+ | API endpoints | 10+ |
282
+ | Frontend pages | 6 |
283
+ ---
284
+
285
+ ## 📋 Complete ML Feature List (90+ Features)
286
+
287
+ The model uses approximately **90 features** organized into these categories:
288
+
289
+ ### 1️⃣ ELO Rating Features (5 features)
290
+ | Feature | Description |
291
+ |---------|-------------|
292
+ | `team_elo` | Team's current ELO rating |
293
+ | `opponent_elo` | Opponent's current ELO rating |
294
+ | `elo_diff` | Difference between team and opponent ELO |
295
+ | `elo_win_prob` | Expected win probability from ELO |
296
+ | `home_elo_boost` | ELO boost for home court (100 points) |
297
+
298
+ ### 2️⃣ Basic Stats - Rolling Averages (21 features)
299
+ For each of 7 stats × 3 windows (5, 10, 20 games):
300
+
301
+ | Base Stat | Windows |
302
+ |-----------|---------|
303
+ | `PTS` (Points) | `PTS_last5`, `PTS_last10`, `PTS_last20` |
304
+ | `AST` (Assists) | `AST_last5`, `AST_last10`, `AST_last20` |
305
+ | `REB` (Rebounds) | `REB_last5`, `REB_last10`, `REB_last20` |
306
+ | `FG_PCT` (Field Goal %) | `FG_PCT_last5`, `FG_PCT_last10`, `FG_PCT_last20` |
307
+ | `FG3_PCT` (3-Point %) | `FG3_PCT_last5`, `FG3_PCT_last10`, `FG3_PCT_last20` |
308
+ | `FT_PCT` (Free Throw %) | `FT_PCT_last5`, `FT_PCT_last10`, `FT_PCT_last20` |
309
+ | `PLUS_MINUS` (Point Diff) | `PLUS_MINUS_last5`, `PLUS_MINUS_last10`, `PLUS_MINUS_last20` |
310
+
311
+ ### 3️⃣ Season Statistics (9 features)
312
+ | Feature | Description |
313
+ |---------|-------------|
314
+ | `PTS_season_avg` | Season average points |
315
+ | `AST_season_avg` | Season average assists |
316
+ | `REB_season_avg` | Season average rebounds |
317
+ | `FG_PCT_season_avg` | Season field goal % |
318
+ | `FG3_PCT_season_avg` | Season 3-point % |
319
+ | `FT_PCT_season_avg` | Season free throw % |
320
+ | `PLUS_MINUS_season_avg` | Season point differential |
321
+ | `win_pct_season` | Season win percentage |
322
+ | `games_played` | Games played in season |
323
+
324
+ ### 4️⃣ Defensive Features (4 features)
325
+ | Feature | Description |
326
+ |---------|-------------|
327
+ | `STL_last10` | Steals per game (last 10) |
328
+ | `BLK_last10` | Blocks per game (last 10) |
329
+ | `DREB_last10` | Defensive rebounds (last 10) |
330
+ | `pts_allowed_last10` | Points allowed (last 10) |
331
+
332
+ ### 5️⃣ Momentum Features (6 features)
333
+ | Feature | Description |
334
+ |---------|-------------|
335
+ | `wins_last5` | Wins in last 5 games (0-5) |
336
+ | `wins_last10` | Wins in last 10 games (0-10) |
337
+ | `hot_streak` | 1 if 4+ wins in last 5 |
338
+ | `cold_streak` | 1 if 1 or fewer wins in last 5 |
339
+ | `plus_minus_last5` | Point differential trend |
340
+ | `form_trend` | Comparison of last 3 vs previous 3 |
341
+
342
+ ### 6️⃣ Rest & Fatigue Features (4 features)
343
+ | Feature | Description |
344
+ |---------|-------------|
345
+ | `days_rest` | Days since last game |
346
+ | `back_to_back` | 1 if playing consecutive days |
347
+ | `well_rested` | 1 if 3+ days rest |
348
+ | `games_last_week` | Games played in last 7 days |
349
+
350
+ ### 7️⃣ Form Index Features (3 features)
351
+ | Feature | Description |
352
+ |---------|-------------|
353
+ | `form_index` | Exponentially-weighted recent performance (0-1) |
354
+ | `form_trend` | Trend direction (improving/declining) |
355
+ | `form_plus_minus` | Weighted point differential |
356
+
357
+ ### 8️⃣ Basic Stat Columns (17 raw features)
358
+ ```python
359
+ BASIC_STATS = [
360
+ "PTS", "AST", "REB", "STL", "BLK", "TOV",
361
+ "FGM", "FGA", "FG_PCT",
362
+ "FG3M", "FG3A", "FG3_PCT",
363
+ "FTM", "FTA", "FT_PCT",
364
+ "OREB", "DREB"
365
+ ]
366
+ ```
367
+
368
+ ### 9️⃣ Advanced Team Stats (11 features)
369
+ ```python
370
+ ADVANCED_STATS = [
371
+ "E_OFF_RATING", # Offensive Rating
372
+ "E_DEF_RATING", # Defensive Rating
373
+ "E_NET_RATING", # Net Rating
374
+ "E_PACE", # Pace (possessions per game)
375
+ "E_AST_RATIO", # Assist Ratio
376
+ "E_OREB_PCT", # Offensive Rebound %
377
+ "E_DREB_PCT", # Defensive Rebound %
378
+ "E_REB_PCT", # Total Rebound %
379
+ "E_TM_TOV_PCT", # Team Turnover %
380
+ "E_EFG_PCT", # Effective FG%
381
+ "E_TS_PCT" # True Shooting %
382
+ ]
383
+ ```
384
+
385
+ ### 🔟 Clutch Stats (4 features)
386
+ ```python
387
+ CLUTCH_STATS = [
388
+ "CLUTCH_PTS", # Points in clutch time
389
+ "CLUTCH_FG_PCT", # FG% in clutch
390
+ "CLUTCH_FG3_PCT", # 3PT% in clutch
391
+ "CLUTCH_PLUS_MINUS" # +/- in clutch
392
+ ]
393
+ ```
394
+
395
+ ### 1️⃣1️⃣ Hustle Stats (5 features)
396
+ ```python
397
+ HUSTLE_STATS = [
398
+ "DEFLECTIONS", # Passes deflected
399
+ "LOOSE_BALLS_RECOVERED", # Loose balls recovered
400
+ "CHARGES_DRAWN", # Offensive fouls drawn
401
+ "CONTESTED_SHOTS", # Shots contested
402
+ "SCREEN_ASSISTS" # Screen assists
403
+ ]
404
+ ```
405
+
406
+ ### 1️⃣2️⃣ Top Player Stats (6 features)
407
+ | Feature | Description |
408
+ |---------|-------------|
409
+ | `top_players_avg_pts` | Avg points of top 5 players |
410
+ | `top_players_avg_ast` | Avg assists of top 5 players |
411
+ | `top_players_avg_reb` | Avg rebounds of top 5 players |
412
+ | `top_players_avg_stl` | Avg steals of top 5 players |
413
+ | `top_players_avg_blk` | Avg blocks of top 5 players |
414
+ | `star_concentration` | % of scoring from top player |
415
+
416
+ ### 1️⃣3️⃣ Game Context (1 feature)
417
+ | Feature | Description |
418
+ |---------|-------------|
419
+ | `is_home` | 1 if home team, 0 if away |
420
+
421
+ ---
422
+
423
+ ## 📊 Feature Summary
424
+
425
+ | Category | Feature Count |
426
+ |----------|---------------|
427
+ | ELO Ratings | 5 |
428
+ | Rolling Averages (5/10/20) | 21 |
429
+ | Season Statistics | 9 |
430
+ | Defensive Stats | 4 |
431
+ | Momentum Features | 6 |
432
+ | Rest/Fatigue | 4 |
433
+ | Form Index | 3 |
434
+ | Advanced Team Stats | 11 |
435
+ | Clutch Stats | 4 |
436
+ | Hustle Stats | 5 |
437
+ | Top Player Stats | 6 |
438
+ | Game Context | 1 |
439
+ | **TOTAL** | **~79 core features** |
440
+
441
+ *Plus Z-score normalized versions of stats for era adjustment = **90+ total features***
442
+
443
+ ---
444
+
445
+ *Built with Python, React, and a passion for basketball analytics* 🏀
generate_starters.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate starters cache from NBA API"""
2
+ from nba_api.stats.endpoints import leaguedashplayerstats
3
+ import pandas as pd
4
+ import json
5
+ import sys
6
+
7
+ # Fix encoding for Windows console
8
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
9
+
10
+ print("Fetching player stats from NBA API...")
11
+ stats = leaguedashplayerstats.LeagueDashPlayerStats(
12
+ season='2025-26',
13
+ per_mode_detailed='PerGame',
14
+ timeout=120
15
+ )
16
+ df = stats.get_data_frames()[0]
17
+ print(f'Got {len(df)} players')
18
+
19
+ # Build starters for each team (top 5 by minutes)
20
+ all_starters = {}
21
+ teams = df['TEAM_ABBREVIATION'].unique()
22
+
23
+ for team in sorted(teams):
24
+ team_players = df[df['TEAM_ABBREVIATION'] == team].sort_values('MIN', ascending=False).head(5)
25
+ starters = []
26
+ for _, p in team_players.iterrows():
27
+ # Infer position from stats
28
+ reb = float(p.get('REB', 0) or 0)
29
+ ast = float(p.get('AST', 0) or 0)
30
+ blk = float(p.get('BLK', 0) or 0)
31
+ if ast > 5 and reb < 6:
32
+ pos = "G"
33
+ elif reb > 8 or blk > 1.5:
34
+ pos = "C"
35
+ elif reb > 5:
36
+ pos = "F"
37
+ else:
38
+ pos = "G-F"
39
+
40
+ starters.append({
41
+ 'name': p['PLAYER_NAME'],
42
+ 'position': pos,
43
+ 'pts': round(float(p['PTS']), 1),
44
+ 'reb': round(float(p['REB']), 1),
45
+ 'ast': round(float(p['AST']), 1),
46
+ 'min': round(float(p['MIN']), 1)
47
+ })
48
+ all_starters[team] = starters
49
+ print(f'{team}: {len(starters)} players')
50
+
51
+ # Save to file
52
+ output_path = 'data/api_data/starters_cache.json'
53
+ with open(output_path, 'w', encoding='utf-8') as f:
54
+ json.dump(all_starters, f, indent=2, ensure_ascii=False)
55
+ print(f'\nSaved {len(all_starters)} teams to {output_path}')
server.py CHANGED
@@ -28,8 +28,8 @@ from apscheduler.triggers.cron import CronTrigger
28
  # CACHE CONFIGURATION - For lightning-fast responses
29
  # =============================================================================
30
  cache = {
31
- "mvp": {"data": None, "timestamp": None, "ttl": 300}, # 5 min cache
32
- "championship": {"data": None, "timestamp": None, "ttl": 300}, # 5 min cache
33
  "teams": {"data": None, "timestamp": None, "ttl": 3600}, # 1 hour cache
34
  "rosters": {}, # Per-team roster cache: {team_abbrev: {"data": [...], "timestamp": datetime}}
35
  "roster_ttl": 3600, # 1 hour cache for rosters
@@ -666,6 +666,100 @@ def predict_game():
666
  except Exception as e:
667
  return jsonify({"error": str(e)}), 500
668
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
669
  @app.route("/api/accuracy")
670
  def get_accuracy():
671
  """Get comprehensive model accuracy statistics."""
@@ -721,6 +815,128 @@ def get_accuracy():
721
  logger.error(f"Error in get_accuracy: {e}")
722
  return jsonify({"stats": {}, "recent_predictions": [], "error": str(e)})
723
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
724
  @app.route("/api/mvp")
725
  def get_mvp_race():
726
  """Get current MVP race standings with caching."""
 
28
  # CACHE CONFIGURATION - For lightning-fast responses
29
  # =============================================================================
30
  cache = {
31
+ "mvp": {"data": None, "timestamp": None, "ttl": 86400}, # 24 hours - daily refresh
32
+ "championship": {"data": None, "timestamp": None, "ttl": 86400}, # 24 hours - daily refresh
33
  "teams": {"data": None, "timestamp": None, "ttl": 3600}, # 1 hour cache
34
  "rosters": {}, # Per-team roster cache: {team_abbrev: {"data": [...], "timestamp": datetime}}
35
  "roster_ttl": 3600, # 1 hour cache for rosters
 
666
  except Exception as e:
667
  return jsonify({"error": str(e)}), 500
668
 
669
+ @app.route("/api/team-stats")
670
+ def get_team_stats():
671
+ """Get detailed team statistics for head-to-head comparison."""
672
+ team = request.args.get("team", "").upper()
673
+
674
+ if not team:
675
+ return jsonify({"error": "Missing team parameter"}), 400
676
+
677
+ try:
678
+ from nba_api.stats.endpoints import teamgamelog, commonteamroster
679
+ from nba_api.stats.static import teams as nba_teams
680
+ import time
681
+
682
+ # Find team ID
683
+ team_info = next((t for t in nba_teams.get_teams() if t['abbreviation'] == team), None)
684
+ if not team_info:
685
+ return jsonify({"error": f"Team not found: {team}"}), 404
686
+
687
+ team_id = team_info['id']
688
+
689
+ # Get team game log for current season
690
+ time.sleep(0.6) # Rate limit
691
+ game_log = teamgamelog.TeamGameLog(team_id=team_id, season='2024-25')
692
+ games_df = game_log.get_data_frames()[0]
693
+
694
+ if len(games_df) == 0:
695
+ return jsonify({
696
+ "team": team,
697
+ "record": {"wins": 0, "losses": 0},
698
+ "stats": {},
699
+ "recent_form": [],
700
+ "key_players": []
701
+ })
702
+
703
+ # Calculate stats
704
+ wins = len(games_df[games_df['WL'] == 'W'])
705
+ losses = len(games_df[games_df['WL'] == 'L'])
706
+
707
+ # Scoring stats
708
+ avg_pts = games_df['PTS'].mean()
709
+ avg_pts_allowed = games_df['PTS'].mean() - games_df['PLUS_MINUS'].mean() # Approximate
710
+ avg_reb = games_df['REB'].mean()
711
+ avg_ast = games_df['AST'].mean()
712
+ avg_fg_pct = games_df['FG_PCT'].mean() * 100
713
+ avg_fg3_pct = games_df['FG3_PCT'].mean() * 100
714
+
715
+ # Recent form (last 5 games)
716
+ recent_games = games_df.head(5)
717
+ recent_form = []
718
+ for _, game in recent_games.iterrows():
719
+ recent_form.append({
720
+ "date": game['GAME_DATE'],
721
+ "opponent": game['MATCHUP'].split()[-1],
722
+ "result": game['WL'],
723
+ "score": f"{int(game['PTS'])} pts",
724
+ "plus_minus": int(game['PLUS_MINUS'])
725
+ })
726
+
727
+ # Get key players from starters cache
728
+ key_players = []
729
+ if team in starters_cache:
730
+ starters = starters_cache[team].get('starters', [])[:3] # Top 3 by minutes
731
+ key_players = [{"name": p['name'], "position": p.get('position', 'G')} for p in starters]
732
+
733
+ return jsonify({
734
+ "team": team,
735
+ "record": {
736
+ "wins": wins,
737
+ "losses": losses,
738
+ "pct": round(wins / max(wins + losses, 1), 3)
739
+ },
740
+ "stats": {
741
+ "ppg": round(avg_pts, 1),
742
+ "opp_ppg": round(avg_pts_allowed, 1),
743
+ "rpg": round(avg_reb, 1),
744
+ "apg": round(avg_ast, 1),
745
+ "fg_pct": round(avg_fg_pct, 1),
746
+ "fg3_pct": round(avg_fg3_pct, 1)
747
+ },
748
+ "recent_form": recent_form,
749
+ "key_players": key_players
750
+ })
751
+
752
+ except Exception as e:
753
+ logger.error(f"Error getting team stats for {team}: {e}")
754
+ # Return fallback data
755
+ return jsonify({
756
+ "team": team,
757
+ "record": {"wins": 0, "losses": 0, "pct": 0},
758
+ "stats": {"ppg": 0, "opp_ppg": 0, "rpg": 0, "apg": 0, "fg_pct": 0, "fg3_pct": 0},
759
+ "recent_form": [],
760
+ "key_players": []
761
+ })
762
+
763
  @app.route("/api/accuracy")
764
  def get_accuracy():
765
  """Get comprehensive model accuracy statistics."""
 
815
  logger.error(f"Error in get_accuracy: {e}")
816
  return jsonify({"stats": {}, "recent_predictions": [], "error": str(e)})
817
 
818
+ @app.route("/api/analytics")
819
+ def get_analytics():
820
+ """Get analytics data for charts and visualizations."""
821
+ if not pipeline:
822
+ return jsonify({"error": "Pipeline not ready"})
823
+
824
+ try:
825
+ # Get prediction history
826
+ recent = pipeline.prediction_tracker.get_recent_predictions(100)
827
+ completed = [p for p in recent if p.get("is_correct", -1) != -1]
828
+
829
+ # 1. Accuracy Trend (last 7 days)
830
+ from collections import defaultdict
831
+ daily_stats = defaultdict(lambda: {"correct": 0, "total": 0})
832
+
833
+ for pred in completed:
834
+ date = pred.get("game_date", "")[:10] # YYYY-MM-DD
835
+ if date:
836
+ daily_stats[date]["total"] += 1
837
+ if pred.get("is_correct") == 1:
838
+ daily_stats[date]["correct"] += 1
839
+
840
+ accuracy_trend = []
841
+ for date in sorted(daily_stats.keys())[-7:]:
842
+ stats = daily_stats[date]
843
+ acc = (stats["correct"] / stats["total"] * 100) if stats["total"] > 0 else 0
844
+ accuracy_trend.append({
845
+ "date": date[5:], # MM-DD format
846
+ "accuracy": round(acc, 1),
847
+ "predictions": stats["total"]
848
+ })
849
+
850
+ # 2. Accuracy by Team
851
+ team_stats = defaultdict(lambda: {"correct": 0, "total": 0})
852
+ for pred in completed:
853
+ winner = pred.get("predicted_winner", "")
854
+ if winner:
855
+ team_stats[winner]["total"] += 1
856
+ if pred.get("is_correct") == 1:
857
+ team_stats[winner]["correct"] += 1
858
+
859
+ team_accuracy = []
860
+ for team, stats in sorted(team_stats.items(), key=lambda x: x[1]["total"], reverse=True)[:10]:
861
+ acc = (stats["correct"] / stats["total"] * 100) if stats["total"] > 0 else 0
862
+ team_accuracy.append({
863
+ "team": team,
864
+ "correct": stats["correct"],
865
+ "total": stats["total"],
866
+ "accuracy": round(acc, 1)
867
+ })
868
+
869
+ # 3. Confidence Distribution
870
+ high = medium = low = 0
871
+ for pred in completed:
872
+ conf = max(pred.get("home_win_prob", 0.5), pred.get("away_win_prob", 0.5))
873
+ if conf > 0.7:
874
+ high += 1
875
+ elif conf > 0.6:
876
+ medium += 1
877
+ else:
878
+ low += 1
879
+
880
+ confidence_distribution = [
881
+ {"name": "High (>70%)", "value": high, "color": "#4ade80"},
882
+ {"name": "Medium (60-70%)", "value": medium, "color": "#facc15"},
883
+ {"name": "Low (<60%)", "value": low, "color": "#f87171"},
884
+ ]
885
+
886
+ # 4. Calibration Data
887
+ calibration_buckets = defaultdict(lambda: {"correct": 0, "total": 0})
888
+ for pred in completed:
889
+ conf = max(pred.get("home_win_prob", 0.5), pred.get("away_win_prob", 0.5))
890
+ bucket = int(conf * 100 // 5) * 5 # 5% buckets
891
+ if 55 <= bucket <= 85:
892
+ calibration_buckets[bucket]["total"] += 1
893
+ if pred.get("is_correct") == 1:
894
+ calibration_buckets[bucket]["correct"] += 1
895
+
896
+ calibration = []
897
+ for bucket in sorted(calibration_buckets.keys()):
898
+ stats = calibration_buckets[bucket]
899
+ actual = (stats["correct"] / stats["total"] * 100) if stats["total"] > 0 else bucket
900
+ calibration.append({
901
+ "predicted": bucket,
902
+ "actual": round(actual, 1)
903
+ })
904
+
905
+ # 5. Overall Stats
906
+ correct_count = len([p for p in completed if p.get("is_correct") == 1])
907
+ total_count = len(completed)
908
+ overall = {
909
+ "total_predictions": total_count,
910
+ "correct": correct_count,
911
+ "accuracy": round(correct_count / total_count * 100, 1) if total_count > 0 else 0,
912
+ "avg_confidence": round(sum(max(p.get("home_win_prob", 0.5), p.get("away_win_prob", 0.5))
913
+ for p in completed) / len(completed) * 100, 1) if completed else 0
914
+ }
915
+
916
+ # 6. Recent Predictions for table
917
+ recent_display = []
918
+ for pred in completed[:10]:
919
+ recent_display.append({
920
+ "date": pred.get("game_date", "")[:10],
921
+ "matchup": f"{pred.get('away_team', '?')} @ {pred.get('home_team', '?')}",
922
+ "prediction": pred.get("predicted_winner", "?"),
923
+ "confidence": round(max(pred.get("home_win_prob", 0.5), pred.get("away_win_prob", 0.5)) * 100, 0),
924
+ "correct": pred.get("is_correct") == 1
925
+ })
926
+
927
+ return jsonify({
928
+ "accuracy_trend": accuracy_trend,
929
+ "team_accuracy": team_accuracy,
930
+ "confidence_distribution": confidence_distribution,
931
+ "calibration": calibration,
932
+ "overall": overall,
933
+ "recent_predictions": recent_display
934
+ })
935
+
936
+ except Exception as e:
937
+ logger.error(f"Error in get_analytics: {e}")
938
+ return jsonify({"error": str(e)})
939
+
940
  @app.route("/api/mvp")
941
  def get_mvp_race():
942
  """Get current MVP race standings with caching."""
src/feature_engineering.py CHANGED
@@ -1,3 +1,4 @@
 
1
  """
2
  NBA ML Prediction System - Comprehensive Feature Engineering
3
  =============================================================
 
1
+
2
  """
3
  NBA ML Prediction System - Comprehensive Feature Engineering
4
  =============================================================
web/package-lock.json CHANGED
@@ -9,7 +9,8 @@
9
  "version": "0.0.0",
10
  "dependencies": {
11
  "react": "^19.2.0",
12
- "react-dom": "^19.2.0"
 
13
  },
14
  "devDependencies": {
15
  "@eslint/js": "^9.39.1",
@@ -1006,6 +1007,42 @@
1006
  "@jridgewell/sourcemap-codec": "^1.4.14"
1007
  }
1008
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1009
  "node_modules/@rolldown/pluginutils": {
1010
  "version": "1.0.0-beta.53",
1011
  "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
@@ -1363,6 +1400,18 @@
1363
  "win32"
1364
  ]
1365
  },
 
 
 
 
 
 
 
 
 
 
 
 
1366
  "node_modules/@types/babel__core": {
1367
  "version": "7.20.5",
1368
  "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1408,6 +1457,69 @@
1408
  "@babel/types": "^7.28.2"
1409
  }
1410
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1411
  "node_modules/@types/estree": {
1412
  "version": "1.0.8",
1413
  "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1426,7 +1538,7 @@
1426
  "version": "19.2.8",
1427
  "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz",
1428
  "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
1429
- "dev": true,
1430
  "license": "MIT",
1431
  "dependencies": {
1432
  "csstype": "^3.2.2"
@@ -1442,6 +1554,12 @@
1442
  "@types/react": "^19.2.0"
1443
  }
1444
  },
 
 
 
 
 
 
1445
  "node_modules/@vitejs/plugin-react": {
1446
  "version": "5.1.2",
1447
  "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz",
@@ -1636,6 +1754,15 @@
1636
  "url": "https://github.com/chalk/chalk?sponsor=1"
1637
  }
1638
  },
 
 
 
 
 
 
 
 
 
1639
  "node_modules/color-convert": {
1640
  "version": "2.0.1",
1641
  "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1689,9 +1816,130 @@
1689
  "version": "3.2.3",
1690
  "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1691
  "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1692
- "dev": true,
1693
  "license": "MIT"
1694
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1695
  "node_modules/debug": {
1696
  "version": "4.4.3",
1697
  "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1710,6 +1958,12 @@
1710
  }
1711
  }
1712
  },
 
 
 
 
 
 
1713
  "node_modules/deep-is": {
1714
  "version": "0.1.4",
1715
  "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -1724,6 +1978,16 @@
1724
  "dev": true,
1725
  "license": "ISC"
1726
  },
 
 
 
 
 
 
 
 
 
 
1727
  "node_modules/esbuild": {
1728
  "version": "0.27.2",
1729
  "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
@@ -1973,6 +2237,12 @@
1973
  "node": ">=0.10.0"
1974
  }
1975
  },
 
 
 
 
 
 
1976
  "node_modules/fast-deep-equal": {
1977
  "version": "3.1.3",
1978
  "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2151,6 +2421,16 @@
2151
  "node": ">= 4"
2152
  }
2153
  },
 
 
 
 
 
 
 
 
 
 
2154
  "node_modules/import-fresh": {
2155
  "version": "3.3.1",
2156
  "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -2178,6 +2458,15 @@
2178
  "node": ">=0.8.19"
2179
  }
2180
  },
 
 
 
 
 
 
 
 
 
2181
  "node_modules/is-extglob": {
2182
  "version": "2.1.1",
2183
  "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2558,6 +2847,36 @@
2558
  "react": "^19.2.3"
2559
  }
2560
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2561
  "node_modules/react-refresh": {
2562
  "version": "0.18.0",
2563
  "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -2568,6 +2887,57 @@
2568
  "node": ">=0.10.0"
2569
  }
2570
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2571
  "node_modules/resolve-from": {
2572
  "version": "4.0.0",
2573
  "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -2698,6 +3068,12 @@
2698
  "node": ">=8"
2699
  }
2700
  },
 
 
 
 
 
 
2701
  "node_modules/tinyglobby": {
2702
  "version": "0.2.15",
2703
  "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -2769,6 +3145,37 @@
2769
  "punycode": "^2.1.0"
2770
  }
2771
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2772
  "node_modules/vite": {
2773
  "version": "7.3.1",
2774
  "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
 
9
  "version": "0.0.0",
10
  "dependencies": {
11
  "react": "^19.2.0",
12
+ "react-dom": "^19.2.0",
13
+ "recharts": "^3.6.0"
14
  },
15
  "devDependencies": {
16
  "@eslint/js": "^9.39.1",
 
1007
  "@jridgewell/sourcemap-codec": "^1.4.14"
1008
  }
1009
  },
1010
+ "node_modules/@reduxjs/toolkit": {
1011
+ "version": "2.11.2",
1012
+ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
1013
+ "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
1014
+ "license": "MIT",
1015
+ "dependencies": {
1016
+ "@standard-schema/spec": "^1.0.0",
1017
+ "@standard-schema/utils": "^0.3.0",
1018
+ "immer": "^11.0.0",
1019
+ "redux": "^5.0.1",
1020
+ "redux-thunk": "^3.1.0",
1021
+ "reselect": "^5.1.0"
1022
+ },
1023
+ "peerDependencies": {
1024
+ "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
1025
+ "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
1026
+ },
1027
+ "peerDependenciesMeta": {
1028
+ "react": {
1029
+ "optional": true
1030
+ },
1031
+ "react-redux": {
1032
+ "optional": true
1033
+ }
1034
+ }
1035
+ },
1036
+ "node_modules/@reduxjs/toolkit/node_modules/immer": {
1037
+ "version": "11.1.3",
1038
+ "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
1039
+ "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
1040
+ "license": "MIT",
1041
+ "funding": {
1042
+ "type": "opencollective",
1043
+ "url": "https://opencollective.com/immer"
1044
+ }
1045
+ },
1046
  "node_modules/@rolldown/pluginutils": {
1047
  "version": "1.0.0-beta.53",
1048
  "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
 
1400
  "win32"
1401
  ]
1402
  },
1403
+ "node_modules/@standard-schema/spec": {
1404
+ "version": "1.1.0",
1405
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
1406
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
1407
+ "license": "MIT"
1408
+ },
1409
+ "node_modules/@standard-schema/utils": {
1410
+ "version": "0.3.0",
1411
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
1412
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
1413
+ "license": "MIT"
1414
+ },
1415
  "node_modules/@types/babel__core": {
1416
  "version": "7.20.5",
1417
  "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
 
1457
  "@babel/types": "^7.28.2"
1458
  }
1459
  },
1460
+ "node_modules/@types/d3-array": {
1461
+ "version": "3.2.2",
1462
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
1463
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
1464
+ "license": "MIT"
1465
+ },
1466
+ "node_modules/@types/d3-color": {
1467
+ "version": "3.1.3",
1468
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
1469
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
1470
+ "license": "MIT"
1471
+ },
1472
+ "node_modules/@types/d3-ease": {
1473
+ "version": "3.0.2",
1474
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
1475
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
1476
+ "license": "MIT"
1477
+ },
1478
+ "node_modules/@types/d3-interpolate": {
1479
+ "version": "3.0.4",
1480
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
1481
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
1482
+ "license": "MIT",
1483
+ "dependencies": {
1484
+ "@types/d3-color": "*"
1485
+ }
1486
+ },
1487
+ "node_modules/@types/d3-path": {
1488
+ "version": "3.1.1",
1489
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
1490
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
1491
+ "license": "MIT"
1492
+ },
1493
+ "node_modules/@types/d3-scale": {
1494
+ "version": "4.0.9",
1495
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
1496
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
1497
+ "license": "MIT",
1498
+ "dependencies": {
1499
+ "@types/d3-time": "*"
1500
+ }
1501
+ },
1502
+ "node_modules/@types/d3-shape": {
1503
+ "version": "3.1.8",
1504
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
1505
+ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
1506
+ "license": "MIT",
1507
+ "dependencies": {
1508
+ "@types/d3-path": "*"
1509
+ }
1510
+ },
1511
+ "node_modules/@types/d3-time": {
1512
+ "version": "3.0.4",
1513
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
1514
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
1515
+ "license": "MIT"
1516
+ },
1517
+ "node_modules/@types/d3-timer": {
1518
+ "version": "3.0.2",
1519
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
1520
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
1521
+ "license": "MIT"
1522
+ },
1523
  "node_modules/@types/estree": {
1524
  "version": "1.0.8",
1525
  "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
 
1538
  "version": "19.2.8",
1539
  "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz",
1540
  "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
1541
+ "devOptional": true,
1542
  "license": "MIT",
1543
  "dependencies": {
1544
  "csstype": "^3.2.2"
 
1554
  "@types/react": "^19.2.0"
1555
  }
1556
  },
1557
+ "node_modules/@types/use-sync-external-store": {
1558
+ "version": "0.0.6",
1559
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
1560
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
1561
+ "license": "MIT"
1562
+ },
1563
  "node_modules/@vitejs/plugin-react": {
1564
  "version": "5.1.2",
1565
  "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz",
 
1754
  "url": "https://github.com/chalk/chalk?sponsor=1"
1755
  }
1756
  },
1757
+ "node_modules/clsx": {
1758
+ "version": "2.1.1",
1759
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
1760
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
1761
+ "license": "MIT",
1762
+ "engines": {
1763
+ "node": ">=6"
1764
+ }
1765
+ },
1766
  "node_modules/color-convert": {
1767
  "version": "2.0.1",
1768
  "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
 
1816
  "version": "3.2.3",
1817
  "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1818
  "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1819
+ "devOptional": true,
1820
  "license": "MIT"
1821
  },
1822
+ "node_modules/d3-array": {
1823
+ "version": "3.2.4",
1824
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
1825
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
1826
+ "license": "ISC",
1827
+ "dependencies": {
1828
+ "internmap": "1 - 2"
1829
+ },
1830
+ "engines": {
1831
+ "node": ">=12"
1832
+ }
1833
+ },
1834
+ "node_modules/d3-color": {
1835
+ "version": "3.1.0",
1836
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
1837
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
1838
+ "license": "ISC",
1839
+ "engines": {
1840
+ "node": ">=12"
1841
+ }
1842
+ },
1843
+ "node_modules/d3-ease": {
1844
+ "version": "3.0.1",
1845
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
1846
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
1847
+ "license": "BSD-3-Clause",
1848
+ "engines": {
1849
+ "node": ">=12"
1850
+ }
1851
+ },
1852
+ "node_modules/d3-format": {
1853
+ "version": "3.1.2",
1854
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
1855
+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
1856
+ "license": "ISC",
1857
+ "engines": {
1858
+ "node": ">=12"
1859
+ }
1860
+ },
1861
+ "node_modules/d3-interpolate": {
1862
+ "version": "3.0.1",
1863
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
1864
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
1865
+ "license": "ISC",
1866
+ "dependencies": {
1867
+ "d3-color": "1 - 3"
1868
+ },
1869
+ "engines": {
1870
+ "node": ">=12"
1871
+ }
1872
+ },
1873
+ "node_modules/d3-path": {
1874
+ "version": "3.1.0",
1875
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
1876
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
1877
+ "license": "ISC",
1878
+ "engines": {
1879
+ "node": ">=12"
1880
+ }
1881
+ },
1882
+ "node_modules/d3-scale": {
1883
+ "version": "4.0.2",
1884
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
1885
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
1886
+ "license": "ISC",
1887
+ "dependencies": {
1888
+ "d3-array": "2.10.0 - 3",
1889
+ "d3-format": "1 - 3",
1890
+ "d3-interpolate": "1.2.0 - 3",
1891
+ "d3-time": "2.1.1 - 3",
1892
+ "d3-time-format": "2 - 4"
1893
+ },
1894
+ "engines": {
1895
+ "node": ">=12"
1896
+ }
1897
+ },
1898
+ "node_modules/d3-shape": {
1899
+ "version": "3.2.0",
1900
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
1901
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
1902
+ "license": "ISC",
1903
+ "dependencies": {
1904
+ "d3-path": "^3.1.0"
1905
+ },
1906
+ "engines": {
1907
+ "node": ">=12"
1908
+ }
1909
+ },
1910
+ "node_modules/d3-time": {
1911
+ "version": "3.1.0",
1912
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
1913
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
1914
+ "license": "ISC",
1915
+ "dependencies": {
1916
+ "d3-array": "2 - 3"
1917
+ },
1918
+ "engines": {
1919
+ "node": ">=12"
1920
+ }
1921
+ },
1922
+ "node_modules/d3-time-format": {
1923
+ "version": "4.1.0",
1924
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
1925
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
1926
+ "license": "ISC",
1927
+ "dependencies": {
1928
+ "d3-time": "1 - 3"
1929
+ },
1930
+ "engines": {
1931
+ "node": ">=12"
1932
+ }
1933
+ },
1934
+ "node_modules/d3-timer": {
1935
+ "version": "3.0.1",
1936
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
1937
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
1938
+ "license": "ISC",
1939
+ "engines": {
1940
+ "node": ">=12"
1941
+ }
1942
+ },
1943
  "node_modules/debug": {
1944
  "version": "4.4.3",
1945
  "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
 
1958
  }
1959
  }
1960
  },
1961
+ "node_modules/decimal.js-light": {
1962
+ "version": "2.5.1",
1963
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
1964
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
1965
+ "license": "MIT"
1966
+ },
1967
  "node_modules/deep-is": {
1968
  "version": "0.1.4",
1969
  "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
 
1978
  "dev": true,
1979
  "license": "ISC"
1980
  },
1981
+ "node_modules/es-toolkit": {
1982
+ "version": "1.44.0",
1983
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
1984
+ "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
1985
+ "license": "MIT",
1986
+ "workspaces": [
1987
+ "docs",
1988
+ "benchmarks"
1989
+ ]
1990
+ },
1991
  "node_modules/esbuild": {
1992
  "version": "0.27.2",
1993
  "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
 
2237
  "node": ">=0.10.0"
2238
  }
2239
  },
2240
+ "node_modules/eventemitter3": {
2241
+ "version": "5.0.4",
2242
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
2243
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
2244
+ "license": "MIT"
2245
+ },
2246
  "node_modules/fast-deep-equal": {
2247
  "version": "3.1.3",
2248
  "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
 
2421
  "node": ">= 4"
2422
  }
2423
  },
2424
+ "node_modules/immer": {
2425
+ "version": "10.2.0",
2426
+ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
2427
+ "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
2428
+ "license": "MIT",
2429
+ "funding": {
2430
+ "type": "opencollective",
2431
+ "url": "https://opencollective.com/immer"
2432
+ }
2433
+ },
2434
  "node_modules/import-fresh": {
2435
  "version": "3.3.1",
2436
  "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
 
2458
  "node": ">=0.8.19"
2459
  }
2460
  },
2461
+ "node_modules/internmap": {
2462
+ "version": "2.0.3",
2463
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
2464
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
2465
+ "license": "ISC",
2466
+ "engines": {
2467
+ "node": ">=12"
2468
+ }
2469
+ },
2470
  "node_modules/is-extglob": {
2471
  "version": "2.1.1",
2472
  "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
 
2847
  "react": "^19.2.3"
2848
  }
2849
  },
2850
+ "node_modules/react-is": {
2851
+ "version": "19.2.3",
2852
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz",
2853
+ "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==",
2854
+ "license": "MIT",
2855
+ "peer": true
2856
+ },
2857
+ "node_modules/react-redux": {
2858
+ "version": "9.2.0",
2859
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
2860
+ "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
2861
+ "license": "MIT",
2862
+ "dependencies": {
2863
+ "@types/use-sync-external-store": "^0.0.6",
2864
+ "use-sync-external-store": "^1.4.0"
2865
+ },
2866
+ "peerDependencies": {
2867
+ "@types/react": "^18.2.25 || ^19",
2868
+ "react": "^18.0 || ^19",
2869
+ "redux": "^5.0.0"
2870
+ },
2871
+ "peerDependenciesMeta": {
2872
+ "@types/react": {
2873
+ "optional": true
2874
+ },
2875
+ "redux": {
2876
+ "optional": true
2877
+ }
2878
+ }
2879
+ },
2880
  "node_modules/react-refresh": {
2881
  "version": "0.18.0",
2882
  "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
 
2887
  "node": ">=0.10.0"
2888
  }
2889
  },
2890
+ "node_modules/recharts": {
2891
+ "version": "3.6.0",
2892
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz",
2893
+ "integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==",
2894
+ "license": "MIT",
2895
+ "workspaces": [
2896
+ "www"
2897
+ ],
2898
+ "dependencies": {
2899
+ "@reduxjs/toolkit": "1.x.x || 2.x.x",
2900
+ "clsx": "^2.1.1",
2901
+ "decimal.js-light": "^2.5.1",
2902
+ "es-toolkit": "^1.39.3",
2903
+ "eventemitter3": "^5.0.1",
2904
+ "immer": "^10.1.1",
2905
+ "react-redux": "8.x.x || 9.x.x",
2906
+ "reselect": "5.1.1",
2907
+ "tiny-invariant": "^1.3.3",
2908
+ "use-sync-external-store": "^1.2.2",
2909
+ "victory-vendor": "^37.0.2"
2910
+ },
2911
+ "engines": {
2912
+ "node": ">=18"
2913
+ },
2914
+ "peerDependencies": {
2915
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
2916
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
2917
+ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
2918
+ }
2919
+ },
2920
+ "node_modules/redux": {
2921
+ "version": "5.0.1",
2922
+ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
2923
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
2924
+ "license": "MIT"
2925
+ },
2926
+ "node_modules/redux-thunk": {
2927
+ "version": "3.1.0",
2928
+ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
2929
+ "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
2930
+ "license": "MIT",
2931
+ "peerDependencies": {
2932
+ "redux": "^5.0.0"
2933
+ }
2934
+ },
2935
+ "node_modules/reselect": {
2936
+ "version": "5.1.1",
2937
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
2938
+ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
2939
+ "license": "MIT"
2940
+ },
2941
  "node_modules/resolve-from": {
2942
  "version": "4.0.0",
2943
  "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
 
3068
  "node": ">=8"
3069
  }
3070
  },
3071
+ "node_modules/tiny-invariant": {
3072
+ "version": "1.3.3",
3073
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
3074
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
3075
+ "license": "MIT"
3076
+ },
3077
  "node_modules/tinyglobby": {
3078
  "version": "0.2.15",
3079
  "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
 
3145
  "punycode": "^2.1.0"
3146
  }
3147
  },
3148
+ "node_modules/use-sync-external-store": {
3149
+ "version": "1.6.0",
3150
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
3151
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
3152
+ "license": "MIT",
3153
+ "peerDependencies": {
3154
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
3155
+ }
3156
+ },
3157
+ "node_modules/victory-vendor": {
3158
+ "version": "37.3.6",
3159
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
3160
+ "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
3161
+ "license": "MIT AND ISC",
3162
+ "dependencies": {
3163
+ "@types/d3-array": "^3.0.3",
3164
+ "@types/d3-ease": "^3.0.0",
3165
+ "@types/d3-interpolate": "^3.0.1",
3166
+ "@types/d3-scale": "^4.0.2",
3167
+ "@types/d3-shape": "^3.1.0",
3168
+ "@types/d3-time": "^3.0.0",
3169
+ "@types/d3-timer": "^3.0.0",
3170
+ "d3-array": "^3.1.6",
3171
+ "d3-ease": "^3.0.1",
3172
+ "d3-interpolate": "^3.0.1",
3173
+ "d3-scale": "^4.0.2",
3174
+ "d3-shape": "^3.1.0",
3175
+ "d3-time": "^3.0.0",
3176
+ "d3-timer": "^3.0.1"
3177
+ }
3178
+ },
3179
  "node_modules/vite": {
3180
  "version": "7.3.1",
3181
  "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
web/package.json CHANGED
@@ -11,7 +11,8 @@
11
  },
12
  "dependencies": {
13
  "react": "^19.2.0",
14
- "react-dom": "^19.2.0"
 
15
  },
16
  "devDependencies": {
17
  "@eslint/js": "^9.39.1",
 
11
  },
12
  "dependencies": {
13
  "react": "^19.2.0",
14
+ "react-dom": "^19.2.0",
15
+ "recharts": "^3.6.0"
16
  },
17
  "devDependencies": {
18
  "@eslint/js": "^9.39.1",
web/src/App.jsx CHANGED
@@ -4,7 +4,7 @@ import './index.css'
4
  // Icons
5
  import {
6
  IconLive, IconTarget, IconChart, IconTrophy, IconCrown,
7
- IconVs, IconRefresh
8
  } from './icons'
9
 
10
  // Pages
@@ -14,6 +14,7 @@ import Accuracy from './pages/Accuracy'
14
  import MvpRace from './pages/MvpRace'
15
  import Championship from './pages/Championship'
16
  import HeadToHead from './pages/HeadToHead'
 
17
 
18
  // Chevron icon for collapse toggle
19
  function IconChevron({ className = '', direction = 'left' }) {
@@ -51,6 +52,7 @@ function App() {
51
  title: 'Analysis',
52
  items: [
53
  { id: 'h2h', name: 'Head to Head', icon: IconVs },
 
54
  { id: 'accuracy', name: 'Model Accuracy', icon: IconChart },
55
  ]
56
  },
@@ -68,6 +70,7 @@ function App() {
68
  case 'live': return <LiveGames />
69
  case 'predictions': return <Predictions />
70
  case 'accuracy': return <Accuracy />
 
71
  case 'mvp': return <MvpRace />
72
  case 'championship': return <Championship />
73
  case 'h2h': return <HeadToHead />
 
4
  // Icons
5
  import {
6
  IconLive, IconTarget, IconChart, IconTrophy, IconCrown,
7
+ IconVs, IconRefresh, IconBarChart
8
  } from './icons'
9
 
10
  // Pages
 
14
  import MvpRace from './pages/MvpRace'
15
  import Championship from './pages/Championship'
16
  import HeadToHead from './pages/HeadToHead'
17
+ import Analytics from './pages/Analytics'
18
 
19
  // Chevron icon for collapse toggle
20
  function IconChevron({ className = '', direction = 'left' }) {
 
52
  title: 'Analysis',
53
  items: [
54
  { id: 'h2h', name: 'Head to Head', icon: IconVs },
55
+ { id: 'analytics', name: 'Analytics', icon: IconBarChart },
56
  { id: 'accuracy', name: 'Model Accuracy', icon: IconChart },
57
  ]
58
  },
 
70
  case 'live': return <LiveGames />
71
  case 'predictions': return <Predictions />
72
  case 'accuracy': return <Accuracy />
73
+ case 'analytics': return <Analytics />
74
  case 'mvp': return <MvpRace />
75
  case 'championship': return <Championship />
76
  case 'h2h': return <HeadToHead />
web/src/api.js CHANGED
@@ -85,3 +85,10 @@ export async function getTeams() {
85
  export async function healthCheck() {
86
  return fetchAPI('/health');
87
  }
 
 
 
 
 
 
 
 
85
  export async function healthCheck() {
86
  return fetchAPI('/health');
87
  }
88
+
89
+ /**
90
+ * Get detailed team statistics for head-to-head comparison
91
+ */
92
+ export async function getTeamStats(team) {
93
+ return fetchAPI(`/team-stats?team=${team}`);
94
+ }
web/src/icons.jsx CHANGED
@@ -155,8 +155,18 @@ export function IconClock({ className = '' }) {
155
  );
156
  }
157
 
 
 
 
 
 
 
 
 
 
 
158
  export default {
159
  IconLive, IconTarget, IconChart, IconTrophy, IconCrown, IconUsers,
160
  IconPerson, IconVs, IconStandings, IconRefresh, IconSearch, IconCheck,
161
- IconX, IconBasketball, IconCalendar, IconClock
162
  };
 
155
  );
156
  }
157
 
158
+ export function IconBarChart({ className = '' }) {
159
+ return (
160
+ <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
161
+ <rect x="3" y="12" width="4" height="9" rx="1" />
162
+ <rect x="10" y="6" width="4" height="15" rx="1" />
163
+ <rect x="17" y="3" width="4" height="18" rx="1" />
164
+ </svg>
165
+ );
166
+ }
167
+
168
  export default {
169
  IconLive, IconTarget, IconChart, IconTrophy, IconCrown, IconUsers,
170
  IconPerson, IconVs, IconStandings, IconRefresh, IconSearch, IconCheck,
171
+ IconX, IconBasketball, IconCalendar, IconClock, IconBarChart
172
  };
web/src/index.css CHANGED
@@ -731,6 +731,129 @@ h4 {
731
  color: var(--accent-primary);
732
  }
733
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
734
  /* =========================================================================
735
  Stats Grid
736
  ========================================================================= */
 
731
  color: var(--accent-primary);
732
  }
733
 
734
+ /* Confidence Meter */
735
+ .confidence-meter-container {
736
+ margin-top: var(--space-2);
737
+ }
738
+
739
+ .confidence-meter {
740
+ position: relative;
741
+ display: flex;
742
+ flex-direction: column;
743
+ align-items: center;
744
+ }
745
+
746
+ .confidence-meter svg {
747
+ display: block;
748
+ }
749
+
750
+ .confidence-value {
751
+ position: absolute;
752
+ top: 28px;
753
+ font-size: 1.25rem;
754
+ font-weight: 700;
755
+ text-shadow: 0 0 10px currentColor;
756
+ }
757
+
758
+ .confidence-label {
759
+ font-size: 0.625rem;
760
+ font-weight: 600;
761
+ text-transform: uppercase;
762
+ letter-spacing: 0.1em;
763
+ margin-top: -4px;
764
+ }
765
+
766
+ /* Stats Comparison (Head to Head) */
767
+ .stats-comparison {
768
+ display: flex;
769
+ flex-direction: column;
770
+ gap: var(--space-3);
771
+ }
772
+
773
+ .stat-comparison-row {
774
+ display: grid;
775
+ grid-template-columns: 1fr auto 1fr;
776
+ gap: var(--space-3);
777
+ align-items: center;
778
+ }
779
+
780
+ .stat-bar-container {
781
+ display: flex;
782
+ align-items: center;
783
+ gap: var(--space-2);
784
+ height: 24px;
785
+ }
786
+
787
+ .stat-bar-container.right {
788
+ justify-content: flex-end;
789
+ }
790
+
791
+ .stat-bar {
792
+ height: 8px;
793
+ border-radius: 4px;
794
+ transition: width 0.3s ease;
795
+ }
796
+
797
+ .stat-bar.left {
798
+ margin-left: auto;
799
+ }
800
+
801
+ .stat-value-label {
802
+ font-size: 0.875rem;
803
+ font-weight: 600;
804
+ min-width: 40px;
805
+ }
806
+
807
+ .stat-value-label.left {
808
+ text-align: right;
809
+ }
810
+
811
+ .stat-value-label.right {
812
+ text-align: left;
813
+ }
814
+
815
+ .stat-label-center {
816
+ font-size: 0.75rem;
817
+ font-weight: 600;
818
+ text-transform: uppercase;
819
+ letter-spacing: 0.05em;
820
+ color: var(--text-muted);
821
+ min-width: 70px;
822
+ text-align: center;
823
+ }
824
+
825
+ /* Recent Form */
826
+ .recent-form {
827
+ display: flex;
828
+ justify-content: center;
829
+ }
830
+
831
+ .form-badges {
832
+ display: flex;
833
+ gap: var(--space-1);
834
+ }
835
+
836
+ .form-badge {
837
+ width: 22px;
838
+ height: 22px;
839
+ display: flex;
840
+ align-items: center;
841
+ justify-content: center;
842
+ font-size: 0.625rem;
843
+ font-weight: 700;
844
+ border-radius: 4px;
845
+ }
846
+
847
+ .form-badge.win {
848
+ background: rgba(0, 210, 106, 0.15);
849
+ color: var(--accent-success);
850
+ }
851
+
852
+ .form-badge.loss {
853
+ background: rgba(255, 59, 59, 0.15);
854
+ color: var(--accent-danger);
855
+ }
856
+
857
  /* =========================================================================
858
  Stats Grid
859
  ========================================================================= */
web/src/pages/Analytics.jsx ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react'
2
+ import {
3
+ LineChart, Line, BarChart, Bar, PieChart, Pie, Cell,
4
+ XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
5
+ AreaChart, Area
6
+ } from 'recharts'
7
+
8
+ // API Base URL
9
+ const API_BASE = import.meta.env.DEV ? 'http://localhost:8000' : '';
10
+
11
+ function Analytics() {
12
+ const [data, setData] = useState(null)
13
+ const [loading, setLoading] = useState(true)
14
+
15
+ useEffect(() => {
16
+ fetchAnalytics()
17
+ }, [])
18
+
19
+ const fetchAnalytics = async () => {
20
+ try {
21
+ const response = await fetch(`${API_BASE}/api/analytics`)
22
+ const result = await response.json()
23
+ setData(result)
24
+ } catch (err) {
25
+ console.error('Failed to fetch analytics:', err)
26
+ } finally {
27
+ setLoading(false)
28
+ }
29
+ }
30
+
31
+ if (loading) {
32
+ return (
33
+ <div className="loading">
34
+ <div className="spinner"></div>
35
+ <p className="loading-text">Loading analytics...</p>
36
+ </div>
37
+ )
38
+ }
39
+
40
+ // Chart colors
41
+ const COLORS = ['#4ade80', '#facc15', '#f87171', '#60a5fa', '#a78bfa']
42
+ const GRADIENT_COLORS = {
43
+ primary: '#4ade80',
44
+ secondary: '#60a5fa',
45
+ accent: '#a78bfa'
46
+ }
47
+
48
+ // Mock data if API fails
49
+ const accuracyTrend = data?.accuracy_trend || [
50
+ { date: 'Jan 15', accuracy: 62, predictions: 8 },
51
+ { date: 'Jan 16', accuracy: 75, predictions: 12 },
52
+ { date: 'Jan 17', accuracy: 58, predictions: 10 },
53
+ { date: 'Jan 18', accuracy: 70, predictions: 14 },
54
+ { date: 'Jan 19', accuracy: 65, predictions: 11 },
55
+ { date: 'Jan 20', accuracy: 72, predictions: 9 },
56
+ { date: 'Jan 21', accuracy: 68, predictions: 7 },
57
+ ]
58
+
59
+ const teamAccuracy = data?.team_accuracy || [
60
+ { team: 'BOS', correct: 12, total: 15, accuracy: 80 },
61
+ { team: 'OKC', correct: 10, total: 14, accuracy: 71 },
62
+ { team: 'CLE', correct: 9, total: 13, accuracy: 69 },
63
+ { team: 'DEN', correct: 8, total: 12, accuracy: 67 },
64
+ { team: 'LAL', correct: 7, total: 11, accuracy: 64 },
65
+ { team: 'MIL', correct: 6, total: 10, accuracy: 60 },
66
+ { team: 'NYK', correct: 5, total: 9, accuracy: 56 },
67
+ { team: 'PHX', correct: 4, total: 8, accuracy: 50 },
68
+ ]
69
+
70
+ const confidenceDistribution = data?.confidence_distribution || [
71
+ { name: 'High (>70%)', value: 45, color: '#4ade80' },
72
+ { name: 'Medium (60-70%)', value: 35, color: '#facc15' },
73
+ { name: 'Low (<60%)', value: 20, color: '#f87171' },
74
+ ]
75
+
76
+ const calibrationData = data?.calibration || [
77
+ { predicted: 55, actual: 52 },
78
+ { predicted: 60, actual: 58 },
79
+ { predicted: 65, actual: 63 },
80
+ { predicted: 70, actual: 68 },
81
+ { predicted: 75, actual: 72 },
82
+ { predicted: 80, actual: 76 },
83
+ { predicted: 85, actual: 82 },
84
+ ]
85
+
86
+ const overallStats = data?.overall || {
87
+ total_predictions: 156,
88
+ correct: 102,
89
+ accuracy: 65.4,
90
+ avg_confidence: 67.2
91
+ }
92
+
93
+ return (
94
+ <div className="animate-fadeIn">
95
+ <div className="page-header">
96
+ <h1 className="page-title">Analytics Dashboard</h1>
97
+ <p className="page-description">
98
+ Model performance and NBA statistics at a glance
99
+ </p>
100
+ </div>
101
+
102
+ {/* Stats Overview */}
103
+ <div className="stats-grid" style={{ marginBottom: 'var(--space-6)' }}>
104
+ <div className="stat-card">
105
+ <div className="stat-value accent">{overallStats.total_predictions}</div>
106
+ <div className="stat-label">Total Predictions</div>
107
+ </div>
108
+ <div className="stat-card">
109
+ <div className="stat-value" style={{ color: 'var(--accent-success)' }}>
110
+ {overallStats.correct}
111
+ </div>
112
+ <div className="stat-label">Correct Predictions</div>
113
+ </div>
114
+ <div className="stat-card">
115
+ <div className="stat-value accent">{overallStats.accuracy}%</div>
116
+ <div className="stat-label">Overall Accuracy</div>
117
+ </div>
118
+ <div className="stat-card">
119
+ <div className="stat-value" style={{ color: 'var(--accent-secondary)' }}>
120
+ {overallStats.avg_confidence}%
121
+ </div>
122
+ <div className="stat-label">Avg Confidence</div>
123
+ </div>
124
+ </div>
125
+
126
+ {/* Charts Grid */}
127
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-6)' }}>
128
+
129
+ {/* Accuracy Trend */}
130
+ <div className="card">
131
+ <div className="card-header">
132
+ <span className="card-title">Accuracy Trend (Last 7 Days)</span>
133
+ </div>
134
+ <div style={{ height: '300px' }}>
135
+ <ResponsiveContainer width="100%" height="100%">
136
+ <AreaChart data={accuracyTrend}>
137
+ <defs>
138
+ <linearGradient id="colorAccuracy" x1="0" y1="0" x2="0" y2="1">
139
+ <stop offset="5%" stopColor={GRADIENT_COLORS.primary} stopOpacity={0.3} />
140
+ <stop offset="95%" stopColor={GRADIENT_COLORS.primary} stopOpacity={0} />
141
+ </linearGradient>
142
+ </defs>
143
+ <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
144
+ <XAxis dataKey="date" stroke="rgba(255,255,255,0.5)" fontSize={12} />
145
+ <YAxis domain={[0, 100]} stroke="rgba(255,255,255,0.5)" fontSize={12} />
146
+ <Tooltip
147
+ contentStyle={{
148
+ background: 'rgba(0,0,0,0.8)',
149
+ border: '1px solid rgba(255,255,255,0.2)',
150
+ borderRadius: '8px'
151
+ }}
152
+ />
153
+ <Area
154
+ type="monotone"
155
+ dataKey="accuracy"
156
+ stroke={GRADIENT_COLORS.primary}
157
+ strokeWidth={2}
158
+ fill="url(#colorAccuracy)"
159
+ />
160
+ </AreaChart>
161
+ </ResponsiveContainer>
162
+ </div>
163
+ </div>
164
+
165
+ {/* Confidence Distribution */}
166
+ <div className="card">
167
+ <div className="card-header">
168
+ <span className="card-title">Prediction Confidence Distribution</span>
169
+ </div>
170
+ <div style={{ height: '300px' }}>
171
+ <ResponsiveContainer width="100%" height="100%">
172
+ <PieChart>
173
+ <Pie
174
+ data={confidenceDistribution}
175
+ cx="50%"
176
+ cy="50%"
177
+ innerRadius={60}
178
+ outerRadius={100}
179
+ paddingAngle={5}
180
+ dataKey="value"
181
+ label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
182
+ labelLine={false}
183
+ >
184
+ {confidenceDistribution.map((entry, index) => (
185
+ <Cell key={`cell-${index}`} fill={entry.color} />
186
+ ))}
187
+ </Pie>
188
+ <Tooltip />
189
+ </PieChart>
190
+ </ResponsiveContainer>
191
+ </div>
192
+ </div>
193
+
194
+ {/* Team Accuracy */}
195
+ <div className="card">
196
+ <div className="card-header">
197
+ <span className="card-title">Accuracy by Team</span>
198
+ </div>
199
+ <div style={{ height: '300px' }}>
200
+ <ResponsiveContainer width="100%" height="100%">
201
+ <BarChart data={teamAccuracy} layout="vertical">
202
+ <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
203
+ <XAxis type="number" domain={[0, 100]} stroke="rgba(255,255,255,0.5)" fontSize={12} />
204
+ <YAxis dataKey="team" type="category" stroke="rgba(255,255,255,0.5)" fontSize={12} width={40} />
205
+ <Tooltip
206
+ contentStyle={{
207
+ background: 'rgba(0,0,0,0.8)',
208
+ border: '1px solid rgba(255,255,255,0.2)',
209
+ borderRadius: '8px'
210
+ }}
211
+ formatter={(value) => [`${value}%`, 'Accuracy']}
212
+ />
213
+ <Bar dataKey="accuracy" fill={GRADIENT_COLORS.secondary} radius={[0, 4, 4, 0]} />
214
+ </BarChart>
215
+ </ResponsiveContainer>
216
+ </div>
217
+ </div>
218
+
219
+ {/* Calibration Chart */}
220
+ <div className="card">
221
+ <div className="card-header">
222
+ <span className="card-title">Prediction Calibration</span>
223
+ <span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
224
+ Predicted vs Actual Win %
225
+ </span>
226
+ </div>
227
+ <div style={{ height: '300px' }}>
228
+ <ResponsiveContainer width="100%" height="100%">
229
+ <LineChart data={calibrationData}>
230
+ <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
231
+ <XAxis
232
+ dataKey="predicted"
233
+ stroke="rgba(255,255,255,0.5)"
234
+ fontSize={12}
235
+ label={{ value: 'Predicted %', position: 'bottom', fill: 'rgba(255,255,255,0.5)' }}
236
+ />
237
+ <YAxis
238
+ stroke="rgba(255,255,255,0.5)"
239
+ fontSize={12}
240
+ domain={[50, 90]}
241
+ />
242
+ <Tooltip
243
+ contentStyle={{
244
+ background: 'rgba(0,0,0,0.8)',
245
+ border: '1px solid rgba(255,255,255,0.2)',
246
+ borderRadius: '8px'
247
+ }}
248
+ />
249
+ <Legend />
250
+ {/* Perfect calibration line */}
251
+ <Line
252
+ type="monotone"
253
+ dataKey="predicted"
254
+ stroke="rgba(255,255,255,0.3)"
255
+ strokeDasharray="5 5"
256
+ name="Perfect"
257
+ dot={false}
258
+ />
259
+ <Line
260
+ type="monotone"
261
+ dataKey="actual"
262
+ stroke={GRADIENT_COLORS.accent}
263
+ strokeWidth={2}
264
+ name="Actual"
265
+ dot={{ fill: GRADIENT_COLORS.accent, r: 4 }}
266
+ />
267
+ </LineChart>
268
+ </ResponsiveContainer>
269
+ </div>
270
+ </div>
271
+ </div>
272
+
273
+ {/* Recent Predictions Table */}
274
+ <div className="card" style={{ marginTop: 'var(--space-6)' }}>
275
+ <div className="card-header">
276
+ <span className="card-title">Recent Predictions</span>
277
+ </div>
278
+ <div style={{ overflowX: 'auto' }}>
279
+ <table style={{ width: '100%', borderCollapse: 'collapse' }}>
280
+ <thead>
281
+ <tr style={{ borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
282
+ <th style={tableHeaderStyle}>Date</th>
283
+ <th style={tableHeaderStyle}>Matchup</th>
284
+ <th style={tableHeaderStyle}>Prediction</th>
285
+ <th style={tableHeaderStyle}>Confidence</th>
286
+ <th style={tableHeaderStyle}>Result</th>
287
+ </tr>
288
+ </thead>
289
+ <tbody>
290
+ {(data?.recent_predictions || [
291
+ { date: 'Jan 21', matchup: 'LAL @ BOS', prediction: 'BOS', confidence: 72, correct: true },
292
+ { date: 'Jan 21', matchup: 'MIL @ PHX', prediction: 'MIL', confidence: 65, correct: false },
293
+ { date: 'Jan 20', matchup: 'DEN @ GSW', prediction: 'DEN', confidence: 68, correct: true },
294
+ { date: 'Jan 20', matchup: 'OKC @ CLE', prediction: 'OKC', confidence: 71, correct: true },
295
+ { date: 'Jan 19', matchup: 'NYK @ MIA', prediction: 'NYK', confidence: 58, correct: false },
296
+ ]).map((pred, idx) => (
297
+ <tr key={idx} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
298
+ <td style={tableCellStyle}>{pred.date}</td>
299
+ <td style={tableCellStyle}>{pred.matchup}</td>
300
+ <td style={tableCellStyle}><strong>{pred.prediction}</strong></td>
301
+ <td style={tableCellStyle}>
302
+ <span style={{
303
+ color: pred.confidence > 70 ? '#4ade80' : pred.confidence > 60 ? '#facc15' : '#f87171'
304
+ }}>
305
+ {pred.confidence}%
306
+ </span>
307
+ </td>
308
+ <td style={tableCellStyle}>
309
+ <span className={`badge ${pred.correct ? 'badge-success' : 'badge-danger'}`}>
310
+ {pred.correct ? '✓' : '✗'}
311
+ </span>
312
+ </td>
313
+ </tr>
314
+ ))}
315
+ </tbody>
316
+ </table>
317
+ </div>
318
+ </div>
319
+ </div>
320
+ )
321
+ }
322
+
323
+ const tableHeaderStyle = {
324
+ textAlign: 'left',
325
+ padding: 'var(--space-3) var(--space-4)',
326
+ fontSize: '0.75rem',
327
+ fontWeight: '600',
328
+ textTransform: 'uppercase',
329
+ letterSpacing: '0.05em',
330
+ color: 'var(--text-muted)'
331
+ }
332
+
333
+ const tableCellStyle = {
334
+ padding: 'var(--space-3) var(--space-4)',
335
+ fontSize: '0.875rem'
336
+ }
337
+
338
+ export default Analytics
web/src/pages/HeadToHead.jsx CHANGED
@@ -1,13 +1,59 @@
1
  import { useState, useEffect } from 'react'
2
- import { getTeams, predictGame } from '../api'
3
  import { TeamLogo, getTeamName } from '../teamLogos'
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  function HeadToHead() {
6
  const [teams, setTeams] = useState([])
7
  const [team1, setTeam1] = useState('LAL')
8
  const [team2, setTeam2] = useState('BOS')
9
  const [comparison, setComparison] = useState(null)
 
10
  const [loading, setLoading] = useState(false)
 
11
 
12
  useEffect(() => {
13
  getTeams().then(data => {
@@ -19,6 +65,7 @@ function HeadToHead() {
19
  if (!team1 || !team2 || team1 === team2) return
20
 
21
  setLoading(true)
 
22
  try {
23
  // Get predictions for both scenarios
24
  const [homeGame, awayGame] = await Promise.all([
@@ -32,13 +79,25 @@ function HeadToHead() {
32
  homeGame, // Team1 hosting Team2
33
  awayGame, // Team2 hosting Team1
34
  })
 
 
 
 
 
 
 
 
35
  } catch (err) {
36
  console.error('Comparison failed:', err)
37
  } finally {
38
  setLoading(false)
 
39
  }
40
  }
41
 
 
 
 
42
  return (
43
  <div className="animate-fadeIn">
44
  <div className="page-header">
@@ -99,17 +158,28 @@ function HeadToHead() {
99
  {/* Comparison Results */}
100
  {comparison && (
101
  <div className="animate-slideUp">
102
- {/* ELO Comparison */}
103
  <div className="stats-grid" style={{ marginBottom: 'var(--space-6)' }}>
104
  <div className="stat-card" style={{ textAlign: 'center' }}>
105
  <TeamLogo abbrev={comparison.team1} size="lg" style={{ margin: '0 auto var(--space-3)' }} />
106
  <div style={{ fontSize: '1.125rem', fontWeight: '600', marginBottom: 'var(--space-2)' }}>
107
- {comparison.team1}
108
  </div>
 
 
 
 
 
109
  <div className="stat-value accent">
110
  {comparison.homeGame?.home_elo?.toFixed(0) || 'N/A'}
111
  </div>
112
  <div className="stat-label">ELO Rating</div>
 
 
 
 
 
 
113
  </div>
114
 
115
  <div className="stat-card" style={{ textAlign: 'center', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
@@ -125,15 +195,74 @@ function HeadToHead() {
125
  <div className="stat-card" style={{ textAlign: 'center' }}>
126
  <TeamLogo abbrev={comparison.team2} size="lg" style={{ margin: '0 auto var(--space-3)' }} />
127
  <div style={{ fontSize: '1.125rem', fontWeight: '600', marginBottom: 'var(--space-2)' }}>
128
- {comparison.team2}
129
  </div>
 
 
 
 
 
130
  <div className="stat-value accent">
131
  {comparison.homeGame?.away_elo?.toFixed(0) || 'N/A'}
132
  </div>
133
  <div className="stat-label">ELO Rating</div>
 
 
 
 
 
 
134
  </div>
135
  </div>
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  {/* Scenario Cards */}
138
  <h3 style={{ marginBottom: 'var(--space-4)' }}>Win Probabilities by Venue</h3>
139
  <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)' }}>
 
1
  import { useState, useEffect } from 'react'
2
+ import { getTeams, predictGame, getTeamStats } from '../api'
3
  import { TeamLogo, getTeamName } from '../teamLogos'
4
 
5
+ // Stats Comparison Bar Component
6
+ function StatBar({ label, value1, value2, team1, team2, higherIsBetter = true }) {
7
+ const max = Math.max(value1, value2);
8
+ const width1 = max > 0 ? (value1 / max) * 100 : 50;
9
+ const width2 = max > 0 ? (value2 / max) * 100 : 50;
10
+
11
+ const team1Better = higherIsBetter ? value1 >= value2 : value1 <= value2;
12
+
13
+ return (
14
+ <div className="stat-comparison-row">
15
+ <div className="stat-bar-container">
16
+ <div className="stat-bar left" style={{ width: `${width1}%`, background: team1Better ? 'var(--accent-success)' : 'var(--bg-tertiary)' }} />
17
+ <span className="stat-value-label left" style={{ color: team1Better ? 'var(--accent-success)' : 'var(--text-secondary)' }}>{value1}</span>
18
+ </div>
19
+ <div className="stat-label-center">{label}</div>
20
+ <div className="stat-bar-container right">
21
+ <span className="stat-value-label right" style={{ color: !team1Better ? 'var(--accent-success)' : 'var(--text-secondary)' }}>{value2}</span>
22
+ <div className="stat-bar right" style={{ width: `${width2}%`, background: !team1Better ? 'var(--accent-success)' : 'var(--bg-tertiary)' }} />
23
+ </div>
24
+ </div>
25
+ );
26
+ }
27
+
28
+ // Recent Form Display
29
+ function RecentForm({ form }) {
30
+ if (!form || form.length === 0) return null;
31
+
32
+ return (
33
+ <div className="recent-form">
34
+ <div className="form-badges">
35
+ {form.map((game, i) => (
36
+ <span
37
+ key={i}
38
+ className={`form-badge ${game.result === 'W' ? 'win' : 'loss'}`}
39
+ title={`${game.result} vs ${game.opponent} (${game.score})`}
40
+ >
41
+ {game.result}
42
+ </span>
43
+ ))}
44
+ </div>
45
+ </div>
46
+ );
47
+ }
48
+
49
  function HeadToHead() {
50
  const [teams, setTeams] = useState([])
51
  const [team1, setTeam1] = useState('LAL')
52
  const [team2, setTeam2] = useState('BOS')
53
  const [comparison, setComparison] = useState(null)
54
+ const [teamStats, setTeamStats] = useState({ team1: null, team2: null })
55
  const [loading, setLoading] = useState(false)
56
+ const [statsLoading, setStatsLoading] = useState(false)
57
 
58
  useEffect(() => {
59
  getTeams().then(data => {
 
65
  if (!team1 || !team2 || team1 === team2) return
66
 
67
  setLoading(true)
68
+ setStatsLoading(true)
69
  try {
70
  // Get predictions for both scenarios
71
  const [homeGame, awayGame] = await Promise.all([
 
79
  homeGame, // Team1 hosting Team2
80
  awayGame, // Team2 hosting Team1
81
  })
82
+
83
+ // Fetch detailed team stats in parallel
84
+ const [stats1, stats2] = await Promise.all([
85
+ getTeamStats(team1),
86
+ getTeamStats(team2)
87
+ ])
88
+
89
+ setTeamStats({ team1: stats1, team2: stats2 })
90
  } catch (err) {
91
  console.error('Comparison failed:', err)
92
  } finally {
93
  setLoading(false)
94
+ setStatsLoading(false)
95
  }
96
  }
97
 
98
+ const stats1 = teamStats.team1;
99
+ const stats2 = teamStats.team2;
100
+
101
  return (
102
  <div className="animate-fadeIn">
103
  <div className="page-header">
 
158
  {/* Comparison Results */}
159
  {comparison && (
160
  <div className="animate-slideUp">
161
+ {/* Team Records Header */}
162
  <div className="stats-grid" style={{ marginBottom: 'var(--space-6)' }}>
163
  <div className="stat-card" style={{ textAlign: 'center' }}>
164
  <TeamLogo abbrev={comparison.team1} size="lg" style={{ margin: '0 auto var(--space-3)' }} />
165
  <div style={{ fontSize: '1.125rem', fontWeight: '600', marginBottom: 'var(--space-2)' }}>
166
+ {getTeamName(comparison.team1)}
167
  </div>
168
+ {stats1?.record && (
169
+ <div style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', marginBottom: 'var(--space-2)' }}>
170
+ {stats1.record.wins}-{stats1.record.losses}
171
+ </div>
172
+ )}
173
  <div className="stat-value accent">
174
  {comparison.homeGame?.home_elo?.toFixed(0) || 'N/A'}
175
  </div>
176
  <div className="stat-label">ELO Rating</div>
177
+ {stats1?.recent_form && (
178
+ <div style={{ marginTop: 'var(--space-3)' }}>
179
+ <div style={{ fontSize: '0.625rem', color: 'var(--text-muted)', marginBottom: 'var(--space-1)' }}>LAST 5</div>
180
+ <RecentForm form={stats1.recent_form} />
181
+ </div>
182
+ )}
183
  </div>
184
 
185
  <div className="stat-card" style={{ textAlign: 'center', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
 
195
  <div className="stat-card" style={{ textAlign: 'center' }}>
196
  <TeamLogo abbrev={comparison.team2} size="lg" style={{ margin: '0 auto var(--space-3)' }} />
197
  <div style={{ fontSize: '1.125rem', fontWeight: '600', marginBottom: 'var(--space-2)' }}>
198
+ {getTeamName(comparison.team2)}
199
  </div>
200
+ {stats2?.record && (
201
+ <div style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', marginBottom: 'var(--space-2)' }}>
202
+ {stats2.record.wins}-{stats2.record.losses}
203
+ </div>
204
+ )}
205
  <div className="stat-value accent">
206
  {comparison.homeGame?.away_elo?.toFixed(0) || 'N/A'}
207
  </div>
208
  <div className="stat-label">ELO Rating</div>
209
+ {stats2?.recent_form && (
210
+ <div style={{ marginTop: 'var(--space-3)' }}>
211
+ <div style={{ fontSize: '0.625rem', color: 'var(--text-muted)', marginBottom: 'var(--space-1)' }}>LAST 5</div>
212
+ <RecentForm form={stats2.recent_form} />
213
+ </div>
214
+ )}
215
  </div>
216
  </div>
217
 
218
+ {/* Stats Comparison */}
219
+ {stats1?.stats && stats2?.stats && (
220
+ <div className="card" style={{ marginBottom: 'var(--space-6)' }}>
221
+ <h3 style={{ marginBottom: 'var(--space-4)', textAlign: 'center' }}>Season Statistics</h3>
222
+ <div className="stats-comparison">
223
+ <StatBar label="PPG" value1={stats1.stats.ppg} value2={stats2.stats.ppg} team1={team1} team2={team2} />
224
+ <StatBar label="Opp PPG" value1={stats1.stats.opp_ppg} value2={stats2.stats.opp_ppg} team1={team1} team2={team2} higherIsBetter={false} />
225
+ <StatBar label="RPG" value1={stats1.stats.rpg} value2={stats2.stats.rpg} team1={team1} team2={team2} />
226
+ <StatBar label="APG" value1={stats1.stats.apg} value2={stats2.stats.apg} team1={team1} team2={team2} />
227
+ <StatBar label="FG%" value1={stats1.stats.fg_pct} value2={stats2.stats.fg_pct} team1={team1} team2={team2} />
228
+ <StatBar label="3P%" value1={stats1.stats.fg3_pct} value2={stats2.stats.fg3_pct} team1={team1} team2={team2} />
229
+ </div>
230
+ </div>
231
+ )}
232
+
233
+ {/* Key Players */}
234
+ {(stats1?.key_players?.length > 0 || stats2?.key_players?.length > 0) && (
235
+ <div className="card" style={{ marginBottom: 'var(--space-6)' }}>
236
+ <h3 style={{ marginBottom: 'var(--space-4)', textAlign: 'center' }}>Key Players</h3>
237
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)' }}>
238
+ <div>
239
+ <div style={{ textAlign: 'center', marginBottom: 'var(--space-2)' }}>
240
+ <TeamLogo abbrev={team1} size="sm" />
241
+ <span style={{ marginLeft: 'var(--space-2)', fontWeight: '600' }}>{team1}</span>
242
+ </div>
243
+ {stats1?.key_players?.map((player, i) => (
244
+ <div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: 'var(--space-2) 0', borderBottom: 'var(--border-subtle)' }}>
245
+ <span>{player.name}</span>
246
+ <span style={{ color: 'var(--text-muted)' }}>{player.position}</span>
247
+ </div>
248
+ ))}
249
+ </div>
250
+ <div>
251
+ <div style={{ textAlign: 'center', marginBottom: 'var(--space-2)' }}>
252
+ <TeamLogo abbrev={team2} size="sm" />
253
+ <span style={{ marginLeft: 'var(--space-2)', fontWeight: '600' }}>{team2}</span>
254
+ </div>
255
+ {stats2?.key_players?.map((player, i) => (
256
+ <div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: 'var(--space-2) 0', borderBottom: 'var(--border-subtle)' }}>
257
+ <span>{player.name}</span>
258
+ <span style={{ color: 'var(--text-muted)' }}>{player.position}</span>
259
+ </div>
260
+ ))}
261
+ </div>
262
+ </div>
263
+ </div>
264
+ )}
265
+
266
  {/* Scenario Cards */}
267
  <h3 style={{ marginBottom: 'var(--space-4)' }}>Win Probabilities by Venue</h3>
268
  <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)' }}>
web/src/pages/LiveGames.jsx CHANGED
@@ -3,6 +3,67 @@ import { getLiveGames } from '../api'
3
  import { TeamLogo } from '../teamLogos'
4
  import { IconRefresh } from '../icons'
5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  // Fetch roster for a team - use relative URL in production
7
  const API_BASE = import.meta.env.DEV ? 'http://localhost:8000' : '';
8
  async function getTeamRoster(teamAbbrev) {
@@ -199,15 +260,17 @@ function GameCard({ game, isLive, isFinal, showLineups }) {
199
  )}
200
  </div>
201
 
202
- {/* Center */}
203
  <div className="game-center">
204
  {!isFinal && prediction.predicted_winner && (
205
  <div className="prediction-indicator">
206
  <div className="prediction-label">Prediction</div>
207
  <div className="prediction-pick">{prediction.predicted_winner}</div>
208
- <span className={`badge confidence-${prediction.confidence || 'medium'}`} style={{ marginTop: 'var(--space-2)' }}>
209
- {(prediction.confidence || 'medium').toUpperCase()}
210
- </span>
 
 
211
  </div>
212
  )}
213
  {isFinal && (
 
3
  import { TeamLogo } from '../teamLogos'
4
  import { IconRefresh } from '../icons'
5
 
6
+ // Confidence Meter Component - Visual gauge showing prediction confidence
7
+ function ConfidenceMeter({ value }) {
8
+ // Value is expected to be between 0.5 and 1.0 (50% to 100%)
9
+ const percentage = Math.round(value * 100);
10
+ // Normalize to 0-100 scale for display (50% = 0%, 100% = 100%)
11
+ const normalizedValue = Math.max(0, (value - 0.5) * 2);
12
+
13
+ // Color based on confidence level
14
+ const getColor = () => {
15
+ if (percentage >= 70) return '#22c55e'; // Green - High confidence
16
+ if (percentage >= 60) return '#eab308'; // Yellow - Medium confidence
17
+ return '#ef4444'; // Red - Low confidence
18
+ };
19
+
20
+ const getLabel = () => {
21
+ if (percentage >= 70) return 'HIGH';
22
+ if (percentage >= 60) return 'MEDIUM';
23
+ return 'LOW';
24
+ };
25
+
26
+ // SVG arc calculation
27
+ const radius = 40;
28
+ const strokeWidth = 8;
29
+ const circumference = Math.PI * radius; // Half circle
30
+ const fillLength = normalizedValue * circumference;
31
+
32
+ return (
33
+ <div className="confidence-meter">
34
+ <svg width="100" height="60" viewBox="0 0 100 60">
35
+ {/* Background arc */}
36
+ <path
37
+ d="M 10 50 A 40 40 0 0 1 90 50"
38
+ fill="none"
39
+ stroke="rgba(255,255,255,0.1)"
40
+ strokeWidth={strokeWidth}
41
+ strokeLinecap="round"
42
+ />
43
+ {/* Filled arc */}
44
+ <path
45
+ d="M 10 50 A 40 40 0 0 1 90 50"
46
+ fill="none"
47
+ stroke={getColor()}
48
+ strokeWidth={strokeWidth}
49
+ strokeLinecap="round"
50
+ strokeDasharray={`${fillLength} ${circumference}`}
51
+ style={{
52
+ filter: `drop-shadow(0 0 6px ${getColor()})`,
53
+ transition: 'stroke-dasharray 0.5s ease-out'
54
+ }}
55
+ />
56
+ </svg>
57
+ <div className="confidence-value" style={{ color: getColor() }}>
58
+ {percentage}%
59
+ </div>
60
+ <div className="confidence-label" style={{ color: getColor() }}>
61
+ {getLabel()}
62
+ </div>
63
+ </div>
64
+ );
65
+ }
66
+
67
  // Fetch roster for a team - use relative URL in production
68
  const API_BASE = import.meta.env.DEV ? 'http://localhost:8000' : '';
69
  async function getTeamRoster(teamAbbrev) {
 
260
  )}
261
  </div>
262
 
263
+ {/* Center - Prediction with Confidence Meter */}
264
  <div className="game-center">
265
  {!isFinal && prediction.predicted_winner && (
266
  <div className="prediction-indicator">
267
  <div className="prediction-label">Prediction</div>
268
  <div className="prediction-pick">{prediction.predicted_winner}</div>
269
+
270
+ {/* Confidence Meter */}
271
+ <div className="confidence-meter-container">
272
+ <ConfidenceMeter value={Math.max(homeProb, awayProb)} />
273
+ </div>
274
  </div>
275
  )}
276
  {isFinal && (