use crate::components::constants::lookup_team_by_id;
use crate::components::date_selector::DateSelector;
use crate::components::stats::search::SearchState;
use crate::components::stats::table::{
PLAYER_COLUMN_NAME, Sort, StatType, StatsTable, TEAM_COLUMN_NAME, TableData, TeamOrPlayer,
};
use crate::state::messages::NetworkRequest;
use crate::state::player_profile::PlayerProfileState;
use crate::state::team_page::TeamPageState;
use chrono::{Datelike, NaiveDate};
use chrono_tz::Tz;
use mlbt_api::client::StatGroup;
use mlbt_api::player::PeopleResponse;
use mlbt_api::schedule::ScheduleResponse;
use mlbt_api::season::GameType;
use mlbt_api::stats::StatsResponse;
use mlbt_api::team::{RosterResponse, RosterType, TransactionsResponse};
use std::collections::HashMap;
use std::sync::Arc;
use tui::widgets::TableState;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum ActivePane {
#[default]
Data,
Options,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
struct ViewKey {
group: StatGroup,
team_player: TeamOrPlayer,
}
#[derive(Clone, Default)]
struct ViewPrefs {
sort: Option<Sort>,
column_overrides: HashMap<String, bool>,
}
pub struct StatsState {
pub options_state: TableState,
pub data_state: TableState,
pub active_pane: ActivePane,
pub search_previous_pane: Option<ActivePane>,
pub stat_type: StatType,
pub table: StatsTable,
pub show_options: bool,
pub date_selector: DateSelector,
pub visible_rows: usize,
pub search: SearchState,
pub player_profile: Option<PlayerProfileState>,
pub team_page: Option<TeamPageState>,
view_prefs: HashMap<ViewKey, ViewPrefs>,
last_view_key: Option<ViewKey>,
}
impl Default for StatsState {
fn default() -> Self {
let stat_type = StatType::default();
let mut ss = StatsState {
options_state: TableState::default(),
data_state: TableState::default(),
active_pane: ActivePane::default(),
search_previous_pane: None,
stat_type,
table: StatsTable::new(stat_type),
show_options: true,
date_selector: DateSelector::default(),
visible_rows: 0,
search: SearchState::default(),
player_profile: None,
team_page: None,
view_prefs: HashMap::new(),
last_view_key: None,
};
ss.options_state.select(Some(0));
ss.data_state.select(Some(0));
ss
}
}
impl StatsState {
pub fn update(&mut self, stats: &StatsResponse) {
let current_key = self.view_key();
let same_view = self.last_view_key == Some(current_key);
self.last_view_key = Some(current_key);
self.player_profile = None;
self.table.load(stats, self.stat_type);
self.apply_view_prefs();
self.data_state.select(Some(0));
if !same_view {
self.options_state.select(Some(0));
}
self.search.close();
self.search_previous_pane = None;
}
fn view_key(&self) -> ViewKey {
ViewKey {
group: self.stat_type.group,
team_player: self.stat_type.team_player,
}
}
fn apply_view_prefs(&mut self) {
let Some(prefs) = self.view_prefs.get(&self.view_key()) else {
return;
};
for (name, active) in &prefs.column_overrides {
if let Some(entry) = self.table.columns.get_mut(name) {
entry.active = *active;
}
}
if let Some(sort) = &prefs.sort
&& let Some(name) = &sort.column_name
&& self
.table
.columns
.get(name)
.is_some_and(|entry| entry.active)
{
self.table.sorting = sort.clone();
}
}
pub fn has_player_profile(&self) -> bool {
self.player_profile.is_some()
}
pub fn has_team_page(&self) -> bool {
self.team_page.is_some()
}
pub fn close_overlay(&mut self) {
if let Some(tp) = &mut self.team_page {
if tp.player_profile.is_some() {
tp.player_profile = None;
} else {
self.team_page = None;
}
} else {
self.player_profile = None;
}
}
pub fn update_team_page(
&mut self,
team_id: u16,
date: NaiveDate,
schedule: &ScheduleResponse,
roster: &RosterResponse,
transactions: &TransactionsResponse,
tz: Tz,
) {
let team = lookup_team_by_id(team_id).unwrap_or_default();
self.team_page = Some(TeamPageState::from_response(
team,
date,
schedule,
roster,
transactions,
tz,
));
}
pub fn update_team_roster(
&mut self,
team_id: u16,
roster: &RosterResponse,
roster_type: RosterType,
) {
if let Some(tp) = &mut self.team_page
&& tp.team.id == team_id
{
tp.update_roster(roster, roster_type);
}
}
pub fn update_team_player_profile(&mut self, data: Arc<PeopleResponse>, game_type: GameType) {
if let Some(tp) = &mut self.team_page {
tp.update_player_profile(data, game_type);
}
}
pub fn update_player_profile(&mut self, data: Arc<PeopleResponse>, game_type: GameType) {
let season_year = self.date_selector.date.year();
self.player_profile =
PlayerProfileState::from_response(data, self.stat_type.group, game_type, season_year);
}
pub fn open_selected_request(&self) -> Option<NetworkRequest> {
if self.stat_type.team_player == TeamOrPlayer::Team {
let team_id = self.get_selected_id()? as u16;
return Some(NetworkRequest::TeamPage {
team_id,
date: self.date_selector.date,
});
}
let player_id = self.get_selected_id()?;
Some(NetworkRequest::PlayerProfile {
player_id,
group: self.stat_type.group,
date: self.date_selector.date,
game_type: GameType::RegularSeason,
})
}
pub fn set_date_from_valid_input(&mut self, date: NaiveDate) {
self.date_selector.set_date_from_valid_input(date);
self.select(Some(0));
}
pub fn set_date_with_arrows(&mut self, forward: bool) -> NaiveDate {
self.date_selector.set_date_with_arrows(forward)
}
pub fn update_search_matches(&mut self) {
self.table.invalidate_cache();
let name_key = match self.stat_type.team_player {
TeamOrPlayer::Team => TEAM_COLUMN_NAME,
TeamOrPlayer::Player => PLAYER_COLUMN_NAME,
};
let empty = Vec::new();
let names = self
.table
.columns
.get(name_key)
.map(|entry| &entry.rows)
.unwrap_or(&empty);
self.search.update_matches(names);
}
pub fn open_search(&mut self) {
if !self.search.is_open {
self.search_previous_pane = Some(self.active_pane);
}
self.active_pane = ActivePane::Data;
self.data_state.select(Some(0));
self.search.open();
}
pub fn submit_search(&mut self) {
self.search.submit();
self.search_previous_pane = None;
self.active_pane = ActivePane::Data;
}
pub fn cancel_search(&mut self) {
self.search.close();
self.table.invalidate_cache();
if let Some(previous) = self.search_previous_pane.take() {
self.active_pane = if previous == ActivePane::Options && !self.show_options {
ActivePane::Data
} else {
previous
};
}
}
pub fn generate_table(&mut self) -> Arc<TableData> {
let filter = if self.search.is_filtering() {
Some(self.search.matched_indices.as_slice())
} else {
None
};
self.table.generate(filter)
}
pub fn toggle_options(&mut self) {
self.show_options = !self.show_options;
if !self.show_options {
self.active_pane = ActivePane::Data;
}
}
pub fn switch_pane(&mut self) {
if !self.show_options {
return;
}
self.active_pane = match self.active_pane {
ActivePane::Data => ActivePane::Options,
ActivePane::Options => ActivePane::Data,
};
}
pub fn toggle_stat(&mut self) {
let idx = self.options_state.selected().unwrap_or_default();
self.table.toggle_stat(idx);
let key = self.view_key();
let prefs = self.view_prefs.entry(key).or_default();
if let Some((name, entry)) = self.table.columns.get_index(idx) {
prefs.column_overrides.insert(name.clone(), entry.active);
}
if self.table.sorting.column_name.is_none() {
prefs.sort = None;
}
}
pub fn store_sort_column(&mut self) {
let Some(idx) = self.options_state.selected() else {
return;
};
self.table.store_sort_column(idx);
let key = self.view_key();
let prefs = self.view_prefs.entry(key).or_default();
prefs.sort = if self.table.sorting.column_name.is_some() {
Some(self.table.sorting.clone())
} else {
None
};
}
pub fn get_selected_id(&self) -> Option<u64> {
let selected = self.data_state.selected()?;
let (_, ids, _) = self.table.cached()?.as_ref();
ids.get(selected).copied()
}
pub fn total_row_count(&self) -> usize {
self.table.total_row_count()
}
fn row_count(&self) -> usize {
if self.search.is_filtering() {
return self.search.matched_indices.len();
}
self.total_row_count()
}
pub fn reset_data_selection(&mut self) {
if self.row_count() > 0 {
self.data_state.select(Some(0));
} else {
self.data_state.select(None);
}
}
pub fn next(&mut self) {
let len = self.active_pane_len();
if len == 0 {
return;
}
let next = match self.selected() {
Some(i) if i >= len - 1 => 0,
Some(i) => i + 1,
None => 0,
};
self.select(Some(next));
}
pub fn previous(&mut self) {
let len = self.active_pane_len();
if len == 0 {
return;
}
let previous = match self.selected() {
Some(0) => len - 1,
Some(i) => i - 1,
None => 0,
};
self.select(Some(previous));
}
fn select(&mut self, index: Option<usize>) {
match self.active_pane {
ActivePane::Data => self.data_state.select(index),
ActivePane::Options => self.options_state.select(index),
}
}
fn selected(&self) -> Option<usize> {
match self.active_pane {
ActivePane::Data => self.data_state.selected(),
ActivePane::Options => self.options_state.selected(),
}
}
fn active_pane_len(&self) -> usize {
match self.active_pane {
ActivePane::Data => self.row_count(),
ActivePane::Options => self.table.columns.len(),
}
}
pub fn page_down(&mut self) {
if !self.can_page_data() {
return;
}
let len = self.row_count();
let offset = self.data_state.offset();
let last_visible = (offset + self.visible_rows - 1).min(len - 1);
self.select_data_row(last_visible);
}
pub fn page_up(&mut self) {
if !self.can_page_data() {
return;
}
let offset = self.data_state.offset();
let new_offset = offset.saturating_sub(self.visible_rows - 1);
self.select_data_row(new_offset);
}
fn can_page_data(&self) -> bool {
self.active_pane == ActivePane::Data && self.row_count() > 0 && self.visible_rows > 0
}
fn select_data_row(&mut self, index: usize) {
*self.data_state.offset_mut() = index;
self.data_state.select(Some(index));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::components::stats::table::TableEntry;
fn state_with(n_rows: usize, n_cols: usize) -> StatsState {
let mut s = StatsState::default();
for i in 0..n_cols {
s.table.columns.insert(
format!("c{i}"),
TableEntry {
description: String::new(),
active: true,
rows: (0..n_rows).map(|r| r.to_string()).collect(),
},
);
}
s
}
#[test]
fn next_wraps_at_end() {
let mut s = state_with(3, 1);
s.data_state.select(Some(2));
s.next();
assert_eq!(s.data_state.selected(), Some(0));
}
#[test]
fn previous_wraps_at_start() {
let mut s = state_with(3, 1);
s.data_state.select(Some(0));
s.previous();
assert_eq!(s.data_state.selected(), Some(2));
}
#[test]
fn next_targets_active_pane() {
let mut s = state_with(5, 3);
s.active_pane = ActivePane::Options;
s.options_state.select(Some(0));
s.next();
assert_eq!(s.options_state.selected(), Some(1));
assert_eq!(s.data_state.selected(), Some(0));
}
#[test]
fn page_down_jumps_to_last_visible() {
let mut s = state_with(20, 1);
s.visible_rows = 5;
s.data_state.select(Some(0));
s.page_down();
assert_eq!(s.data_state.selected(), Some(4));
}
#[test]
fn page_up_reverses_page_down() {
let mut s = state_with(20, 1);
s.visible_rows = 5;
*s.data_state.offset_mut() = 10;
s.data_state.select(Some(10));
s.page_up();
assert_eq!(s.data_state.selected(), Some(6));
}
#[test]
fn page_down_clamps_at_end() {
let mut s = state_with(20, 1);
s.visible_rows = 5;
*s.data_state.offset_mut() = 19;
s.data_state.select(Some(19));
s.page_down();
assert_eq!(s.data_state.selected(), Some(19));
}
#[test]
fn page_up_clamps_at_start() {
let mut s = state_with(20, 1);
s.visible_rows = 5;
s.data_state.select(Some(0));
s.page_up();
assert_eq!(s.data_state.selected(), Some(0));
}
#[test]
fn page_noop_on_options_pane() {
let mut s = state_with(20, 1);
s.visible_rows = 5;
s.active_pane = ActivePane::Options;
s.data_state.select(Some(0));
s.page_down();
assert_eq!(s.data_state.selected(), Some(0));
}
}