//! History screen. Per-exercise view: current progression state (for //! reps), sparkline of the kind-appropriate top-set metric, and the //! last ten sessions in a compact table. The layout dispatches on //! `exercise.effort_kind` so a plank sees a duration sparkline instead //! of a load sparkline. use chrono::NaiveDate; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Sparkline}; use ripgrow_core::{ Db, DistanceKind, DistanceSet, Duration, EffortKind, Exercise, Kind, RepsKind, RepsSet, State, TimedKind, TimedSet, }; pub struct HistoryScreen { exercises: Vec, list_state: ListState, pub status: String, } pub enum HistoryAction { None, Quit, } impl HistoryScreen { pub fn load(db: &Db) -> Result { let exercises = db.list_exercises()?; let mut list_state = ListState::default(); if !exercises.is_empty() { list_state.select(Some(0)); } Ok(Self { exercises, list_state, status: String::new(), }) } pub fn on_key(&mut self, _db: &Db, key: KeyEvent) -> HistoryAction { match (key.code, key.modifiers) { (KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => { HistoryAction::Quit } (KeyCode::Down | KeyCode::Char('j'), _) => { self.move_selection(1); HistoryAction::None } (KeyCode::Up | KeyCode::Char('k'), _) => { self.move_selection(-1); HistoryAction::None } _ => HistoryAction::None, } } fn move_selection(&mut self, delta: isize) { if self.exercises.is_empty() { return; } let n = self.exercises.len() as isize; let cur = self.list_state.selected().unwrap_or(0) as isize; let next = (cur + delta).rem_euclid(n) as usize; self.list_state.select(Some(next)); } fn selected(&self) -> Option<&Exercise> { self.list_state.selected().and_then(|i| self.exercises.get(i)) } pub fn render(&mut self, frame: &mut Frame, area: Rect, db: &Db) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(3), Constraint::Length(1)]) .split(area); let panes = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) .split(chunks[0]); let items: Vec = if self.exercises.is_empty() { vec![ListItem::new("no templates yet")] } else { self.exercises .iter() .map(|e| ListItem::new(e.name.as_str())) .collect() }; let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(" exercises ")) .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) .highlight_symbol("> "); frame.render_stateful_widget(list, panes[0], &mut self.list_state); match self.selected() { Some(ex) => render_detail(frame, panes[1], db, ex), None => { let block = Block::default().borders(Borders::ALL).title(" detail "); frame.render_widget( Paragraph::new("no exercise selected").block(block), panes[1], ); } } frame.render_widget( Paragraph::new(Line::from(vec![ Span::raw("j/k move q quit "), Span::styled( self.status.as_str(), Style::default().add_modifier(Modifier::DIM), ), ])), chunks[1], ); } } fn render_detail(frame: &mut Frame, area: Rect, db: &Db, ex: &Exercise) { let block = Block::default() .borders(Borders::ALL) .title(format!(" {} ", ex.name)); let inner = block.inner(area); frame.render_widget(block, area); match ex.effort_kind { EffortKind::Reps => render_reps_detail(frame, inner, db, ex), EffortKind::Timed => render_timed_detail(frame, inner, db, ex), EffortKind::Distance => render_distance_detail(frame, inner, db, ex), } } fn split_detail(area: Rect) -> (Rect, Rect, Rect) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), Constraint::Length(6), Constraint::Min(1), ]) .split(area); (chunks[0], chunks[1], chunks[2]) } // ---- reps ------------------------------------------------------------------ fn render_reps_detail(frame: &mut Frame, inner: Rect, db: &Db, ex: &Exercise) { let series = RepsKind::top_loads_over_time(db, ex.id).unwrap_or_default(); let state_str = db .read_progression_state(ex.id) .ok() .flatten() .map(state_name) .unwrap_or("(no state cached)"); let (header_area, spark_area, table_area) = split_detail(inner); let e1rm_str = match RepsKind::best_e1rm(db, ex.id).ok().flatten() { Some(v) => format!("{:.1} {}", v, ex.load_unit), None => "-".to_string(), }; let header = format!( "state: {} sessions: {} e1RM: {}", state_str, series.len(), e1rm_str, ); frame.render_widget(Paragraph::new(header), header_area); if !series.is_empty() { // Sparkline takes u64. Multiply by 10 so 0.1 kg resolution survives // the cast; ratatui normalizes across the series so absolute scale // does not matter. let data: Vec = series.iter().map(|(_, l)| (l * 10.0) as u64).collect(); let min = series.iter().map(|(_, l)| *l).fold(f64::INFINITY, f64::min); let max = series .iter() .map(|(_, l)| *l) .fold(f64::NEG_INFINITY, f64::max); let title = format!( " top-set load min {} max {} ({}) ", min, max, ex.load_unit ); let spark = Sparkline::default() .block(Block::default().borders(Borders::ALL).title(title)) .data(&data); frame.render_widget(spark, spark_area); } else { frame.render_widget( Paragraph::new("no history yet — log some sets first") .block(Block::default().borders(Borders::ALL).title(" top-set load ")), spark_area, ); } render_reps_recent_table(frame, table_area, db, ex); } fn render_reps_recent_table(frame: &mut Frame, area: Rect, db: &Db, ex: &Exercise) { let sessions = RepsKind::list_sessions_for_exercise(db, ex.id).unwrap_or_default(); let recent: Vec<&Vec> = sessions.iter().rev().take(10).collect(); let mut lines: Vec = vec![Line::from(Span::styled( format!( " {:<10} {:>4} {:>8} {:>4} {:>3}", "date", "sets", "top load", "reps", "rpe" ), Style::default().add_modifier(Modifier::BOLD), ))]; for session in &recent { let date = session[0].session_date; let top_load = reps_top_load(session); let top_reps = session.iter().map(|s| s.reps.get()).max().unwrap_or(0); let max_rpe = session.iter().map(|s| s.rpe.get()).max().unwrap_or(0); lines.push(Line::from(format!( " {:<10} {:>4} {:>8} {:>4} {:>3}", date.format("%Y-%m-%d"), session.len(), top_load, top_reps, max_rpe ))); } if recent.is_empty() { lines.push(Line::from(" (no sessions logged)")); } frame.render_widget( Paragraph::new(lines).block( Block::default().borders(Borders::ALL).title(" recent sessions "), ), area, ); } fn reps_top_load(session: &[RepsSet]) -> f64 { session .iter() .map(|s| s.load.get()) .fold(f64::NEG_INFINITY, f64::max) } // ---- timed ----------------------------------------------------------------- fn render_timed_detail(frame: &mut Frame, inner: Rect, db: &Db, ex: &Exercise) { let sessions = TimedKind::list_sessions_for_exercise(db, ex.id).unwrap_or_default(); let series: Vec<(NaiveDate, i32)> = sessions .iter() .map(|s| (s[0].session_date, timed_top_seconds(s))) .collect(); let (header_area, spark_area, table_area) = split_detail(inner); let longest_str = series .iter() .map(|(_, s)| *s) .max() .and_then(|s| Duration::from_seconds(s).ok()) .map(|d| d.to_string()) .unwrap_or_else(|| "-".to_string()); let header = format!( "sessions: {} longest hold: {}", series.len(), longest_str, ); frame.render_widget(Paragraph::new(header), header_area); if !series.is_empty() { let data: Vec = series.iter().map(|(_, s)| *s as u64).collect(); let min = series.iter().map(|(_, s)| *s).min().unwrap_or(0); let max = series.iter().map(|(_, s)| *s).max().unwrap_or(0); let title = format!(" top duration min {}s max {}s ", min, max); let spark = Sparkline::default() .block(Block::default().borders(Borders::ALL).title(title)) .data(&data); frame.render_widget(spark, spark_area); } else { frame.render_widget( Paragraph::new("no history yet — log some sets first") .block(Block::default().borders(Borders::ALL).title(" top duration ")), spark_area, ); } render_timed_recent_table(frame, table_area, &sessions); } fn render_timed_recent_table(frame: &mut Frame, area: Rect, sessions: &[Vec]) { let recent: Vec<&Vec> = sessions.iter().rev().take(10).collect(); let mut lines: Vec = vec![Line::from(Span::styled( format!( " {:<10} {:>4} {:>10} {:>3}", "date", "sets", "top hold", "rpe" ), Style::default().add_modifier(Modifier::BOLD), ))]; for session in &recent { let date = session[0].session_date; let top = Duration::from_seconds(timed_top_seconds(session)) .map(|d| d.to_string()) .unwrap_or_else(|_| "-".to_string()); let max_rpe = session.iter().map(|s| s.rpe.get()).max().unwrap_or(0); lines.push(Line::from(format!( " {:<10} {:>4} {:>10} {:>3}", date.format("%Y-%m-%d"), session.len(), top, max_rpe ))); } if recent.is_empty() { lines.push(Line::from(" (no sessions logged)")); } frame.render_widget( Paragraph::new(lines).block( Block::default().borders(Borders::ALL).title(" recent sessions "), ), area, ); } fn timed_top_seconds(session: &[TimedSet]) -> i32 { session .iter() .map(|s| s.duration.seconds()) .max() .unwrap_or(0) } // ---- distance -------------------------------------------------------------- fn render_distance_detail(frame: &mut Frame, inner: Rect, db: &Db, ex: &Exercise) { let sessions = DistanceKind::list_sessions_for_exercise(db, ex.id).unwrap_or_default(); let series: Vec<(NaiveDate, f64)> = sessions .iter() .map(|s| (s[0].session_date, distance_top_meters(s))) .collect(); let (header_area, spark_area, table_area) = split_detail(inner); let longest = series .iter() .map(|(_, m)| *m) .fold(f64::NEG_INFINITY, f64::max); let longest_str = if longest.is_finite() { format!("{} m", longest) } else { "-".to_string() }; let header = format!( "sessions: {} longest: {}", series.len(), longest_str, ); frame.render_widget(Paragraph::new(header), header_area); if !series.is_empty() { // Distance in meters can be large (5000+), so scale to u64 // directly without the 10x reps trick. let data: Vec = series.iter().map(|(_, m)| *m as u64).collect(); let min = series.iter().map(|(_, m)| *m).fold(f64::INFINITY, f64::min); let max = longest; let title = format!(" top distance min {} m max {} m ", min, max); let spark = Sparkline::default() .block(Block::default().borders(Borders::ALL).title(title)) .data(&data); frame.render_widget(spark, spark_area); } else { frame.render_widget( Paragraph::new("no history yet — log some sets first") .block(Block::default().borders(Borders::ALL).title(" top distance ")), spark_area, ); } render_distance_recent_table(frame, table_area, &sessions); } fn render_distance_recent_table( frame: &mut Frame, area: Rect, sessions: &[Vec], ) { let recent: Vec<&Vec> = sessions.iter().rev().take(10).collect(); let mut lines: Vec = vec![Line::from(Span::styled( format!( " {:<10} {:>4} {:>10} {:>10} {:>3}", "date", "sets", "distance", "duration", "rpe" ), Style::default().add_modifier(Modifier::BOLD), ))]; for session in &recent { let date = session[0].session_date; // Row surfaces the set with the greatest distance; its duration // is what you actually want next to it (pace-adjacent). let top_set = session .iter() .max_by(|a, b| { a.distance .meters() .partial_cmp(&b.distance.meters()) .unwrap_or(std::cmp::Ordering::Equal) }) .expect("session is non-empty"); let max_rpe = session.iter().map(|s| s.rpe.get()).max().unwrap_or(0); lines.push(Line::from(format!( " {:<10} {:>4} {:>10} {:>10} {:>3}", date.format("%Y-%m-%d"), session.len(), top_set.distance, top_set.duration, max_rpe ))); } if recent.is_empty() { lines.push(Line::from(" (no sessions logged)")); } frame.render_widget( Paragraph::new(lines).block( Block::default().borders(Borders::ALL).title(" recent sessions "), ), area, ); } fn distance_top_meters(session: &[DistanceSet]) -> f64 { session .iter() .map(|s| s.distance.meters()) .fold(f64::NEG_INFINITY, f64::max) } // ---- shared --------------------------------------------------------------- fn state_name(s: State) -> &'static str { match s { State::Progressing => "progressing", State::Probation => "probation", State::Deloading => "deloading", State::Rebuilding => "rebuilding", } } #[cfg(test)] mod tests { use super::*; use crossterm::event::KeyEvent; use ratatui::backend::TestBackend; use ratatui::Terminal; use ripgrow_core::{ Distance as CoreDistance, DistancePayload, Duration as CoreDuration, Load, LoadUnit, Reps as CoreReps, RepsPayload, ResistanceType, Rpe, TimedPayload, }; fn key(c: KeyCode) -> KeyEvent { KeyEvent::new(c, KeyModifiers::empty()) } fn setup() -> (Db, i64) { let db = Db::open_in_memory().unwrap(); db.init_profile("self", LoadUnit::Kg).unwrap(); let id = db .create_exercise("squat", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[]) .unwrap(); (db, id) } fn render_once(screen: &mut HistoryScreen, db: &Db) -> String { let backend = TestBackend::new(80, 20); let mut terminal = Terminal::new(backend).unwrap(); terminal .draw(|f| screen.render(f, f.area(), db)) .unwrap(); let buffer = terminal.backend().buffer().clone(); // Concatenate rows, trimming trailing spaces on each so cell // fill doesn't bury the content in whitespace. let mut out = String::new(); for y in 0..buffer.area.height { for x in 0..buffer.area.width { out.push_str(buffer[(x, y)].symbol()); } out.push('\n'); } out } #[test] fn q_signals_quit() { let (db, _) = setup(); let mut screen = HistoryScreen::load(&db).unwrap(); assert!(matches!( screen.on_key(&db, key(KeyCode::Char('q'))), HistoryAction::Quit )); } #[test] fn j_k_wraps_selection() { let db = Db::open_in_memory().unwrap(); db.init_profile("self", LoadUnit::Kg).unwrap(); db.create_exercise("a", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[]) .unwrap(); db.create_exercise("b", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[]) .unwrap(); let mut screen = HistoryScreen::load(&db).unwrap(); assert_eq!(screen.list_state.selected(), Some(0)); screen.on_key(&db, key(KeyCode::Char('j'))); assert_eq!(screen.list_state.selected(), Some(1)); screen.on_key(&db, key(KeyCode::Char('j'))); assert_eq!(screen.list_state.selected(), Some(0), "wraps to top"); } #[test] fn selection_reflects_current_exercise() { let (db, id) = setup(); let screen = HistoryScreen::load(&db).unwrap(); assert_eq!(screen.selected().map(|e| e.id), Some(id)); } #[test] fn reps_detail_shows_top_load_and_e1rm() { let (db, ex) = setup(); let d = NaiveDate::from_ymd_opt(2026, 7, 10).unwrap(); RepsKind::append( &db, ex, d, RepsPayload::new(Load::new(100.0).unwrap(), CoreReps::new(5).unwrap()), Rpe::new(3).unwrap(), false, ) .unwrap(); let mut screen = HistoryScreen::load(&db).unwrap(); let out = render_once(&mut screen, &db); assert!(out.contains("top-set load"), "output: {out}"); assert!(out.contains("e1RM"), "output: {out}"); } #[test] fn timed_detail_shows_duration_columns() { let db = Db::open_in_memory().unwrap(); db.init_profile("self", LoadUnit::Kg).unwrap(); let plank = db .create_exercise("plank", ResistanceType::CardioTime, LoadUnit::Kg, 10.0, &[]) .unwrap(); let d = NaiveDate::from_ymd_opt(2026, 7, 10).unwrap(); TimedKind::append( &db, plank, d, TimedPayload::new(CoreDuration::from_seconds(90).unwrap()), Rpe::new(3).unwrap(), false, ) .unwrap(); let mut screen = HistoryScreen::load(&db).unwrap(); let out = render_once(&mut screen, &db); assert!(out.contains("longest hold"), "output: {out}"); assert!(out.contains("top duration"), "output: {out}"); // Sparkline header carries the min/max s labels. assert!(out.contains("min 90s"), "output: {out}"); // Detail should NOT mention reps-only e1RM. assert!(!out.contains("e1RM"), "output: {out}"); } #[test] fn distance_detail_shows_distance_columns() { let db = Db::open_in_memory().unwrap(); db.init_profile("self", LoadUnit::Kg).unwrap(); let run = db .create_exercise( "5k", ResistanceType::CardioDistance, LoadUnit::Kg, 0.0, &[], ) .unwrap(); let d = NaiveDate::from_ymd_opt(2026, 7, 10).unwrap(); DistanceKind::append( &db, run, d, DistancePayload::new( CoreDistance::from_meters(5000.0).unwrap(), CoreDuration::from_seconds(1500).unwrap(), ), Rpe::new(3).unwrap(), false, ) .unwrap(); let mut screen = HistoryScreen::load(&db).unwrap(); let out = render_once(&mut screen, &db); assert!(out.contains("top distance"), "output: {out}"); assert!(out.contains("longest:"), "output: {out}"); // Distance is not scored by the reps state machine. assert!(!out.contains("state:"), "output: {out}"); assert!(!out.contains("e1RM"), "output: {out}"); } }