Skip to main content

max / ripgrow

20.3 KB · 618 lines History Blame Raw
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.
6
7 use chrono::NaiveDate;
8 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
9 use ratatui::Frame;
10 use ratatui::layout::{Constraint, Direction, Layout, Rect};
11 use ratatui::style::{Modifier, Style};
12 use ratatui::text::{Line, Span};
13 use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Sparkline};
14 use ripgrow_core::{
15 Db, DistanceKind, DistanceSet, Duration, EffortKind, Exercise, Kind, RepsKind, RepsSet,
16 State, TimedKind, TimedSet,
17 };
18
19 pub struct HistoryScreen {
20 exercises: Vec<Exercise>,
21 list_state: ListState,
22 pub status: String,
23 }
24
25 pub enum HistoryAction {
26 None,
27 Quit,
28 }
29
30 impl HistoryScreen {
31 pub fn load(db: &Db) -> Result<Self, ripgrow_core::Error> {
32 let exercises = db.list_exercises()?;
33 let mut list_state = ListState::default();
34 if !exercises.is_empty() {
35 list_state.select(Some(0));
36 }
37 Ok(Self {
38 exercises,
39 list_state,
40 status: String::new(),
41 })
42 }
43
44 pub fn on_key(&mut self, _db: &Db, key: KeyEvent) -> HistoryAction {
45 match (key.code, key.modifiers) {
46 (KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
47 HistoryAction::Quit
48 }
49 (KeyCode::Down | KeyCode::Char('j'), _) => {
50 self.move_selection(1);
51 HistoryAction::None
52 }
53 (KeyCode::Up | KeyCode::Char('k'), _) => {
54 self.move_selection(-1);
55 HistoryAction::None
56 }
57 _ => HistoryAction::None,
58 }
59 }
60
61 fn move_selection(&mut self, delta: isize) {
62 if self.exercises.is_empty() {
63 return;
64 }
65 let n = self.exercises.len() as isize;
66 let cur = self.list_state.selected().unwrap_or(0) as isize;
67 let next = (cur + delta).rem_euclid(n) as usize;
68 self.list_state.select(Some(next));
69 }
70
71 fn selected(&self) -> Option<&Exercise> {
72 self.list_state.selected().and_then(|i| self.exercises.get(i))
73 }
74
75 pub fn render(&mut self, frame: &mut Frame, area: Rect, db: &Db) {
76 let chunks = Layout::default()
77 .direction(Direction::Vertical)
78 .constraints([Constraint::Min(3), Constraint::Length(1)])
79 .split(area);
80
81 let panes = Layout::default()
82 .direction(Direction::Horizontal)
83 .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
84 .split(chunks[0]);
85
86 let items: Vec<ListItem> = if self.exercises.is_empty() {
87 vec![ListItem::new("no templates yet")]
88 } else {
89 self.exercises
90 .iter()
91 .map(|e| ListItem::new(e.name.as_str()))
92 .collect()
93 };
94 let list = List::new(items)
95 .block(Block::default().borders(Borders::ALL).title(" exercises "))
96 .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
97 .highlight_symbol("> ");
98 frame.render_stateful_widget(list, panes[0], &mut self.list_state);
99
100 match self.selected() {
101 Some(ex) => render_detail(frame, panes[1], db, ex),
102 None => {
103 let block = Block::default().borders(Borders::ALL).title(" detail ");
104 frame.render_widget(
105 Paragraph::new("no exercise selected").block(block),
106 panes[1],
107 );
108 }
109 }
110
111 frame.render_widget(
112 Paragraph::new(Line::from(vec![
113 Span::raw("j/k move q quit "),
114 Span::styled(
115 self.status.as_str(),
116 Style::default().add_modifier(Modifier::DIM),
117 ),
118 ])),
119 chunks[1],
120 );
121 }
122 }
123
124 fn render_detail(frame: &mut Frame, area: Rect, db: &Db, ex: &Exercise) {
125 let block = Block::default()
126 .borders(Borders::ALL)
127 .title(format!(" {} ", ex.name));
128 let inner = block.inner(area);
129 frame.render_widget(block, area);
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) {
153 let series = RepsKind::top_loads_over_time(db, ex.id).unwrap_or_default();
154 let state_str = db
155 .read_progression_state(ex.id)
156 .ok()
157 .flatten()
158 .map(state_name)
159 .unwrap_or("(no state cached)");
160
161 let (header_area, spark_area, table_area) = split_detail(inner);
162
163 let e1rm_str = match RepsKind::best_e1rm(db, ex.id).ok().flatten() {
164 Some(v) => format!("{:.1} {}", v, ex.load_unit),
165 None => "-".to_string(),
166 };
167 let header = format!(
168 "state: {} sessions: {} e1RM: {}",
169 state_str,
170 series.len(),
171 e1rm_str,
172 );
173 frame.render_widget(Paragraph::new(header), header_area);
174
175 if !series.is_empty() {
176 // Sparkline takes u64. Multiply by 10 so 0.1 kg resolution survives
177 // the cast; ratatui normalizes across the series so absolute scale
178 // does not matter.
179 let data: Vec<u64> = series.iter().map(|(_, l)| (l * 10.0) as u64).collect();
180 let min = series.iter().map(|(_, l)| *l).fold(f64::INFINITY, f64::min);
181 let max = series
182 .iter()
183 .map(|(_, l)| *l)
184 .fold(f64::NEG_INFINITY, f64::max);
185 let title = format!(
186 " top-set load min {} max {} ({}) ",
187 min, max, ex.load_unit
188 );
189 let spark = Sparkline::default()
190 .block(Block::default().borders(Borders::ALL).title(title))
191 .data(&data);
192 frame.render_widget(spark, spark_area);
193 } else {
194 frame.render_widget(
195 Paragraph::new("no history yet — log some sets first")
196 .block(Block::default().borders(Borders::ALL).title(" top-set load ")),
197 spark_area,
198 );
199 }
200
201 render_reps_recent_table(frame, table_area, db, ex);
202 }
203
204 fn render_reps_recent_table(frame: &mut Frame, area: Rect, db: &Db, ex: &Exercise) {
205 let sessions =
206 RepsKind::list_sessions_for_exercise(db, ex.id).unwrap_or_default();
207 let recent: Vec<&Vec<RepsSet>> = sessions.iter().rev().take(10).collect();
208
209 let mut lines: Vec<Line> = vec![Line::from(Span::styled(
210 format!(
211 " {:<10} {:>4} {:>8} {:>4} {:>3}",
212 "date", "sets", "top load", "reps", "rpe"
213 ),
214 Style::default().add_modifier(Modifier::BOLD),
215 ))];
216
217 for session in &recent {
218 let date = session[0].session_date;
219 let top_load = reps_top_load(session);
220 let top_reps = session.iter().map(|s| s.reps.get()).max().unwrap_or(0);
221 let max_rpe = session.iter().map(|s| s.rpe.get()).max().unwrap_or(0);
222 lines.push(Line::from(format!(
223 " {:<10} {:>4} {:>8} {:>4} {:>3}",
224 date.format("%Y-%m-%d"),
225 session.len(),
226 top_load,
227 top_reps,
228 max_rpe
229 )));
230 }
231
232 if recent.is_empty() {
233 lines.push(Line::from(" (no sessions logged)"));
234 }
235
236 frame.render_widget(
237 Paragraph::new(lines).block(
238 Block::default().borders(Borders::ALL).title(" recent sessions "),
239 ),
240 area,
241 );
242 }
243
244 fn reps_top_load(session: &[RepsSet]) -> f64 {
245 session
246 .iter()
247 .map(|s| s.load.get())
248 .fold(f64::NEG_INFINITY, f64::max)
249 }
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
452 fn state_name(s: State) -> &'static str {
453 match s {
454 State::Progressing => "progressing",
455 State::Probation => "probation",
456 State::Deloading => "deloading",
457 State::Rebuilding => "rebuilding",
458 }
459 }
460
461 #[cfg(test)]
462 mod tests {
463 use super::*;
464 use crossterm::event::KeyEvent;
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 };
471
472 fn key(c: KeyCode) -> KeyEvent {
473 KeyEvent::new(c, KeyModifiers::empty())
474 }
475
476 fn setup() -> (Db, i64) {
477 let db = Db::open_in_memory().unwrap();
478 db.init_profile("self", LoadUnit::Kg).unwrap();
479 let id = db
480 .create_exercise("squat", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
481 .unwrap();
482 (db, id)
483 }
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
504 #[test]
505 fn q_signals_quit() {
506 let (db, _) = setup();
507 let mut screen = HistoryScreen::load(&db).unwrap();
508 assert!(matches!(
509 screen.on_key(&db, key(KeyCode::Char('q'))),
510 HistoryAction::Quit
511 ));
512 }
513
514 #[test]
515 fn j_k_wraps_selection() {
516 let db = Db::open_in_memory().unwrap();
517 db.init_profile("self", LoadUnit::Kg).unwrap();
518 db.create_exercise("a", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
519 .unwrap();
520 db.create_exercise("b", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
521 .unwrap();
522 let mut screen = HistoryScreen::load(&db).unwrap();
523 assert_eq!(screen.list_state.selected(), Some(0));
524 screen.on_key(&db, key(KeyCode::Char('j')));
525 assert_eq!(screen.list_state.selected(), Some(1));
526 screen.on_key(&db, key(KeyCode::Char('j')));
527 assert_eq!(screen.list_state.selected(), Some(0), "wraps to top");
528 }
529
530 #[test]
531 fn selection_reflects_current_exercise() {
532 let (db, id) = setup();
533 let screen = HistoryScreen::load(&db).unwrap();
534 assert_eq!(screen.selected().map(|e| e.id), Some(id));
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 }
617 }
618