Skip to main content

max / ripgrow

history screen: sparkline, state, recent sessions Adds Db::top_loads_over_time which returns one row per session date with MAX(load) — the natural feed for a top-set sparkline. History tab (Alt+4) has a left exercise list and a right detail pane showing: current progression state from the cached row, top-set sparkline (ratatui Sparkline, values scaled by 10 so 0.1 kg resolution survives the u64 cast, then normalized by the widget), and the last ten sessions in a compact date/sets/top-load/reps/rpe table. 75 tests total (49 core + 26 tui), all passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 16:55 UTC
Commit: 2dcd87a82b51c540686ef4ca8a9f2302548d0d1e
Parent: be128a0
4 files changed, +361 insertions, -1 deletion
@@ -131,6 +131,31 @@ impl Db {
131 131 Ok(sessions)
132 132 }
133 133
134 + /// One row per session for an exercise: (session_date, top_load).
135 + /// Oldest first. Empty when the exercise has no history.
136 + pub fn top_loads_over_time(
137 + &self,
138 + exercise_id: i64,
139 + ) -> Result<Vec<(NaiveDate, f64)>, Error> {
140 + let mut stmt = self.conn().prepare(
141 + "SELECT session_date, MAX(load) FROM sets WHERE exercise_id = ?1 \
142 + GROUP BY session_date ORDER BY session_date ASC",
143 + )?;
144 + let rows = stmt.query_map(params![exercise_id], |row| {
145 + let date_str: String = row.get(0)?;
146 + let load: f64 = row.get(1)?;
147 + Ok((date_str, load))
148 + })?;
149 + let mut out = Vec::new();
150 + for row in rows {
151 + let (date_str, load) = row?;
152 + if let Ok(d) = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") {
153 + out.push((d, load));
154 + }
155 + }
156 + Ok(out)
157 + }
158 +
134 159 /// Delete a specific set row by id. Returns `NotFound` if it did not
135 160 /// exist. Subsequent `append_set` calls do NOT reuse the freed index;
136 161 /// gaps are fine for the state machine (which reads ordered by index).
@@ -206,6 +231,18 @@ mod tests {
206 231 }
207 232
208 233 #[test]
234 + fn top_loads_over_time_picks_max_per_date() {
235 + let (db, ex) = setup();
236 + let d1 = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
237 + let d2 = NaiveDate::from_ymd_opt(2026, 7, 3).unwrap();
238 + db.append_set(ex, d1, 100.0, 5, 3).unwrap();
239 + db.append_set(ex, d1, 105.0, 5, 4).unwrap();
240 + db.append_set(ex, d2, 100.0, 5, 3).unwrap();
241 + let series = db.top_loads_over_time(ex).unwrap();
242 + assert_eq!(series, vec![(d1, 105.0), (d2, 100.0)]);
243 + }
244 +
245 + #[test]
209 246 fn delete_removes_and_gap_is_fine() {
210 247 let (db, ex) = setup();
211 248 let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
@@ -15,6 +15,7 @@ use ratatui::widgets::{Block, Borders, Tabs};
15 15 use ripgrow_core::{Db, Unit, create_profile_in, list_profiles, profiles_dir};
16 16
17 17 use crate::screens::generate::GenerateScreen;
18 + use crate::screens::history::{HistoryAction, HistoryScreen};
18 19 use crate::screens::log::LogScreen;
19 20 use crate::screens::profile_picker::{PickerAction, ProfilePicker};
20 21 use crate::screens::templates::{TemplatesAction, TemplatesScreen};
@@ -33,6 +34,7 @@ struct Opened {
33 34 templates: TemplatesScreen,
34 35 log: LogScreen,
35 36 generate: GenerateScreen,
37 + history: HistoryScreen,
36 38 }
37 39
38 40 #[derive(Clone, Copy, PartialEq, Eq)]
@@ -40,16 +42,18 @@ enum Tab {
40 42 Templates,
41 43 Log,
42 44 Generate,
45 + History,
43 46 }
44 47
45 48 impl Tab {
46 - const ALL: [Tab; 3] = [Tab::Templates, Tab::Log, Tab::Generate];
49 + const ALL: [Tab; 4] = [Tab::Templates, Tab::Log, Tab::Generate, Tab::History];
47 50
48 51 fn title(self) -> &'static str {
49 52 match self {
50 53 Tab::Templates => "templates",
51 54 Tab::Log => "log",
52 55 Tab::Generate => "generate",
56 + Tab::History => "history",
53 57 }
54 58 }
55 59 }
@@ -103,6 +107,7 @@ impl App {
103 107 Tab::Templates => op.templates.render(frame, chunks[2]),
104 108 Tab::Log => op.log.render(frame, chunks[2]),
105 109 Tab::Generate => op.generate.render(frame, chunks[2], &op.db),
110 + Tab::History => op.history.render(frame, chunks[2], &op.db),
106 111 }
107 112 }
108 113 None => {
@@ -139,6 +144,10 @@ impl App {
139 144 op.tab = Tab::Generate;
140 145 return;
141 146 }
147 + KeyCode::Char('4') => {
148 + op.tab = Tab::History;
149 + return;
150 + }
142 151 _ => {}
143 152 }
144 153 }
@@ -163,10 +172,17 @@ impl App {
163 172 if let Ok(next) = GenerateScreen::load(&op.db) {
164 173 op.generate = next;
165 174 }
175 + if let Ok(next) = HistoryScreen::load(&op.db) {
176 + op.history = next;
177 + }
166 178 }
167 179 },
168 180 Tab::Log => op.log.on_key(&op.db, key),
169 181 Tab::Generate => op.generate.on_key(&op.db, key),
182 + Tab::History => match op.history.on_key(&op.db, key) {
183 + HistoryAction::None => {}
184 + HistoryAction::Quit => self.should_quit = true,
185 + },
170 186 }
171 187 return;
172 188 }
@@ -210,6 +226,13 @@ impl App {
210 226 return;
211 227 }
212 228 };
229 + let history = match HistoryScreen::load(&db) {
230 + Ok(s) => s,
231 + Err(e) => {
232 + self.status = format!("load history failed: {e}");
233 + return;
234 + }
235 + };
213 236 self.status = format!("opened {}", path.display());
214 237 self.opened = Some(Opened {
215 238 path,
@@ -218,6 +241,7 @@ impl App {
218 241 templates,
219 242 log,
220 243 generate,
244 + history,
221 245 });
222 246 }
223 247
@@ -252,6 +276,13 @@ impl App {
252 276 return;
253 277 }
254 278 };
279 + let history = match HistoryScreen::load(&db) {
280 + Ok(s) => s,
281 + Err(e) => {
282 + self.status = format!("load history failed: {e}");
283 + return;
284 + }
285 + };
255 286 self.status = format!("created {}", profile.slug);
256 287 self.opened = Some(Opened {
257 288 path: profile.path,
@@ -260,6 +291,7 @@ impl App {
260 291 templates,
261 292 log,
262 293 generate,
294 + history,
263 295 });
264 296 }
265 297 Err(e) => self.status = format!("create failed: {e}"),
@@ -0,0 +1,290 @@
1 + //! History screen. Per-exercise view: current progression state, top-set
2 + //! load sparkline, and the last ten sessions in a compact table.
3 +
4 + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5 + use ratatui::Frame;
6 + use ratatui::layout::{Constraint, Direction, Layout, Rect};
7 + use ratatui::style::{Modifier, Style};
8 + use ratatui::text::{Line, Span};
9 + use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Sparkline};
10 + use ripgrow_core::{Db, Exercise, SessionSet, State};
11 +
12 + pub struct HistoryScreen {
13 + exercises: Vec<Exercise>,
14 + list_state: ListState,
15 + pub status: String,
16 + }
17 +
18 + pub enum HistoryAction {
19 + None,
20 + Quit,
21 + }
22 +
23 + impl HistoryScreen {
24 + pub fn load(db: &Db) -> Result<Self, ripgrow_core::Error> {
25 + let exercises = db.list_exercises()?;
26 + let mut list_state = ListState::default();
27 + if !exercises.is_empty() {
28 + list_state.select(Some(0));
29 + }
30 + Ok(Self {
31 + exercises,
32 + list_state,
33 + status: String::new(),
34 + })
35 + }
36 +
37 + pub fn on_key(&mut self, _db: &Db, key: KeyEvent) -> HistoryAction {
38 + match (key.code, key.modifiers) {
39 + (KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
40 + HistoryAction::Quit
41 + }
42 + (KeyCode::Down | KeyCode::Char('j'), _) => {
43 + self.move_selection(1);
44 + HistoryAction::None
45 + }
46 + (KeyCode::Up | KeyCode::Char('k'), _) => {
47 + self.move_selection(-1);
48 + HistoryAction::None
49 + }
50 + _ => HistoryAction::None,
51 + }
52 + }
53 +
54 + fn move_selection(&mut self, delta: isize) {
55 + if self.exercises.is_empty() {
56 + return;
57 + }
58 + let n = self.exercises.len() as isize;
59 + let cur = self.list_state.selected().unwrap_or(0) as isize;
60 + let next = (cur + delta).rem_euclid(n) as usize;
61 + self.list_state.select(Some(next));
62 + }
63 +
64 + fn selected(&self) -> Option<&Exercise> {
65 + self.list_state.selected().and_then(|i| self.exercises.get(i))
66 + }
67 +
68 + pub fn render(&mut self, frame: &mut Frame, area: Rect, db: &Db) {
69 + let chunks = Layout::default()
70 + .direction(Direction::Vertical)
71 + .constraints([Constraint::Min(3), Constraint::Length(1)])
72 + .split(area);
73 +
74 + let panes = Layout::default()
75 + .direction(Direction::Horizontal)
76 + .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
77 + .split(chunks[0]);
78 +
79 + let items: Vec<ListItem> = if self.exercises.is_empty() {
80 + vec![ListItem::new("no templates yet")]
81 + } else {
82 + self.exercises
83 + .iter()
84 + .map(|e| ListItem::new(e.name.as_str()))
85 + .collect()
86 + };
87 + let list = List::new(items)
88 + .block(Block::default().borders(Borders::ALL).title(" exercises "))
89 + .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
90 + .highlight_symbol("> ");
91 + frame.render_stateful_widget(list, panes[0], &mut self.list_state);
92 +
93 + match self.selected() {
94 + Some(ex) => render_detail(frame, panes[1], db, ex),
95 + None => {
96 + let block = Block::default().borders(Borders::ALL).title(" detail ");
97 + frame.render_widget(
98 + Paragraph::new("no exercise selected").block(block),
99 + panes[1],
100 + );
101 + }
102 + }
103 +
104 + frame.render_widget(
105 + Paragraph::new(Line::from(vec![
106 + Span::raw("j/k move q quit "),
107 + Span::styled(
108 + self.status.as_str(),
109 + Style::default().add_modifier(Modifier::DIM),
110 + ),
111 + ])),
112 + chunks[1],
113 + );
114 + }
115 + }
116 +
117 + fn render_detail(frame: &mut Frame, area: Rect, db: &Db, ex: &Exercise) {
118 + let block = Block::default()
119 + .borders(Borders::ALL)
120 + .title(format!(" {} ", ex.name));
121 + let inner = block.inner(area);
122 + frame.render_widget(block, area);
123 +
124 + let series = db.top_loads_over_time(ex.id).unwrap_or_default();
125 + let state_str = db
126 + .read_progression_state(ex.id)
127 + .ok()
128 + .flatten()
129 + .map(state_name)
130 + .unwrap_or("(no state cached)");
131 +
132 + let (header_area, spark_area, table_area) = split_detail(inner);
133 +
134 + let header = format!(
135 + "state: {} sessions: {}",
136 + state_str,
137 + series.len(),
138 + );
139 + frame.render_widget(Paragraph::new(header), header_area);
140 +
141 + if !series.is_empty() {
142 + // Sparkline takes u64. Multiply by 10 so 0.1 kg resolution survives
143 + // the cast; ratatui normalizes across the series so absolute scale
144 + // does not matter.
145 + let data: Vec<u64> = series.iter().map(|(_, l)| (l * 10.0) as u64).collect();
146 + let min = series.iter().map(|(_, l)| *l).fold(f64::INFINITY, f64::min);
147 + let max = series
148 + .iter()
149 + .map(|(_, l)| *l)
150 + .fold(f64::NEG_INFINITY, f64::max);
151 + let title = format!(
152 + " top-set load min {} max {} ({}) ",
153 + min, max, ex.load_unit
154 + );
155 + let spark = Sparkline::default()
156 + .block(Block::default().borders(Borders::ALL).title(title))
157 + .data(&data);
158 + frame.render_widget(spark, spark_area);
159 + } else {
160 + frame.render_widget(
161 + Paragraph::new("no history yet — log some sets first")
162 + .block(Block::default().borders(Borders::ALL).title(" top-set load ")),
163 + spark_area,
164 + );
165 + }
166 +
167 + render_recent_table(frame, table_area, db, ex);
168 + }
169 +
170 + fn split_detail(area: Rect) -> (Rect, Rect, Rect) {
171 + let chunks = Layout::default()
172 + .direction(Direction::Vertical)
173 + .constraints([
174 + Constraint::Length(1),
175 + Constraint::Length(6),
176 + Constraint::Min(1),
177 + ])
178 + .split(area);
179 + (chunks[0], chunks[1], chunks[2])
180 + }
181 +
182 + fn render_recent_table(frame: &mut Frame, area: Rect, db: &Db, ex: &Exercise) {
183 + let sessions = db
184 + .list_sessions_for_exercise(ex.id)
185 + .unwrap_or_default();
186 + let recent: Vec<&Vec<SessionSet>> = sessions.iter().rev().take(10).collect();
187 +
188 + let mut lines: Vec<Line> = vec![Line::from(Span::styled(
189 + format!(
190 + " {:<10} {:>4} {:>8} {:>4} {:>3}",
191 + "date", "sets", "top load", "reps", "rpe"
192 + ),
193 + Style::default().add_modifier(Modifier::BOLD),
194 + ))];
195 +
196 + for session in &recent {
197 + let date = session[0].session_date;
198 + let top_load = top_load(session);
199 + let top_reps = session.iter().map(|s| s.reps).max().unwrap_or(0);
200 + let max_rpe = session.iter().map(|s| s.rpe).max().unwrap_or(0);
201 + lines.push(Line::from(format!(
202 + " {:<10} {:>4} {:>8} {:>4} {:>3}",
203 + date.format("%Y-%m-%d"),
204 + session.len(),
205 + top_load,
206 + top_reps,
207 + max_rpe
208 + )));
209 + }
210 +
211 + if recent.is_empty() {
212 + lines.push(Line::from(" (no sessions logged)"));
213 + }
214 +
215 + frame.render_widget(
216 + Paragraph::new(lines).block(
217 + Block::default().borders(Borders::ALL).title(" recent sessions "),
218 + ),
219 + area,
220 + );
221 + }
222 +
223 + fn top_load(session: &[SessionSet]) -> f64 {
224 + session
225 + .iter()
226 + .map(|s| s.load)
227 + .fold(f64::NEG_INFINITY, f64::max)
228 + }
229 +
230 + fn state_name(s: State) -> &'static str {
231 + match s {
232 + State::Progressing => "progressing",
233 + State::Probation => "probation",
234 + State::Deloading => "deloading",
235 + State::Rebuilding => "rebuilding",
236 + }
237 + }
238 +
239 + #[cfg(test)]
240 + mod tests {
241 + use super::*;
242 + use crossterm::event::KeyEvent;
243 + use ripgrow_core::{ResistanceType, Unit};
244 +
245 + fn key(c: KeyCode) -> KeyEvent {
246 + KeyEvent::new(c, KeyModifiers::empty())
247 + }
248 +
249 + fn setup() -> (Db, i64) {
250 + let db = Db::open_in_memory().unwrap();
251 + db.init_profile("self", Unit::Kg).unwrap();
252 + let id = db
253 + .create_exercise("squat", ResistanceType::Freeweight, "kg", 2.5, &[])
254 + .unwrap();
255 + (db, id)
256 + }
257 +
258 + #[test]
259 + fn q_signals_quit() {
260 + let (db, _) = setup();
261 + let mut screen = HistoryScreen::load(&db).unwrap();
262 + assert!(matches!(
263 + screen.on_key(&db, key(KeyCode::Char('q'))),
264 + HistoryAction::Quit
265 + ));
266 + }
267 +
268 + #[test]
269 + fn j_k_wraps_selection() {
270 + let db = Db::open_in_memory().unwrap();
271 + db.init_profile("self", Unit::Kg).unwrap();
272 + db.create_exercise("a", ResistanceType::Freeweight, "kg", 2.5, &[])
273 + .unwrap();
274 + db.create_exercise("b", ResistanceType::Freeweight, "kg", 2.5, &[])
275 + .unwrap();
276 + let mut screen = HistoryScreen::load(&db).unwrap();
277 + assert_eq!(screen.list_state.selected(), Some(0));
278 + screen.on_key(&db, key(KeyCode::Char('j')));
279 + assert_eq!(screen.list_state.selected(), Some(1));
280 + screen.on_key(&db, key(KeyCode::Char('j')));
281 + assert_eq!(screen.list_state.selected(), Some(0), "wraps to top");
282 + }
283 +
284 + #[test]
285 + fn selection_reflects_current_exercise() {
286 + let (db, id) = setup();
287 + let screen = HistoryScreen::load(&db).unwrap();
288 + assert_eq!(screen.selected().map(|e| e.id), Some(id));
289 + }
290 + }
@@ -1,4 +1,5 @@
1 1 pub mod generate;
2 + pub mod history;
2 3 pub mod log;
3 4 pub mod profile_picker;
4 5 pub mod templates;