Fix Phase 5 live testing bugs across all four new screens
Roster: sWAR only sums majors, stateful table rendering for j/k scroll, rows sorted batters-first for correct selection mapping. Matchup: initial focus highlight, descriptive sort key hints, state cached on nav-away to preserve opponent/pitcher selections. Lineup: load/delete/clear promoted to work from any focus, load selector popup anchored correctly, delete confirmation prompt. Settings: API key toggle changed to Ctrl+T so t can be typed, per-field yellow change markers, sWAR/cap formatting to two decimal places. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b6da926258
commit
ad8cf33139
@ -145,6 +145,8 @@ pub struct App {
|
||||
pub tx: mpsc::UnboundedSender<AppMessage>,
|
||||
pub notification: Option<Notification>,
|
||||
pub tick_count: u64,
|
||||
/// Cached matchup state so navigating away and back preserves selections.
|
||||
cached_matchup: Option<Box<MatchupState>>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@ -165,6 +167,7 @@ impl App {
|
||||
tx,
|
||||
notification: None,
|
||||
tick_count: 0,
|
||||
cached_matchup: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -270,7 +273,24 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// If leaving the Matchup screen, save its state for later restoration.
|
||||
fn cache_matchup_if_active(&mut self) {
|
||||
if matches!(&self.screen, ActiveScreen::Matchup(_)) {
|
||||
// Swap screen with a temporary placeholder, extract the matchup state
|
||||
let placeholder = ActiveScreen::Dashboard(Box::new(DashboardState::new(
|
||||
String::new(),
|
||||
0,
|
||||
&self.settings,
|
||||
)));
|
||||
let old = std::mem::replace(&mut self.screen, placeholder);
|
||||
if let ActiveScreen::Matchup(state) = old {
|
||||
self.cached_matchup = Some(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn switch_to_dashboard(&mut self) {
|
||||
self.cache_matchup_if_active();
|
||||
let mut state = DashboardState::new(
|
||||
self.settings.team.abbrev.clone(),
|
||||
self.settings.team.season as i64,
|
||||
@ -281,6 +301,7 @@ impl App {
|
||||
}
|
||||
|
||||
fn switch_to_gameday(&mut self) {
|
||||
self.cache_matchup_if_active();
|
||||
let mut state = GamedayState::new(
|
||||
self.settings.team.abbrev.clone(),
|
||||
self.settings.team.season as i64,
|
||||
@ -290,6 +311,7 @@ impl App {
|
||||
}
|
||||
|
||||
fn switch_to_roster(&mut self) {
|
||||
self.cache_matchup_if_active();
|
||||
let mut state = RosterState::new(
|
||||
self.settings.team.abbrev.clone(),
|
||||
self.settings.team.season as i64,
|
||||
@ -300,6 +322,11 @@ impl App {
|
||||
}
|
||||
|
||||
fn switch_to_matchup(&mut self) {
|
||||
// Restore cached state if available (preserves opponent/pitcher selection)
|
||||
if let Some(state) = self.cached_matchup.take() {
|
||||
self.screen = ActiveScreen::Matchup(state);
|
||||
return;
|
||||
}
|
||||
let mut state = MatchupState::new(
|
||||
self.settings.team.abbrev.clone(),
|
||||
self.settings.team.season as i64,
|
||||
@ -309,6 +336,7 @@ impl App {
|
||||
}
|
||||
|
||||
fn switch_to_lineup(&mut self) {
|
||||
self.cache_matchup_if_active();
|
||||
let mut state = LineupState::new(
|
||||
self.settings.team.abbrev.clone(),
|
||||
self.settings.team.season as i64,
|
||||
@ -318,6 +346,7 @@ impl App {
|
||||
}
|
||||
|
||||
fn switch_to_settings(&mut self) {
|
||||
self.cache_matchup_if_active();
|
||||
let state = SettingsState::new(&self.settings);
|
||||
self.screen = ActiveScreen::Settings(Box::new(state));
|
||||
}
|
||||
|
||||
@ -72,6 +72,7 @@ pub struct LineupState {
|
||||
pub lineup_name: String,
|
||||
pub lineup_name_cursor: usize,
|
||||
pub load_selector: SelectorWidget<String>,
|
||||
pub confirm_delete: bool,
|
||||
}
|
||||
|
||||
impl LineupState {
|
||||
@ -90,11 +91,12 @@ impl LineupState {
|
||||
lineup_name: String::new(),
|
||||
lineup_name_cursor: 0,
|
||||
load_selector: SelectorWidget::new("Load Lineup"),
|
||||
confirm_delete: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_input_captured(&self) -> bool {
|
||||
matches!(self.focus, LineupFocus::LineupName) || self.load_selector.is_open
|
||||
matches!(self.focus, LineupFocus::LineupName) || self.load_selector.is_open || self.confirm_delete
|
||||
}
|
||||
|
||||
pub fn mount(&mut self, pool: &SqlitePool, tx: &mpsc::UnboundedSender<AppMessage>) {
|
||||
@ -141,6 +143,20 @@ impl LineupState {
|
||||
_settings: &Settings,
|
||||
tx: &mpsc::UnboundedSender<AppMessage>,
|
||||
) {
|
||||
// Delete confirmation
|
||||
if self.confirm_delete {
|
||||
self.confirm_delete = false;
|
||||
if key.code == KeyCode::Char('y') || key.code == KeyCode::Char('Y') {
|
||||
self.delete_lineup(pool, tx);
|
||||
} else {
|
||||
let _ = tx.send(AppMessage::Notify(
|
||||
"Delete cancelled".to_string(),
|
||||
NotifyLevel::Info,
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Load selector
|
||||
if self.load_selector.is_open {
|
||||
if let SelectorEvent::Selected(_) = self.load_selector.handle_key(key) {
|
||||
@ -190,10 +206,43 @@ impl LineupState {
|
||||
return;
|
||||
}
|
||||
|
||||
// Lineup-wide keys (work from any focus except text input)
|
||||
match key.code {
|
||||
KeyCode::Char('l') => {
|
||||
self.refresh_load_selector();
|
||||
self.focus = LineupFocus::LoadSelector;
|
||||
self.load_selector.is_focused = true;
|
||||
self.load_selector.is_open = true;
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('D') => {
|
||||
if self.lineup_name.is_empty() {
|
||||
let _ = tx.send(AppMessage::Notify(
|
||||
"No lineup loaded to delete".to_string(),
|
||||
NotifyLevel::Error,
|
||||
));
|
||||
} else {
|
||||
self.confirm_delete = true;
|
||||
let _ = tx.send(AppMessage::Notify(
|
||||
format!("Delete '{}'? Press y to confirm", self.lineup_name),
|
||||
NotifyLevel::Info,
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('c') => {
|
||||
for i in 0..9 {
|
||||
self.lineup_slots[i] = LineupSlot::empty(i + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Focus-specific keys
|
||||
match self.focus {
|
||||
LineupFocus::AvailableTable => self.handle_available_key(key),
|
||||
LineupFocus::LineupTable => self.handle_lineup_table_key(key, pool, tx),
|
||||
LineupFocus::LineupTable => self.handle_lineup_table_key(key),
|
||||
LineupFocus::LoadSelector => match key.code {
|
||||
KeyCode::Enter | KeyCode::Char(' ') => {
|
||||
self.refresh_load_selector();
|
||||
@ -241,12 +290,7 @@ impl LineupState {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_lineup_table_key(
|
||||
&mut self,
|
||||
key: KeyEvent,
|
||||
pool: &SqlitePool,
|
||||
tx: &mpsc::UnboundedSender<AppMessage>,
|
||||
) {
|
||||
fn handle_lineup_table_key(&mut self, key: KeyEvent) {
|
||||
let sel = self.lineup_table_state.selected().unwrap_or(0);
|
||||
match key.code {
|
||||
KeyCode::Char('j') | KeyCode::Down => {
|
||||
@ -280,20 +324,6 @@ impl LineupState {
|
||||
self.lineup_table_state.select(Some(sel - 1));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('l') => {
|
||||
self.refresh_load_selector();
|
||||
self.focus = LineupFocus::LoadSelector;
|
||||
self.load_selector.is_focused = true;
|
||||
self.load_selector.is_open = true;
|
||||
}
|
||||
KeyCode::Char('D') => {
|
||||
self.delete_lineup(pool, tx);
|
||||
}
|
||||
KeyCode::Char('c') => {
|
||||
for i in 0..9 {
|
||||
self.lineup_slots[i] = LineupSlot::empty(i + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@ -594,9 +624,15 @@ impl LineupState {
|
||||
self.render_available_panel(frame, panels[0]);
|
||||
self.render_lineup_panel(frame, panels[1]);
|
||||
|
||||
// Load selector overlay
|
||||
// Load selector overlay (anchor at top of right panel so popup renders over it)
|
||||
if self.load_selector.is_open {
|
||||
self.load_selector.render_popup(frame, panels[1]);
|
||||
let anchor = Rect {
|
||||
x: panels[1].x,
|
||||
y: panels[1].y,
|
||||
width: panels[1].width,
|
||||
height: 0,
|
||||
};
|
||||
self.load_selector.render_popup(frame, anchor);
|
||||
}
|
||||
}
|
||||
|
||||
@ -807,7 +843,7 @@ fn format_rating(val: Option<f64>) -> String {
|
||||
|
||||
fn format_swar(val: Option<f64>) -> String {
|
||||
match val {
|
||||
Some(v) => format!("{:.1}", v),
|
||||
Some(v) => format!("{:.2}", v),
|
||||
None => "—".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -70,7 +70,7 @@ pub struct MatchupState {
|
||||
|
||||
impl MatchupState {
|
||||
pub fn new(team_abbrev: String, season: i64) -> Self {
|
||||
Self {
|
||||
let mut state = Self {
|
||||
team_abbrev,
|
||||
season,
|
||||
team_selector: SelectorWidget::new("Opponent"),
|
||||
@ -84,7 +84,9 @@ impl MatchupState {
|
||||
focus: MatchupFocus::TeamSelector,
|
||||
table_state: TableState::default(),
|
||||
sort_mode: SortMode::Rating,
|
||||
}
|
||||
};
|
||||
state.team_selector.is_focused = true;
|
||||
state
|
||||
}
|
||||
|
||||
pub fn is_input_captured(&self) -> bool {
|
||||
@ -498,7 +500,7 @@ impl MatchupState {
|
||||
|
||||
fn render_hints(&self, frame: &mut Frame, area: Rect) {
|
||||
let hints = Paragraph::new(format!(
|
||||
" [Tab] Focus [s/n/p] Sort ({}) [f] Refresh",
|
||||
" [Tab] Focus [j/k] Scroll Sort: [s]core [n]ame [p]os ({}) [f] Refresh",
|
||||
self.sort_mode.label()
|
||||
))
|
||||
.style(Style::default().fg(Color::DarkGray));
|
||||
@ -523,7 +525,7 @@ fn tier_style(tier: &str) -> Style {
|
||||
|
||||
fn format_swar(val: Option<f64>) -> String {
|
||||
match val {
|
||||
Some(v) => format!("{:.1}", v),
|
||||
Some(v) => format!("{:.2}", v),
|
||||
None => "—".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -119,8 +119,9 @@ impl RosterState {
|
||||
let mut swar_total = 0.0;
|
||||
|
||||
let majors = build_roster_rows(&pool, &roster.majors, &mut swar_total).await;
|
||||
let minors = build_roster_rows(&pool, &roster.minors, &mut swar_total).await;
|
||||
let il = build_roster_rows(&pool, &roster.il, &mut swar_total).await;
|
||||
let mut _unused = 0.0;
|
||||
let minors = build_roster_rows(&pool, &roster.minors, &mut _unused).await;
|
||||
let il = build_roster_rows(&pool, &roster.il, &mut _unused).await;
|
||||
|
||||
let _ = tx.send(AppMessage::RosterPlayersLoaded {
|
||||
majors,
|
||||
@ -192,6 +193,10 @@ impl RosterState {
|
||||
self.majors = majors;
|
||||
self.minors = minors;
|
||||
self.il = il;
|
||||
// Sort each list: batters first, then pitchers (matches render order)
|
||||
for list in [&mut self.majors, &mut self.minors, &mut self.il] {
|
||||
list.sort_by_key(|r| if r.player.is_batter() { 0 } else { 1 });
|
||||
}
|
||||
self.swar_total = swar_total;
|
||||
self.is_loading = false;
|
||||
self.table_state = TableState::default();
|
||||
@ -229,11 +234,11 @@ impl RosterState {
|
||||
|
||||
fn render_title(&self, frame: &mut Frame, area: Rect) {
|
||||
let cap_str = match self.swar_cap {
|
||||
Some(cap) => format!(" / {:.1}", cap),
|
||||
Some(cap) => format!(" / {:.2}", cap),
|
||||
None => String::new(),
|
||||
};
|
||||
let title = format!(
|
||||
" Roster: {} — sWAR: {:.1}{}",
|
||||
" Roster: {} — sWAR: {:.2}{}",
|
||||
if self.team_name.is_empty() {
|
||||
&self.team_abbrev
|
||||
} else {
|
||||
@ -298,10 +303,19 @@ impl RosterState {
|
||||
return;
|
||||
}
|
||||
|
||||
// Split into batters and pitchers
|
||||
// Split into batters and pitchers (rows are pre-sorted: batters first)
|
||||
let batters: Vec<&RosterRow> = rows_data.iter().filter(|r| r.player.is_batter()).collect();
|
||||
let pitchers: Vec<&RosterRow> = rows_data.iter().filter(|r| r.player.is_pitcher()).collect();
|
||||
|
||||
// Map combined selection index to the correct sub-table
|
||||
let selected = self.table_state.selected();
|
||||
let batter_count = batters.len();
|
||||
let (batter_selected, pitcher_selected) = match selected {
|
||||
Some(i) if i < batter_count => (Some(i), None),
|
||||
Some(i) => (None, Some(i - batter_count)),
|
||||
None => (None, None),
|
||||
};
|
||||
|
||||
// Calculate split area
|
||||
let batter_height = batters.len() as u16 + 2; // +2 for header + section label
|
||||
let available = area.height;
|
||||
@ -366,7 +380,9 @@ impl RosterState {
|
||||
.header(batter_header)
|
||||
.row_highlight_style(Style::default().bg(Color::DarkGray));
|
||||
|
||||
frame.render_widget(batter_table, table_chunks[1]);
|
||||
let mut batter_state = TableState::default();
|
||||
batter_state.select(batter_selected);
|
||||
frame.render_stateful_widget(batter_table, table_chunks[1], &mut batter_state);
|
||||
|
||||
// Pitcher section label
|
||||
frame.render_widget(
|
||||
@ -426,7 +442,9 @@ impl RosterState {
|
||||
.header(pitcher_header)
|
||||
.row_highlight_style(Style::default().bg(Color::DarkGray));
|
||||
|
||||
frame.render_widget(pitcher_table, table_chunks[3]);
|
||||
let mut pitcher_state = TableState::default();
|
||||
pitcher_state.select(pitcher_selected);
|
||||
frame.render_stateful_widget(pitcher_table, table_chunks[3], &mut pitcher_state);
|
||||
}
|
||||
|
||||
fn render_hints(&self, frame: &mut Frame, area: Rect) {
|
||||
@ -499,7 +517,7 @@ fn format_rating(val: Option<f64>) -> String {
|
||||
|
||||
fn format_swar(val: Option<f64>) -> String {
|
||||
match val {
|
||||
Some(v) => format!("{:.1}", v),
|
||||
Some(v) => format!("{:.2}", v),
|
||||
None => "—".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -165,7 +165,10 @@ impl SettingsState {
|
||||
// Text input handling
|
||||
if self.focus.is_text_input() {
|
||||
match key.code {
|
||||
KeyCode::Char('t') if self.focus == SettingsFocus::ApiKey => {
|
||||
KeyCode::Char('t')
|
||||
if self.focus == SettingsFocus::ApiKey
|
||||
&& key.modifiers.contains(KeyModifiers::CONTROL) =>
|
||||
{
|
||||
self.api_key_visible = !self.api_key_visible;
|
||||
return;
|
||||
}
|
||||
@ -367,7 +370,7 @@ impl SettingsState {
|
||||
AppMessage::SettingsTeamValidated(result) => {
|
||||
self.team_info = match result {
|
||||
Some((name, cap)) => {
|
||||
let cap_str = cap.map(|c| format!(" (Cap: {:.1})", c)).unwrap_or_default();
|
||||
let cap_str = cap.map(|c| format!(" (Cap: {:.2})", c)).unwrap_or_default();
|
||||
Some(format!("{}{}", name, cap_str))
|
||||
}
|
||||
None => Some("Team not found".to_string()),
|
||||
@ -401,11 +404,7 @@ impl SettingsState {
|
||||
.split(area);
|
||||
|
||||
// Title
|
||||
let title = if self.has_unsaved_changes {
|
||||
" Settings *"
|
||||
} else {
|
||||
" Settings"
|
||||
};
|
||||
let title = " Settings";
|
||||
frame.render_widget(
|
||||
Paragraph::new(title)
|
||||
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
|
||||
@ -422,8 +421,8 @@ impl SettingsState {
|
||||
let team_row = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(chunks[3]);
|
||||
|
||||
self.render_input(frame, team_row[0], "Abbreviation", &self.abbrev, SettingsFocus::TeamAbbrev, false);
|
||||
self.render_input(frame, team_row[1], "Season", &self.season_str, SettingsFocus::TeamSeason, false);
|
||||
self.render_input(frame, team_row[0], "Abbreviation", &self.abbrev, SettingsFocus::TeamAbbrev, false, self.abbrev != self.original.abbrev);
|
||||
self.render_input(frame, team_row[1], "Season", &self.season_str, SettingsFocus::TeamSeason, false, self.season_str != self.original.season_str);
|
||||
|
||||
// Team validation
|
||||
let team_info_style = if self.team_info.as_deref() == Some("Team not found") {
|
||||
@ -441,8 +440,8 @@ impl SettingsState {
|
||||
// Major/Minor slots
|
||||
let slots_row = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(chunks[5]);
|
||||
self.render_input(frame, slots_row[0], "Major Slots", &self.major_slots_str, SettingsFocus::MajorSlots, false);
|
||||
self.render_input(frame, slots_row[1], "Minor Slots", &self.minor_slots_str, SettingsFocus::MinorSlots, false);
|
||||
self.render_input(frame, slots_row[0], "Major Slots", &self.major_slots_str, SettingsFocus::MajorSlots, false, self.major_slots_str != self.original.major_slots_str);
|
||||
self.render_input(frame, slots_row[1], "Minor Slots", &self.minor_slots_str, SettingsFocus::MinorSlots, false, self.minor_slots_str != self.original.minor_slots_str);
|
||||
|
||||
// API section
|
||||
frame.render_widget(
|
||||
@ -450,13 +449,13 @@ impl SettingsState {
|
||||
chunks[7],
|
||||
);
|
||||
|
||||
self.render_input(frame, chunks[8], "Base URL", &self.base_url, SettingsFocus::ApiUrl, false);
|
||||
self.render_input(frame, chunks[8], "Base URL", &self.base_url, SettingsFocus::ApiUrl, false, self.base_url != self.original.base_url);
|
||||
let api_key_display = if self.api_key_visible {
|
||||
self.api_key.clone()
|
||||
} else {
|
||||
"•".repeat(self.api_key.len().min(20))
|
||||
};
|
||||
self.render_input(frame, chunks[9], "API Key", &api_key_display, SettingsFocus::ApiKey, true);
|
||||
self.render_input(frame, chunks[9], "API Key", &api_key_display, SettingsFocus::ApiKey, true, self.api_key != self.original.api_key);
|
||||
|
||||
// Buttons
|
||||
let button_row = Layout::horizontal([
|
||||
@ -482,17 +481,21 @@ impl SettingsState {
|
||||
frame.render_widget(Paragraph::new(" [ Reset to Defaults ]").style(reset_style), button_row[2]);
|
||||
|
||||
// Hints
|
||||
let hint_text = if self.focus == SettingsFocus::ApiKey {
|
||||
" [Tab] Next [Shift+Tab] Prev [t] Toggle key [Ctrl+S] Save [Esc] Buttons"
|
||||
let hint_base = if self.focus == SettingsFocus::ApiKey {
|
||||
" [Tab] Next [Shift+Tab] Prev [Ctrl+T] Toggle key [Ctrl+S] Save [Esc] Buttons"
|
||||
} else if self.focus.is_text_input() {
|
||||
" [Tab] Next [Shift+Tab] Prev [Ctrl+S] Save [Esc] Buttons"
|
||||
} else {
|
||||
" [Tab] Next [Enter] Activate [Ctrl+S] Save"
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(hint_text).style(Style::default().fg(Color::DarkGray)),
|
||||
chunks[13],
|
||||
);
|
||||
let mut spans = vec![Span::styled(hint_base, Style::default().fg(Color::DarkGray))];
|
||||
if self.has_unsaved_changes {
|
||||
spans.push(Span::styled(
|
||||
" * unsaved changes",
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
frame.render_widget(Paragraph::new(Line::from(spans)), chunks[13]);
|
||||
}
|
||||
|
||||
fn render_input(
|
||||
@ -503,6 +506,7 @@ impl SettingsState {
|
||||
value: &str,
|
||||
target_focus: SettingsFocus,
|
||||
is_password: bool,
|
||||
changed: bool,
|
||||
) {
|
||||
let is_focused = self.focus == target_focus;
|
||||
let label_style = Style::default().fg(Color::DarkGray);
|
||||
@ -513,10 +517,12 @@ impl SettingsState {
|
||||
};
|
||||
|
||||
let cursor = if is_focused { "▏" } else { "" };
|
||||
let toggle_hint = if is_password && is_focused { " [t]" } else { "" };
|
||||
let change_marker = if changed { "* " } else { " " };
|
||||
let toggle_hint = if is_password && is_focused { " [Ctrl+T]" } else { "" };
|
||||
|
||||
let line = Line::from(vec![
|
||||
Span::styled(format!(" {}: ", label), label_style),
|
||||
Span::styled(change_marker, Style::default().fg(Color::Yellow)),
|
||||
Span::styled(format!(" {}: ", label), label_style),
|
||||
Span::styled(format!("[{}{}]", value, cursor), value_style),
|
||||
Span::styled(toggle_hint, Style::default().fg(Color::DarkGray)),
|
||||
]);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user