max / ripgrow
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 | } |