Skip to main content

max / ripgrow

diagnostic screen: fifth tab drives the protocol per exercise Fuzzy-pick an exercise, confirm or type a seed load (prefilled from the last diagnostic on that exercise if there is one), then log reps + rpe as the app escalates. Load is prescribed, not typed; the user only reports what the set actually cost them. Ctrl-Backspace undoes the most recent diagnostic set on this date. Completion shows top load, working weight, and e1RM. Alt+5 switches to the tab. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 17:40 UTC
Commit: 82a0764f3e5663d27eb2bc45aab5a0f381a16f11
Parent: b01907a
3 files changed, +537 insertions, -1 deletion
@@ -14,6 +14,7 @@ use ratatui::text::{Line, Span};
14 14 use ratatui::widgets::{Block, Borders, Tabs};
15 15 use ripgrow_core::{Db, Unit, create_profile_in, list_profiles, profiles_dir};
16 16
17 + use crate::screens::diagnostic::DiagnosticScreen;
17 18 use crate::screens::generate::GenerateScreen;
18 19 use crate::screens::history::{HistoryAction, HistoryScreen};
19 20 use crate::screens::log::LogScreen;
@@ -35,6 +36,7 @@ struct Opened {
35 36 log: LogScreen,
36 37 generate: GenerateScreen,
37 38 history: HistoryScreen,
39 + diagnostic: DiagnosticScreen,
38 40 }
39 41
40 42 #[derive(Clone, Copy, PartialEq, Eq)]
@@ -43,10 +45,17 @@ enum Tab {
43 45 Log,
44 46 Generate,
45 47 History,
48 + Diagnostic,
46 49 }
47 50
48 51 impl Tab {
49 - const ALL: [Tab; 4] = [Tab::Templates, Tab::Log, Tab::Generate, Tab::History];
52 + const ALL: [Tab; 5] = [
53 + Tab::Templates,
54 + Tab::Log,
55 + Tab::Generate,
56 + Tab::History,
57 + Tab::Diagnostic,
58 + ];
50 59
51 60 fn title(self) -> &'static str {
52 61 match self {
@@ -54,6 +63,7 @@ impl Tab {
54 63 Tab::Log => "log",
55 64 Tab::Generate => "generate",
56 65 Tab::History => "history",
66 + Tab::Diagnostic => "diagnostic",
57 67 }
58 68 }
59 69 }
@@ -108,6 +118,7 @@ impl App {
108 118 Tab::Log => op.log.render(frame, chunks[2]),
109 119 Tab::Generate => op.generate.render(frame, chunks[2], &op.db),
110 120 Tab::History => op.history.render(frame, chunks[2], &op.db),
121 + Tab::Diagnostic => op.diagnostic.render(frame, chunks[2]),
111 122 }
112 123 }
113 124 None => {
@@ -148,6 +159,10 @@ impl App {
148 159 op.tab = Tab::History;
149 160 return;
150 161 }
162 + KeyCode::Char('5') => {
163 + op.tab = Tab::Diagnostic;
164 + return;
165 + }
151 166 _ => {}
152 167 }
153 168 }
@@ -175,6 +190,9 @@ impl App {
175 190 if let Ok(next) = HistoryScreen::load(&op.db) {
176 191 op.history = next;
177 192 }
193 + if let Ok(next) = DiagnosticScreen::load(&op.db) {
194 + op.diagnostic = next;
195 + }
178 196 }
179 197 },
180 198 Tab::Log => op.log.on_key(&op.db, key),
@@ -183,6 +201,7 @@ impl App {
183 201 HistoryAction::None => {}
184 202 HistoryAction::Quit => self.should_quit = true,
185 203 },
204 + Tab::Diagnostic => op.diagnostic.on_key(&op.db, key),
186 205 }
187 206 return;
188 207 }
@@ -233,6 +252,13 @@ impl App {
233 252 return;
234 253 }
235 254 };
255 + let diagnostic = match DiagnosticScreen::load(&db) {
256 + Ok(s) => s,
257 + Err(e) => {
258 + self.status = format!("load diagnostic failed: {e}");
259 + return;
260 + }
261 + };
236 262 self.status = format!("opened {}", path.display());
237 263 self.opened = Some(Opened {
238 264 path,
@@ -242,6 +268,7 @@ impl App {
242 268 log,
243 269 generate,
244 270 history,
271 + diagnostic,
245 272 });
246 273 }
247 274
@@ -283,6 +310,13 @@ impl App {
283 310 return;
284 311 }
285 312 };
313 + let diagnostic = match DiagnosticScreen::load(&db) {
314 + Ok(s) => s,
315 + Err(e) => {
316 + self.status = format!("load diagnostic failed: {e}");
317 + return;
318 + }
319 + };
286 320 self.status = format!("created {}", profile.slug);
287 321 self.opened = Some(Opened {
288 322 path: profile.path,
@@ -292,6 +326,7 @@ impl App {
292 326 log,
293 327 generate,
294 328 history,
329 + diagnostic,
295 330 });
296 331 }
297 332 Err(e) => self.status = format!("create failed: {e}"),
@@ -0,0 +1,783 @@
1 + //! Diagnostic screen: drives the first-session (or recalibration)
2 + //! protocol on one exercise at a time.
3 + //!
4 + //! Flow:
5 + //!
6 + //! Pick -> fuzzy-select an exercise (same as Log)
7 + //! Seed -> confirm/type the starting load (pre-filled from last
8 + //! diagnostic if one exists)
9 + //! Run -> app prescribes load x 5, user types reps + rpe, Enter
10 + //! commits the set and asks core for the next step
11 + //! Result -> completion summary; any key returns to Pick
12 + //!
13 + //! Diagnostic sets are written with `append_diagnostic_set`, so the
14 + //! progression state machine will not see them. Generate can seed from
15 + //! the top load of the most recent diagnostic via `Db::diagnostic_seed`.
16 +
17 + use chrono::{Local, NaiveDate};
18 + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
19 + use ratatui::Frame;
20 + use ratatui::layout::{Constraint, Direction, Layout, Rect};
21 + use ratatui::style::{Modifier, Style};
22 + use ratatui::text::{Line, Span};
23 + use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
24 + use ripgrow_core::diagnostic::{DiagnosticSet, DiagnosticStep, next_step};
25 + use ripgrow_core::{Db, Exercise, SessionSet};
26 +
27 + pub struct DiagnosticScreen {
28 + date: NaiveDate,
29 + exercises: Vec<Exercise>,
30 + picker: PickState,
31 + session: Option<SessionState>,
32 + pub status: String,
33 + }
34 +
35 + struct PickState {
36 + query: String,
37 + list_state: ListState,
38 + }
39 +
40 + struct SessionState {
41 + exercise: Exercise,
42 + stage: Stage,
43 + }
44 +
45 + enum Stage {
46 + Seed {
47 + seed: String,
48 + prefilled: bool,
49 + },
50 + Run {
51 + next_load: f64,
52 + target_reps: i32,
53 + committed: Vec<DiagnosticSet>,
54 + pending: PendingRow,
55 + focus: RunField,
56 + },
57 + Result {
58 + top_load: f64,
59 + working_weight: f64,
60 + e1rm: Option<f64>,
61 + },
62 + }
63 +
64 + #[derive(Default)]
65 + struct PendingRow {
66 + reps: String,
67 + rpe: String,
68 + }
69 +
70 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
71 + enum RunField {
72 + Reps,
73 + Rpe,
74 + }
75 +
76 + impl DiagnosticScreen {
77 + pub fn load(db: &Db) -> Result<Self, ripgrow_core::Error> {
78 + let exercises = db.list_exercises()?;
79 + let mut list_state = ListState::default();
80 + if !exercises.is_empty() {
81 + list_state.select(Some(0));
82 + }
83 + Ok(Self {
84 + date: Local::now().date_naive(),
85 + exercises,
86 + picker: PickState {
87 + query: String::new(),
88 + list_state,
89 + },
90 + session: None,
91 + status: String::new(),
92 + })
93 + }
94 +
95 + pub fn on_key(&mut self, db: &Db, key: KeyEvent) {
96 + match self.session.as_mut() {
97 + None => self.on_pick_key(db, key),
98 + Some(s) => match &mut s.stage {
99 + Stage::Seed { .. } => self.on_seed_key(db, key),
100 + Stage::Run { .. } => self.on_run_key(db, key),
101 + Stage::Result { .. } => self.on_result_key(key),
102 + },
103 + }
104 + }
105 +
106 + // ---- picker ---------------------------------------------------------
107 +
108 + fn filtered_indices(&self) -> Vec<usize> {
109 + let q = self.picker.query.trim().to_lowercase();
110 + if q.is_empty() {
111 + (0..self.exercises.len()).collect()
112 + } else {
113 + self.exercises
114 + .iter()
115 + .enumerate()
116 + .filter(|(_, e)| subsequence_match(&e.name.to_lowercase(), &q))
117 + .map(|(i, _)| i)
118 + .collect()
119 + }
120 + }
121 +
122 + fn on_pick_key(&mut self, db: &Db, key: KeyEvent) {
123 + match key.code {
124 + KeyCode::Esc => {
125 + self.picker.query.clear();
126 + }
127 + KeyCode::Enter => {
128 + let filt = self.filtered_indices();
129 + if let Some(row) = self.picker.list_state.selected()
130 + && let Some(ex_idx) = filt.get(row)
131 + && let Some(ex) = self.exercises.get(*ex_idx).cloned()
132 + {
133 + self.enter_seed(db, ex);
134 + }
135 + }
136 + KeyCode::Down => self.move_pick(1),
137 + KeyCode::Up => self.move_pick(-1),
138 + KeyCode::Backspace => {
139 + self.picker.query.pop();
140 + self.picker.list_state.select(Some(0));
141 + }
142 + KeyCode::Char(c) => {
143 + self.picker.query.push(c);
144 + self.picker.list_state.select(Some(0));
145 + }
146 + _ => {}
147 + }
148 + }
149 +
150 + fn move_pick(&mut self, delta: isize) {
151 + let n = self.filtered_indices().len();
152 + if n == 0 {
153 + return;
154 + }
155 + let cur = self.picker.list_state.selected().unwrap_or(0) as isize;
156 + let next = (cur + delta).rem_euclid(n as isize) as usize;
157 + self.picker.list_state.select(Some(next));
158 + }
159 +
160 + fn enter_seed(&mut self, db: &Db, exercise: Exercise) {
161 + let prior = db.diagnostic_seed(exercise.id).ok().flatten();
162 + let (seed, prefilled) = match prior {
163 + Some(v) => (format!("{v}"), true),
164 + None => (String::new(), false),
165 + };
166 + self.session = Some(SessionState {
167 + exercise,
168 + stage: Stage::Seed { seed, prefilled },
169 + });
170 + self.status = if prefilled {
171 + "seed prefilled from prior diagnostic".to_string()
172 + } else {
173 + "no prior diagnostic — type a starting load".to_string()
174 + };
175 + }
176 +
177 + // ---- seed sub-mode --------------------------------------------------
178 +
179 + fn on_seed_key(&mut self, db: &Db, key: KeyEvent) {
180 + let Some(s) = self.session.as_mut() else { return };
181 + let Stage::Seed { seed, prefilled } = &mut s.stage else {
182 + return;
183 + };
184 + match key.code {
185 + KeyCode::Esc => {
186 + self.session = None;
187 + self.status.clear();
188 + }
189 + KeyCode::Enter => {
190 + let Ok(value) = seed.trim().parse::<f64>() else {
191 + self.status = "seed must be a number".to_string();
192 + return;
193 + };
194 + let increment = s.exercise.increment;
195 + let step = next_step(value, increment, &[]);
196 + let DiagnosticStep::Prescribe { load, reps } = step else {
197 + // next_step on an empty history always prescribes.
198 + self.status = "unable to start diagnostic".to_string();
199 + return;
200 + };
201 + s.stage = Stage::Run {
202 + next_load: load,
203 + target_reps: reps,
204 + committed: load_prior_diagnostic_today(db, s.exercise.id, self.date),
205 + pending: PendingRow::default(),
206 + focus: RunField::Reps,
207 + };
208 + self.status = "diagnostic started".to_string();
209 + }
210 + KeyCode::Backspace => {
211 + if *prefilled {
212 + seed.clear();
213 + *prefilled = false;
214 + } else {
215 + seed.pop();
216 + }
217 + }
218 + KeyCode::Char(c) if c.is_ascii_digit() || c == '.' => {
219 + if *prefilled {
220 + seed.clear();
221 + *prefilled = false;
222 + }
223 + seed.push(c);
224 + }
225 + _ => {}
226 + }
227 + }
228 +
229 + // ---- run sub-mode ---------------------------------------------------
230 +
231 + fn on_run_key(&mut self, db: &Db, key: KeyEvent) {
232 + let Some(s) = self.session.as_mut() else { return };
233 + let Stage::Run {
234 + next_load,
235 + target_reps,
236 + committed,
237 + pending,
238 + focus,
239 + } = &mut s.stage
240 + else {
241 + return;
242 + };
243 + match (key.code, key.modifiers) {
244 + (KeyCode::Esc, _) => {
245 + self.session = None;
246 + self.status.clear();
247 + return;
248 + }
249 + (KeyCode::Tab, _) | (KeyCode::BackTab, _) => {
250 + *focus = match focus {
251 + RunField::Reps => RunField::Rpe,
252 + RunField::Rpe => RunField::Reps,
253 + };
254 + return;
255 + }
256 + (KeyCode::Enter, _) => {
257 + match commit_diagnostic_set(
258 + db,
259 + &s.exercise,
260 + self.date,
261 + *next_load,
262 + *target_reps,
263 + pending,
264 + committed,
265 + ) {
266 + Ok(()) => {
267 + *focus = RunField::Reps;
268 + self.status = "set logged".to_string();
269 + let step = next_step(0.0, s.exercise.increment, committed);
270 + match step {
271 + DiagnosticStep::Prescribe { load, reps } => {
272 + *next_load = load;
273 + *target_reps = reps;
274 + }
275 + DiagnosticStep::Complete {
276 + top_load,
277 + working_weight,
278 + e1rm,
279 + } => {
280 + s.stage = Stage::Result {
281 + top_load,
282 + working_weight,
283 + e1rm,
284 + };
285 + self.status = "diagnostic complete".to_string();
286 + }
287 + }
288 + }
289 + Err(e) => self.status = format!("save failed: {e}"),
290 + }
291 + return;
292 + }
293 + (KeyCode::Backspace, KeyModifiers::CONTROL) => {
294 + if let Some(last) = committed.pop() {
295 + // Remove the corresponding DB row too. The most-recent
296 + // diagnostic set on this date-and-exercise matches.
297 + if let Err(e) = delete_last_diagnostic_row_today(
298 + db,
299 + s.exercise.id,
300 + self.date,
301 + ) {
302 + self.status = format!("undo failed: {e}");
303 + committed.push(last);
304 + return;
305 + }
306 + // Reset the prescription to what next_step wants now.
307 + let step = next_step(*next_load, s.exercise.increment, committed);
308 + if let DiagnosticStep::Prescribe { load, reps } = step {
309 + *next_load = load;
310 + *target_reps = reps;
311 + }
312 + self.status = "last set removed".to_string();
313 + }
314 + return;
315 + }
316 + _ => {}
317 + }
318 +
319 + let target = match focus {
320 + RunField::Reps => &mut pending.reps,
321 + RunField::Rpe => &mut pending.rpe,
322 + };
323 + match key.code {
324 + KeyCode::Backspace => {
325 + target.pop();
326 + }
327 + KeyCode::Char(c) => target.push(c),
328 + _ => {}
329 + }
330 + }
331 +
332 + // ---- result sub-mode ------------------------------------------------
333 +
334 + fn on_result_key(&mut self, key: KeyEvent) {
335 + match key.code {
336 + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {
337 + self.session = None;
338 + self.status.clear();
339 + }
340 + _ => {}
341 + }
342 + }
343 +
344 + // ---- rendering ------------------------------------------------------
345 +
346 + pub fn render(&mut self, frame: &mut Frame, area: Rect) {
347 + let chunks = Layout::default()
348 + .direction(Direction::Vertical)
349 + .constraints([
350 + Constraint::Length(1),
351 + Constraint::Min(3),
352 + Constraint::Length(1),
353 + ])
354 + .split(area);
355 +
356 + let date_hint = format!("session: {}", self.date.format("%Y-%m-%d"));
357 + frame.render_widget(Paragraph::new(date_hint), chunks[0]);
358 +
359 + match &mut self.session {
360 + None => render_picker(frame, chunks[1], &mut self.picker, &self.exercises),
361 + Some(s) => render_session(frame, chunks[1], s),
362 + }
363 +
364 + let hint = match self.session.as_ref().map(|s| &s.stage) {
365 + None => "type to filter enter pick esc clear",
366 + Some(Stage::Seed { .. }) => "type seed load enter start esc back",
367 + Some(Stage::Run { .. }) => {
368 + "type reps + rpe tab move enter commit ctrl-backspace undo esc back"
369 + }
370 + Some(Stage::Result { .. }) => "enter/esc back to picker",
371 + };
372 + frame.render_widget(
373 + Paragraph::new(Line::from(vec![
374 + Span::raw(hint),
375 + Span::raw(" "),
376 + Span::styled(
377 + self.status.as_str(),
378 + Style::default().add_modifier(Modifier::DIM),
379 + ),
380 + ])),
381 + chunks[2],
382 + );
383 + }
384 + }
385 +
386 + fn commit_diagnostic_set(
387 + db: &Db,
388 + exercise: &Exercise,
389 + date: NaiveDate,
390 + prescribed_load: f64,
391 + target_reps: i32,
392 + pending: &mut PendingRow,
393 + committed: &mut Vec<DiagnosticSet>,
394 + ) -> Result<(), ripgrow_core::Error> {
395 + let reps: i32 = parse_field("reps", &pending.reps)?;
396 + let rpe: i32 = parse_field("rpe", &pending.rpe)?;
397 + db.append_diagnostic_set(exercise.id, date, prescribed_load, reps, rpe)?;
398 + committed.push(DiagnosticSet {
399 + load: prescribed_load,
400 + reps,
401 + rpe,
402 + });
403 + *pending = PendingRow::default();
404 + let _ = target_reps; // reserved for future variable-target variants
405 + Ok(())
406 + }
407 +
408 + fn parse_field<T: std::str::FromStr>(
409 + field: &'static str,
410 + raw: &str,
411 + ) -> Result<T, ripgrow_core::Error> {
412 + raw.trim()
413 + .parse()
414 + .map_err(|_| ripgrow_core::Error::ParseField {
415 + field,
416 + value: raw.to_string(),
417 + })
418 + }
419 +
420 + /// Pick up an in-progress diagnostic on this date if the user re-entered
421 + /// the tab. Simple heuristic: read every diagnostic set logged for this
422 + /// exercise on this date and treat them as `committed`.
423 + fn load_prior_diagnostic_today(
424 + db: &Db,
425 + exercise_id: i64,
426 + date: NaiveDate,
427 + ) -> Vec<DiagnosticSet> {
428 + let Ok(list) = db.list_sets_for_session(exercise_id, date) else {
429 + return Vec::new();
430 + };
431 + list.into_iter()
432 + .filter(|s| s.is_diagnostic)
433 + .map(|s: SessionSet| DiagnosticSet {
434 + load: s.load,
435 + reps: s.reps,
436 + rpe: s.rpe,
437 + })
438 + .collect()
439 + }
440 +
441 + fn delete_last_diagnostic_row_today(
442 + db: &Db,
443 + exercise_id: i64,
444 + date: NaiveDate,
445 + ) -> Result<(), ripgrow_core::Error> {
446 + let list = db.list_sets_for_session(exercise_id, date)?;
447 + if let Some(last) = list.iter().rev().find(|s| s.is_diagnostic) {
448 + db.delete_set(last.id)?;
449 + }
450 + Ok(())
451 + }
452 +
453 + fn render_picker(
454 + frame: &mut Frame,
455 + area: Rect,
456 + picker: &mut PickState,
457 + exercises: &[Exercise],
458 + ) {
459 + let chunks = Layout::default()
460 + .direction(Direction::Vertical)
461 + .constraints([Constraint::Length(3), Constraint::Min(1)])
462 + .split(area);
463 +
464 + let query_block = Block::default()
465 + .borders(Borders::ALL)
466 + .title(" pick exercise to diagnose ");
467 + let query = Paragraph::new(format!("> {}_", picker.query)).block(query_block);
468 + frame.render_widget(query, chunks[0]);
469 +
470 + let q = picker.query.trim().to_lowercase();
471 + let items: Vec<ListItem> = exercises
472 + .iter()
473 + .filter(|e| q.is_empty() || subsequence_match(&e.name.to_lowercase(), &q))
474 + .map(|e| ListItem::new(e.name.as_str()))
475 + .collect();
476 + let list = List::new(items)
477 + .block(Block::default().borders(Borders::ALL).title(" matches "))
478 + .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
479 + .highlight_symbol("> ");
480 + frame.render_stateful_widget(list, chunks[1], &mut picker.list_state);
481 + }
482 +
483 + fn render_session(frame: &mut Frame, area: Rect, s: &SessionState) {
484 + let block = Block::default()
485 + .borders(Borders::ALL)
486 + .title(format!(" diagnostic: {} ", s.exercise.name));
487 + let inner = block.inner(area);
488 + frame.render_widget(block, area);
489 +
490 + match &s.stage {
491 + Stage::Seed { seed, prefilled } => {
492 + let mark = if *prefilled { " (prefilled)" } else { "" };
493 + let text = format!("seed load ({}){mark}\n\n> {}_", s.exercise.load_unit, seed);
494 + frame.render_widget(Paragraph::new(text), inner);
495 + }
496 + Stage::Run {
497 + next_load,
498 + target_reps,
499 + committed,
500 + pending,
Lines truncated
@@ -1,3 +1,4 @@
1 + pub mod diagnostic;
1 2 pub mod generate;
2 3 pub mod history;
3 4 pub mod log;