//! Diagnostic screen: drives the first-session (or recalibration) //! protocol on one exercise at a time. //! //! Flow: //! //! Pick -> fuzzy-select an exercise (same as Log) //! Seed -> confirm/type the starting load (pre-filled from last //! diagnostic if one exists) //! Run -> app prescribes load x 5, user types reps + rpe, Enter //! commits the set and asks core for the next step //! Result -> completion summary; any key returns to Pick //! //! Diagnostic sets are written with `append_diagnostic_set`, so the //! progression state machine will not see them. Generate can seed from //! the top load of the most recent diagnostic via `Db::diagnostic_seed`. use chrono::{Local, 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}; use ripgrow_core::diagnostic::{DiagnosticSet, DiagnosticStep, next_step}; use ripgrow_core::{ Db, EffortKind, Estimate, Exercise, Kind, Load, Reps, RepsKind, RepsPayload, RepsSet, Rpe, }; pub struct DiagnosticScreen { date: NaiveDate, exercises: Vec, picker: PickState, session: Option, pub status: String, } struct PickState { query: String, list_state: ListState, } struct SessionState { exercise: Exercise, stage: Stage, } enum Stage { Seed { seed: String, prefilled: bool, }, Run { next_load: Load, target_reps: Reps, committed: Vec, pending: PendingRow, focus: RunField, }, Result { top_load: Load, working_weight: Load, e1rm: Option>, }, } #[derive(Default)] struct PendingRow { reps: String, rpe: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RunField { Reps, Rpe, } impl DiagnosticScreen { pub fn load(db: &Db) -> Result { // Diagnostic is reps-only for now: the DiagnosticStep protocol // and its "escalate on easy sets" heuristic are reps-shaped. // Timed / distance diagnostics need their own protocol design // (what does an "easy" plank hold or 5k run look like?) which // is deferred; for now those exercises are filtered out of the // picker and the status hints at what was skipped. let all = db.list_exercises()?; let skipped = all .iter() .filter(|e| e.effort_kind != EffortKind::Reps) .count(); let exercises: Vec = all .into_iter() .filter(|e| e.effort_kind == EffortKind::Reps) .collect(); let mut list_state = ListState::default(); if !exercises.is_empty() { list_state.select(Some(0)); } let status = if skipped > 0 { format!( "hiding {skipped} non-reps exercise(s) — diagnostic is reps-only for now" ) } else { String::new() }; Ok(Self { date: Local::now().date_naive(), exercises, picker: PickState { query: String::new(), list_state, }, session: None, status, }) } pub fn on_key(&mut self, db: &Db, key: KeyEvent) { match self.session.as_mut() { None => self.on_pick_key(db, key), Some(s) => match &mut s.stage { Stage::Seed { .. } => self.on_seed_key(db, key), Stage::Run { .. } => self.on_run_key(db, key), Stage::Result { .. } => self.on_result_key(key), }, } } // ---- picker --------------------------------------------------------- fn filtered_indices(&self) -> Vec { let q = self.picker.query.trim().to_lowercase(); if q.is_empty() { (0..self.exercises.len()).collect() } else { self.exercises .iter() .enumerate() .filter(|(_, e)| subsequence_match(&e.name.to_lowercase(), &q)) .map(|(i, _)| i) .collect() } } fn on_pick_key(&mut self, db: &Db, key: KeyEvent) { match key.code { KeyCode::Esc => { self.picker.query.clear(); } KeyCode::Enter => { let filt = self.filtered_indices(); if let Some(row) = self.picker.list_state.selected() && let Some(ex_idx) = filt.get(row) && let Some(ex) = self.exercises.get(*ex_idx).cloned() { self.enter_seed(db, ex); } } KeyCode::Down => self.move_pick(1), KeyCode::Up => self.move_pick(-1), KeyCode::Backspace => { self.picker.query.pop(); self.picker.list_state.select(Some(0)); } KeyCode::Char(c) => { self.picker.query.push(c); self.picker.list_state.select(Some(0)); } _ => {} } } fn move_pick(&mut self, delta: isize) { let n = self.filtered_indices().len(); if n == 0 { return; } let cur = self.picker.list_state.selected().unwrap_or(0) as isize; let next = (cur + delta).rem_euclid(n as isize) as usize; self.picker.list_state.select(Some(next)); } fn enter_seed(&mut self, db: &Db, exercise: Exercise) { let prior = RepsKind::diagnostic_seed(db, exercise.id).ok().flatten(); let (seed, prefilled) = match prior { Some(v) => (format!("{v}"), true), None => (String::new(), false), }; self.session = Some(SessionState { exercise, stage: Stage::Seed { seed, prefilled }, }); self.status = if prefilled { "seed prefilled from prior diagnostic".to_string() } else { "no prior diagnostic — type a starting load".to_string() }; } // ---- seed sub-mode -------------------------------------------------- fn on_seed_key(&mut self, db: &Db, key: KeyEvent) { let Some(s) = self.session.as_mut() else { return }; let Stage::Seed { seed, prefilled } = &mut s.stage else { return; }; match key.code { KeyCode::Esc => { self.session = None; self.status.clear(); } KeyCode::Enter => { let Ok(value) = seed.trim().parse::() else { self.status = "seed must be a number".to_string(); return; }; let increment = s.exercise.increment; let step = next_step(value, increment, &[]); let DiagnosticStep::Prescribe { load, reps } = step else { // next_step on an empty history always prescribes. self.status = "unable to start diagnostic".to_string(); return; }; s.stage = Stage::Run { next_load: load, target_reps: reps, committed: load_prior_diagnostic_today(db, s.exercise.id, self.date), pending: PendingRow::default(), focus: RunField::Reps, }; self.status = "diagnostic started".to_string(); } KeyCode::Backspace => { if *prefilled { seed.clear(); *prefilled = false; } else { seed.pop(); } } KeyCode::Char(c) if c.is_ascii_digit() || c == '.' => { if *prefilled { seed.clear(); *prefilled = false; } seed.push(c); } _ => {} } } // ---- run sub-mode --------------------------------------------------- fn on_run_key(&mut self, db: &Db, key: KeyEvent) { let Some(s) = self.session.as_mut() else { return }; let Stage::Run { next_load, target_reps, committed, pending, focus, } = &mut s.stage else { return; }; match (key.code, key.modifiers) { (KeyCode::Esc, _) => { self.session = None; self.status.clear(); return; } (KeyCode::Tab, _) | (KeyCode::BackTab, _) => { *focus = match focus { RunField::Reps => RunField::Rpe, RunField::Rpe => RunField::Reps, }; return; } (KeyCode::Enter, _) => { match commit_diagnostic_set( db, &s.exercise, self.date, *next_load, *target_reps, pending, committed, ) { Ok(()) => { *focus = RunField::Reps; self.status = "set logged".to_string(); let step = next_step(0.0, s.exercise.increment, committed); match step { DiagnosticStep::Prescribe { load, reps } => { *next_load = load; *target_reps = reps; } DiagnosticStep::Complete { top_load, working_weight, e1rm, } => { s.stage = Stage::Result { top_load, working_weight, e1rm, }; self.status = "diagnostic complete".to_string(); } } } Err(e) => self.status = format!("save failed: {e}"), } return; } (KeyCode::Backspace, KeyModifiers::CONTROL) => { if let Some(last) = committed.pop() { // Remove the corresponding DB row too. The most-recent // diagnostic set on this date-and-exercise matches. if let Err(e) = delete_last_diagnostic_row_today( db, s.exercise.id, self.date, ) { self.status = format!("undo failed: {e}"); committed.push(last); return; } // Reset the prescription to what next_step wants now. let step = next_step(next_load.get(), s.exercise.increment, committed); if let DiagnosticStep::Prescribe { load, reps } = step { *next_load = load; *target_reps = reps; } self.status = "last set removed".to_string(); } return; } _ => {} } let target = match focus { RunField::Reps => &mut pending.reps, RunField::Rpe => &mut pending.rpe, }; match key.code { KeyCode::Backspace => { target.pop(); } KeyCode::Char(c) => target.push(c), _ => {} } } // ---- result sub-mode ------------------------------------------------ fn on_result_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => { self.session = None; self.status.clear(); } _ => {} } } // ---- rendering ------------------------------------------------------ pub fn render(&mut self, frame: &mut Frame, area: Rect) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), Constraint::Min(3), Constraint::Length(1), ]) .split(area); let date_hint = format!("session: {}", self.date.format("%Y-%m-%d")); frame.render_widget(Paragraph::new(date_hint), chunks[0]); match &mut self.session { None => render_picker(frame, chunks[1], &mut self.picker, &self.exercises), Some(s) => render_session(frame, chunks[1], s), } let hint = match self.session.as_ref().map(|s| &s.stage) { None => "type to filter enter pick esc clear", Some(Stage::Seed { .. }) => "type seed load enter start esc back", Some(Stage::Run { .. }) => { "type reps + rpe tab move enter commit ctrl-backspace undo esc back" } Some(Stage::Result { .. }) => "enter/esc back to picker", }; frame.render_widget( Paragraph::new(Line::from(vec![ Span::raw(hint), Span::raw(" "), Span::styled( self.status.as_str(), Style::default().add_modifier(Modifier::DIM), ), ])), chunks[2], ); } } fn commit_diagnostic_set( db: &Db, exercise: &Exercise, date: NaiveDate, prescribed_load: Load, target_reps: Reps, pending: &mut PendingRow, committed: &mut Vec, ) -> Result<(), ripgrow_core::Error> { let reps = Reps::new(parse_field("reps", &pending.reps)?)?; let rpe = Rpe::new(parse_field("rpe", &pending.rpe)?)?; RepsKind::append( db, exercise.id, date, RepsPayload::new(prescribed_load, reps), rpe, true, )?; committed.push(DiagnosticSet { load: prescribed_load, reps, rpe, }); *pending = PendingRow::default(); let _ = target_reps; // reserved for future variable-target variants Ok(()) } fn parse_field( field: &'static str, raw: &str, ) -> Result { raw.trim() .parse() .map_err(|_| ripgrow_core::Error::ParseField { field, value: raw.to_string(), }) } /// Pick up an in-progress diagnostic on this date if the user re-entered /// the tab. Simple heuristic: read every diagnostic set logged for this /// exercise on this date and treat them as `committed`. fn load_prior_diagnostic_today( db: &Db, exercise_id: i64, date: NaiveDate, ) -> Vec { let Ok(list) = RepsKind::list_sets_for_session(db, exercise_id, date) else { return Vec::new(); }; list.into_iter() .filter(|s| s.is_diagnostic) .map(|s: RepsSet| DiagnosticSet { load: s.load, reps: s.reps, rpe: s.rpe, }) .collect() } fn delete_last_diagnostic_row_today( db: &Db, exercise_id: i64, date: NaiveDate, ) -> Result<(), ripgrow_core::Error> { let list = RepsKind::list_sets_for_session(db, exercise_id, date)?; if let Some(last) = list.iter().rev().find(|s| s.is_diagnostic) { RepsKind::delete(db, last.id)?; } Ok(()) } fn render_picker( frame: &mut Frame, area: Rect, picker: &mut PickState, exercises: &[Exercise], ) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(3), Constraint::Min(1)]) .split(area); let query_block = Block::default() .borders(Borders::ALL) .title(" pick exercise to diagnose "); let query = Paragraph::new(format!("> {}_", picker.query)).block(query_block); frame.render_widget(query, chunks[0]); let q = picker.query.trim().to_lowercase(); let items: Vec = exercises .iter() .filter(|e| q.is_empty() || subsequence_match(&e.name.to_lowercase(), &q)) .map(|e| ListItem::new(e.name.as_str())) .collect(); let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(" matches ")) .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) .highlight_symbol("> "); frame.render_stateful_widget(list, chunks[1], &mut picker.list_state); } fn render_session(frame: &mut Frame, area: Rect, s: &SessionState) { let block = Block::default() .borders(Borders::ALL) .title(format!(" diagnostic: {} ", s.exercise.name)); let inner = block.inner(area); frame.render_widget(block, area); match &s.stage { Stage::Seed { seed, prefilled } => { let mark = if *prefilled { " (prefilled)" } else { "" }; let text = format!("seed load ({}){mark}\n\n> {}_", s.exercise.load_unit, seed); frame.render_widget(Paragraph::new(text), inner); } Stage::Run { next_load, target_reps, committed, pending, focus, } => render_run(frame, inner, &s.exercise, *next_load, *target_reps, committed, pending, *focus), Stage::Result { top_load, working_weight, e1rm, } => { let e1rm_str = match e1rm { Some(est) => format!("{value:.1} {unit} ({method})", value = est.value, unit = s.exercise.load_unit, method = est.method), None => "(no signal)".to_string(), }; let text = format!( "diagnostic complete\n\n\ top load: {top} {unit}\n\ working weight: {working:.1} {unit}\n\ e1RM: {e1rm_str}\n\n\ generate will now seed this exercise from the top load.", top = top_load.get(), unit = s.exercise.load_unit, working = working_weight.get(), e1rm_str = e1rm_str, ); frame.render_widget(Paragraph::new(text), inner); } } } #[allow(clippy::too_many_arguments)] fn render_run( frame: &mut Frame, area: Rect, exercise: &Exercise, next_load: Load, target_reps: Reps, committed: &[DiagnosticSet], pending: &PendingRow, focus: RunField, ) { let header = format!( " # {:>8} {:>4} {:>3}", format!("load ({})", exercise.load_unit), "reps", "rpe", ); let mut lines: Vec = vec![Line::from(Span::styled( header, Style::default().add_modifier(Modifier::BOLD), ))]; for (i, s) in committed.iter().enumerate() { lines.push(Line::from(format!( " {:<3} {:>8} {:>4} {:>3}", i + 1, s.load, s.reps, s.rpe, ))); } let next_idx = committed.len() + 1; let reps_span = focused_span(&pending.reps, focus == RunField::Reps, 4); let rpe_span = focused_span(&pending.rpe, focus == RunField::Rpe, 3); lines.push(Line::from(vec![ Span::raw(format!(" {:<3} {:>8} ", next_idx, next_load)), reps_span, Span::raw(" "), rpe_span, ])); lines.push(Line::from("")); lines.push(Line::from(Span::styled( format!( "prescribed: {} {} x {} log what you actually did", next_load, exercise.load_unit, target_reps ), Style::default().add_modifier(Modifier::DIM), ))); frame.render_widget(Paragraph::new(lines), area); } fn focused_span(value: &str, focused: bool, width: usize) -> Span<'_> { let content = format!("{:>width$}", value, width = width); if focused { Span::styled(content, Style::default().add_modifier(Modifier::REVERSED)) } else { Span::raw(content) } } fn subsequence_match(haystack: &str, needle: &str) -> bool { let mut chars = haystack.chars(); needle.chars().all(|nc| chars.any(|hc| hc == nc)) } #[cfg(test)] mod tests { use super::*; use crossterm::event::KeyEvent; use ripgrow_core::{Load, LoadUnit, Reps, ResistanceType, Rpe}; 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 sq = db .create_exercise("squat", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[]) .unwrap(); db.create_exercise("bench", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[]) .unwrap(); (db, sq) } fn stage_kind(screen: &DiagnosticScreen) -> &'static str { match screen.session.as_ref().map(|s| &s.stage) { None => "picker", Some(Stage::Seed { .. }) => "seed", Some(Stage::Run { .. }) => "run", Some(Stage::Result { .. }) => "result", } } #[test] fn load_filters_out_non_reps_exercises_and_reports_skip_count() { let db = Db::open_in_memory().unwrap(); db.init_profile("self", LoadUnit::Kg).unwrap(); db.create_exercise("squat", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[]) .unwrap(); db.create_exercise("plank", ResistanceType::CardioTime, LoadUnit::Kg, 10.0, &[]) .unwrap(); db.create_exercise("5k run", ResistanceType::CardioDistance, LoadUnit::Kg, 0.0, &[]) .unwrap(); let screen = DiagnosticScreen::load(&db).unwrap(); let names: Vec<&str> = screen.exercises.iter().map(|e| e.name.as_str()).collect(); assert_eq!(names, vec!["squat"]); assert!(screen.status.contains("hiding 2"), "status: {}", screen.status); } #[test] fn load_status_is_empty_when_every_exercise_is_reps() { let (db, _) = setup(); let screen = DiagnosticScreen::load(&db).unwrap(); assert!(screen.status.is_empty(), "status: {:?}", screen.status); } #[test] fn picking_exercise_enters_seed_stage() { let (db, _) = setup(); let mut screen = DiagnosticScreen::load(&db).unwrap(); for c in "squat".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, key(KeyCode::Enter)); assert_eq!(stage_kind(&screen), "seed"); } #[test] fn seed_typed_then_enter_enters_run_stage() { let (db, _) = setup(); let mut screen = DiagnosticScreen::load(&db).unwrap(); for c in "squat".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, key(KeyCode::Enter)); for c in "60".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, key(KeyCode::Enter)); assert_eq!(stage_kind(&screen), "run"); } #[test] fn committing_easy_set_escalates_load() { let (db, sq) = setup(); let mut screen = DiagnosticScreen::load(&db).unwrap(); for c in "squat".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, key(KeyCode::Enter)); for c in "60".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, key(KeyCode::Enter)); // to Run // Reps 5, tab, rpe 2, enter. for c in "5".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, key(KeyCode::Tab)); for c in "2".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, key(KeyCode::Enter)); // Set persisted with is_diagnostic = true. let today = chrono::Local::now().date_naive(); let rows = RepsKind::list_sets_for_session(&db, sq, today).unwrap(); assert_eq!(rows.len(), 1); assert!(rows[0].is_diagnostic); // Next load should be 60 * 1.15 = 69, rounded to 2.5 -> 70. let Some(SessionState { stage: Stage::Run { next_load, .. }, .. }) = screen.session.as_ref() else { panic!("expected run stage"); }; assert_eq!(next_load.get(), 70.0); } #[test] fn hard_set_completes_with_result_stage() { let (db, _) = setup(); let mut screen = DiagnosticScreen::load(&db).unwrap(); for c in "squat".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, key(KeyCode::Enter)); for c in "100".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, key(KeyCode::Enter)); for c in "5".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, key(KeyCode::Tab)); for c in "4".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, key(KeyCode::Enter)); assert_eq!(stage_kind(&screen), "result"); } #[test] fn result_stage_returns_to_picker_on_enter() { let (db, _) = setup(); let mut screen = DiagnosticScreen::load(&db).unwrap(); for c in "squat".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, key(KeyCode::Enter)); for c in "100".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, key(KeyCode::Enter)); for c in "5".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, key(KeyCode::Tab)); for c in "5".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, key(KeyCode::Enter)); assert_eq!(stage_kind(&screen), "result"); screen.on_key(&db, key(KeyCode::Enter)); assert_eq!(stage_kind(&screen), "picker"); } #[test] fn seed_prefills_from_prior_diagnostic() { let (db, sq) = setup(); let d = chrono::NaiveDate::from_ymd_opt(2026, 7, 10).unwrap(); RepsKind::append( &db, sq, d, RepsPayload::new(Load::new(85.0).unwrap(), Reps::new(5).unwrap()), Rpe::new(4).unwrap(), true, ) .unwrap(); let mut screen = DiagnosticScreen::load(&db).unwrap(); for c in "squat".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, key(KeyCode::Enter)); let Some(SessionState { stage: Stage::Seed { seed, prefilled }, .. }) = screen.session.as_ref() else { panic!("expected seed stage"); }; assert!(*prefilled); assert_eq!(seed, "85"); } #[test] fn ctrl_backspace_removes_last_diagnostic_set() { let (db, sq) = setup(); let mut screen = DiagnosticScreen::load(&db).unwrap(); for c in "squat".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, key(KeyCode::Enter)); for c in "60".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, key(KeyCode::Enter)); // Commit one set. for c in "5".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, key(KeyCode::Tab)); for c in "2".chars() { screen.on_key(&db, key(KeyCode::Char(c))); } screen.on_key(&db, key(KeyCode::Enter)); let today = chrono::Local::now().date_naive(); assert_eq!(RepsKind::list_sets_for_session(&db, sq, today).unwrap().len(), 1); // Undo. screen.on_key(&db, KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL)); assert_eq!(RepsKind::list_sets_for_session(&db, sq, today).unwrap().len(), 0); } }