Skip to main content

max / ripgrow

history: dispatch on effort_kind; per-kind sparkline and recent-sessions table Detail pane now branches on exercise.effort_kind: - Reps keeps the existing state / e1RM header, top-load sparkline, and date/sets/top-load/reps/rpe table. - Timed shows "sessions: N longest hold: M m S s", a top-duration sparkline, and a date/sets/top-hold/rpe table. - Distance shows "sessions: N longest: X m", a top-distance sparkline, and a date/sets/distance/duration/rpe table (row picks the set with the greatest distance and shows its duration alongside). Adds a render_once TestBackend helper so the new per-kind tests can assert real rendered content instead of just data-shape roundtrips.
Author: Max Johnson <me@maxj.phd> · 2026-07-18 20:29 UTC
Commit: 68f617e98e73908464f0bd2ecce2cc1041e0ca46
Parent: 6e85aac
1 file changed, +343 insertions, -20 deletions
@@ -1,13 +1,20 @@
1 - //! History screen. Per-exercise view: current progression state, top-set
2 - //! load sparkline, and the last ten sessions in a compact table.
1 + //! History screen. Per-exercise view: current progression state (for
2 + //! reps), sparkline of the kind-appropriate top-set metric, and the
3 + //! last ten sessions in a compact table. The layout dispatches on
4 + //! `exercise.effort_kind` so a plank sees a duration sparkline instead
5 + //! of a load sparkline.
3 6
7 + use chrono::NaiveDate;
4 8 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5 9 use ratatui::Frame;
6 10 use ratatui::layout::{Constraint, Direction, Layout, Rect};
7 11 use ratatui::style::{Modifier, Style};
8 12 use ratatui::text::{Line, Span};
9 13 use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Sparkline};
10 - use ripgrow_core::{Db, Exercise, Kind, RepsKind, RepsSet, State};
14 + use ripgrow_core::{
15 + Db, DistanceKind, DistanceSet, Duration, EffortKind, Exercise, Kind, RepsKind, RepsSet,
16 + State, TimedKind, TimedSet,
17 + };
11 18
12 19 pub struct HistoryScreen {
13 20 exercises: Vec<Exercise>,
@@ -121,6 +128,28 @@ fn render_detail(frame: &mut Frame, area: Rect, db: &Db, ex: &Exercise) {
121 128 let inner = block.inner(area);
122 129 frame.render_widget(block, area);
123 130
131 + match ex.effort_kind {
132 + EffortKind::Reps => render_reps_detail(frame, inner, db, ex),
133 + EffortKind::Timed => render_timed_detail(frame, inner, db, ex),
134 + EffortKind::Distance => render_distance_detail(frame, inner, db, ex),
135 + }
136 + }
137 +
138 + fn split_detail(area: Rect) -> (Rect, Rect, Rect) {
139 + let chunks = Layout::default()
140 + .direction(Direction::Vertical)
141 + .constraints([
142 + Constraint::Length(1),
143 + Constraint::Length(6),
144 + Constraint::Min(1),
145 + ])
146 + .split(area);
147 + (chunks[0], chunks[1], chunks[2])
148 + }
149 +
150 + // ---- reps ------------------------------------------------------------------
151 +
152 + fn render_reps_detail(frame: &mut Frame, inner: Rect, db: &Db, ex: &Exercise) {
124 153 let series = RepsKind::top_loads_over_time(db, ex.id).unwrap_or_default();
125 154 let state_str = db
126 155 .read_progression_state(ex.id)
@@ -169,22 +198,10 @@ fn render_detail(frame: &mut Frame, area: Rect, db: &Db, ex: &Exercise) {
169 198 );
170 199 }
171 200
172 - render_recent_table(frame, table_area, db, ex);
173 - }
174 -
175 - fn split_detail(area: Rect) -> (Rect, Rect, Rect) {
176 - let chunks = Layout::default()
177 - .direction(Direction::Vertical)
178 - .constraints([
179 - Constraint::Length(1),
180 - Constraint::Length(6),
181 - Constraint::Min(1),
182 - ])
183 - .split(area);
184 - (chunks[0], chunks[1], chunks[2])
201 + render_reps_recent_table(frame, table_area, db, ex);
185 202 }
186 203
187 - fn render_recent_table(frame: &mut Frame, area: Rect, db: &Db, ex: &Exercise) {
204 + fn render_reps_recent_table(frame: &mut Frame, area: Rect, db: &Db, ex: &Exercise) {
188 205 let sessions =
189 206 RepsKind::list_sessions_for_exercise(db, ex.id).unwrap_or_default();
190 207 let recent: Vec<&Vec<RepsSet>> = sessions.iter().rev().take(10).collect();
@@ -199,7 +216,7 @@ fn render_recent_table(frame: &mut Frame, area: Rect, db: &Db, ex: &Exercise) {
199 216
200 217 for session in &recent {
201 218 let date = session[0].session_date;
202 - let top_load = top_load(session);
219 + let top_load = reps_top_load(session);
203 220 let top_reps = session.iter().map(|s| s.reps.get()).max().unwrap_or(0);
204 221 let max_rpe = session.iter().map(|s| s.rpe.get()).max().unwrap_or(0);
205 222 lines.push(Line::from(format!(
@@ -224,13 +241,214 @@ fn render_recent_table(frame: &mut Frame, area: Rect, db: &Db, ex: &Exercise) {
224 241 );
225 242 }
226 243
227 - fn top_load(session: &[RepsSet]) -> f64 {
244 + fn reps_top_load(session: &[RepsSet]) -> f64 {
228 245 session
229 246 .iter()
230 247 .map(|s| s.load.get())
231 248 .fold(f64::NEG_INFINITY, f64::max)
232 249 }
233 250
251 + // ---- timed -----------------------------------------------------------------
252 +
253 + fn render_timed_detail(frame: &mut Frame, inner: Rect, db: &Db, ex: &Exercise) {
254 + let sessions = TimedKind::list_sessions_for_exercise(db, ex.id).unwrap_or_default();
255 + let series: Vec<(NaiveDate, i32)> = sessions
256 + .iter()
257 + .map(|s| (s[0].session_date, timed_top_seconds(s)))
258 + .collect();
259 +
260 + let (header_area, spark_area, table_area) = split_detail(inner);
261 +
262 + let longest_str = series
263 + .iter()
264 + .map(|(_, s)| *s)
265 + .max()
266 + .and_then(|s| Duration::from_seconds(s).ok())
267 + .map(|d| d.to_string())
268 + .unwrap_or_else(|| "-".to_string());
269 + let header = format!(
270 + "sessions: {} longest hold: {}",
271 + series.len(),
272 + longest_str,
273 + );
274 + frame.render_widget(Paragraph::new(header), header_area);
275 +
276 + if !series.is_empty() {
277 + let data: Vec<u64> = series.iter().map(|(_, s)| *s as u64).collect();
278 + let min = series.iter().map(|(_, s)| *s).min().unwrap_or(0);
279 + let max = series.iter().map(|(_, s)| *s).max().unwrap_or(0);
280 + let title = format!(" top duration min {}s max {}s ", min, max);
281 + let spark = Sparkline::default()
282 + .block(Block::default().borders(Borders::ALL).title(title))
283 + .data(&data);
284 + frame.render_widget(spark, spark_area);
285 + } else {
286 + frame.render_widget(
287 + Paragraph::new("no history yet — log some sets first")
288 + .block(Block::default().borders(Borders::ALL).title(" top duration ")),
289 + spark_area,
290 + );
291 + }
292 +
293 + render_timed_recent_table(frame, table_area, &sessions);
294 + }
295 +
296 + fn render_timed_recent_table(frame: &mut Frame, area: Rect, sessions: &[Vec<TimedSet>]) {
297 + let recent: Vec<&Vec<TimedSet>> = sessions.iter().rev().take(10).collect();
298 +
299 + let mut lines: Vec<Line> = vec![Line::from(Span::styled(
300 + format!(
301 + " {:<10} {:>4} {:>10} {:>3}",
302 + "date", "sets", "top hold", "rpe"
303 + ),
304 + Style::default().add_modifier(Modifier::BOLD),
305 + ))];
306 +
307 + for session in &recent {
308 + let date = session[0].session_date;
309 + let top = Duration::from_seconds(timed_top_seconds(session))
310 + .map(|d| d.to_string())
311 + .unwrap_or_else(|_| "-".to_string());
312 + let max_rpe = session.iter().map(|s| s.rpe.get()).max().unwrap_or(0);
313 + lines.push(Line::from(format!(
314 + " {:<10} {:>4} {:>10} {:>3}",
315 + date.format("%Y-%m-%d"),
316 + session.len(),
317 + top,
318 + max_rpe
319 + )));
320 + }
321 +
322 + if recent.is_empty() {
323 + lines.push(Line::from(" (no sessions logged)"));
324 + }
325 +
326 + frame.render_widget(
327 + Paragraph::new(lines).block(
328 + Block::default().borders(Borders::ALL).title(" recent sessions "),
329 + ),
330 + area,
331 + );
332 + }
333 +
334 + fn timed_top_seconds(session: &[TimedSet]) -> i32 {
335 + session
336 + .iter()
337 + .map(|s| s.duration.seconds())
338 + .max()
339 + .unwrap_or(0)
340 + }
341 +
342 + // ---- distance --------------------------------------------------------------
343 +
344 + fn render_distance_detail(frame: &mut Frame, inner: Rect, db: &Db, ex: &Exercise) {
345 + let sessions =
346 + DistanceKind::list_sessions_for_exercise(db, ex.id).unwrap_or_default();
347 + let series: Vec<(NaiveDate, f64)> = sessions
348 + .iter()
349 + .map(|s| (s[0].session_date, distance_top_meters(s)))
350 + .collect();
351 +
352 + let (header_area, spark_area, table_area) = split_detail(inner);
353 +
354 + let longest = series
355 + .iter()
356 + .map(|(_, m)| *m)
357 + .fold(f64::NEG_INFINITY, f64::max);
358 + let longest_str = if longest.is_finite() {
359 + format!("{} m", longest)
360 + } else {
361 + "-".to_string()
362 + };
363 + let header = format!(
364 + "sessions: {} longest: {}",
365 + series.len(),
366 + longest_str,
367 + );
368 + frame.render_widget(Paragraph::new(header), header_area);
369 +
370 + if !series.is_empty() {
371 + // Distance in meters can be large (5000+), so scale to u64
372 + // directly without the 10x reps trick.
373 + let data: Vec<u64> = series.iter().map(|(_, m)| *m as u64).collect();
374 + let min = series.iter().map(|(_, m)| *m).fold(f64::INFINITY, f64::min);
375 + let max = longest;
376 + let title = format!(" top distance min {} m max {} m ", min, max);
377 + let spark = Sparkline::default()
378 + .block(Block::default().borders(Borders::ALL).title(title))
379 + .data(&data);
380 + frame.render_widget(spark, spark_area);
381 + } else {
382 + frame.render_widget(
383 + Paragraph::new("no history yet — log some sets first")
384 + .block(Block::default().borders(Borders::ALL).title(" top distance ")),
385 + spark_area,
386 + );
387 + }
388 +
389 + render_distance_recent_table(frame, table_area, &sessions);
390 + }
391 +
392 + fn render_distance_recent_table(
393 + frame: &mut Frame,
394 + area: Rect,
395 + sessions: &[Vec<DistanceSet>],
396 + ) {
397 + let recent: Vec<&Vec<DistanceSet>> = sessions.iter().rev().take(10).collect();
398 +
399 + let mut lines: Vec<Line> = vec![Line::from(Span::styled(
400 + format!(
401 + " {:<10} {:>4} {:>10} {:>10} {:>3}",
402 + "date", "sets", "distance", "duration", "rpe"
403 + ),
404 + Style::default().add_modifier(Modifier::BOLD),
405 + ))];
406 +
407 + for session in &recent {
408 + let date = session[0].session_date;
409 + // Row surfaces the set with the greatest distance; its duration
410 + // is what you actually want next to it (pace-adjacent).
411 + let top_set = session
412 + .iter()
413 + .max_by(|a, b| {
414 + a.distance
415 + .meters()
416 + .partial_cmp(&b.distance.meters())
417 + .unwrap_or(std::cmp::Ordering::Equal)
418 + })
419 + .expect("session is non-empty");
420 + let max_rpe = session.iter().map(|s| s.rpe.get()).max().unwrap_or(0);
421 + lines.push(Line::from(format!(
422 + " {:<10} {:>4} {:>10} {:>10} {:>3}",
423 + date.format("%Y-%m-%d"),
424 + session.len(),
425 + top_set.distance,
426 + top_set.duration,
427 + max_rpe
428 + )));
429 + }
430 +
431 + if recent.is_empty() {
432 + lines.push(Line::from(" (no sessions logged)"));
433 + }
434 +
435 + frame.render_widget(
436 + Paragraph::new(lines).block(
437 + Block::default().borders(Borders::ALL).title(" recent sessions "),
438 + ),
439 + area,
440 + );
441 + }
442 +
443 + fn distance_top_meters(session: &[DistanceSet]) -> f64 {
444 + session
445 + .iter()
446 + .map(|s| s.distance.meters())
447 + .fold(f64::NEG_INFINITY, f64::max)
448 + }
449 +
450 + // ---- shared ---------------------------------------------------------------
451 +
234 452 fn state_name(s: State) -> &'static str {
235 453 match s {
236 454 State::Progressing => "progressing",
@@ -244,7 +462,12 @@ fn state_name(s: State) -> &'static str {
244 462 mod tests {
245 463 use super::*;
246 464 use crossterm::event::KeyEvent;
247 - use ripgrow_core::{ResistanceType, LoadUnit};
465 + use ratatui::backend::TestBackend;
466 + use ratatui::Terminal;
467 + use ripgrow_core::{
468 + Distance as CoreDistance, DistancePayload, Duration as CoreDuration, Load, LoadUnit,
469 + Reps as CoreReps, RepsPayload, ResistanceType, Rpe, TimedPayload,
470 + };
248 471
249 472 fn key(c: KeyCode) -> KeyEvent {
250 473 KeyEvent::new(c, KeyModifiers::empty())
@@ -259,6 +482,25 @@ mod tests {
259 482 (db, id)
260 483 }
261 484
485 + fn render_once(screen: &mut HistoryScreen, db: &Db) -> String {
486 + let backend = TestBackend::new(80, 20);
487 + let mut terminal = Terminal::new(backend).unwrap();
488 + terminal
489 + .draw(|f| screen.render(f, f.area(), db))
490 + .unwrap();
491 + let buffer = terminal.backend().buffer().clone();
492 + // Concatenate rows, trimming trailing spaces on each so cell
493 + // fill doesn't bury the content in whitespace.
494 + let mut out = String::new();
495 + for y in 0..buffer.area.height {
496 + for x in 0..buffer.area.width {
497 + out.push_str(buffer[(x, y)].symbol());
498 + }
499 + out.push('\n');
500 + }
501 + out
502 + }
503 +
262 504 #[test]
263 505 fn q_signals_quit() {
264 506 let (db, _) = setup();
@@ -291,4 +533,85 @@ mod tests {
291 533 let screen = HistoryScreen::load(&db).unwrap();
292 534 assert_eq!(screen.selected().map(|e| e.id), Some(id));
293 535 }
536 +
537 + #[test]
538 + fn reps_detail_shows_top_load_and_e1rm() {
539 + let (db, ex) = setup();
540 + let d = NaiveDate::from_ymd_opt(2026, 7, 10).unwrap();
541 + RepsKind::append(
542 + &db,
543 + ex,
544 + d,
545 + RepsPayload::new(Load::new(100.0).unwrap(), CoreReps::new(5).unwrap()),
546 + Rpe::new(3).unwrap(),
547 + false,
548 + )
549 + .unwrap();
550 + let mut screen = HistoryScreen::load(&db).unwrap();
551 + let out = render_once(&mut screen, &db);
552 + assert!(out.contains("top-set load"), "output: {out}");
553 + assert!(out.contains("e1RM"), "output: {out}");
554 + }
555 +
556 + #[test]
557 + fn timed_detail_shows_duration_columns() {
558 + let db = Db::open_in_memory().unwrap();
559 + db.init_profile("self", LoadUnit::Kg).unwrap();
560 + let plank = db
561 + .create_exercise("plank", ResistanceType::CardioTime, LoadUnit::Kg, 10.0, &[])
562 + .unwrap();
563 + let d = NaiveDate::from_ymd_opt(2026, 7, 10).unwrap();
564 + TimedKind::append(
565 + &db,
566 + plank,
567 + d,
568 + TimedPayload::new(CoreDuration::from_seconds(90).unwrap()),
569 + Rpe::new(3).unwrap(),
570 + false,
571 + )
572 + .unwrap();
573 + let mut screen = HistoryScreen::load(&db).unwrap();
574 + let out = render_once(&mut screen, &db);
575 + assert!(out.contains("longest hold"), "output: {out}");
576 + assert!(out.contains("top duration"), "output: {out}");
577 + // Sparkline header carries the min/max s labels.
578 + assert!(out.contains("min 90s"), "output: {out}");
579 + // Detail should NOT mention reps-only e1RM.
580 + assert!(!out.contains("e1RM"), "output: {out}");
581 + }
582 +
583 + #[test]
584 + fn distance_detail_shows_distance_columns() {
585 + let db = Db::open_in_memory().unwrap();
586 + db.init_profile("self", LoadUnit::Kg).unwrap();
587 + let run = db
588 + .create_exercise(
589 + "5k",
590 + ResistanceType::CardioDistance,
591 + LoadUnit::Kg,
592 + 0.0,
593 + &[],
594 + )
595 + .unwrap();
596 + let d = NaiveDate::from_ymd_opt(2026, 7, 10).unwrap();
597 + DistanceKind::append(
598 + &db,
599 + run,
600 + d,
601 + DistancePayload::new(
602 + CoreDistance::from_meters(5000.0).unwrap(),
603 + CoreDuration::from_seconds(1500).unwrap(),
604 + ),
605 + Rpe::new(3).unwrap(),
606 + false,
607 + )
608 + .unwrap();
609 + let mut screen = HistoryScreen::load(&db).unwrap();
610 + let out = render_once(&mut screen, &db);
611 + assert!(out.contains("top distance"), "output: {out}");
612 + assert!(out.contains("longest:"), "output: {out}");
613 + // Distance is not scored by the reps state machine.
614 + assert!(!out.contains("state:"), "output: {out}");
615 + assert!(!out.contains("e1RM"), "output: {out}");
616 + }
294 617 }