diff --git a/PROJECT_PLAN.json b/PROJECT_PLAN.json index ff43cec..76a3ba3 100644 --- a/PROJECT_PLAN.json +++ b/PROJECT_PLAN.json @@ -2,13 +2,13 @@ "meta": { "version": "1.0.0", "created": "2026-01-25", - "lastUpdated": "2026-01-25", + "lastUpdated": "2026-03-01", "planType": "feature", "project": "SBA Scout TUI", "description": "Fantasy baseball scouting TUI application for the SBA (Strat-o-Matic Baseball Association) league", - "totalEstimatedHours": 40, - "totalTasks": 18, - "completedTasks": 7 + "totalEstimatedHours": 82, + "totalTasks": 29, + "completedTasks": 8 }, "categories": { "critical": "Must fix immediately - blocks core functionality", @@ -184,23 +184,17 @@ }, { "id": "FEAT-009", - "name": "Player Detail Modal", - "description": "Click on a player row to see full card details, stats, and ratings breakdown", + "name": "Player Overview Popup", + "description": "Global player overview popup accessible from any screen that displays a player name. Press a hotkey (e.g. Enter or 'i') on any highlighted player row to open a floating overlay showing full card details, stats, and ratings breakdown. Must work consistently across Dashboard, Roster, Matchup, Lineup, Gameday, and any future screens.", "category": "feature", "priority": 7, "completed": false, "tested": false, "dependencies": ["FEAT-002"], - "files": [ - { - "path": "src/sba_scout/screens/roster.py", - "lines": [], - "issue": "Row selection currently does nothing" - } - ], - "suggestedFix": "1. Create PlayerDetailModal widget\n2. Show full batter card (all vs LHP and vs RHP stats)\n3. Show full pitcher card (all vs LHB and vs RHB stats)\n4. Show fielding details at each position\n5. Show running game stats (stealing, speed)\n6. Display card image if available", - "estimatedHours": 4, - "notes": "Should work from both Roster and Matchup screens" + "files": [], + "suggestedFix": "1. Create a reusable PlayerOverlay widget in src/widgets/player_overlay.rs\n2. Show full batter card (all vs LHP and vs RHP columns, OBP/SLG/OPS, power, eye, speed)\n3. Show full pitcher card (all vs LHB and vs RHB columns, endurance S/R/C, hold rating)\n4. Show fielding details at each eligible position (range, error, arm, T-rating)\n5. Show running game stats (stealing, speed, bunt)\n6. Render as a centered floating panel with Esc to dismiss\n7. Wire the hotkey into App-level key handling so it works from any screen with a selected player", + "estimatedHours": 5, + "notes": "Universal access is key — this should be an App-level overlay, not per-screen logic. Each screen just needs to expose a 'currently selected player ID' method." }, { "id": "HIGH-001", @@ -367,6 +361,160 @@ "suggestedFix": "1. Test importer with sample CSV data\n2. Test database queries\n3. Test API client with mocked responses\n4. Test config loading", "estimatedHours": 4, "notes": "Focus on data layer tests first" + }, + { + "id": "FEAT-010", + "name": "Waiver Wire / Free Agent Browser", + "description": "Browse all unrostered players across the league, sorted by rating. Filter by position, handedness, and rating tier. Essential for finding replacements mid-season when a roster gap opens up.", + "category": "feature", + "priority": 17, + "completed": false, + "tested": false, + "dependencies": ["FEAT-004", "FEAT-003"], + "files": [], + "suggestedFix": "1. Create src/screens/waiver_wire.rs\n2. Query all players not on any team's major league roster\n3. Display with card ratings (vL/vR/Ovr), position eligibility, and tier\n4. Add filters: position dropdown, handedness toggle, min rating threshold\n5. Sort by overall rating, vL, vR, or position\n6. Add hotkey to jump directly to Player Overview popup for deeper look\n7. Consider adding a 'watchlist' save feature for tracking targets", + "estimatedHours": 5, + "notes": "Depends on API exposing all league players, not just your team. Check if the sync pipeline already pulls all teams' rosters." + }, + { + "id": "FEAT-011", + "name": "Trade Evaluator", + "description": "Side-by-side comparison tool for evaluating potential trades. Select players from your roster and an opponent's roster to compare sWAR, ratings, position scarcity, and contract value. Not AI-powered — just structured data presentation to inform trade decisions.", + "category": "feature", + "priority": 18, + "completed": false, + "tested": false, + "dependencies": ["FEAT-002", "FEAT-003"], + "files": [], + "suggestedFix": "1. Create src/screens/trade_eval.rs\n2. Two-panel layout: 'My Players' (left) vs 'Their Players' (right)\n3. Select opponent team, then pick players from each side\n4. Show per-player: name, position(s), vL/vR/Ovr, sWAR, contract/salary if available\n5. Show aggregate comparison: total sWAR, position coverage gained/lost\n6. Highlight position scarcity impact (losing your only SS vs trading from OF depth)\n7. Allow saving trade scenarios for later review", + "estimatedHours": 5, + "notes": "Keep it simple — structured comparison, no valuation algorithm needed. The human makes the judgment call." + }, + { + "id": "FEAT-012", + "name": "Bullpen Usage Planner", + "description": "Plan bullpen deployment for a game based on pitcher endurance ratings (S/R/C), handedness, and the opposing lineup's handedness mix. Strat-o-Matic bullpen management is nuanced — this screen helps optimize usage.", + "category": "feature", + "priority": 19, + "completed": false, + "tested": false, + "dependencies": ["FEAT-002", "FEAT-005"], + "files": [], + "suggestedFix": "1. Create src/screens/bullpen.rs\n2. Show all pitchers with role (SP/RP/CL), endurance (S/R/C), handedness, vLHB/vRHB ratings\n3. Select opposing lineup to see handedness breakdown (how many LHB vs RHB)\n4. Suggest optimal bullpen order: closer, setup, long relief, mop-up\n5. Show fatigue/availability indicators if usage tracking data is available\n6. Highlight platoon advantages (bring in LHP vs cluster of RHB in lineup)", + "estimatedHours": 4, + "notes": "Could integrate with the Gameday screen as a bullpen tab, or be standalone." + }, + { + "id": "FEAT-013", + "name": "Fuzzy Search / Command Palette", + "description": "Global fuzzy search overlay (Ctrl+P or /) that lets you jump to any player, team, or screen by typing. Searches across all player names, team names, and screen names with ranked fuzzy matching. Massive TUI productivity boost.", + "category": "feature", + "priority": 20, + "completed": false, + "tested": false, + "dependencies": [], + "files": [], + "suggestedFix": "1. Create src/widgets/command_palette.rs as a floating overlay\n2. Bind to Ctrl+P or / at the App level\n3. Index: all player names (with team), all team names, all screen names\n4. Fuzzy match using a simple substring/prefix scorer (or nucleo/fuzzy-matcher crate)\n5. Show results ranked by match quality with type indicators ([Player] [Team] [Screen])\n6. Enter on a player opens the Player Overview popup\n7. Enter on a team navigates to Roster filtered to that team\n8. Enter on a screen navigates to that screen\n9. Esc dismisses without action", + "estimatedHours": 4, + "notes": "Consider the nucleo crate for fuzzy matching. Keep it simple — even basic substring matching is useful." + }, + { + "id": "FEAT-014", + "name": "Notifications / Alerts Panel", + "description": "Surface actionable items that need attention: players eligible to come off IL, minor leaguers past their demotion window eligible for promotion, new transactions affecting your team, upcoming roster deadlines. Could be a dashboard widget or a dedicated panel.", + "category": "feature", + "priority": 21, + "completed": false, + "tested": false, + "dependencies": ["FEAT-004", "FEAT-007"], + "files": [], + "suggestedFix": "1. Create src/widgets/alerts.rs for the alert display component\n2. Alert types: IL_ELIGIBLE (player can come off IL), PROMOTION_ELIGIBLE (minors demotion weeks served), TRANSACTION_ALERT (new trade/waiver affecting your team), ROSTER_DEADLINE (upcoming deadline)\n3. Compute alerts on sync or mount from DB data\n4. Show count badge in nav bar or dashboard\n5. Clicking an alert navigates to the relevant screen/player\n6. Allow dismissing alerts", + "estimatedHours": 4, + "notes": "Requires transaction and roster status data to compute alerts. Some alerts may need API data not yet synced (like schedule deadlines)." + }, + { + "id": "FEAT-015", + "name": "Clipboard Lineup Export", + "description": "Copy a built lineup to the system clipboard in the exact format needed for league submission or Discord posting. Eliminates manual transcription of lineup data — the 'last mile' feature that saves real time every game day.", + "category": "feature", + "priority": 22, + "completed": false, + "tested": false, + "dependencies": ["FEAT-006"], + "files": [], + "suggestedFix": "1. Add a 'copy to clipboard' hotkey (e.g. Ctrl+C or 'y') to the Lineup and Gameday screens\n2. Format lineup as plain text: batting order with position and player name\n3. Use the arboard or cli-clipboard crate for cross-platform clipboard access\n4. Show a notification confirming the copy\n5. Consider multiple export formats: plain text, Discord markdown table, league submission format\n6. Optionally include matchup ratings in the export for Discord scouting posts", + "estimatedHours": 2, + "notes": "Small feature, huge time savings. arboard crate works on Linux/Mac/Windows." + }, + { + "id": "FEAT-016", + "name": "Standings / League Overview", + "description": "Display current league standings with W-L records, division/conference breakdowns, and optionally strength-of-schedule indicators. Provides context for scouting decisions — knowing where teams sit affects trade and waiver strategy.", + "category": "feature", + "priority": 23, + "completed": true, + "tested": true, + "dependencies": ["FEAT-004"], + "files": [], + "suggestedFix": "1. Create src/screens/standings.rs\n2. Add standings endpoint to API client (or compute from game results if available)\n3. Show table: Team, W, L, Pct, GB, Streak, Last 10\n4. Group by division/conference if the league uses them\n5. Highlight your team's row\n6. Add nav hotkey (e.g. 'w' for standings)\n7. Optional: strength of remaining schedule based on opponent records", + "estimatedHours": 3, + "notes": "Depends on whether the Major Domo API exposes standings data. May need a new sync endpoint." + }, + { + "id": "FEAT-017", + "name": "Position Scarcity Index", + "description": "Analyze how your roster depth at each defensive position compares to the league average. Helps prioritize draft picks, trades, and waiver claims by quantifying where you're deep and where you're thin.", + "category": "feature", + "priority": 24, + "completed": false, + "tested": false, + "dependencies": ["FEAT-002", "FEAT-004"], + "files": [], + "suggestedFix": "1. Could be a widget on Dashboard or a tab in the Depth Chart screen\n2. For each position: count eligible players on your roster vs league average\n3. Show quality depth: how many A/B-tier players you have at each position\n4. Color code: green (deep), yellow (adequate), red (thin)\n5. Factor in multi-position eligibility (a player covering SS+2B helps both)\n6. Show league-wide scarcity: positions where few quality players exist across all teams", + "estimatedHours": 3, + "notes": "Builds naturally on the Depth Chart screen (FEAT-008). Could share a screen or be a dashboard widget." + }, + { + "id": "FEAT-018", + "name": "What-If Roster Simulator", + "description": "Temporarily add or remove players from your roster to preview how sWAR, position coverage, depth chart, and overall team composition would change. Essential for evaluating trade and waiver scenarios before committing.", + "category": "feature", + "priority": 25, + "completed": false, + "tested": false, + "dependencies": ["FEAT-002", "FEAT-008"], + "files": [], + "suggestedFix": "1. Create src/screens/simulator.rs or add a 'simulate' mode to existing screens\n2. Start with current roster as baseline\n3. Allow adding players from other teams (search/select) and removing your own\n4. Show before/after comparison: total sWAR, position coverage grid, depth chart diff\n5. Highlight what improves and what gets worse\n6. Allow saving simulation scenarios with names for later comparison\n7. Do NOT modify the actual database — all changes are in-memory only", + "estimatedHours": 5, + "notes": "Must be clearly marked as simulation — never write simulated changes to DB. Could reuse trade evaluator components." + }, + { + "id": "FEAT-019", + "name": "Pitcher Matchup Heatmap", + "description": "Visual TUI representation of how each batter fares against a specific pitcher's card, broken down by result type (HR, 2B, 3B, 1B, BB, K, GO, FO) rather than just a composite score. Uses colored cells to show hot/cold zones across the result distribution.", + "category": "feature", + "priority": 26, + "completed": false, + "tested": false, + "dependencies": ["FEAT-005", "FEAT-003"], + "files": [], + "suggestedFix": "1. Create src/widgets/heatmap.rs for the colored-cell grid renderer\n2. For a selected pitcher, show each batter as a row\n3. Columns: HR%, XBH%, 1B%, BB%, K%, GO%, FO% (derived from card data)\n4. Color cells: green (favorable) through yellow to red (unfavorable) relative to league averages\n5. Could be a mode/tab within the Matchup screen rather than a separate screen\n6. Allow toggling between composite score view and heatmap view\n7. Use Unicode block characters or background colors for the heat visualization", + "estimatedHours": 4, + "notes": "Requires granular card result data beyond just vL/vR composite ratings. Check if BatterCalcs/PitcherCalcs CSVs contain per-result breakdowns." + }, + { + "id": "FEAT-020", + "name": "UI Overhaul", + "description": "Comprehensive visual refresh of the entire TUI. Establish a consistent design system with unified color palette, spacing, borders, header styles, and widget patterns across all screens. Current screens were built incrementally — this pass unifies the look and feel into a polished, cohesive application.", + "category": "feature", + "priority": 27, + "completed": false, + "tested": false, + "dependencies": [], + "files": [], + "suggestedFix": "1. Define a theme/style module in src/theme.rs with named colors, border styles, and layout constants\n2. Standardize screen layout: consistent nav bar, status bar, content area margins\n3. Unify table styles: header row colors, alternating row backgrounds, selection highlight\n4. Consistent use of borders (rounded vs sharp), padding, and spacing\n5. Standardize popup/overlay styling (selector, player overview, command palette)\n6. Add screen transition polish (clear rendering, no flicker)\n7. Review color choices for accessibility (sufficient contrast, colorblind-friendly tier colors)\n8. Ensure consistent key hint bar format across all screens\n9. Test at multiple terminal sizes (80x24 minimum, wide/tall layouts)\n10. Consider adding a compact mode for smaller terminals", + "estimatedHours": 6, + "notes": "Best done after most feature screens exist, so the design system covers all cases. Should touch every screen file but the changes are primarily render() methods and style constants." } ], "quickWins": [ diff --git a/rust/.claude/settings.json b/rust/.claude/settings.json new file mode 100644 index 0000000..1e9c730 --- /dev/null +++ b/rust/.claude/settings.json @@ -0,0 +1,13 @@ +{ + "hooks": { + "PostToolCall": [ + { + "matcher": { + "tool_name": "Edit|Write", + "file_glob": "**/*.rs" + }, + "command": "cd /mnt/NV2/Development/sba-scouting/rust && cargo check --message-format=short 2>&1 | grep '^src/' | head -20" + } + ] + } +} diff --git a/rust/CLAUDE.md b/rust/CLAUDE.md index f14a262..aa58d74 100644 --- a/rust/CLAUDE.md +++ b/rust/CLAUDE.md @@ -53,8 +53,10 @@ src/ - `matchup.rs` — Standalone matchup analysis with sort modes, state cached on nav-away - `lineup.rs` — Standalone lineup builder, save/load/delete with confirmation - `settings.rs` — Config form with TOML save, live team validation, per-field change indicators +- `standings.rs` — League standings with Division and Wild Card tabs, fetched live from API ## Code Style - Run `cargo fmt` and `cargo clippy` before committing +- A `cargo check` hook runs automatically after every `.rs` file edit (via `.claude/settings.json`). Run `cargo clippy` manually at logical checkpoints (before commits, after finishing a feature, after a batch of edits) to catch lint warnings beyond compile errors. - Screen pattern: state struct → `new()` → `mount()` → `handle_key()` → `handle_message()` → `render()` diff --git a/rust/src/api/client.rs b/rust/src/api/client.rs index 64d1ffd..5b96ac7 100644 --- a/rust/src/api/client.rs +++ b/rust/src/api/client.rs @@ -218,8 +218,9 @@ impl LeagueApiClient { self.get("/schedules", ¶ms).await } - pub async fn get_standings(&self, season: i64) -> Result { + pub async fn get_standings(&self, season: i64) -> Result, ApiError> { let params = vec![("season".to_string(), season.to_string())]; - self.get("/standings", ¶ms).await + let resp: StandingsResponse = self.get("/standings", ¶ms).await?; + Ok(resp.standings) } } diff --git a/rust/src/api/types.rs b/rust/src/api/types.rs index ab62a23..ed674ca 100644 --- a/rust/src/api/types.rs +++ b/rust/src/api/types.rs @@ -1,4 +1,4 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; // ============================================================================= // Shared nested types @@ -173,3 +173,74 @@ pub struct CurrentResponse { pub season: i64, pub week: i64, } + +// ============================================================================= +// Standings +// ============================================================================= + +#[derive(Debug, Deserialize, Serialize)] +pub struct StandingsTeamRef { + pub id: i64, + #[serde(default)] + pub abbrev: Option, + #[serde(rename = "sname", default)] + pub short_name: Option, + #[serde(rename = "lname", default)] + pub long_name: Option, + #[serde(default)] + pub division: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct StandingsDivision { + pub id: Option, + #[serde(default)] + pub division_name: Option, + #[serde(default)] + pub division_abbrev: Option, +} + +#[derive(Debug, Deserialize)] +pub struct StandingsResponse { + pub count: u32, + pub standings: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct StandingsEntry { + pub team: StandingsTeamRef, + #[serde(default)] + pub wins: i64, + #[serde(default)] + pub losses: i64, + #[serde(default)] + pub run_diff: i64, + #[serde(default)] + pub div_gb: Option, + #[serde(default)] + pub wc_gb: Option, + #[serde(default)] + pub home_wins: i64, + #[serde(default)] + pub home_losses: i64, + #[serde(default)] + pub away_wins: i64, + #[serde(default)] + pub away_losses: i64, + #[serde(default)] + pub last8_wins: i64, + #[serde(default)] + pub last8_losses: i64, + #[serde(default)] + pub streak_wl: Option, + #[serde(default)] + pub streak_num: i64, + #[serde(default)] + pub one_run_wins: i64, + #[serde(default)] + pub one_run_losses: i64, + #[serde(default)] + pub pythag_wins: i64, + #[serde(default)] + pub pythag_losses: i64, +} diff --git a/rust/src/app.rs b/rust/src/app.rs index 9ea3463..d83de61 100644 --- a/rust/src/app.rs +++ b/rust/src/app.rs @@ -10,6 +10,7 @@ use sqlx::sqlite::SqlitePool; use std::time::Instant; use tokio::sync::mpsc; +use crate::api::types::StandingsEntry; use crate::calc::matchup::MatchupResult; use crate::config::Settings; use crate::db::models::{BatterCard, Lineup, Player, Roster, SyncStatus, Team}; @@ -19,6 +20,7 @@ use crate::screens::lineup::LineupState; use crate::screens::matchup::MatchupState; use crate::screens::roster::{RosterRow, RosterState}; use crate::screens::settings::SettingsState; +use crate::screens::standings::StandingsState; // ============================================================================= // Messages @@ -71,6 +73,10 @@ pub enum AppMessage { // Settings SettingsTeamValidated(Option<(String, Option)>), + // Standings + StandingsCacheLoaded(Option>, Option), + StandingsRefreshed(Vec), + // General Notify(String, NotifyLevel), } @@ -119,6 +125,7 @@ pub enum ActiveScreen { Matchup(Box), Lineup(Box), Settings(Box), + Standings(Box), } impl ActiveScreen { @@ -130,6 +137,7 @@ impl ActiveScreen { ActiveScreen::Matchup(_) => "Matchup", ActiveScreen::Lineup(_) => "Lineup", ActiveScreen::Settings(_) => "Settings", + ActiveScreen::Standings(_) => "Standings", } } } @@ -236,6 +244,12 @@ impl App { return; } } + KeyCode::Char('w') => { + if !matches!(&self.screen, ActiveScreen::Standings(_)) { + self.switch_to_standings(); + return; + } + } KeyCode::Char('S') => { if !matches!(&self.screen, ActiveScreen::Settings(_)) { self.switch_to_settings(); @@ -254,6 +268,7 @@ impl App { ActiveScreen::Matchup(s) => s.handle_key(key, &self.pool, &self.settings, &self.tx), ActiveScreen::Lineup(s) => s.handle_key(key, &self.pool, &self.settings, &self.tx), ActiveScreen::Settings(s) => s.handle_key(key, &self.pool, &self.settings, &self.tx), + ActiveScreen::Standings(s) => s.handle_key(key, &self.pool, &self.settings, &self.tx), } } @@ -269,6 +284,7 @@ impl App { ActiveScreen::Matchup(s) => s.handle_message(msg, &self.pool, &self.settings, &self.tx), ActiveScreen::Lineup(s) => s.handle_message(msg, &self.pool, &self.tx), ActiveScreen::Settings(s) => s.handle_message(msg), + ActiveScreen::Standings(s) => s.handle_message(msg), }, } } @@ -345,6 +361,16 @@ impl App { self.screen = ActiveScreen::Lineup(Box::new(state)); } + fn switch_to_standings(&mut self) { + self.cache_matchup_if_active(); + let mut state = StandingsState::new( + self.settings.team.abbrev.clone(), + self.settings.team.season as i64, + ); + state.mount(&self.pool, &self.settings, &self.tx); + self.screen = ActiveScreen::Standings(Box::new(state)); + } + fn switch_to_settings(&mut self) { self.cache_matchup_if_active(); let state = SettingsState::new(&self.settings); @@ -369,6 +395,7 @@ impl App { ActiveScreen::Matchup(s) => s.render(frame, chunks[1], self.tick_count), ActiveScreen::Lineup(s) => s.render(frame, chunks[1]), ActiveScreen::Settings(s) => s.render(frame, chunks[1]), + ActiveScreen::Standings(s) => s.render(frame, chunks[1], self.tick_count), } self.render_status_bar(frame, chunks[2]); @@ -382,6 +409,7 @@ impl App { ("r", "Roster"), ("m", "Matchup"), ("l", "Lineup"), + ("w", "Standings"), ("S", "Settings"), ]; diff --git a/rust/src/db/queries.rs b/rust/src/db/queries.rs index b364032..9167ec0 100644 --- a/rust/src/db/queries.rs +++ b/rust/src/db/queries.rs @@ -518,3 +518,40 @@ pub async fn get_my_roster( Ok(Roster { majors, minors, il }) } + +// ============================================================================= +// Standings Cache +// ============================================================================= + +/// Returns (json_data, fetched_at) if cached standings exist for the season. +pub async fn get_cached_standings( + pool: &SqlitePool, + season: i64, +) -> Result> { + let row: Option<(String, chrono::NaiveDateTime)> = sqlx::query_as( + "SELECT data, fetched_at FROM standings_cache WHERE season = ?", + ) + .bind(season) + .fetch_optional(pool) + .await?; + Ok(row) +} + +pub async fn upsert_standings_cache( + pool: &SqlitePool, + season: i64, + data: &str, +) -> Result<()> { + let fetched_at = chrono::Utc::now().naive_utc(); + sqlx::query( + "INSERT INTO standings_cache (season, data, fetched_at) \ + VALUES (?, ?, ?) \ + ON CONFLICT(season) DO UPDATE SET data = excluded.data, fetched_at = excluded.fetched_at", + ) + .bind(season) + .bind(data) + .bind(fetched_at) + .execute(pool) + .await?; + Ok(()) +} diff --git a/rust/src/db/schema.rs b/rust/src/db/schema.rs index 7759dab..bcf33bc 100644 --- a/rust/src/db/schema.rs +++ b/rust/src/db/schema.rs @@ -237,7 +237,19 @@ pub async fn create_tables(pool: &SqlitePool) -> Result<()> { .execute(pool) .await?; - // 9. sync_status + // 9. standings_cache — JSON blob cache for league standings + sqlx::query( + "CREATE TABLE IF NOT EXISTS standings_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + season INTEGER NOT NULL UNIQUE, + data TEXT NOT NULL, + fetched_at TEXT NOT NULL + )", + ) + .execute(pool) + .await?; + + // 10. sync_status sqlx::query( "CREATE TABLE IF NOT EXISTS sync_status ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -256,6 +268,7 @@ pub async fn create_tables(pool: &SqlitePool) -> Result<()> { pub async fn reset_database(pool: &SqlitePool) -> Result<()> { // Drop in reverse dependency order to satisfy foreign key constraints for table in &[ + "standings_cache", "standardized_score_cache", "matchup_cache", "lineups", diff --git a/rust/src/screens/mod.rs b/rust/src/screens/mod.rs index b743ce3..3ab80f5 100644 --- a/rust/src/screens/mod.rs +++ b/rust/src/screens/mod.rs @@ -4,3 +4,4 @@ pub mod lineup; pub mod matchup; pub mod roster; pub mod settings; +pub mod standings; diff --git a/rust/src/screens/standings.rs b/rust/src/screens/standings.rs new file mode 100644 index 0000000..ff118c7 --- /dev/null +++ b/rust/src/screens/standings.rs @@ -0,0 +1,573 @@ +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Cell, Paragraph, Row, Table}, + Frame, +}; +use tokio::sync::mpsc; + +use crate::api::types::StandingsEntry; +use crate::app::{AppMessage, NotifyLevel}; +use crate::config::Settings; +use sqlx::sqlite::SqlitePool; + +// ============================================================================= +// Types +// ============================================================================= + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StandingsTab { + Division, + Wildcard, +} + +#[derive(Debug, Clone)] +pub struct StandingsRow { + pub abbrev: String, + pub team_name: String, + pub wins: i64, + pub losses: i64, + pub pct: f64, + pub gb: String, + pub run_diff: i64, + pub home: String, + pub away: String, + pub last8: String, + pub streak: String, + pub division_name: String, + pub is_my_team: bool, +} + +#[derive(Debug, Clone)] +pub struct DivisionGroup { + pub name: String, + pub teams: Vec, +} + +// ============================================================================= +// State +// ============================================================================= + +pub struct StandingsState { + pub team_abbrev: String, + pub season: i64, + pub is_loading: bool, + pub is_refreshing: bool, + pub last_updated: Option, + + // Data + pub divisions: Vec, + pub wildcard: Vec, + + // UI + pub active_tab: StandingsTab, + pub scroll_offset: usize, +} + +impl StandingsState { + pub fn new(team_abbrev: String, season: i64) -> Self { + Self { + team_abbrev, + season, + is_loading: true, + is_refreshing: false, + last_updated: None, + divisions: Vec::new(), + wildcard: Vec::new(), + active_tab: StandingsTab::Division, + scroll_offset: 0, + } + } + + pub fn mount( + &mut self, + pool: &SqlitePool, + settings: &Settings, + tx: &mpsc::UnboundedSender, + ) { + // Phase 1: Load from DB cache (instant) + { + let pool = pool.clone(); + let season = self.season; + let tx = tx.clone(); + tokio::spawn(async move { + let cached = crate::db::queries::get_cached_standings(&pool, season).await; + match cached { + Ok(Some((json_data, fetched_at))) => { + let entries: Option> = + serde_json::from_str(&json_data).ok(); + let _ = tx.send(AppMessage::StandingsCacheLoaded(entries, Some(fetched_at))); + } + _ => { + let _ = tx.send(AppMessage::StandingsCacheLoaded(None, None)); + } + } + }); + } + + // Phase 2: Fetch fresh from API (background) + self.is_refreshing = true; + { + let pool = pool.clone(); + let base_url = settings.api.base_url.clone(); + let api_key = settings.api.api_key.clone(); + let timeout = settings.api.timeout; + let season = self.season; + let tx = tx.clone(); + tokio::spawn(async move { + let client = + match crate::api::client::LeagueApiClient::new(&base_url, &api_key, timeout) { + Ok(c) => c, + Err(e) => { + let _ = tx.send(AppMessage::Notify( + format!("API client error: {e}"), + NotifyLevel::Error, + )); + return; + } + }; + + match client.get_standings(season).await { + Ok(entries) => { + // Cache to DB + if let Ok(json) = serde_json::to_string(&entries) { + let _ = crate::db::queries::upsert_standings_cache( + &pool, season, &json, + ) + .await; + } + let _ = tx.send(AppMessage::StandingsRefreshed(entries)); + } + Err(e) => { + tracing::error!("Failed to fetch standings: {e}"); + let _ = tx.send(AppMessage::Notify( + format!("Standings refresh failed: {e}"), + NotifyLevel::Error, + )); + // Send empty refresh to clear the refreshing indicator + let _ = tx.send(AppMessage::StandingsRefreshed(Vec::new())); + } + } + }); + } + } + + pub fn handle_key( + &mut self, + key: KeyEvent, + pool: &SqlitePool, + settings: &Settings, + tx: &mpsc::UnboundedSender, + ) { + match key.code { + KeyCode::Char('1') => { + self.active_tab = StandingsTab::Division; + self.scroll_offset = 0; + } + KeyCode::Char('2') => { + self.active_tab = StandingsTab::Wildcard; + self.scroll_offset = 0; + } + KeyCode::Char('j') | KeyCode::Down => { + let max = self.active_row_count().saturating_sub(1); + self.scroll_offset = (self.scroll_offset + 1).min(max); + } + KeyCode::Char('k') | KeyCode::Up => { + self.scroll_offset = self.scroll_offset.saturating_sub(1); + } + KeyCode::Char('f') => { + self.mount(pool, settings, tx); + } + _ => {} + } + } + + pub fn handle_message(&mut self, msg: AppMessage) { + match msg { + AppMessage::StandingsCacheLoaded(entries, fetched_at) => { + if let Some(entries) = entries { + self.process_standings(&entries); + self.last_updated = fetched_at; + } + // Only clear loading if we got data; otherwise wait for API + if !self.divisions.is_empty() { + self.is_loading = false; + } + } + AppMessage::StandingsRefreshed(entries) => { + self.is_refreshing = false; + if !entries.is_empty() { + self.process_standings(&entries); + self.last_updated = Some(chrono::Utc::now().naive_utc()); + } + self.is_loading = false; + } + _ => {} + } + } + + fn process_standings(&mut self, entries: &[StandingsEntry]) { + let rows: Vec = entries.iter().map(|e| self.entry_to_row(e)).collect(); + + // Group by division + let mut div_map: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for row in &rows { + let key = if row.division_name.is_empty() { + "Unknown".to_string() + } else { + row.division_name.clone() + }; + div_map.entry(key).or_default().push(row.clone()); + } + + self.divisions = div_map + .into_iter() + .map(|(name, mut teams)| { + teams.sort_by(|a, b| { + b.pct + .partial_cmp(&a.pct) + .unwrap_or(std::cmp::Ordering::Equal) + }); + DivisionGroup { name, teams } + }) + .collect(); + + // Wildcard: non-division-leaders sorted by record + let mut div_leaders: std::collections::HashSet = std::collections::HashSet::new(); + for div in &self.divisions { + if let Some(leader) = div.teams.first() { + div_leaders.insert(leader.abbrev.clone()); + } + } + let mut wc: Vec = rows + .into_iter() + .filter(|r| !div_leaders.contains(&r.abbrev)) + .collect(); + wc.sort_by(|a, b| { + b.pct + .partial_cmp(&a.pct) + .unwrap_or(std::cmp::Ordering::Equal) + }); + self.wildcard = wc; + } + + fn entry_to_row(&self, e: &StandingsEntry) -> StandingsRow { + let abbrev = e.team.abbrev.clone().unwrap_or_default(); + let team_name = e + .team + .short_name + .clone() + .unwrap_or_else(|| abbrev.clone()); + let total = e.wins + e.losses; + let pct = if total > 0 { + e.wins as f64 / total as f64 + } else { + 0.0 + }; + let gb = match e.div_gb { + Some(0.0) => "—".to_string(), + Some(gb) => { + if gb == gb.floor() { + format!("{:.0}", gb) + } else { + format!("{:.1}", gb) + } + } + None => "—".to_string(), + }; + let streak = match &e.streak_wl { + Some(wl) => format!("{}{}", wl.to_uppercase(), e.streak_num), + None => "—".to_string(), + }; + let div_name = e + .team + .division + .as_ref() + .and_then(|d| d.division_name.clone()) + .unwrap_or_default(); + + StandingsRow { + is_my_team: abbrev == self.team_abbrev, + abbrev, + team_name, + wins: e.wins, + losses: e.losses, + pct, + gb, + run_diff: e.run_diff, + home: format!("{}-{}", e.home_wins, e.home_losses), + away: format!("{}-{}", e.away_wins, e.away_losses), + last8: format!("{}-{}", e.last8_wins, e.last8_losses), + streak, + division_name: div_name, + } + } + + fn active_row_count(&self) -> usize { + match self.active_tab { + StandingsTab::Division => self.divisions.iter().map(|d| d.teams.len()).sum(), + StandingsTab::Wildcard => self.wildcard.len(), + } + } + + // ========================================================================= + // Rendering + // ========================================================================= + + pub fn render(&self, frame: &mut Frame, area: Rect, tick_count: u64) { + let chunks = Layout::vertical([ + Constraint::Length(1), // title + status + Constraint::Length(1), // tabs + Constraint::Min(0), // content + Constraint::Length(1), // hints + ]) + .split(area); + + self.render_title(frame, chunks[0], tick_count); + self.render_tabs(frame, chunks[1]); + + if self.is_loading && self.divisions.is_empty() { + let loading = Paragraph::new(" Loading standings...") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(loading, chunks[2]); + } else { + match self.active_tab { + StandingsTab::Division => self.render_division_view(frame, chunks[2]), + StandingsTab::Wildcard => self.render_wildcard_view(frame, chunks[2]), + } + } + + self.render_hints(frame, chunks[3]); + } + + fn render_title(&self, frame: &mut Frame, area: Rect, tick_count: u64) { + let mut spans = vec![Span::styled( + format!(" Standings — Season {}", self.season), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )]; + + // Last updated + if let Some(ts) = &self.last_updated { + spans.push(Span::styled( + format!(" (updated {})", format_relative_time(ts)), + Style::default().fg(Color::DarkGray), + )); + } + + // Refreshing indicator + if self.is_refreshing { + let spinner = ['|', '/', '-', '\\'][(tick_count as usize / 2) % 4]; + spans.push(Span::styled( + format!(" {} refreshing...", spinner), + Style::default().fg(Color::Yellow), + )); + } + + frame.render_widget(Paragraph::new(Line::from(spans)), area); + } + + fn render_tabs(&self, frame: &mut Frame, area: Rect) { + let tabs = [ + (StandingsTab::Division, "1", "Divisions"), + (StandingsTab::Wildcard, "2", "Wild Card"), + ]; + + let spans: Vec = tabs + .iter() + .map(|(tab, key, label)| { + let is_active = self.active_tab == *tab; + let style = if is_active { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + Span::styled(format!(" [{}] {}", key, label), style) + }) + .collect(); + + frame.render_widget(Paragraph::new(Line::from(spans)), area); + } + + fn render_division_view(&self, frame: &mut Frame, area: Rect) { + if self.divisions.is_empty() { + let empty = Paragraph::new(" No standings data") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(empty, area); + return; + } + + // Build flat list of rows with division headers + let mut all_rows: Vec = Vec::new(); + let mut team_index = 0usize; + + for div in &self.divisions { + // Division header row + let header_cells = vec![ + Cell::from(Span::styled( + format!(" {}", div.name), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), + ]; + all_rows.push(Row::new(header_cells)); + + for team in &div.teams { + let is_selected = self.scroll_offset == team_index; + all_rows.push(build_standings_row(team, is_selected)); + team_index += 1; + } + } + + let table = Table::new(all_rows, standings_widths()).header(standings_header()); + frame.render_widget(table, area); + } + + fn render_wildcard_view(&self, frame: &mut Frame, area: Rect) { + if self.wildcard.is_empty() { + let empty = Paragraph::new(" No wildcard data") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(empty, area); + return; + } + + let rows: Vec = self + .wildcard + .iter() + .enumerate() + .map(|(i, team)| build_standings_row(team, self.scroll_offset == i)) + .collect(); + + let table = Table::new(rows, standings_widths()).header(standings_header()); + frame.render_widget(table, area); + } + + fn render_hints(&self, frame: &mut Frame, area: Rect) { + let hints = Paragraph::new(" [1/2] Tab [j/k] Scroll [f] Refresh") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(hints, area); + } +} + +// ============================================================================= +// Helpers +// ============================================================================= + +fn build_standings_row(team: &StandingsRow, is_selected: bool) -> Row<'static> { + let base_style = if team.is_my_team { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else if is_selected { + Style::default().bg(Color::DarkGray) + } else { + Style::default() + }; + + let combined_style = if team.is_my_team && is_selected { + base_style.bg(Color::DarkGray) + } else { + base_style + }; + + let diff_str = if team.run_diff > 0 { + format!("+{}", team.run_diff) + } else { + format!("{}", team.run_diff) + }; + + let diff_style = if team.run_diff > 0 { + combined_style.fg(if team.is_my_team { + Color::Yellow + } else { + Color::Green + }) + } else if team.run_diff < 0 { + combined_style.fg(Color::Red) + } else { + combined_style + }; + + Row::new(vec![ + Cell::from(format!(" {} {}", team.abbrev, team.team_name)).style(combined_style), + Cell::from(format!("{:>2}", team.wins)).style(combined_style), + Cell::from(format!("{:>2}", team.losses)).style(combined_style), + Cell::from(format!("{:>5.3}", team.pct)).style(combined_style), + Cell::from(format!("{:>5}", team.gb)).style(combined_style), + Cell::from(format!("{:>5}", diff_str)).style(diff_style), + Cell::from(format!("{:>5}", team.home)).style(combined_style), + Cell::from(format!("{:>5}", team.away)).style(combined_style), + Cell::from(format!("{:>4}", team.last8)).style(combined_style), + Cell::from(format!("{:>4}", team.streak)).style(combined_style), + ]) +} + +fn standings_header() -> Row<'static> { + Row::new(vec![ + Cell::from("Team"), + Cell::from(" W"), + Cell::from(" L"), + Cell::from(" Pct"), + Cell::from(" GB"), + Cell::from(" Diff"), + Cell::from(" Home"), + Cell::from(" Away"), + Cell::from(" L8"), + Cell::from(" Strk"), + ]) + .style( + Style::default() + .add_modifier(Modifier::BOLD) + .fg(Color::White), + ) +} + +fn standings_widths() -> [Constraint; 10] { + [ + Constraint::Length(24), // Team + Constraint::Length(3), // W + Constraint::Length(3), // L + Constraint::Length(6), // Pct + Constraint::Length(6), // GB + Constraint::Length(6), // Diff + Constraint::Length(6), // Home + Constraint::Length(6), // Away + Constraint::Length(5), // L8 + Constraint::Length(5), // Streak + ] +} + +fn format_relative_time(dt: &chrono::NaiveDateTime) -> String { + let now = chrono::Utc::now().naive_utc(); + let diff = now.signed_duration_since(*dt); + let secs = diff.num_seconds(); + 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) +}