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:
Cal Corum 2026-02-28 15:59:09 -06:00
parent b6da926258
commit ad8cf33139
5 changed files with 149 additions and 58 deletions

View File

@ -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));
}

View File

@ -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(),
}
}

View File

@ -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(),
}
}

View File

@ -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(),
}
}

View File

@ -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)),
]);