From 3e736442c784f96611fb12ed825d85b92dccf837 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sat, 28 Feb 2026 11:52:38 -0600 Subject: [PATCH] Redesign dashboard with roster health, card coverage, and sync refresh - Add position coverage, IL players, missing card warnings, and detailed sync status sections to the dashboard - Fix team sync to include IL/MiL teams (active_only=false), matching Python behavior so get_my_roster() finds WVIL/WVMiL teams - Re-fetch all dashboard data after successful sync so changes reflect immediately without navigating away - Add sWAR budget bar (90-100% range) with color thresholds from Team.salary_cap - Add team-scoped missing card queries and bulk sync status query - Mark Phase 2 and Phase 3 project plans as complete Co-Authored-By: Claude Opus 4.6 --- rust/CLAUDE.md | 60 ++++ rust/PHASE2_PROJECT_PLAN.json | 173 +++++++---- rust/PHASE3_PROJECT_PLAN.json | 198 ++++++++---- rust/src/api/sync.rs | 2 +- rust/src/app.rs | 12 +- rust/src/config.rs | 4 + rust/src/db/queries.rs | 53 ++++ rust/src/screens/dashboard.rs | 549 +++++++++++++++++++++++++++++++--- 8 files changed, 877 insertions(+), 174 deletions(-) create mode 100644 rust/CLAUDE.md diff --git a/rust/CLAUDE.md b/rust/CLAUDE.md new file mode 100644 index 0000000..b349ca1 --- /dev/null +++ b/rust/CLAUDE.md @@ -0,0 +1,60 @@ +# CLAUDE.md — Rust Rewrite + +## Session Start + +Read the rewrite memory file before starting work: +`~/.claude/projects/-mnt-NV2-Development-sba-scouting/memory/rust-rewrite.md` + +It has the phase plan, completion status, architecture decisions, and current file layout. + +## Project Overview + +Rust rewrite of the SBA Scout TUI. Uses ratatui + crossterm for the terminal UI, sqlx + SQLite for data, reqwest for API calls, and figment + TOML for configuration. + +## Commands + +```bash +cargo build # compile +cargo run # run TUI +cargo test # run tests +cargo clippy # lint +cargo fmt # format +``` + +## Architecture + +Same layered structure as the Python version, ported to Rust idioms: + +``` +src/ +├── main.rs # tokio runtime, event loop, file logging +├── app.rs # App struct, screen routing, nav/status bars +├── config.rs # figment-based TOML settings +├── lib.rs # module re-exports +├── api/ # reqwest HTTP client, sync pipeline, CSV importers +├── calc/ # league stats, matchup scoring, score cache, weights +├── db/ # sqlx models, queries, schema +├── screens/ # ratatui screen states (dashboard, gameday, stubs) +└── widgets/ # reusable UI components (selector) +``` + +### Key Patterns + +- **Async throughout**: tokio runtime, sqlx async, reqwest async +- **Message passing**: `mpsc::UnboundedSender` for async task → UI communication +- **No global state**: Settings and SqlitePool passed by reference, no singletons +- **TOML config** (not YAML): `data/settings.toml` with `SBA_SCOUT_` env var overrides + +### Current Screen Status + +- `dashboard.rs` — Done (roster summary, sync) +- `gameday.rs` — Done (matchup table + lineup builder) +- `roster.rs` — Stub +- `matchup.rs` — Stub +- `lineup.rs` — Stub +- `settings.rs` — Stub + +## Code Style + +- Run `cargo fmt` and `cargo clippy` before committing +- Follow existing patterns in `gameday.rs` for new screens (state struct + handle_key + handle_message + render) diff --git a/rust/PHASE2_PROJECT_PLAN.json b/rust/PHASE2_PROJECT_PLAN.json index 8d65f3f..0925417 100644 --- a/rust/PHASE2_PROJECT_PLAN.json +++ b/rust/PHASE2_PROJECT_PLAN.json @@ -5,10 +5,10 @@ "lastUpdated": "2026-02-27", "planType": "migration", "phase": "Phase 2: API Client + Sync + CSV Import", - "description": "Port the data ingestion pipeline — HTTP API client, sync functions, and CSV card importer — from Python to Rust. This phase populates the database that Phase 1 created.", + "description": "Port the data ingestion pipeline \u2014 HTTP API client, sync functions, and CSV card importer \u2014 from Python to Rust. This phase populates the database that Phase 1 created.", "totalEstimatedHours": 16, "totalTasks": 11, - "completedTasks": 0 + "completedTasks": 11 }, "categories": { "critical": "Must complete before sync or import can work", @@ -20,22 +20,24 @@ { "id": "CRIT-001", "name": "Define API response types (serde structs)", - "description": "Create typed response structs for all API endpoints. The league API returns JSON with nested objects and field names that differ from DB column names (e.g. 'sname' → 'short_name', 'wara' → 'swar'). Use serde rename attributes to handle the mismatches at deserialization time.", + "description": "Create typed response structs for all API endpoints. The league API returns JSON with nested objects and field names that differ from DB column names (e.g. 'sname' \u2192 'short_name', 'wara' \u2192 'swar'). Use serde rename attributes to handle the mismatches at deserialization time.", "category": "critical", "priority": 1, - "completed": false, - "tested": false, + "completed": true, + "tested": true, "dependencies": [], "files": [ { "path": "rust/src/api/client.rs", - "lines": [1], + "lines": [ + 1 + ], "issue": "No response types defined" } ], - "suggestedFix": "Create a new file `rust/src/api/types.rs` and add to `api/mod.rs`. Define structs:\n\n1. `TeamsResponse { count: u32, teams: Vec }`\n2. `TeamData` — flat fields plus nested `manager1: Option`, `manager2: Option`, `division: Option`. Use `#[serde(rename = \"sname\")]` for `short_name`, `#[serde(rename = \"lname\")]` for `long_name`, `#[serde(rename = \"gmid\")]` for `gm_discord_id`, `#[serde(rename = \"gmid2\")]` for `gm2_discord_id`. Manager struct: `{ name: Option }`. Division struct: `{ id: Option, division_name: Option, league_abbrev: Option }`.\n3. `PlayersResponse { count: u32, players: Vec }`\n4. `PlayerData` — use `#[serde(rename = \"wara\")]` for `swar`, `#[serde(rename = \"sbaplayer\")]` for `sbaplayer_id`, `#[serde(rename = \"image\")]` for `card_image`, `#[serde(rename = \"image2\")]` for `card_image_alt`. Nested `team: Option` where `TeamRef { id: i64 }`.\n5. `TransactionsResponse { count: u32, transactions: Vec }`\n6. `TransactionData` — `#[serde(rename = \"moveid\")]` for `move_id`. Nested `player: Option`, `oldteam: Option`, `newteam: Option`.\n7. `CurrentResponse { season: i64, week: i64 }` (for get_current endpoint).\n\nAll structs derive `Debug, Deserialize`. Use `Option<>` liberally — API responses have many nullable fields. Use `#[serde(default)]` on optional fields to handle missing keys gracefully.", + "suggestedFix": "Create a new file `rust/src/api/types.rs` and add to `api/mod.rs`. Define structs:\n\n1. `TeamsResponse { count: u32, teams: Vec }`\n2. `TeamData` \u2014 flat fields plus nested `manager1: Option`, `manager2: Option`, `division: Option`. Use `#[serde(rename = \"sname\")]` for `short_name`, `#[serde(rename = \"lname\")]` for `long_name`, `#[serde(rename = \"gmid\")]` for `gm_discord_id`, `#[serde(rename = \"gmid2\")]` for `gm2_discord_id`. Manager struct: `{ name: Option }`. Division struct: `{ id: Option, division_name: Option, league_abbrev: Option }`.\n3. `PlayersResponse { count: u32, players: Vec }`\n4. `PlayerData` \u2014 use `#[serde(rename = \"wara\")]` for `swar`, `#[serde(rename = \"sbaplayer\")]` for `sbaplayer_id`, `#[serde(rename = \"image\")]` for `card_image`, `#[serde(rename = \"image2\")]` for `card_image_alt`. Nested `team: Option` where `TeamRef { id: i64 }`.\n5. `TransactionsResponse { count: u32, transactions: Vec }`\n6. `TransactionData` \u2014 `#[serde(rename = \"moveid\")]` for `move_id`. Nested `player: Option`, `oldteam: Option`, `newteam: Option`.\n7. `CurrentResponse { season: i64, week: i64 }` (for get_current endpoint).\n\nAll structs derive `Debug, Deserialize`. Use `Option<>` liberally \u2014 API responses have many nullable fields. Use `#[serde(default)]` on optional fields to handle missing keys gracefully.", "estimatedHours": 1.5, - "notes": "Key gotchas: gmid/gmid2 come as integers from the API but are stored as String in DB — deserialize as Option then .map(|id| id.to_string()). The 'team' field on PlayerData is an object {id: N} not a bare integer." + "notes": "Key gotchas: gmid/gmid2 come as integers from the API but are stored as String in DB \u2014 deserialize as Option then .map(|id| id.to_string()). The 'team' field on PlayerData is an object {id: N} not a bare integer." }, { "id": "CRIT-002", @@ -43,19 +45,21 @@ "description": "Create a proper error enum for API operations using thiserror. The Python client has special handling for Cloudflare 403 errors and distinguishes HTTP errors from network errors.", "category": "critical", "priority": 2, - "completed": false, - "tested": false, + "completed": true, + "tested": true, "dependencies": [], "files": [ { "path": "rust/src/api/client.rs", - "lines": [1], + "lines": [ + 1 + ], "issue": "Uses anyhow::Result with no typed errors" } ], "suggestedFix": "Add to `api/client.rs` or a new `api/error.rs`:\n\n```rust\n#[derive(Debug, thiserror::Error)]\npub enum ApiError {\n #[error(\"HTTP {status}: {message}\")]\n Http { status: u16, message: String },\n\n #[error(\"Cloudflare blocked the request (403). The API may be down or rate-limiting.\")]\n CloudflareBlocked,\n\n #[error(\"Request failed: {0}\")]\n Request(#[from] reqwest::Error),\n\n #[error(\"JSON parse error: {0}\")]\n Parse(#[from] serde_json::Error),\n}\n```\n\nIn the request method: if status == 403 and body contains 'cloudflare' (case-insensitive), return CloudflareBlocked. Otherwise return Http variant.", "estimatedHours": 0.5, - "notes": "Keep it simple. The Python error has status_code as an optional field — in Rust we can use separate variants instead." + "notes": "Keep it simple. The Python error has status_code as an optional field \u2014 in Rust we can use separate variants instead." }, { "id": "CRIT-003", @@ -63,19 +67,25 @@ "description": "Add the internal request method to LeagueApiClient that handles authentication (Bearer token), base URL prefixing (/api/v3), response status checking, Cloudflare detection, and JSON deserialization. All public endpoint methods will delegate to this.", "category": "critical", "priority": 3, - "completed": false, - "tested": false, - "dependencies": ["CRIT-001", "CRIT-002"], + "completed": true, + "tested": true, + "dependencies": [ + "CRIT-001", + "CRIT-002" + ], "files": [ { "path": "rust/src/api/client.rs", - "lines": [11, 23], + "lines": [ + 11, + 23 + ], "issue": "Only has new() constructor, no request logic" } ], - "suggestedFix": "Add a private async method:\n\n```rust\nasync fn get(&self, path: &str, params: &[(& str, String)]) -> Result\n```\n\n1. Build URL: `format!(\"{}/api/v3{}\", self.base_url, path)`\n2. Create request with `self.client.get(url).query(params)`\n3. If `self.api_key` is not empty, add `.bearer_auth(&self.api_key)`\n4. Send and get response\n5. If `!response.status().is_success()`: read body text, check for cloudflare 403, else return Http error\n6. Deserialize JSON body to T\n\nNote: reqwest's .query() handles repeated params correctly when given a slice of tuples — e.g., `&[(\"team_id\", \"1\"), (\"team_id\", \"2\")]` produces `?team_id=1&team_id=2`.", + "suggestedFix": "Add a private async method:\n\n```rust\nasync fn get(&self, path: &str, params: &[(& str, String)]) -> Result\n```\n\n1. Build URL: `format!(\"{}/api/v3{}\", self.base_url, path)`\n2. Create request with `self.client.get(url).query(params)`\n3. If `self.api_key` is not empty, add `.bearer_auth(&self.api_key)`\n4. Send and get response\n5. If `!response.status().is_success()`: read body text, check for cloudflare 403, else return Http error\n6. Deserialize JSON body to T\n\nNote: reqwest's .query() handles repeated params correctly when given a slice of tuples \u2014 e.g., `&[(\"team_id\", \"1\"), (\"team_id\", \"2\")]` produces `?team_id=1&team_id=2`.", "estimatedHours": 1, - "notes": "The Python client uses httpx.AsyncClient as a context manager. In Rust, reqwest::Client is Clone + Send and can be reused freely — no context manager needed. Consider making api_key an Option to properly represent 'no auth' vs 'empty string'." + "notes": "The Python client uses httpx.AsyncClient as a context manager. In Rust, reqwest::Client is Clone + Send and can be reused freely \u2014 no context manager needed. Consider making api_key an Option to properly represent 'no auth' vs 'empty string'." }, { "id": "HIGH-001", @@ -83,49 +93,60 @@ "description": "Port all 10 public API methods from the Python client. Each method builds the correct path and query params, then delegates to the core _request/get method.", "category": "high", "priority": 4, - "completed": false, - "tested": false, - "dependencies": ["CRIT-003"], + "completed": true, + "tested": true, + "dependencies": [ + "CRIT-003" + ], "files": [ { "path": "rust/src/api/client.rs", - "lines": [11, 23], + "lines": [ + 11, + 23 + ], "issue": "No endpoint methods" } ], - "suggestedFix": "Implement these methods on LeagueApiClient:\n\n1. `get_teams(season, team_abbrev, active_only, short_output)` → GET /teams\n2. `get_team(team_id)` → GET /teams/{id}\n3. `get_team_roster(team_id, which)` → GET /teams/{id}/roster/{which}\n4. `get_players(season, team_id, pos, name, short_output)` → GET /players\n5. `get_player(player_id, short_output)` → GET /players/{id}\n6. `search_players(query, season, limit)` → GET /players/search\n7. `get_transactions(season, week_start, week_end, team_abbrev, cancelled, frozen, short_output)` → GET /transactions\n8. `get_current()` → GET /current\n9. `get_schedule(season, week, team_id)` → GET /schedules\n10. `get_standings(season)` → GET /standings\n\nFor optional params, use `Option` in the method signature and only add the param to the query slice when Some. For array params (team_id, team_abbrev, pos), accept `Option<&[T]>` and emit one tuple per element.\n\nReturn the typed response structs from CRIT-001.", + "suggestedFix": "Implement these methods on LeagueApiClient:\n\n1. `get_teams(season, team_abbrev, active_only, short_output)` \u2192 GET /teams\n2. `get_team(team_id)` \u2192 GET /teams/{id}\n3. `get_team_roster(team_id, which)` \u2192 GET /teams/{id}/roster/{which}\n4. `get_players(season, team_id, pos, name, short_output)` \u2192 GET /players\n5. `get_player(player_id, short_output)` \u2192 GET /players/{id}\n6. `search_players(query, season, limit)` \u2192 GET /players/search\n7. `get_transactions(season, week_start, week_end, team_abbrev, cancelled, frozen, short_output)` \u2192 GET /transactions\n8. `get_current()` \u2192 GET /current\n9. `get_schedule(season, week, team_id)` \u2192 GET /schedules\n10. `get_standings(season)` \u2192 GET /standings\n\nFor optional params, use `Option` in the method signature and only add the param to the query slice when Some. For array params (team_id, team_abbrev, pos), accept `Option<&[T]>` and emit one tuple per element.\n\nReturn the typed response structs from CRIT-001.", "estimatedHours": 2.5, - "notes": "get_players and get_transactions have the most params. Consider a builder pattern or param struct if the signatures get unwieldy, but plain method args are fine for now — matches the Python style." + "notes": "get_players and get_transactions have the most params. Consider a builder pattern or param struct if the signatures get unwieldy, but plain method args are fine for now \u2014 matches the Python style." }, { "id": "HIGH-002", "name": "Implement sync_teams", - "description": "Port the team sync function. Fetches teams from API and upserts into the database. Must handle the JSON field name mismatches (sname→short_name, lname→long_name, gmid→gm_discord_id, nested manager/division objects).", + "description": "Port the team sync function. Fetches teams from API and upserts into the database. Must handle the JSON field name mismatches (sname\u2192short_name, lname\u2192long_name, gmid\u2192gm_discord_id, nested manager/division objects).", "category": "high", "priority": 5, - "completed": false, - "tested": false, - "dependencies": ["HIGH-001"], + "completed": true, + "tested": true, + "dependencies": [ + "HIGH-001" + ], "files": [ { "path": "rust/src/api/mod.rs", - "lines": [1], + "lines": [ + 1 + ], "issue": "No sync module" } ], - "suggestedFix": "Create `rust/src/api/sync.rs` and add `pub mod sync;` to api/mod.rs.\n\nImplement `pub async fn sync_teams(pool: &SqlitePool, season: i64, client: &LeagueApiClient) -> Result`:\n\n1. Call `client.get_teams(Some(season), None, true, false)`\n2. Iterate response.teams\n3. For each TeamData: use `INSERT OR REPLACE INTO teams (id, abbrev, short_name, long_name, season, ...) VALUES (?, ?, ?, ...)`. Map: `data.short_name` (already renamed by serde), flatten `data.manager1.map(|m| m.name)`, `data.division.map(|d| d.id)`, `data.gm_discord_id.map(|id| id.to_string())`, set `synced_at = Utc::now().naive_utc()`.\n4. Call `update_sync_status(pool, \"teams\", count, None)` from db::queries.\n5. Return count.\n\nUse a transaction (pool.begin()) to batch all inserts — faster and atomic.", + "suggestedFix": "Create `rust/src/api/sync.rs` and add `pub mod sync;` to api/mod.rs.\n\nImplement `pub async fn sync_teams(pool: &SqlitePool, season: i64, client: &LeagueApiClient) -> Result`:\n\n1. Call `client.get_teams(Some(season), None, true, false)`\n2. Iterate response.teams\n3. For each TeamData: use `INSERT OR REPLACE INTO teams (id, abbrev, short_name, long_name, season, ...) VALUES (?, ?, ?, ...)`. Map: `data.short_name` (already renamed by serde), flatten `data.manager1.map(|m| m.name)`, `data.division.map(|d| d.id)`, `data.gm_discord_id.map(|id| id.to_string())`, set `synced_at = Utc::now().naive_utc()`.\n4. Call `update_sync_status(pool, \"teams\", count, None)` from db::queries.\n5. Return count.\n\nUse a transaction (pool.begin()) to batch all inserts \u2014 faster and atomic.", "estimatedHours": 1.5, - "notes": "The Python code does individual get-then-update/insert. The Rust version should use INSERT OR REPLACE for simplicity (matching Phase 1 upsert pattern). The season field is set on insert only in Python — in Rust, just always set it since we're using REPLACE." + "notes": "The Python code does individual get-then-update/insert. The Rust version should use INSERT OR REPLACE for simplicity (matching Phase 1 upsert pattern). The season field is set on insert only in Python \u2014 in Rust, just always set it since we're using REPLACE." }, { "id": "HIGH-003", "name": "Implement sync_players", - "description": "Port the player sync function. Similar to team sync but with different field mappings (wara→swar, sbaplayer→sbaplayer_id, nested team.id).", + "description": "Port the player sync function. Similar to team sync but with different field mappings (wara\u2192swar, sbaplayer\u2192sbaplayer_id, nested team.id).", "category": "high", "priority": 6, - "completed": false, - "tested": false, - "dependencies": ["HIGH-001"], + "completed": true, + "tested": true, + "dependencies": [ + "HIGH-001" + ], "files": [ { "path": "rust/src/api/sync.rs", @@ -133,7 +154,7 @@ "issue": "File doesn't exist yet" } ], - "suggestedFix": "Implement `pub async fn sync_players(pool: &SqlitePool, season: i64, team_id: Option, client: &LeagueApiClient) -> Result`:\n\n1. Call `client.get_players(Some(season), team_id.map(|id| vec![id]).as_deref(), None, None, false)`\n2. Iterate response.players\n3. INSERT OR REPLACE with field mapping: `data.swar` (renamed by serde from wara), `data.team.map(|t| t.id)` for team_id, `data.sbaplayer_id` (renamed from sbaplayer), pos_1 through pos_8, etc.\n4. Important: do NOT overwrite `hand` field — it comes from CSV import only. Use `INSERT OR REPLACE` but if you need to preserve hand, use `INSERT ... ON CONFLICT(id) DO UPDATE SET name=excluded.name, ... ` and omit `hand` from the SET clause.\n5. Call `update_sync_status(pool, \"players\", count, None)`.\n6. Return count.\n\nUse transaction for batching.", + "suggestedFix": "Implement `pub async fn sync_players(pool: &SqlitePool, season: i64, team_id: Option, client: &LeagueApiClient) -> Result`:\n\n1. Call `client.get_players(Some(season), team_id.map(|id| vec![id]).as_deref(), None, None, false)`\n2. Iterate response.players\n3. INSERT OR REPLACE with field mapping: `data.swar` (renamed by serde from wara), `data.team.map(|t| t.id)` for team_id, `data.sbaplayer_id` (renamed from sbaplayer), pos_1 through pos_8, etc.\n4. Important: do NOT overwrite `hand` field \u2014 it comes from CSV import only. Use `INSERT OR REPLACE` but if you need to preserve hand, use `INSERT ... ON CONFLICT(id) DO UPDATE SET name=excluded.name, ... ` and omit `hand` from the SET clause.\n5. Call `update_sync_status(pool, \"players\", count, None)`.\n6. Return count.\n\nUse transaction for batching.", "estimatedHours": 1.5, "notes": "Critical: player.hand must NOT be overwritten by API sync (it's CSV-only). Use ON CONFLICT DO UPDATE with explicit column list instead of INSERT OR REPLACE, which would null out hand. This is different from the team sync pattern." }, @@ -143,9 +164,11 @@ "description": "Port the transaction sync function. Transactions use a composite key (move_id, player_id) for dedup, not a simple PK. Must skip rows with missing moveid or player.id.", "category": "high", "priority": 7, - "completed": false, - "tested": false, - "dependencies": ["HIGH-001"], + "completed": true, + "tested": true, + "dependencies": [ + "HIGH-001" + ], "files": [ { "path": "rust/src/api/sync.rs", @@ -153,7 +176,7 @@ "issue": "File doesn't exist yet" } ], - "suggestedFix": "Implement `pub async fn sync_transactions(pool: &SqlitePool, season: i64, week_start: i64, week_end: Option, team_abbrev: Option<&str>, client: &LeagueApiClient) -> Result`:\n\n1. Call `client.get_transactions(season, week_start, week_end, team_abbrev, false, false, false)`\n2. Iterate response.transactions\n3. Skip if moveid is None or player is None/player.id is None\n4. Use `INSERT INTO transactions (...) VALUES (...) ON CONFLICT(move_id, player_id) DO UPDATE SET cancelled=excluded.cancelled, frozen=excluded.frozen, synced_at=excluded.synced_at` — only update the mutable fields on conflict.\n5. Map: `data.move_id` (renamed from moveid), `data.player.unwrap().id`, `data.oldteam.map(|t| t.id)`, `data.newteam.map(|t| t.id)`, set season and week.\n6. Call `update_sync_status(pool, \"transactions\", count, None)`.\n7. Return count.\n\nUse transaction for batching.", + "suggestedFix": "Implement `pub async fn sync_transactions(pool: &SqlitePool, season: i64, week_start: i64, week_end: Option, team_abbrev: Option<&str>, client: &LeagueApiClient) -> Result`:\n\n1. Call `client.get_transactions(season, week_start, week_end, team_abbrev, false, false, false)`\n2. Iterate response.transactions\n3. Skip if moveid is None or player is None/player.id is None\n4. Use `INSERT INTO transactions (...) VALUES (...) ON CONFLICT(move_id, player_id) DO UPDATE SET cancelled=excluded.cancelled, frozen=excluded.frozen, synced_at=excluded.synced_at` \u2014 only update the mutable fields on conflict.\n5. Map: `data.move_id` (renamed from moveid), `data.player.unwrap().id`, `data.oldteam.map(|t| t.id)`, `data.newteam.map(|t| t.id)`, set season and week.\n6. Call `update_sync_status(pool, \"transactions\", count, None)`.\n7. Return count.\n\nUse transaction for batching.", "estimatedHours": 1, "notes": "The transactions table has UNIQUE(move_id, player_id). ON CONFLICT on that unique constraint is the right approach. Python does a SELECT-then-INSERT/UPDATE which is racy." }, @@ -163,9 +186,13 @@ "description": "Port sync_all which creates one API client and runs all three sync functions sequentially, returning a summary of counts.", "category": "high", "priority": 8, - "completed": false, - "tested": false, - "dependencies": ["HIGH-002", "HIGH-003", "HIGH-004"], + "completed": true, + "tested": true, + "dependencies": [ + "HIGH-002", + "HIGH-003", + "HIGH-004" + ], "files": [ { "path": "rust/src/api/sync.rs", @@ -175,19 +202,19 @@ ], "suggestedFix": "Implement `pub async fn sync_all(pool: &SqlitePool, settings: &Settings) -> Result`:\n\n1. Create `LeagueApiClient::new(&settings.api.base_url, &settings.api.api_key, settings.api.timeout)?`\n2. Call sync_teams, sync_players, sync_transactions sequentially\n3. For transactions, use week_start=1, week_end=None (sync all weeks)\n4. Return a SyncResult struct: `{ teams: i64, players: i64, transactions: i64 }`\n\nDefine `SyncResult` in sync.rs or types.rs.", "estimatedHours": 0.5, - "notes": "Keep it simple. The Python version passes the client to each sync function — do the same. Consider logging each step's count." + "notes": "Keep it simple. The Python version passes the client to each sync function \u2014 do the same. Consider logging each step's count." }, { "id": "MED-001", "name": "Implement CSV helper functions (parse_float, parse_int, parse_endurance)", - "description": "Port the three CSV parsing helpers. parse_int must handle '5.0' → 5 (parse as float then truncate). parse_endurance uses regex to extract S(n), R(n), C(n) from endurance strings like 'S(5) R(4)' or 'R(1) C(6)*'.", + "description": "Port the three CSV parsing helpers. parse_int must handle '5.0' \u2192 5 (parse as float then truncate). parse_endurance uses regex to extract S(n), R(n), C(n) from endurance strings like 'S(5) R(4)' or 'R(1) C(6)*'.", "category": "medium", "priority": 9, - "completed": false, - "tested": false, + "completed": true, + "tested": true, "dependencies": [], "files": [], - "suggestedFix": "Create `rust/src/api/importer.rs` and add `pub mod importer;` to api/mod.rs.\n\nImplement:\n\n1. `fn parse_float(value: &str, default: f64) -> f64` — trim, return default if empty, parse f64 or return default.\n\n2. `fn parse_int(value: &str, default: i32) -> i32` — trim, return default if empty, parse as f64 first then truncate to i32. This handles '5.0' → 5.\n\n3. `fn parse_endurance(value: &str) -> (Option, Option, Option)` — use lazy_static or once_cell for compiled regexes:\n - `S\\((\\d+)\\*?\\)` → start\n - `R\\((\\d+)\\)` → relief\n - `C\\((\\d+)\\)` → close\n Return (start, relief, close) where any component may be None.\n\nWrite unit tests for all three, especially edge cases: empty string, whitespace, '5.0', 'S(5*) R(4)', 'C(6)', unparseable values.", + "suggestedFix": "Create `rust/src/api/importer.rs` and add `pub mod importer;` to api/mod.rs.\n\nImplement:\n\n1. `fn parse_float(value: &str, default: f64) -> f64` \u2014 trim, return default if empty, parse f64 or return default.\n\n2. `fn parse_int(value: &str, default: i32) -> i32` \u2014 trim, return default if empty, parse as f64 first then truncate to i32. This handles '5.0' \u2192 5.\n\n3. `fn parse_endurance(value: &str) -> (Option, Option, Option)` \u2014 use lazy_static or once_cell for compiled regexes:\n - `S\\((\\d+)\\*?\\)` \u2192 start\n - `R\\((\\d+)\\)` \u2192 relief\n - `C\\((\\d+)\\)` \u2192 close\n Return (start, relief, close) where any component may be None.\n\nWrite unit tests for all three, especially edge cases: empty string, whitespace, '5.0', 'S(5*) R(4)', 'C(6)', unparseable values.", "estimatedHours": 1, "notes": "Use `std::sync::LazyLock` (stable since Rust 1.80) for compiled regexes instead of lazy_static or once_cell. Keep these as module-level functions, not methods." }, @@ -197,9 +224,11 @@ "description": "Port the batter CSV importer. Reads BatterCalcs.csv, maps columns to BatterCard fields, upserts into database. Also updates player.hand from CSV. Returns (imported, skipped, errors) counts.", "category": "medium", "priority": 10, - "completed": false, - "tested": false, - "dependencies": ["MED-001"], + "completed": true, + "tested": true, + "dependencies": [ + "MED-001" + ], "files": [ { "path": "rust/src/api/importer.rs", @@ -207,7 +236,7 @@ "issue": "File doesn't exist yet" } ], - "suggestedFix": "Implement `pub async fn import_batter_cards(pool: &SqlitePool, csv_path: &Path, update_existing: bool) -> Result`:\n\nDefine `ImportResult { imported: i64, skipped: i64, errors: Vec }`.\n\n1. Open CSV with `csv::ReaderBuilder::new().has_headers(true).from_path(csv_path)`\n2. Get headers, iterate records via `reader.records()`\n3. For each record:\n a. Parse player_id from column 'player_id' — skip if 0\n b. Look up player in DB — if not found, push error string and continue\n c. Update player.hand if column 'hand' is L/R/S: `UPDATE players SET hand = ? WHERE id = ?`\n d. If !update_existing, check if batter_card exists — if so, increment skipped and continue\n e. INSERT OR REPLACE batter card with all field mappings (see column map)\n f. Increment imported\n4. Catch per-row errors (wrap in match) → push to errors vec\n\nColumn mappings (CSV header → parse function → DB field):\n- 'SO vlhp' → parse_float → so_vlhp\n- 'BB v lhp' → parse_float → bb_vlhp\n- 'HIT v lhp' → parse_float → hit_vlhp\n- (etc. — 19 stat columns per side, plus stealing, STL, SPD, B, H, FIELDING, cArm/CA, vL, vR, Total)\n- Source set to csv filename\n\nUse a helper fn or macro to avoid repeating `get_column(headers, record, 'name')` 40+ times. Consider a HashMap for header→index lookup.", + "suggestedFix": "Implement `pub async fn import_batter_cards(pool: &SqlitePool, csv_path: &Path, update_existing: bool) -> Result`:\n\nDefine `ImportResult { imported: i64, skipped: i64, errors: Vec }`.\n\n1. Open CSV with `csv::ReaderBuilder::new().has_headers(true).from_path(csv_path)`\n2. Get headers, iterate records via `reader.records()`\n3. For each record:\n a. Parse player_id from column 'player_id' \u2014 skip if 0\n b. Look up player in DB \u2014 if not found, push error string and continue\n c. Update player.hand if column 'hand' is L/R/S: `UPDATE players SET hand = ? WHERE id = ?`\n d. If !update_existing, check if batter_card exists \u2014 if so, increment skipped and continue\n e. INSERT OR REPLACE batter card with all field mappings (see column map)\n f. Increment imported\n4. Catch per-row errors (wrap in match) \u2192 push to errors vec\n\nColumn mappings (CSV header \u2192 parse function \u2192 DB field):\n- 'SO vlhp' \u2192 parse_float \u2192 so_vlhp\n- 'BB v lhp' \u2192 parse_float \u2192 bb_vlhp\n- 'HIT v lhp' \u2192 parse_float \u2192 hit_vlhp\n- (etc. \u2014 19 stat columns per side, plus stealing, STL, SPD, B, H, FIELDING, cArm/CA, vL, vR, Total)\n- Source set to csv filename\n\nUse a helper fn or macro to avoid repeating `get_column(headers, record, 'name')` 40+ times. Consider a HashMap for header\u2192index lookup.", "estimatedHours": 2.5, "notes": "The catcher_arm column has two possible CSV header names: 'cArm' OR 'CA'. Check both. SPD defaults to 10 if missing. Use a transaction to batch all inserts." }, @@ -217,9 +246,11 @@ "description": "Port the pitcher CSV importer (similar structure to batter but with endurance parsing, different column names, and pitcher-specific fields). Also port import_all_cards orchestrator that runs both imports and defaults CSV paths.", "category": "medium", "priority": 11, - "completed": false, - "tested": false, - "dependencies": ["MED-002"], + "completed": true, + "tested": true, + "dependencies": [ + "MED-002" + ], "files": [ { "path": "rust/src/api/importer.rs", @@ -227,9 +258,9 @@ "issue": "File doesn't exist yet" } ], - "suggestedFix": "Implement `pub async fn import_pitcher_cards(pool: &SqlitePool, csv_path: &Path, update_existing: bool) -> Result`:\n\nSame structure as batter import with key differences:\n1. player.hand validated as L/R only (no S for pitchers)\n2. CSV 'vlhp' columns map to DB 'vlhb' fields (vs Left-Handed Batters)\n3. Endurance parsing: try columns 'ENDURANCE' or 'cleanEndur' first with parse_endurance(), then override with individual 'SP'/'RP'/'CP' columns if present (parse_int)\n4. Additional fields: hold_rating ('HO'), fielding_range ('Range'), fielding_error ('Error'), wild_pitch ('WP'), balk ('BK'), batting_rating ('BAT-B')\n5. Watch for 'BP1b v rhp' — lowercase 'b' in CSV header\n6. 'BPHR vL' and 'BPHR vR' (not 'BPHR v lhp'/'BPHR v rhp') for pitcher ballpark HR columns\n\nImplement `pub async fn import_all_cards(pool: &SqlitePool, batter_csv: Option<&Path>, pitcher_csv: Option<&Path>, update_existing: bool) -> Result`:\n1. Default paths: docs/sheets_export/BatterCalcs.csv and PitcherCalcs.csv\n2. Run batter import, then pitcher import\n3. Return combined results\n4. Note: Python version calls rebuild_score_cache() after import — defer this to Phase 3 (calc layer) and add a TODO comment.", + "suggestedFix": "Implement `pub async fn import_pitcher_cards(pool: &SqlitePool, csv_path: &Path, update_existing: bool) -> Result`:\n\nSame structure as batter import with key differences:\n1. player.hand validated as L/R only (no S for pitchers)\n2. CSV 'vlhp' columns map to DB 'vlhb' fields (vs Left-Handed Batters)\n3. Endurance parsing: try columns 'ENDURANCE' or 'cleanEndur' first with parse_endurance(), then override with individual 'SP'/'RP'/'CP' columns if present (parse_int)\n4. Additional fields: hold_rating ('HO'), fielding_range ('Range'), fielding_error ('Error'), wild_pitch ('WP'), balk ('BK'), batting_rating ('BAT-B')\n5. Watch for 'BP1b v rhp' \u2014 lowercase 'b' in CSV header\n6. 'BPHR vL' and 'BPHR vR' (not 'BPHR v lhp'/'BPHR v rhp') for pitcher ballpark HR columns\n\nImplement `pub async fn import_all_cards(pool: &SqlitePool, batter_csv: Option<&Path>, pitcher_csv: Option<&Path>, update_existing: bool) -> Result`:\n1. Default paths: docs/sheets_export/BatterCalcs.csv and PitcherCalcs.csv\n2. Run batter import, then pitcher import\n3. Return combined results\n4. Note: Python version calls rebuild_score_cache() after import \u2014 defer this to Phase 3 (calc layer) and add a TODO comment.", "estimatedHours": 2.5, - "notes": "The pitcher importer has more gotchas than the batter importer. The endurance column fallback logic (ENDURANCE → cleanEndur → individual SP/RP/CP) must be implemented carefully. Extract shared CSV reading logic (header lookup, record iteration, error collection) into helper functions to avoid duplication with the batter importer." + "notes": "The pitcher importer has more gotchas than the batter importer. The endurance column fallback logic (ENDURANCE \u2192 cleanEndur \u2192 individual SP/RP/CP) must be implemented carefully. Extract shared CSV reading logic (header lookup, record iteration, error collection) into helper functions to avoid duplication with the batter importer." } ], "quickWins": [ @@ -241,7 +272,7 @@ { "taskId": "MED-001", "estimatedMinutes": 30, - "impact": "Pure functions with no dependencies — can be implemented and tested immediately" + "impact": "Pure functions with no dependencies \u2014 can be implemented and tested immediately" } ], "productionBlockers": [ @@ -257,30 +288,44 @@ "weeklyRoadmap": { "session1": { "theme": "API Client Foundation", - "tasks": ["CRIT-001", "CRIT-002", "CRIT-003", "HIGH-001"], + "tasks": [ + "CRIT-001", + "CRIT-002", + "CRIT-003", + "HIGH-001" + ], "estimatedHours": 5.5, "notes": "Get the HTTP client fully working with typed responses. Test against the live API with cargo run." }, "session2": { "theme": "Sync Pipeline", - "tasks": ["HIGH-002", "HIGH-003", "HIGH-004", "HIGH-005"], + "tasks": [ + "HIGH-002", + "HIGH-003", + "HIGH-004", + "HIGH-005" + ], "estimatedHours": 4.5, "notes": "All three sync functions plus the orchestrator. Test by syncing real data and querying with Phase 1 query functions." }, "session3": { "theme": "CSV Import Pipeline", - "tasks": ["MED-001", "MED-002", "MED-003"], + "tasks": [ + "MED-001", + "MED-002", + "MED-003" + ], "estimatedHours": 6, "notes": "Parse helpers, batter importer, pitcher importer. Test against existing BatterCalcs.csv and PitcherCalcs.csv files." } }, "architecturalDecisions": { - "serde_rename_for_field_mapping": "Use #[serde(rename = \"...\")] on API response structs to handle JSON↔Rust name mismatches at deserialization time, not at the sync layer. This keeps sync functions clean.", + "serde_rename_for_field_mapping": "Use #[serde(rename = \"...\")] on API response structs to handle JSON\u2194Rust name mismatches at deserialization time, not at the sync layer. This keeps sync functions clean.", "player_hand_preservation": "sync_players must use ON CONFLICT DO UPDATE with explicit column list (omitting hand) instead of INSERT OR REPLACE, which would null out hand. Hand is populated only by CSV import.", "transaction_batching": "Wrap all sync and import operations in sqlx transactions for atomicity and performance. SQLite is much faster with batched inserts inside a transaction.", "error_collection_not_abort": "CSV import collects per-row errors into a Vec and continues processing remaining rows, matching the Python behavior. Only truly fatal errors (file not found, DB connection lost) abort the entire import.", "csv_header_lookup": "Build a HashMap from CSV headers for O(1) column lookup by name. This handles the variant column names (cArm vs CA, ENDURANCE vs cleanEndur) cleanly.", - "no_score_cache_rebuild_yet": "import_all_cards will NOT call rebuild_score_cache() — that belongs to Phase 3 (calc layer). Add a TODO comment at the call site." + "no_score_cache_rebuild_yet": "import_all_cards will NOT call rebuild_score_cache() \u2014 that belongs to Phase 3 (calc layer). Add a TODO comment at the call site." }, "testingStrategy": { "api_client": "Write integration tests that call the live API (behind a #[cfg(feature = \"integration\")] gate or #[ignore] attribute). Test at minimum: get_teams, get_players, get_current. Verify serde deserialization works with real API responses.", @@ -288,4 +333,4 @@ "csv_helpers": "Full unit test coverage for parse_float, parse_int, parse_endurance with edge cases.", "csv_import": "Create small test CSV files (3-5 rows) with known values. Import into in-memory DB, then query and assert field values match. Test both insert and update paths." } -} +} \ No newline at end of file diff --git a/rust/PHASE3_PROJECT_PLAN.json b/rust/PHASE3_PROJECT_PLAN.json index 20472c7..1f0b677 100644 --- a/rust/PHASE3_PROJECT_PLAN.json +++ b/rust/PHASE3_PROJECT_PLAN.json @@ -5,14 +5,14 @@ "lastUpdated": "2026-02-27", "planType": "migration", "phase": "Phase 3: Calc Layer (League Stats, Matchup Scoring, Score Cache)", - "description": "Port the matchup calculation pipeline — league stat distributions, batter/pitcher component scoring, matchup result assembly, and score cache — from Python to Rust. Builds on the existing standardize_value/weights stubs from Phase 1.", + "description": "Port the matchup calculation pipeline \u2014 league stat distributions, batter/pitcher component scoring, matchup result assembly, and score cache \u2014 from Python to Rust. Builds on the existing standardize_value/weights stubs from Phase 1.", "totalEstimatedHours": 14, "totalTasks": 9, - "completedTasks": 0, + "completedTasks": 9, "existingCode": "weights.rs is complete (StatWeight, BATTER_WEIGHTS, PITCHER_WEIGHTS, max scores). matchup.rs has standardize_value and calculate_weighted_score with tests. league_stats.rs has only the StatDistribution struct." }, "categories": { - "critical": "Foundation — league stats computation that everything else depends on", + "critical": "Foundation \u2014 league stats computation that everything else depends on", "high": "Core matchup calculation logic", "medium": "Score cache for performance", "low": "Cache validation and convenience functions" @@ -24,24 +24,30 @@ "description": "Add BatterLeagueStats and PitcherLeagueStats structs to league_stats.rs, plus the _calc_distribution function. This is the mathematical foundation for all scoring.\n\nBatterLeagueStats has 18 StatDistribution fields (9 stats x 2 splits: vlhp, vrhp). PitcherLeagueStats has 18 fields (9 stats x 2 splits: vlhb, vrhb). Stats: so, bb, hit, ob, tb, hr, dp, bphr, bp1b.\n\nCRITICAL MATH: _calc_distribution(values: &[f64]) -> StatDistribution\n- avg: mean of NON-ZERO values only (filter out 0.0, then mean). If fewer than 2 non-zero values, avg = 0.0.\n- stdev: sample standard deviation of ALL values including zeros (Bessel's correction, divide by N-1). If fewer than 2 values, stdev = 1.0. If stdev == 0.0, use 1.0 to prevent division by zero.\n- This asymmetry (avg excludes zeros, stdev includes zeros) is intentional and matches the Python statistics.mean/statistics.stdev behavior.\n\nImplement sample stdev manually: sqrt(sum((x - mean_all)^2) / (n - 1)). Do NOT use the population stdev formula. The mean used in the stdev formula should be the mean of ALL values (including zeros), not the non-zero mean used for the returned avg field.", "category": "critical", "priority": 1, - "completed": false, - "tested": false, + "completed": true, + "tested": true, "dependencies": [], "files": [ { "path": "rust/src/calc/league_stats.rs", - "lines": [1, 6], + "lines": [ + 1, + 6 + ], "issue": "Only has StatDistribution struct, no calculation functions" }, { "path": "src/sba_scout/calc/league_stats.py", - "lines": [1, 80], - "issue": "Python reference — _calc_distribution and stats structs" + "lines": [ + 1, + 80 + ], + "issue": "Python reference \u2014 _calc_distribution and stats structs" } ], "suggestedFix": "1. Add structs:\n```rust\npub struct BatterLeagueStats {\n pub so_vlhp: StatDistribution, pub bb_vlhp: StatDistribution, ...\n pub so_vrhp: StatDistribution, pub bb_vrhp: StatDistribution, ...\n}\npub struct PitcherLeagueStats {\n pub so_vlhb: StatDistribution, pub bb_vlhb: StatDistribution, ...\n pub so_vrhb: StatDistribution, pub bb_vrhb: StatDistribution, ...\n}\n```\n\n2. Add calc_distribution:\n```rust\nfn calc_distribution(values: &[f64]) -> StatDistribution {\n let non_zero: Vec = values.iter().copied().filter(|&v| v > 0.0).collect();\n let avg = if non_zero.len() >= 2 {\n non_zero.iter().sum::() / non_zero.len() as f64\n } else { 0.0 };\n \n let n = values.len();\n if n < 2 { return StatDistribution { avg, stdev: 1.0 }; }\n let mean_all = values.iter().sum::() / n as f64;\n let variance = values.iter().map(|&x| (x - mean_all).powi(2)).sum::() / (n - 1) as f64;\n let stdev = variance.sqrt();\n let stdev = if stdev == 0.0 { 1.0 } else { stdev };\n StatDistribution { avg, stdev }\n}\n```\n\n3. Add Default impls that return all StatDistribution { avg: 0.0, stdev: 1.0 }.\n\n4. Write unit tests covering: empty input, single value, all zeros, mixed values with zeros, normal distribution.", "estimatedHours": 2, - "notes": "The stdev formula is the trickiest part. Python's statistics.stdev uses N-1 (sample stdev). The mean used inside the stdev calculation must be the mean of ALL values (including zeros) — NOT the non-zero mean that's returned as the avg field. These are two different means for two different purposes." + "notes": "The stdev formula is the trickiest part. Python's statistics.stdev uses N-1 (sample stdev). The mean used inside the stdev calculation must be the mean of ALL values (including zeros) \u2014 NOT the non-zero mean that's returned as the avg field. These are two different means for two different purposes." }, { "id": "CRIT-002", @@ -49,24 +55,32 @@ "description": "Add async functions to compute BatterLeagueStats and PitcherLeagueStats from the database, plus an in-memory cache with invalidation.\n\ncalculate_batter_league_stats(pool) fetches ALL batter_cards, extracts each stat column into a Vec, and calls calc_distribution for each of the 18 stat+split combinations.\n\ncalculate_pitcher_league_stats(pool) does the same for pitcher_cards.\n\nThe cache uses module-level state (OnceLock or tokio::sync::OnceCell) to avoid recomputing on every matchup. clear_league_stats_cache() resets the cache (called after card imports).", "category": "critical", "priority": 2, - "completed": false, - "tested": false, - "dependencies": ["CRIT-001"], + "completed": true, + "tested": true, + "dependencies": [ + "CRIT-001" + ], "files": [ { "path": "rust/src/calc/league_stats.rs", - "lines": [1, 6], + "lines": [ + 1, + 6 + ], "issue": "No DB functions" }, { "path": "src/sba_scout/calc/league_stats.py", - "lines": [80, 160], - "issue": "Python reference — calculate_*_league_stats, get_*_league_stats, clear cache" + "lines": [ + 80, + 160 + ], + "issue": "Python reference \u2014 calculate_*_league_stats, get_*_league_stats, clear cache" } ], "suggestedFix": "1. Add DB query functions:\n```rust\npub async fn calculate_batter_league_stats(pool: &SqlitePool) -> Result\npub async fn calculate_pitcher_league_stats(pool: &SqlitePool) -> Result\n```\n\nFor batter stats: `SELECT so_vlhp, bb_vlhp, hit_vlhp, ... FROM batter_cards`. Then for each column, collect into Vec and call calc_distribution.\n\nFor the in-memory cache, use `tokio::sync::OnceCell` or `std::sync::OnceLock` with a `Mutex` wrapper:\n```rust\nstatic BATTER_STATS: OnceLock>> = OnceLock::new();\nstatic PITCHER_STATS: OnceLock>> = OnceLock::new();\n\npub async fn get_batter_league_stats(pool: &SqlitePool) -> Result\npub async fn get_pitcher_league_stats(pool: &SqlitePool) -> Result\npub fn clear_league_stats_cache()\n```\n\n2. Since BatterLeagueStats/PitcherLeagueStats will be shared across threads, they need Clone. The cache getter should return a cloned value.\n\n3. Wire clear_league_stats_cache() into the importer: add a call at the end of import_all_cards in api/importer.rs (replace the Phase 3 TODO comment).", "estimatedHours": 2, - "notes": "The Python cache is never invalidated automatically — only by explicit clear_league_stats_cache(). Match this behavior. The DB query fetches ALL cards (no season filter) — this is intentional since league stats should represent the full card pool." + "notes": "The Python cache is never invalidated automatically \u2014 only by explicit clear_league_stats_cache(). Match this behavior. The DB query fetches ALL cards (no season filter) \u2014 this is intentional since league stats should represent the full card pool." }, { "id": "HIGH-001", @@ -74,22 +88,28 @@ "description": "Add the MatchupResult struct and tier assignment function to matchup.rs.\n\nMatchupResult holds the complete output of a matchup calculation: the player, their rating, tier grade, effective batting hand (after switch-hitter resolution), split labels, and individual batter/pitcher component scores.\n\nget_tier maps a rating to A/B/C/D/F tier grades.", "category": "high", "priority": 3, - "completed": false, - "tested": false, + "completed": true, + "tested": true, "dependencies": [], "files": [ { "path": "rust/src/calc/matchup.rs", - "lines": [1, 43], + "lines": [ + 1, + 43 + ], "issue": "Has standardize_value and calculate_weighted_score, but no MatchupResult or get_tier" }, { "path": "src/sba_scout/calc/matchup.py", - "lines": [1, 60], - "issue": "Python reference — MatchupResult dataclass and get_tier" + "lines": [ + 1, + 60 + ], + "issue": "Python reference \u2014 MatchupResult dataclass and get_tier" } ], - "suggestedFix": "Add to matchup.rs:\n\n```rust\nuse crate::db::models::Player;\n\n#[derive(Debug, Clone)]\npub struct MatchupResult {\n pub player: Player,\n pub rating: Option,\n pub tier: String, // \"A\", \"B\", \"C\", \"D\", \"F\", or \"--\"\n pub batter_hand: String, // \"L\" or \"R\" (effective, after switch-hitter)\n pub batter_split: String, // \"vLHP\" or \"vRHP\"\n pub pitcher_split: String, // \"vLHB\" or \"vRHB\"\n pub batter_component: Option,\n pub pitcher_component: Option,\n}\n\npub fn get_tier(rating: Option) -> &'static str {\n match rating {\n None => \"--\",\n Some(r) if r >= 40.0 => \"A\",\n Some(r) if r >= 20.0 => \"B\",\n Some(r) if r >= -19.0 => \"C\",\n Some(r) if r >= -39.0 => \"D\",\n Some(_) => \"F\",\n }\n}\n```\n\nAdd display helpers:\n- `rating_display(&self) -> String` — formats as \"+15\", \"-3\", \"N/A\"\n- `split_display(&self) -> String` — formats as \"vL/vR\"\n\nWrite tests for get_tier covering all boundaries: 40, 20, 0, -19, -20, -39, -40, None.", + "suggestedFix": "Add to matchup.rs:\n\n```rust\nuse crate::db::models::Player;\n\n#[derive(Debug, Clone)]\npub struct MatchupResult {\n pub player: Player,\n pub rating: Option,\n pub tier: String, // \"A\", \"B\", \"C\", \"D\", \"F\", or \"--\"\n pub batter_hand: String, // \"L\" or \"R\" (effective, after switch-hitter)\n pub batter_split: String, // \"vLHP\" or \"vRHP\"\n pub pitcher_split: String, // \"vLHB\" or \"vRHB\"\n pub batter_component: Option,\n pub pitcher_component: Option,\n}\n\npub fn get_tier(rating: Option) -> &'static str {\n match rating {\n None => \"--\",\n Some(r) if r >= 40.0 => \"A\",\n Some(r) if r >= 20.0 => \"B\",\n Some(r) if r >= -19.0 => \"C\",\n Some(r) if r >= -39.0 => \"D\",\n Some(_) => \"F\",\n }\n}\n```\n\nAdd display helpers:\n- `rating_display(&self) -> String` \u2014 formats as \"+15\", \"-3\", \"N/A\"\n- `split_display(&self) -> String` \u2014 formats as \"vL/vR\"\n\nWrite tests for get_tier covering all boundaries: 40, 20, 0, -19, -20, -39, -40, None.", "estimatedHours": 1, "notes": "The tier boundaries are asymmetric: C spans -19 to +19 (39 units), A is >= 40, F is < -39. Player struct needs Clone derive if it doesn't have it already (check models.rs)." }, @@ -99,24 +119,32 @@ "description": "Add _calculate_batter_component and _calculate_pitcher_component functions to matchup.rs. These compute the individual batter and pitcher scores by applying weighted standardization across all 9 stats for the appropriate handedness split.\n\nThe batter component iterates BATTER_WEIGHTS, looks up the stat value on the BatterCard for the correct split (vlhp or vrhp based on pitcher hand), looks up the corresponding distribution from BatterLeagueStats, and sums the weighted scores.\n\nThe pitcher component does the same with PITCHER_WEIGHTS, PitcherCard, and PitcherLeagueStats.", "category": "high", "priority": 4, - "completed": false, - "tested": false, - "dependencies": ["CRIT-001"], + "completed": true, + "tested": true, + "dependencies": [ + "CRIT-001" + ], "files": [ { "path": "rust/src/calc/matchup.rs", - "lines": [36, 43], + "lines": [ + 36, + 43 + ], "issue": "Has calculate_weighted_score but no component functions" }, { "path": "src/sba_scout/calc/matchup.py", - "lines": [60, 130], - "issue": "Python reference — _calculate_batter_component and _calculate_pitcher_component" + "lines": [ + 60, + 130 + ], + "issue": "Python reference \u2014 _calculate_batter_component and _calculate_pitcher_component" } ], "suggestedFix": "The tricky part is mapping stat name strings to struct fields. Since Rust doesn't have runtime attribute access like Python's getattr(), use a helper that maps stat name + split to the card field value and the league stats distribution.\n\nOption A (recommended): Write explicit match arms:\n```rust\nfn get_batter_stat(card: &BatterCard, stat: &str, vs_hand: &str) -> f64 {\n match (stat, vs_hand) {\n (\"so\", \"L\") => card.so_vlhp.unwrap_or(0.0),\n (\"so\", \"R\") => card.so_vrhp.unwrap_or(0.0),\n (\"bb\", \"L\") => card.bb_vlhp.unwrap_or(0.0),\n // ... all 18 combinations\n }\n}\nfn get_batter_dist<'a>(stats: &'a BatterLeagueStats, stat: &str, vs_hand: &str) -> &'a StatDistribution {\n match (stat, vs_hand) {\n (\"so\", \"L\") => &stats.so_vlhp,\n // ... all 18\n }\n}\n```\n\nThen the component functions are clean:\n```rust\nfn calculate_batter_component(card: &BatterCard, pitcher_hand: &str, league_stats: &BatterLeagueStats) -> f64 {\n BATTER_WEIGHTS.iter().map(|(stat, weight)| {\n let value = get_batter_stat(card, stat, pitcher_hand);\n let dist = get_batter_dist(league_stats, stat, pitcher_hand);\n calculate_weighted_score(value, dist, weight)\n }).sum()\n}\n```\n\nSame pattern for pitcher. Result range: batter [-66, +66], pitcher [-69, +69].\n\nWrite tests with known distributions and card values to verify correct summation.", "estimatedHours": 2.5, - "notes": "vs_hand for batter component is the PITCHER's throwing hand (L or R). vs_hand for pitcher component is the BATTER's effective batting hand. The caller resolves switch hitters before calling these functions. All BatterCard stat fields are Option — unwrap_or(0.0) matches the Python behavior where None → standardize_value returns 3." + "notes": "vs_hand for batter component is the PITCHER's throwing hand (L or R). vs_hand for pitcher component is the BATTER's effective batting hand. The caller resolves switch hitters before calling these functions. All BatterCard stat fields are Option \u2014 unwrap_or(0.0) matches the Python behavior where None \u2192 standardize_value returns 3." }, { "id": "HIGH-003", @@ -124,9 +152,12 @@ "description": "Add the main matchup orchestration functions to matchup.rs. These handle switch-hitter resolution, call the component functions, combine scores with pitcher inversion, and assign tiers.\n\ncalculate_matchup: single batter vs single pitcher. Resolves effective batting hand for switch hitters (S bats left vs RHP, right vs LHP). Combines batter_component + (-pitcher_component) for total rating.\n\ncalculate_team_matchups: runs calculate_matchup for a list of batters, sorts by rating descending (None-rated last).", "category": "high", "priority": 5, - "completed": false, - "tested": false, - "dependencies": ["HIGH-001", "HIGH-002"], + "completed": true, + "tested": true, + "dependencies": [ + "HIGH-001", + "HIGH-002" + ], "files": [ { "path": "rust/src/calc/matchup.rs", @@ -135,13 +166,16 @@ }, { "path": "src/sba_scout/calc/matchup.py", - "lines": [130, 230], - "issue": "Python reference — calculate_matchup, calculate_team_matchups" + "lines": [ + 130, + 230 + ], + "issue": "Python reference \u2014 calculate_matchup, calculate_team_matchups" } ], "suggestedFix": "```rust\npub fn calculate_matchup(\n player: &Player,\n batter_card: Option<&BatterCard>,\n pitcher: &Player,\n pitcher_card: Option<&PitcherCard>,\n batter_league_stats: &BatterLeagueStats,\n pitcher_league_stats: &PitcherLeagueStats,\n) -> MatchupResult\n```\n\nSwitch-hitter resolution:\n```rust\nlet batter_hand = player.hand.as_deref().unwrap_or(\"R\");\nlet pitcher_hand = pitcher.hand.as_deref().unwrap_or(\"R\");\nlet effective_batting_hand = if batter_hand == \"S\" {\n if pitcher_hand == \"R\" { \"L\" } else { \"R\" }\n} else { batter_hand };\nlet batter_split = if pitcher_hand == \"L\" { \"vLHP\" } else { \"vRHP\" };\nlet pitcher_split = if effective_batting_hand == \"L\" { \"vLHB\" } else { \"vRHB\" };\n```\n\nScore combination:\n```rust\nlet batter_component = calculate_batter_component(card, pitcher_hand, batter_stats);\nlet pitcher_component = pitcher_card.map(|pc| calculate_pitcher_component(pc, effective_batting_hand, pitcher_stats));\nlet total = match pitcher_component {\n Some(pc) => batter_component + (-pc), // INVERT pitcher score\n None => batter_component,\n};\n```\n\ncalculate_team_matchups: iterate batters, call calculate_matchup for each, sort by (has_rating desc, rating desc).\n\nTests: switch-hitter resolution (S vs R = bats L, S vs L = bats R), batter-only matchup (no pitcher card), full matchup with inversion.", "estimatedHours": 2, - "notes": "The pitcher component is INVERTED (negated) when combining. A high pitcher score means the pitcher is good, which is BAD for the batter — hence the negation. This is the most important sign convention to get right." + "notes": "The pitcher component is INVERTED (negated) when combining. A high pitcher score means the pitcher is good, which is BAD for the batter \u2014 hence the negation. This is the most important sign convention to get right." }, { "id": "MED-001", @@ -149,8 +183,8 @@ "description": "Add query functions for the StandardizedScoreCache table to db/queries.rs. These are used by both the cache rebuild and the cached matchup path.", "category": "medium", "priority": 6, - "completed": false, - "tested": false, + "completed": true, + "tested": true, "dependencies": [], "files": [ { @@ -160,33 +194,45 @@ }, { "path": "src/sba_scout/calc/score_cache.py", - "lines": [120, 160], - "issue": "Python reference — get_cached_batter_score, get_cached_pitcher_score" + "lines": [ + 120, + 160 + ], + "issue": "Python reference \u2014 get_cached_batter_score, get_cached_pitcher_score" } ], - "suggestedFix": "Add to queries.rs:\n\n```rust\npub async fn get_cached_batter_score(\n pool: &SqlitePool,\n batter_card_id: i64,\n split: &str,\n) -> Result>\n```\n`SELECT * FROM standardized_score_cache WHERE batter_card_id = ? AND split = ?`\n\n```rust\npub async fn get_cached_pitcher_score(\n pool: &SqlitePool,\n pitcher_card_id: i64,\n split: &str,\n) -> Result>\n```\n`SELECT * FROM standardized_score_cache WHERE pitcher_card_id = ? AND split = ?`\n\n```rust\npub async fn clear_score_cache(pool: &SqlitePool) -> Result\n```\n`DELETE FROM standardized_score_cache` — returns rows_affected.\n\n```rust\npub async fn insert_score_cache(\n pool: &SqlitePool,\n batter_card_id: Option,\n pitcher_card_id: Option,\n split: &str,\n total_score: f64,\n stat_scores: &str,\n weights_hash: &str,\n league_stats_hash: &str,\n) -> Result<()>\n```\n`INSERT INTO standardized_score_cache (...) VALUES (...)`", + "suggestedFix": "Add to queries.rs:\n\n```rust\npub async fn get_cached_batter_score(\n pool: &SqlitePool,\n batter_card_id: i64,\n split: &str,\n) -> Result>\n```\n`SELECT * FROM standardized_score_cache WHERE batter_card_id = ? AND split = ?`\n\n```rust\npub async fn get_cached_pitcher_score(\n pool: &SqlitePool,\n pitcher_card_id: i64,\n split: &str,\n) -> Result>\n```\n`SELECT * FROM standardized_score_cache WHERE pitcher_card_id = ? AND split = ?`\n\n```rust\npub async fn clear_score_cache(pool: &SqlitePool) -> Result\n```\n`DELETE FROM standardized_score_cache` \u2014 returns rows_affected.\n\n```rust\npub async fn insert_score_cache(\n pool: &SqlitePool,\n batter_card_id: Option,\n pitcher_card_id: Option,\n split: &str,\n total_score: f64,\n stat_scores: &str,\n weights_hash: &str,\n league_stats_hash: &str,\n) -> Result<()>\n```\n`INSERT INTO standardized_score_cache (...) VALUES (...)`", "estimatedHours": 1, "notes": "The StandardizedScoreCache model already exists in models.rs with correct fields. These are straightforward single-table queries." }, { "id": "MED-002", "name": "Implement score cache rebuild (score_cache.rs)", - "description": "Create calc/score_cache.rs with the cache rebuild logic: compute per-stat scores for every card×split combination, serialize as JSON, and insert into the StandardizedScoreCache table.\n\nAlso implement the hash functions for cache validity checking.", + "description": "Create calc/score_cache.rs with the cache rebuild logic: compute per-stat scores for every card\u00d7split combination, serialize as JSON, and insert into the StandardizedScoreCache table.\n\nAlso implement the hash functions for cache validity checking.", "category": "medium", "priority": 7, - "completed": false, - "tested": false, - "dependencies": ["CRIT-002", "MED-001"], + "completed": true, + "tested": true, + "dependencies": [ + "CRIT-002", + "MED-001" + ], "files": [ { "path": "rust/src/calc/mod.rs", - "lines": [1, 3], + "lines": [ + 1, + 3 + ], "issue": "No score_cache module" }, { "path": "src/sba_scout/calc/score_cache.py", - "lines": [1, 120], - "issue": "Python reference — full score cache implementation" + "lines": [ + 1, + 120 + ], + "issue": "Python reference \u2014 full score cache implementation" } ], "suggestedFix": "Create `rust/src/calc/score_cache.rs` and add `pub mod score_cache;` to calc/mod.rs.\n\n1. Define StatScore:\n```rust\n#[derive(Debug, Serialize, Deserialize)]\npub struct StatScore {\n pub raw: f64,\n pub std: i32,\n pub weighted: f64,\n}\n```\n\n2. Hash functions:\n```rust\nfn compute_weights_hash() -> String\n```\nSerialize BATTER_WEIGHTS + PITCHER_WEIGHTS as JSON, SHA-256, take first 16 hex chars. Use the `sha2` crate (already in Cargo.toml).\n\n```rust\nfn compute_league_stats_hash(batter: &BatterLeagueStats, pitcher: &PitcherLeagueStats) -> String\n```\nHash 5 representative values: batter hit_vrhp avg/stdev, batter so_vrhp avg, pitcher hit_vrhb avg, pitcher so_vrhb avg.\n\n3. Split score calculation:\n```rust\nfn calculate_batter_split_scores(card: &BatterCard, split: &str, stats: &BatterLeagueStats) -> (f64, HashMap)\nfn calculate_pitcher_split_scores(card: &PitcherCard, split: &str, stats: &PitcherLeagueStats) -> (f64, HashMap)\n```\nIterate BATTER/PITCHER_WEIGHTS, compute raw/std/weighted for each stat, collect into HashMap, sum total.\n\n4. Main rebuild:\n```rust\npub async fn rebuild_score_cache(pool: &SqlitePool) -> Result\n```\n- Compute league stats\n- Clear existing cache\n- For each batter card: compute vlhp + vrhp splits, insert both\n- For each pitcher card: compute vlhb + vrhb splits, insert both\n- Use transaction for atomicity\n- Return counts\n\n5. Validity check:\n```rust\npub async fn is_cache_valid(pool: &SqlitePool) -> Result\npub async fn ensure_cache_exists(pool: &SqlitePool) -> Result<()>\n```", @@ -199,9 +245,12 @@ "description": "Add calculate_matchup_cached and calculate_team_matchups_cached async functions to matchup.rs. These use the StandardizedScoreCache table instead of computing from raw card data, for faster UI rendering.", "category": "medium", "priority": 8, - "completed": false, - "tested": false, - "dependencies": ["HIGH-003", "MED-001"], + "completed": true, + "tested": true, + "dependencies": [ + "HIGH-003", + "MED-001" + ], "files": [ { "path": "rust/src/calc/matchup.rs", @@ -210,11 +259,14 @@ }, { "path": "src/sba_scout/calc/matchup.py", - "lines": [230, 310], - "issue": "Python reference — calculate_matchup_cached, calculate_team_matchups_cached" + "lines": [ + 230, + 310 + ], + "issue": "Python reference \u2014 calculate_matchup_cached, calculate_team_matchups_cached" } ], - "suggestedFix": "```rust\npub async fn calculate_matchup_cached(\n pool: &SqlitePool,\n player: &Player,\n batter_card: Option<&BatterCard>,\n pitcher: &Player,\n pitcher_card: Option<&PitcherCard>,\n) -> Result\n```\n\n1. Same switch-hitter resolution as calculate_matchup\n2. Convert split labels to DB keys: \"vLHP\" → \"vlhp\", \"vRHP\" → \"vrhp\", \"vLHB\" → \"vlhb\", \"vRHB\" → \"vrhb\"\n3. DB lookup: get_cached_batter_score(pool, card.id, batter_split_key)\n4. If batter cache miss: return MatchupResult with rating=None, tier=\"--\"\n5. DB lookup: get_cached_pitcher_score (only if pitcher_card exists)\n6. Combine: batter_score + (-pitcher_score), same inversion as real-time\n\n```rust\npub async fn calculate_team_matchups_cached(\n pool: &SqlitePool,\n batters: &[(Player, Option)],\n pitcher: &Player,\n pitcher_card: Option<&PitcherCard>,\n) -> Result>\n```\nIterate, call cached version, sort same as real-time.", + "suggestedFix": "```rust\npub async fn calculate_matchup_cached(\n pool: &SqlitePool,\n player: &Player,\n batter_card: Option<&BatterCard>,\n pitcher: &Player,\n pitcher_card: Option<&PitcherCard>,\n) -> Result\n```\n\n1. Same switch-hitter resolution as calculate_matchup\n2. Convert split labels to DB keys: \"vLHP\" \u2192 \"vlhp\", \"vRHP\" \u2192 \"vrhp\", \"vLHB\" \u2192 \"vlhb\", \"vRHB\" \u2192 \"vrhb\"\n3. DB lookup: get_cached_batter_score(pool, card.id, batter_split_key)\n4. If batter cache miss: return MatchupResult with rating=None, tier=\"--\"\n5. DB lookup: get_cached_pitcher_score (only if pitcher_card exists)\n6. Combine: batter_score + (-pitcher_score), same inversion as real-time\n\n```rust\npub async fn calculate_team_matchups_cached(\n pool: &SqlitePool,\n batters: &[(Player, Option)],\n pitcher: &Player,\n pitcher_card: Option<&PitcherCard>,\n) -> Result>\n```\nIterate, call cached version, sort same as real-time.", "estimatedHours": 1.5, "notes": "The cached path avoids recomputing league stats and standardization on every request. The cache should be rebuilt after card imports (ensure_cache_exists) and when weights change (is_cache_valid check)." }, @@ -224,19 +276,23 @@ "description": "Replace the TODO comment in api/importer.rs import_all_cards with an actual call to rebuild_score_cache. Add an integration test that imports test CSV data, rebuilds the cache, and verifies cached matchup scores match real-time scores.", "category": "low", "priority": 9, - "completed": false, - "tested": false, - "dependencies": ["MED-002"], + "completed": true, + "tested": true, + "dependencies": [ + "MED-002" + ], "files": [ { "path": "rust/src/api/importer.rs", - "lines": [513], + "lines": [ + 513 + ], "issue": "TODO comment for Phase 3 cache rebuild" } ], "suggestedFix": "1. In import_all_cards, replace the TODO with:\n```rust\nif batters.imported > 0 || pitchers.imported > 0 {\n crate::calc::league_stats::clear_league_stats_cache();\n crate::calc::score_cache::rebuild_score_cache(pool).await?;\n}\n```\n\n2. Create tests/calc_integration.rs:\n- Create in-memory SQLite DB\n- Insert a few test players, batter cards, pitcher cards with known stat values\n- Call rebuild_score_cache\n- Call calculate_matchup (real-time) and calculate_matchup_cached\n- Assert both produce the same rating and tier\n- Test switch-hitter resolution\n- Test batter-only matchup (no pitcher card)", "estimatedHours": 1.5, - "notes": "This is the integration point that ties Phase 2 (import) to Phase 3 (calc). The test doesn't need real CSV files — just insert data directly into the DB." + "notes": "This is the integration point that ties Phase 2 (import) to Phase 3 (calc). The test doesn't need real CSV files \u2014 just insert data directly into the DB." } ], "quickWins": [ @@ -264,26 +320,38 @@ "weeklyRoadmap": { "session1": { "theme": "League Stats + Matchup Foundation", - "tasks": ["CRIT-001", "HIGH-001", "MED-001"], + "tasks": [ + "CRIT-001", + "HIGH-001", + "MED-001" + ], "estimatedHours": 4, - "notes": "calc_distribution math, MatchupResult/get_tier, and cache DB queries. All independent — can run in parallel." + "notes": "calc_distribution math, MatchupResult/get_tier, and cache DB queries. All independent \u2014 can run in parallel." }, "session2": { "theme": "Component Scoring + DB Integration", - "tasks": ["CRIT-002", "HIGH-002"], + "tasks": [ + "CRIT-002", + "HIGH-002" + ], "estimatedHours": 4.5, "notes": "League stats from DB + batter/pitcher component scoring. CRIT-002 depends on CRIT-001." }, "session3": { "theme": "Matchup Assembly + Cache", - "tasks": ["HIGH-003", "MED-002", "MED-003", "LOW-001"], + "tasks": [ + "HIGH-003", + "MED-002", + "MED-003", + "LOW-001" + ], "estimatedHours": 7.5, "notes": "Full matchup pipeline, score cache rebuild, cached matchup path, and integration test. This completes Phase 3." } }, "architecturalDecisions": { "stat_field_access_via_match": "Use explicit match arms to map stat name + split to struct fields, since Rust doesn't have runtime getattr(). Verbose but compile-time safe. A macro could reduce boilerplate but adds complexity.", - "sample_stdev": "Use sample standard deviation (N-1 denominator / Bessel's correction) to match Python's statistics.stdev. Implement manually — no external stats crate needed for just mean + stdev.", + "sample_stdev": "Use sample standard deviation (N-1 denominator / Bessel's correction) to match Python's statistics.stdev. Implement manually \u2014 no external stats crate needed for just mean + stdev.", "avg_excludes_zeros_stdev_includes": "The avg field excludes zero values (AVERAGEIF semantics) while stdev includes all values including zeros. This asymmetry is intentional and critical to preserve.", "league_stats_cache": "Use OnceLock>> for thread-safe in-memory cache with explicit invalidation. OnceLock for lazy initialization, Mutex for interior mutability, Option for nullable cache state.", "score_cache_json_serialization": "Serialize stat_scores HashMap as JSON string via serde_json before DB insert. Deserialize on read when needed. Matches Python's SQLAlchemy JSON column behavior.", @@ -293,7 +361,7 @@ "calc_distribution": "Test with known data sets where avg and stdev can be hand-computed. Include edge cases: empty, single value, all same, all zeros, mix of zeros and non-zeros.", "component_scoring": "Create a BatterCard/PitcherCard with known values and a BatterLeagueStats/PitcherLeagueStats with known distributions. Verify component scores match hand-calculated expectations.", "matchup_assembly": "Test switch-hitter resolution (3 cases: L, R, S), pitcher inversion sign, batter-only fallback, None card handling.", - "cache_round_trip": "Insert cards → rebuild cache → verify cached scores match real-time computed scores for the same inputs. This is the ultimate correctness check.", + "cache_round_trip": "Insert cards \u2192 rebuild cache \u2192 verify cached scores match real-time computed scores for the same inputs. This is the ultimate correctness check.", "tier_boundaries": "Test get_tier at exact boundary values: 40, 39, 20, 19, 0, -19, -20, -39, -40, None." } -} +} \ No newline at end of file diff --git a/rust/src/api/sync.rs b/rust/src/api/sync.rs index 402cf1d..053b944 100644 --- a/rust/src/api/sync.rs +++ b/rust/src/api/sync.rs @@ -13,7 +13,7 @@ pub struct SyncResult { /// Sync all teams from the league API for the given season. /// Uses INSERT OR REPLACE to handle both inserts and updates atomically. pub async fn sync_teams(pool: &SqlitePool, season: i64, client: &LeagueApiClient) -> Result { - let response = client.get_teams(Some(season), None, true, false).await?; + let response = client.get_teams(Some(season), None, false, false).await?; let mut tx = pool.begin().await?; let mut count: i64 = 0; diff --git a/rust/src/app.rs b/rust/src/app.rs index e34fa5c..3bc8693 100644 --- a/rust/src/app.rs +++ b/rust/src/app.rs @@ -12,7 +12,7 @@ use tokio::sync::mpsc; use crate::calc::matchup::MatchupResult; use crate::config::Settings; -use crate::db::models::{BatterCard, Lineup, Player, Roster, Team}; +use crate::db::models::{BatterCard, Lineup, Player, Roster, SyncStatus, Team}; use crate::screens::dashboard::DashboardState; use crate::screens::gameday::GamedayState; @@ -24,6 +24,12 @@ use crate::screens::gameday::GamedayState; pub enum AppMessage { // Dashboard RosterLoaded(Roster), + TeamInfoLoaded(String, Option), // (team_name, salary_cap) + MissingCardsLoaded { + batters: Vec, + pitchers: Vec, + }, + SyncStatusesLoaded(Vec), SyncStarted, SyncComplete(Result), @@ -143,6 +149,7 @@ impl App { let screen = ActiveScreen::Dashboard(DashboardState::new( settings.team.abbrev.clone(), settings.team.season as i64, + &settings, )); Self { screen, @@ -219,7 +226,7 @@ impl App { self.notification = Some(Notification::new(text, level)); } _ => match &mut self.screen { - ActiveScreen::Dashboard(s) => s.handle_message(msg), + ActiveScreen::Dashboard(s) => s.handle_message(msg, &self.pool, &self.tx), ActiveScreen::Gameday(s) => s.handle_message(msg), ActiveScreen::Stub(_) => {} }, @@ -230,6 +237,7 @@ impl App { let mut state = DashboardState::new( self.settings.team.abbrev.clone(), self.settings.team.season as i64, + &self.settings, ); state.mount(&self.pool, &self.tx); self.screen = ActiveScreen::Dashboard(state); diff --git a/rust/src/config.rs b/rust/src/config.rs index 53e2055..350fa7d 100644 --- a/rust/src/config.rs +++ b/rust/src/config.rs @@ -26,6 +26,8 @@ pub struct ApiSettings { pub struct TeamSettings { pub abbrev: String, pub season: i32, + pub major_league_slots: usize, + pub minor_league_slots: usize, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -74,6 +76,8 @@ impl Default for TeamSettings { Self { abbrev: "WV".to_string(), season: 13, + major_league_slots: 26, + minor_league_slots: 6, } } } diff --git a/rust/src/db/queries.rs b/rust/src/db/queries.rs index 54831c2..b364032 100644 --- a/rust/src/db/queries.rs +++ b/rust/src/db/queries.rs @@ -258,6 +258,59 @@ pub async fn update_sync_status( Ok(()) } +// ============================================================================= +// Sync Status — Bulk +// ============================================================================= + +pub async fn get_all_sync_statuses(pool: &SqlitePool) -> Result> { + let rows = sqlx::query_as::<_, SyncStatus>("SELECT * FROM sync_status ORDER BY entity_type") + .fetch_all(pool) + .await?; + Ok(rows) +} + +// ============================================================================= +// Missing Cards (team-scoped) +// ============================================================================= + +pub async fn get_players_missing_batter_cards( + pool: &SqlitePool, + team_id: i64, + season: i64, +) -> Result> { + let players = sqlx::query_as::<_, Player>( + "SELECT * FROM players \ + WHERE team_id = ? AND season = ? \ + AND pos_1 IN ('C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF', 'DH') \ + AND id NOT IN (SELECT player_id FROM batter_cards) \ + ORDER BY name", + ) + .bind(team_id) + .bind(season) + .fetch_all(pool) + .await?; + Ok(players) +} + +pub async fn get_players_missing_pitcher_cards( + pool: &SqlitePool, + team_id: i64, + season: i64, +) -> Result> { + let players = sqlx::query_as::<_, Player>( + "SELECT * FROM players \ + WHERE team_id = ? AND season = ? \ + AND (pos_1 IN ('SP', 'RP', 'CP') OR pos_2 IN ('SP', 'RP', 'CP')) \ + AND id NOT IN (SELECT player_id FROM pitcher_cards) \ + ORDER BY name", + ) + .bind(team_id) + .bind(season) + .fetch_all(pool) + .await?; + Ok(players) +} + // ============================================================================= // Matchup Cache Queries // ============================================================================= diff --git a/rust/src/screens/dashboard.rs b/rust/src/screens/dashboard.rs index 38bc10b..169d5f7 100644 --- a/rust/src/screens/dashboard.rs +++ b/rust/src/screens/dashboard.rs @@ -1,7 +1,11 @@ +use std::collections::HashMap; + +use chrono::NaiveDateTime; use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ layout::{Alignment, Constraint, Layout, Rect}, - style::{Color, Style}, + style::{Color, Modifier, Style}, + text::{Line, Span}, widgets::{Block, Borders, Paragraph}, Frame, }; @@ -19,19 +23,44 @@ pub enum SyncState { Error, } +pub struct SyncStatusInfo { + pub entity_type: String, + pub last_sync: Option, + pub count: Option, +} + pub struct DashboardState { pub team_abbrev: String, pub season: i64, + + // Roster counts pub majors_count: usize, pub minors_count: usize, pub il_count: usize, pub swar_total: f64, + + // Limits + pub swar_cap: Option, // from Team.salary_cap (DB) + pub major_slots: usize, + pub minor_slots: usize, + + // Roster health + pub team_name: String, + pub position_counts: HashMap, + pub il_players: Vec<(String, Option, Option)>, // (name, pos, il_return) + + // Card coverage + pub batters_missing_cards: Vec, + pub pitchers_missing_cards: Vec, + + // Sync pub sync_state: SyncState, pub sync_message: String, + pub sync_statuses: Vec, } impl DashboardState { - pub fn new(team_abbrev: String, season: i64) -> Self { + pub fn new(team_abbrev: String, season: i64, settings: &Settings) -> Self { Self { team_abbrev, season, @@ -39,33 +68,109 @@ impl DashboardState { minors_count: 0, il_count: 0, swar_total: 0.0, + swar_cap: None, + major_slots: settings.team.major_league_slots, + minor_slots: settings.team.minor_league_slots, + team_name: String::new(), + position_counts: HashMap::new(), + il_players: Vec::new(), + batters_missing_cards: Vec::new(), + pitchers_missing_cards: Vec::new(), sync_state: SyncState::Never, sync_message: String::new(), + sync_statuses: Vec::new(), } } - /// Fire async roster load on mount. + // ========================================================================= + // Mount + // ========================================================================= + pub fn mount(&mut self, pool: &SqlitePool, tx: &mpsc::UnboundedSender) { - let pool = pool.clone(); - let tx = tx.clone(); + // Load roster + let pool_c = pool.clone(); + let tx_c = tx.clone(); let abbrev = self.team_abbrev.clone(); let season = self.season; tokio::spawn(async move { - match crate::db::queries::get_my_roster(&pool, &abbrev, season).await { + match crate::db::queries::get_my_roster(&pool_c, &abbrev, season).await { Ok(roster) => { - let _ = tx.send(AppMessage::RosterLoaded(roster)); + let _ = tx_c.send(AppMessage::RosterLoaded(roster)); } Err(e) => { tracing::error!("Failed to load roster: {e}"); - let _ = tx.send(AppMessage::Notify( + let _ = tx_c.send(AppMessage::Notify( format!("Failed to load roster: {e}"), NotifyLevel::Error, )); } } }); + + // Load team info (full name) + let pool_c = pool.clone(); + let tx_c = tx.clone(); + let abbrev = self.team_abbrev.clone(); + let season = self.season; + tokio::spawn(async move { + if let Ok(Some(team)) = + crate::db::queries::get_team_by_abbrev(&pool_c, &abbrev, season).await + { + let _ = tx_c.send(AppMessage::TeamInfoLoaded(team.short_name, team.salary_cap)); + } + }); + + // Load missing cards + let pool_c = pool.clone(); + let tx_c = tx.clone(); + let abbrev = self.team_abbrev.clone(); + let season = self.season; + tokio::spawn(async move { + if let Ok(Some(team)) = + crate::db::queries::get_team_by_abbrev(&pool_c, &abbrev, season).await + { + let batters = crate::db::queries::get_players_missing_batter_cards( + &pool_c, team.id, season, + ) + .await + .unwrap_or_default() + .into_iter() + .map(|p| { + let pos = p.pos_1.as_deref().unwrap_or("?"); + format!("{} ({})", p.name, pos) + }) + .collect(); + + let pitchers = crate::db::queries::get_players_missing_pitcher_cards( + &pool_c, team.id, season, + ) + .await + .unwrap_or_default() + .into_iter() + .map(|p| { + let pos = p.pos_1.as_deref().unwrap_or("?"); + format!("{} ({})", p.name, pos) + }) + .collect(); + + let _ = tx_c.send(AppMessage::MissingCardsLoaded { batters, pitchers }); + } + }); + + // Load sync statuses + let pool_c = pool.clone(); + let tx_c = tx.clone(); + tokio::spawn(async move { + if let Ok(statuses) = crate::db::queries::get_all_sync_statuses(&pool_c).await { + let _ = tx_c.send(AppMessage::SyncStatusesLoaded(statuses)); + } + }); } + // ========================================================================= + // Key handling + // ========================================================================= + pub fn handle_key( &mut self, key: KeyEvent, @@ -99,13 +204,66 @@ impl DashboardState { }); } - pub fn handle_message(&mut self, msg: AppMessage) { + // ========================================================================= + // Message handling + // ========================================================================= + + pub fn handle_message( + &mut self, + msg: AppMessage, + pool: &SqlitePool, + tx: &mpsc::UnboundedSender, + ) { match msg { AppMessage::RosterLoaded(roster) => { self.majors_count = roster.majors.len(); self.minors_count = roster.minors.len(); self.il_count = roster.il.len(); self.swar_total = roster.majors.iter().filter_map(|p| p.swar).sum(); + + // Compute position counts from majors roster + let mut counts: HashMap = HashMap::new(); + for player in &roster.majors { + if let Some(pos) = &player.pos_1 { + *counts.entry(pos.clone()).or_insert(0) += 1; + } + } + self.position_counts = counts; + + // Extract IL player details + self.il_players = roster + .il + .iter() + .map(|p| { + ( + p.name.clone(), + p.pos_1.clone(), + p.il_return.clone(), + ) + }) + .collect(); + } + AppMessage::TeamInfoLoaded(name, salary_cap) => { + self.team_name = name; + self.swar_cap = salary_cap; + } + AppMessage::MissingCardsLoaded { batters, pitchers } => { + self.batters_missing_cards = batters; + self.pitchers_missing_cards = pitchers; + } + AppMessage::SyncStatusesLoaded(statuses) => { + self.sync_statuses = statuses + .into_iter() + .map(|s| SyncStatusInfo { + entity_type: s.entity_type, + last_sync: s.last_sync, + count: s.last_sync_count, + }) + .collect(); + // Show loaded sync status (don't override if currently syncing) + if self.sync_state == SyncState::Never && !self.sync_statuses.is_empty() { + self.sync_state = SyncState::Success; + } } AppMessage::SyncStarted => { self.sync_state = SyncState::Syncing; @@ -118,6 +276,27 @@ impl DashboardState { "Synced {} teams, {} players, {} transactions", r.teams, r.players, r.transactions ); + // Update sync status display with fresh data + let now = chrono::Utc::now().naive_utc(); + self.sync_statuses = vec![ + SyncStatusInfo { + entity_type: "teams".to_string(), + last_sync: Some(now), + count: Some(r.teams), + }, + SyncStatusInfo { + entity_type: "players".to_string(), + last_sync: Some(now), + count: Some(r.players), + }, + SyncStatusInfo { + entity_type: "transactions".to_string(), + last_sync: Some(now), + count: Some(r.transactions), + }, + ]; + // Re-fetch roster, team info, and card data to reflect sync changes + self.mount(pool, tx); } Err(e) => { tracing::error!("Sync failed: {e}"); @@ -129,68 +308,354 @@ impl DashboardState { } } + // ========================================================================= + // Rendering + // ========================================================================= + pub fn render(&self, frame: &mut Frame, area: Rect, tick_count: u64) { let chunks = Layout::vertical([ Constraint::Length(3), // title Constraint::Length(5), // summary cards - Constraint::Length(3), // sync status + Constraint::Length(4), // position coverage + Constraint::Length(6), // IL players + Constraint::Length(6), // card warnings + Constraint::Length(4), // sync status Constraint::Min(0), // key hints ]) .split(area); - // Title + self.render_title(frame, chunks[0]); + self.render_summary_cards(frame, chunks[1]); + self.render_position_coverage(frame, chunks[2]); + self.render_il_players(frame, chunks[3]); + self.render_card_warnings(frame, chunks[4]); + self.render_sync_status(frame, chunks[5], tick_count); + self.render_hints(frame, chunks[6]); + } + + fn render_title(&self, frame: &mut Frame, area: Rect) { + let display_name = if self.team_name.is_empty() { + &self.team_abbrev + } else { + &self.team_name + }; let title = Paragraph::new(format!( "SBA Scout -- {} -- Season {}", - self.team_abbrev, self.season + display_name, self.season )) .block(Block::default().borders(Borders::ALL)) .alignment(Alignment::Center); - frame.render_widget(title, chunks[0]); + frame.render_widget(title, area); + } - // Summary cards + fn render_summary_cards(&self, frame: &mut Frame, area: Rect) { let card_chunks = Layout::horizontal([ Constraint::Ratio(1, 4), Constraint::Ratio(1, 4), Constraint::Ratio(1, 4), Constraint::Ratio(1, 4), ]) - .split(chunks[1]); + .split(area); - let cards = [ - ("Majors", format!("{}/26", self.majors_count)), - ("Minors", format!("{}/6", self.minors_count)), - ("IL", self.il_count.to_string()), - ("sWAR", format!("{:.1}", self.swar_total)), - ]; - for (i, (label, value)) in cards.iter().enumerate() { - let card = Paragraph::new(value.as_str()) - .block(Block::default().borders(Borders::ALL).title(*label)) - .alignment(Alignment::Center); - frame.render_widget(card, card_chunks[i]); + // Majors + let majors_text = format!("{}/{}", self.majors_count, self.major_slots); + let majors = Paragraph::new(majors_text) + .block(Block::default().borders(Borders::ALL).title("Majors")) + .alignment(Alignment::Center); + frame.render_widget(majors, card_chunks[0]); + + // Minors + let minors_text = format!("{}/{}", self.minors_count, self.minor_slots); + let minors = Paragraph::new(minors_text) + .block(Block::default().borders(Borders::ALL).title("Minors")) + .alignment(Alignment::Center); + frame.render_widget(minors, card_chunks[1]); + + // IL + let il_style = if self.il_count > 0 { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Green) + }; + let il = Paragraph::new(self.il_count.to_string()) + .style(il_style) + .block(Block::default().borders(Borders::ALL).title("IL")) + .alignment(Alignment::Center); + frame.render_widget(il, card_chunks[2]); + + // sWAR with budget bar + let swar_lines = if let Some(cap) = self.swar_cap { + let pct = if cap > 0.0 { + self.swar_total / cap * 100.0 + } else { + 0.0 + }; + let swar_color = if pct >= 97.0 { + Color::Green + } else if pct >= 93.0 { + Color::LightGreen + } else if pct >= 90.0 { + Color::Yellow + } else { + Color::Red + }; + // Bar shows only the 90-100% range (each char = 1%) + let bar_width = 10; + let bar_pct = (pct - 90.0).clamp(0.0, 10.0); + let filled = bar_pct.round() as usize; + let bar: String = format!( + "{}{}", + "\u{2588}".repeat(filled), + "\u{2591}".repeat(bar_width - filled) + ); + vec![ + Line::from(Span::styled( + format!("{:.2}/{:.2} ({:.0}%)", self.swar_total, cap, pct), + Style::default().fg(swar_color), + )), + Line::from(Span::styled(bar, Style::default().fg(swar_color))), + ] + } else { + vec![Line::from(Span::styled( + format!("{:.2}", self.swar_total), + Style::default(), + ))] + }; + let swar = Paragraph::new(swar_lines) + .block(Block::default().borders(Borders::ALL).title("sWAR")) + .alignment(Alignment::Center); + frame.render_widget(swar, card_chunks[3]); + } + + fn render_position_coverage(&self, frame: &mut Frame, area: Rect) { + let batter_positions = ["C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "DH"]; + let pitcher_positions = ["SP", "RP", "CP"]; + + let mut spans: Vec = Vec::new(); + for (i, pos) in batter_positions.iter().enumerate() { + let count = self.position_counts.get(*pos).copied().unwrap_or(0); + let style = if count == 0 { + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + if i > 0 { + spans.push(Span::raw(" ")); + } + spans.push(Span::styled(format!("{}:{}", pos, count), style)); } - // Sync status - let spinner_chars = ['|', '/', '-', '\\']; - let spinner = spinner_chars[(tick_count as usize / 2) % 4]; - let sync_text = match self.sync_state { - SyncState::Never => "[s] Sync data from API".to_string(), - SyncState::Syncing => format!("{spinner} {}", self.sync_message), - SyncState::Success => format!("OK {} [s] Sync again", self.sync_message), - SyncState::Error => format!("ERR {} [s] Retry", self.sync_message), + let mut pitcher_spans: Vec = Vec::new(); + for (i, pos) in pitcher_positions.iter().enumerate() { + let count = self.position_counts.get(*pos).copied().unwrap_or(0); + let style = if count == 0 { + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + if i > 0 { + pitcher_spans.push(Span::raw(" ")); + } + pitcher_spans.push(Span::styled(format!("{}:{}", pos, count), style)); + } + + let lines = vec![ + Line::from(spans), + Line::from(pitcher_spans), + ]; + let widget = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title("Position Coverage"), + ); + frame.render_widget(widget, area); + } + + fn render_il_players(&self, frame: &mut Frame, area: Rect) { + let lines: Vec = if self.il_players.is_empty() { + vec![Line::from(Span::styled( + "No players on IL", + Style::default().fg(Color::Green), + ))] + } else { + self.il_players + .iter() + .take(4) // cap display + .map(|(name, pos, il_return)| { + let pos_str = pos.as_deref().unwrap_or("?"); + let return_str = match il_return { + Some(r) if !r.is_empty() => format!(" — returns {}", r), + _ => String::new(), + }; + Line::from(vec![ + Span::styled( + format!(" {} ({})", name, pos_str), + Style::default().fg(Color::Yellow), + ), + Span::styled(return_str, Style::default().fg(Color::DarkGray)), + ]) + }) + .collect() }; - let sync_style = match self.sync_state { + + let widget = Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .title("IL Players"), + ); + frame.render_widget(widget, area); + } + + fn render_card_warnings(&self, frame: &mut Frame, area: Rect) { + let cols = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + // Batters missing cards + let batter_lines: Vec = if self.batters_missing_cards.is_empty() { + vec![Line::from(Span::styled( + "All cards imported", + Style::default().fg(Color::Green), + ))] + } else { + self.batters_missing_cards + .iter() + .take(4) + .map(|name| { + Line::from(Span::styled( + format!(" {}", name), + Style::default().fg(Color::Red), + )) + }) + .collect() + }; + let batter_widget = Paragraph::new(batter_lines).block( + Block::default() + .borders(Borders::ALL) + .title(format!( + "Batters Missing Cards ({})", + self.batters_missing_cards.len() + )), + ); + frame.render_widget(batter_widget, cols[0]); + + // Pitchers missing cards + let pitcher_lines: Vec = if self.pitchers_missing_cards.is_empty() { + vec![Line::from(Span::styled( + "All cards imported", + Style::default().fg(Color::Green), + ))] + } else { + self.pitchers_missing_cards + .iter() + .take(4) + .map(|name| { + Line::from(Span::styled( + format!(" {}", name), + Style::default().fg(Color::Red), + )) + }) + .collect() + }; + let pitcher_widget = Paragraph::new(pitcher_lines).block( + Block::default() + .borders(Borders::ALL) + .title(format!( + "Pitchers Missing Cards ({})", + self.pitchers_missing_cards.len() + )), + ); + frame.render_widget(pitcher_widget, cols[1]); + } + + fn render_sync_status(&self, frame: &mut Frame, area: Rect, tick_count: u64) { + let lines: Vec = if self.sync_state == SyncState::Syncing { + let spinner = ['|', '/', '-', '\\'][(tick_count as usize / 2) % 4]; + vec![Line::from(Span::styled( + format!(" {} {}", spinner, self.sync_message), + Style::default(), + ))] + } else if self.sync_statuses.is_empty() { + vec![Line::from(Span::styled( + " Never synced [s] Sync now", + Style::default().fg(Color::DarkGray), + ))] + } else { + let mut spans: Vec = Vec::new(); + spans.push(Span::raw(" ")); + for (i, status) in self.sync_statuses.iter().enumerate() { + if i > 0 { + spans.push(Span::styled(" · ", Style::default().fg(Color::DarkGray))); + } + let time_str = match &status.last_sync { + Some(dt) => format_relative_time(dt), + None => "never".to_string(), + }; + let count_str = status + .count + .map(|c| format!(" ({})", c)) + .unwrap_or_default(); + spans.push(Span::styled( + format!("{} {}{}", status.entity_type, time_str, count_str), + Style::default().fg(Color::White), + )); + } + spans.push(Span::styled( + " [s] Sync", + Style::default().fg(Color::DarkGray), + )); + + let mut lines = vec![Line::from(spans)]; + // Show error or last sync message if present + if self.sync_state == SyncState::Error { + lines.push(Line::from(Span::styled( + format!(" {}", self.sync_message), + Style::default().fg(Color::Red), + ))); + } + lines + }; + + let border_style = match self.sync_state { SyncState::Error => Style::default().fg(Color::Red), - SyncState::Success => Style::default().fg(Color::Green), _ => Style::default(), }; - let sync_widget = Paragraph::new(sync_text) - .style(sync_style) - .block(Block::default().borders(Borders::ALL).title("Sync")); - frame.render_widget(sync_widget, chunks[2]); + let widget = Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .title("Sync") + .border_style(border_style), + ); + frame.render_widget(widget, area); + } - // Key hints + fn render_hints(&self, frame: &mut Frame, area: Rect) { let hints = Paragraph::new(" [s] Sync [g] Gameday [q] Quit") .style(Style::default().fg(Color::DarkGray)); - frame.render_widget(hints, chunks[3]); + frame.render_widget(hints, area); } } + +fn format_relative_time(dt: &NaiveDateTime) -> String { + let now = chrono::Utc::now().naive_utc(); + let diff = now.signed_duration_since(*dt); + + let secs = diff.num_seconds(); + if secs < 0 { + return "just now".to_string(); + } + if secs < 60 { + return "just now".to_string(); + } + let mins = secs / 60; + if mins < 60 { + return format!("{}m ago", mins); + } + let hours = mins / 60; + if hours < 24 { + return format!("{}h ago", hours); + } + let days = hours / 24; + format!("{}d ago", days) +}