Skip to main content

max / ripgrow

28.0 KB · 846 lines History Blame Raw
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::{
26 Db, EffortKind, Estimate, Exercise, Kind, Load, Reps, RepsKind, RepsPayload, RepsSet, Rpe,
27 };
28
29 pub struct DiagnosticScreen {
30 date: NaiveDate,
31 exercises: Vec<Exercise>,
32 picker: PickState,
33 session: Option<SessionState>,
34 pub status: String,
35 }
36
37 struct PickState {
38 query: String,
39 list_state: ListState,
40 }
41
42 struct SessionState {
43 exercise: Exercise,
44 stage: Stage,
45 }
46
47 enum Stage {
48 Seed {
49 seed: String,
50 prefilled: bool,
51 },
52 Run {
53 next_load: Load,
54 target_reps: Reps,
55 committed: Vec<DiagnosticSet>,
56 pending: PendingRow,
57 focus: RunField,
58 },
59 Result {
60 top_load: Load,
61 working_weight: Load,
62 e1rm: Option<Estimate<f64>>,
63 },
64 }
65
66 #[derive(Default)]
67 struct PendingRow {
68 reps: String,
69 rpe: String,
70 }
71
72 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
73 enum RunField {
74 Reps,
75 Rpe,
76 }
77
78 impl DiagnosticScreen {
79 pub fn load(db: &Db) -> Result<Self, ripgrow_core::Error> {
80 // Diagnostic is reps-only for now: the DiagnosticStep protocol
81 // and its "escalate on easy sets" heuristic are reps-shaped.
82 // Timed / distance diagnostics need their own protocol design
83 // (what does an "easy" plank hold or 5k run look like?) which
84 // is deferred; for now those exercises are filtered out of the
85 // picker and the status hints at what was skipped.
86 let all = db.list_exercises()?;
87 let skipped = all
88 .iter()
89 .filter(|e| e.effort_kind != EffortKind::Reps)
90 .count();
91 let exercises: Vec<Exercise> = all
92 .into_iter()
93 .filter(|e| e.effort_kind == EffortKind::Reps)
94 .collect();
95 let mut list_state = ListState::default();
96 if !exercises.is_empty() {
97 list_state.select(Some(0));
98 }
99 let status = if skipped > 0 {
100 format!(
101 "hiding {skipped} non-reps exercise(s) — diagnostic is reps-only for now"
102 )
103 } else {
104 String::new()
105 };
106 Ok(Self {
107 date: Local::now().date_naive(),
108 exercises,
109 picker: PickState {
110 query: String::new(),
111 list_state,
112 },
113 session: None,
114 status,
115 })
116 }
117
118 pub fn on_key(&mut self, db: &Db, key: KeyEvent) {
119 match self.session.as_mut() {
120 None => self.on_pick_key(db, key),
121 Some(s) => match &mut s.stage {
122 Stage::Seed { .. } => self.on_seed_key(db, key),
123 Stage::Run { .. } => self.on_run_key(db, key),
124 Stage::Result { .. } => self.on_result_key(key),
125 },
126 }
127 }
128
129 // ---- picker ---------------------------------------------------------
130
131 fn filtered_indices(&self) -> Vec<usize> {
132 let q = self.picker.query.trim().to_lowercase();
133 if q.is_empty() {
134 (0..self.exercises.len()).collect()
135 } else {
136 self.exercises
137 .iter()
138 .enumerate()
139 .filter(|(_, e)| subsequence_match(&e.name.to_lowercase(), &q))
140 .map(|(i, _)| i)
141 .collect()
142 }
143 }
144
145 fn on_pick_key(&mut self, db: &Db, key: KeyEvent) {
146 match key.code {
147 KeyCode::Esc => {
148 self.picker.query.clear();
149 }
150 KeyCode::Enter => {
151 let filt = self.filtered_indices();
152 if let Some(row) = self.picker.list_state.selected()
153 && let Some(ex_idx) = filt.get(row)
154 && let Some(ex) = self.exercises.get(*ex_idx).cloned()
155 {
156 self.enter_seed(db, ex);
157 }
158 }
159 KeyCode::Down => self.move_pick(1),
160 KeyCode::Up => self.move_pick(-1),
161 KeyCode::Backspace => {
162 self.picker.query.pop();
163 self.picker.list_state.select(Some(0));
164 }
165 KeyCode::Char(c) => {
166 self.picker.query.push(c);
167 self.picker.list_state.select(Some(0));
168 }
169 _ => {}
170 }
171 }
172
173 fn move_pick(&mut self, delta: isize) {
174 let n = self.filtered_indices().len();
175 if n == 0 {
176 return;
177 }
178 let cur = self.picker.list_state.selected().unwrap_or(0) as isize;
179 let next = (cur + delta).rem_euclid(n as isize) as usize;
180 self.picker.list_state.select(Some(next));
181 }
182
183 fn enter_seed(&mut self, db: &Db, exercise: Exercise) {
184 let prior = RepsKind::diagnostic_seed(db, exercise.id).ok().flatten();
185 let (seed, prefilled) = match prior {
186 Some(v) => (format!("{v}"), true),
187 None => (String::new(), false),
188 };
189 self.session = Some(SessionState {
190 exercise,
191 stage: Stage::Seed { seed, prefilled },
192 });
193 self.status = if prefilled {
194 "seed prefilled from prior diagnostic".to_string()
195 } else {
196 "no prior diagnostic — type a starting load".to_string()
197 };
198 }
199
200 // ---- seed sub-mode --------------------------------------------------
201
202 fn on_seed_key(&mut self, db: &Db, key: KeyEvent) {
203 let Some(s) = self.session.as_mut() else { return };
204 let Stage::Seed { seed, prefilled } = &mut s.stage else {
205 return;
206 };
207 match key.code {
208 KeyCode::Esc => {
209 self.session = None;
210 self.status.clear();
211 }
212 KeyCode::Enter => {
213 let Ok(value) = seed.trim().parse::<f64>() else {
214 self.status = "seed must be a number".to_string();
215 return;
216 };
217 let increment = s.exercise.increment;
218 let step = next_step(value, increment, &[]);
219 let DiagnosticStep::Prescribe { load, reps } = step else {
220 // next_step on an empty history always prescribes.
221 self.status = "unable to start diagnostic".to_string();
222 return;
223 };
224 s.stage = Stage::Run {
225 next_load: load,
226 target_reps: reps,
227 committed: load_prior_diagnostic_today(db, s.exercise.id, self.date),
228 pending: PendingRow::default(),
229 focus: RunField::Reps,
230 };
231 self.status = "diagnostic started".to_string();
232 }
233 KeyCode::Backspace => {
234 if *prefilled {
235 seed.clear();
236 *prefilled = false;
237 } else {
238 seed.pop();
239 }
240 }
241 KeyCode::Char(c) if c.is_ascii_digit() || c == '.' => {
242 if *prefilled {
243 seed.clear();
244 *prefilled = false;
245 }
246 seed.push(c);
247 }
248 _ => {}
249 }
250 }
251
252 // ---- run sub-mode ---------------------------------------------------
253
254 fn on_run_key(&mut self, db: &Db, key: KeyEvent) {
255 let Some(s) = self.session.as_mut() else { return };
256 let Stage::Run {
257 next_load,
258 target_reps,
259 committed,
260 pending,
261 focus,
262 } = &mut s.stage
263 else {
264 return;
265 };
266 match (key.code, key.modifiers) {
267 (KeyCode::Esc, _) => {
268 self.session = None;
269 self.status.clear();
270 return;
271 }
272 (KeyCode::Tab, _) | (KeyCode::BackTab, _) => {
273 *focus = match focus {
274 RunField::Reps => RunField::Rpe,
275 RunField::Rpe => RunField::Reps,
276 };
277 return;
278 }
279 (KeyCode::Enter, _) => {
280 match commit_diagnostic_set(
281 db,
282 &s.exercise,
283 self.date,
284 *next_load,
285 *target_reps,
286 pending,
287 committed,
288 ) {
289 Ok(()) => {
290 *focus = RunField::Reps;
291 self.status = "set logged".to_string();
292 let step = next_step(0.0, s.exercise.increment, committed);
293 match step {
294 DiagnosticStep::Prescribe { load, reps } => {
295 *next_load = load;
296 *target_reps = reps;
297 }
298 DiagnosticStep::Complete {
299 top_load,
300 working_weight,
301 e1rm,
302 } => {
303 s.stage = Stage::Result {
304 top_load,
305 working_weight,
306 e1rm,
307 };
308 self.status = "diagnostic complete".to_string();
309 }
310 }
311 }
312 Err(e) => self.status = format!("save failed: {e}"),
313 }
314 return;
315 }
316 (KeyCode::Backspace, KeyModifiers::CONTROL) => {
317 if let Some(last) = committed.pop() {
318 // Remove the corresponding DB row too. The most-recent
319 // diagnostic set on this date-and-exercise matches.
320 if let Err(e) = delete_last_diagnostic_row_today(
321 db,
322 s.exercise.id,
323 self.date,
324 ) {
325 self.status = format!("undo failed: {e}");
326 committed.push(last);
327 return;
328 }
329 // Reset the prescription to what next_step wants now.
330 let step = next_step(next_load.get(), s.exercise.increment, committed);
331 if let DiagnosticStep::Prescribe { load, reps } = step {
332 *next_load = load;
333 *target_reps = reps;
334 }
335 self.status = "last set removed".to_string();
336 }
337 return;
338 }
339 _ => {}
340 }
341
342 let target = match focus {
343 RunField::Reps => &mut pending.reps,
344 RunField::Rpe => &mut pending.rpe,
345 };
346 match key.code {
347 KeyCode::Backspace => {
348 target.pop();
349 }
350 KeyCode::Char(c) => target.push(c),
351 _ => {}
352 }
353 }
354
355 // ---- result sub-mode ------------------------------------------------
356
357 fn on_result_key(&mut self, key: KeyEvent) {
358 match key.code {
359 KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {
360 self.session = None;
361 self.status.clear();
362 }
363 _ => {}
364 }
365 }
366
367 // ---- rendering ------------------------------------------------------
368
369 pub fn render(&mut self, frame: &mut Frame, area: Rect) {
370 let chunks = Layout::default()
371 .direction(Direction::Vertical)
372 .constraints([
373 Constraint::Length(1),
374 Constraint::Min(3),
375 Constraint::Length(1),
376 ])
377 .split(area);
378
379 let date_hint = format!("session: {}", self.date.format("%Y-%m-%d"));
380 frame.render_widget(Paragraph::new(date_hint), chunks[0]);
381
382 match &mut self.session {
383 None => render_picker(frame, chunks[1], &mut self.picker, &self.exercises),
384 Some(s) => render_session(frame, chunks[1], s),
385 }
386
387 let hint = match self.session.as_ref().map(|s| &s.stage) {
388 None => "type to filter enter pick esc clear",
389 Some(Stage::Seed { .. }) => "type seed load enter start esc back",
390 Some(Stage::Run { .. }) => {
391 "type reps + rpe tab move enter commit ctrl-backspace undo esc back"
392 }
393 Some(Stage::Result { .. }) => "enter/esc back to picker",
394 };
395 frame.render_widget(
396 Paragraph::new(Line::from(vec![
397 Span::raw(hint),
398 Span::raw(" "),
399 Span::styled(
400 self.status.as_str(),
401 Style::default().add_modifier(Modifier::DIM),
402 ),
403 ])),
404 chunks[2],
405 );
406 }
407 }
408
409 fn commit_diagnostic_set(
410 db: &Db,
411 exercise: &Exercise,
412 date: NaiveDate,
413 prescribed_load: Load,
414 target_reps: Reps,
415 pending: &mut PendingRow,
416 committed: &mut Vec<DiagnosticSet>,
417 ) -> Result<(), ripgrow_core::Error> {
418 let reps = Reps::new(parse_field("reps", &pending.reps)?)?;
419 let rpe = Rpe::new(parse_field("rpe", &pending.rpe)?)?;
420 RepsKind::append(
421 db,
422 exercise.id,
423 date,
424 RepsPayload::new(prescribed_load, reps),
425 rpe,
426 true,
427 )?;
428 committed.push(DiagnosticSet {
429 load: prescribed_load,
430 reps,
431 rpe,
432 });
433 *pending = PendingRow::default();
434 let _ = target_reps; // reserved for future variable-target variants
435 Ok(())
436 }
437
438 fn parse_field<T: std::str::FromStr>(
439 field: &'static str,
440 raw: &str,
441 ) -> Result<T, ripgrow_core::Error> {
442 raw.trim()
443 .parse()
444 .map_err(|_| ripgrow_core::Error::ParseField {
445 field,
446 value: raw.to_string(),
447 })
448 }
449
450 /// Pick up an in-progress diagnostic on this date if the user re-entered
451 /// the tab. Simple heuristic: read every diagnostic set logged for this
452 /// exercise on this date and treat them as `committed`.
453 fn load_prior_diagnostic_today(
454 db: &Db,
455 exercise_id: i64,
456 date: NaiveDate,
457 ) -> Vec<DiagnosticSet> {
458 let Ok(list) = RepsKind::list_sets_for_session(db, exercise_id, date) else {
459 return Vec::new();
460 };
461 list.into_iter()
462 .filter(|s| s.is_diagnostic)
463 .map(|s: RepsSet| DiagnosticSet {
464 load: s.load,
465 reps: s.reps,
466 rpe: s.rpe,
467 })
468 .collect()
469 }
470
471 fn delete_last_diagnostic_row_today(
472 db: &Db,
473 exercise_id: i64,
474 date: NaiveDate,
475 ) -> Result<(), ripgrow_core::Error> {
476 let list = RepsKind::list_sets_for_session(db, exercise_id, date)?;
477 if let Some(last) = list.iter().rev().find(|s| s.is_diagnostic) {
478 RepsKind::delete(db, last.id)?;
479 }
480 Ok(())
481 }
482
483 fn render_picker(
484 frame: &mut Frame,
485 area: Rect,
486 picker: &mut PickState,
487 exercises: &[Exercise],
488 ) {
489 let chunks = Layout::default()
490 .direction(Direction::Vertical)
491 .constraints([Constraint::Length(3), Constraint::Min(1)])
492 .split(area);
493
494 let query_block = Block::default()
495 .borders(Borders::ALL)
496 .title(" pick exercise to diagnose ");
497 let query = Paragraph::new(format!("> {}_", picker.query)).block(query_block);
498 frame.render_widget(query, chunks[0]);
499
500 let q = picker.query.trim().to_lowercase();
501 let items: Vec<ListItem> = exercises
502 .iter()
503 .filter(|e| q.is_empty() || subsequence_match(&e.name.to_lowercase(), &q))
504 .map(|e| ListItem::new(e.name.as_str()))
505 .collect();
506 let list = List::new(items)
507 .block(Block::default().borders(Borders::ALL).title(" matches "))
508 .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
509 .highlight_symbol("> ");
510 frame.render_stateful_widget(list, chunks[1], &mut picker.list_state);
511 }
512
513 fn render_session(frame: &mut Frame, area: Rect, s: &SessionState) {
514 let block = Block::default()
515 .borders(Borders::ALL)
516 .title(format!(" diagnostic: {} ", s.exercise.name));
517 let inner = block.inner(area);
518 frame.render_widget(block, area);
519
520 match &s.stage {
521 Stage::Seed { seed, prefilled } => {
522 let mark = if *prefilled { " (prefilled)" } else { "" };
523 let text = format!("seed load ({}){mark}\n\n> {}_", s.exercise.load_unit, seed);
524 frame.render_widget(Paragraph::new(text), inner);
525 }
526 Stage::Run {
527 next_load,
528 target_reps,
529 committed,
530 pending,
531 focus,
532 } => render_run(frame, inner, &s.exercise, *next_load, *target_reps, committed, pending, *focus),
533 Stage::Result {
534 top_load,
535 working_weight,
536 e1rm,
537 } => {
538 let e1rm_str = match e1rm {
539 Some(est) => format!("{value:.1} {unit} ({method})",
540 value = est.value, unit = s.exercise.load_unit, method = est.method),
541 None => "(no signal)".to_string(),
542 };
543 let text = format!(
544 "diagnostic complete\n\n\
545 top load: {top} {unit}\n\
546 working weight: {working:.1} {unit}\n\
547 e1RM: {e1rm_str}\n\n\
548 generate will now seed this exercise from the top load.",
549 top = top_load.get(),
550 unit = s.exercise.load_unit,
551 working = working_weight.get(),
552 e1rm_str = e1rm_str,
553 );
554 frame.render_widget(Paragraph::new(text), inner);
555 }
556 }
557 }
558
559 #[allow(clippy::too_many_arguments)]
560 fn render_run(
561 frame: &mut Frame,
562 area: Rect,
563 exercise: &Exercise,
564 next_load: Load,
565 target_reps: Reps,
566 committed: &[DiagnosticSet],
567 pending: &PendingRow,
568 focus: RunField,
569 ) {
570 let header = format!(
571 " # {:>8} {:>4} {:>3}",
572 format!("load ({})", exercise.load_unit),
573 "reps",
574 "rpe",
575 );
576 let mut lines: Vec<Line> = vec![Line::from(Span::styled(
577 header,
578 Style::default().add_modifier(Modifier::BOLD),
579 ))];
580
581 for (i, s) in committed.iter().enumerate() {
582 lines.push(Line::from(format!(
583 " {:<3} {:>8} {:>4} {:>3}",
584 i + 1,
585 s.load,
586 s.reps,
587 s.rpe,
588 )));
589 }
590
591 let next_idx = committed.len() + 1;
592 let reps_span = focused_span(&pending.reps, focus == RunField::Reps, 4);
593 let rpe_span = focused_span(&pending.rpe, focus == RunField::Rpe, 3);
594 lines.push(Line::from(vec![
595 Span::raw(format!(" {:<3} {:>8} ", next_idx, next_load)),
596 reps_span,
597 Span::raw(" "),
598 rpe_span,
599 ]));
600
601 lines.push(Line::from(""));
602 lines.push(Line::from(Span::styled(
603 format!(
604 "prescribed: {} {} x {} log what you actually did",
605 next_load, exercise.load_unit, target_reps
606 ),
607 Style::default().add_modifier(Modifier::DIM),
608 )));
609
610 frame.render_widget(Paragraph::new(lines), area);
611 }
612
613 fn focused_span(value: &str, focused: bool, width: usize) -> Span<'_> {
614 let content = format!("{:>width$}", value, width = width);
615 if focused {
616 Span::styled(content, Style::default().add_modifier(Modifier::REVERSED))
617 } else {
618 Span::raw(content)
619 }
620 }
621
622 fn subsequence_match(haystack: &str, needle: &str) -> bool {
623 let mut chars = haystack.chars();
624 needle.chars().all(|nc| chars.any(|hc| hc == nc))
625 }
626
627 #[cfg(test)]
628 mod tests {
629 use super::*;
630 use crossterm::event::KeyEvent;
631 use ripgrow_core::{Load, LoadUnit, Reps, ResistanceType, Rpe};
632
633 fn key(c: KeyCode) -> KeyEvent {
634 KeyEvent::new(c, KeyModifiers::empty())
635 }
636
637 fn setup() -> (Db, i64) {
638 let db = Db::open_in_memory().unwrap();
639 db.init_profile("self", LoadUnit::Kg).unwrap();
640 let sq = db
641 .create_exercise("squat", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
642 .unwrap();
643 db.create_exercise("bench", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
644 .unwrap();
645 (db, sq)
646 }
647
648 fn stage_kind(screen: &DiagnosticScreen) -> &'static str {
649 match screen.session.as_ref().map(|s| &s.stage) {
650 None => "picker",
651 Some(Stage::Seed { .. }) => "seed",
652 Some(Stage::Run { .. }) => "run",
653 Some(Stage::Result { .. }) => "result",
654 }
655 }
656
657 #[test]
658 fn load_filters_out_non_reps_exercises_and_reports_skip_count() {
659 let db = Db::open_in_memory().unwrap();
660 db.init_profile("self", LoadUnit::Kg).unwrap();
661 db.create_exercise("squat", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
662 .unwrap();
663 db.create_exercise("plank", ResistanceType::CardioTime, LoadUnit::Kg, 10.0, &[])
664 .unwrap();
665 db.create_exercise("5k run", ResistanceType::CardioDistance, LoadUnit::Kg, 0.0, &[])
666 .unwrap();
667 let screen = DiagnosticScreen::load(&db).unwrap();
668 let names: Vec<&str> = screen.exercises.iter().map(|e| e.name.as_str()).collect();
669 assert_eq!(names, vec!["squat"]);
670 assert!(screen.status.contains("hiding 2"), "status: {}", screen.status);
671 }
672
673 #[test]
674 fn load_status_is_empty_when_every_exercise_is_reps() {
675 let (db, _) = setup();
676 let screen = DiagnosticScreen::load(&db).unwrap();
677 assert!(screen.status.is_empty(), "status: {:?}", screen.status);
678 }
679
680 #[test]
681 fn picking_exercise_enters_seed_stage() {
682 let (db, _) = setup();
683 let mut screen = DiagnosticScreen::load(&db).unwrap();
684 for c in "squat".chars() {
685 screen.on_key(&db, key(KeyCode::Char(c)));
686 }
687 screen.on_key(&db, key(KeyCode::Enter));
688 assert_eq!(stage_kind(&screen), "seed");
689 }
690
691 #[test]
692 fn seed_typed_then_enter_enters_run_stage() {
693 let (db, _) = setup();
694 let mut screen = DiagnosticScreen::load(&db).unwrap();
695 for c in "squat".chars() {
696 screen.on_key(&db, key(KeyCode::Char(c)));
697 }
698 screen.on_key(&db, key(KeyCode::Enter));
699 for c in "60".chars() {
700 screen.on_key(&db, key(KeyCode::Char(c)));
701 }
702 screen.on_key(&db, key(KeyCode::Enter));
703 assert_eq!(stage_kind(&screen), "run");
704 }
705
706 #[test]
707 fn committing_easy_set_escalates_load() {
708 let (db, sq) = setup();
709 let mut screen = DiagnosticScreen::load(&db).unwrap();
710 for c in "squat".chars() {
711 screen.on_key(&db, key(KeyCode::Char(c)));
712 }
713 screen.on_key(&db, key(KeyCode::Enter));
714 for c in "60".chars() {
715 screen.on_key(&db, key(KeyCode::Char(c)));
716 }
717 screen.on_key(&db, key(KeyCode::Enter)); // to Run
718 // Reps 5, tab, rpe 2, enter.
719 for c in "5".chars() {
720 screen.on_key(&db, key(KeyCode::Char(c)));
721 }
722 screen.on_key(&db, key(KeyCode::Tab));
723 for c in "2".chars() {
724 screen.on_key(&db, key(KeyCode::Char(c)));
725 }
726 screen.on_key(&db, key(KeyCode::Enter));
727 // Set persisted with is_diagnostic = true.
728 let today = chrono::Local::now().date_naive();
729 let rows = RepsKind::list_sets_for_session(&db, sq, today).unwrap();
730 assert_eq!(rows.len(), 1);
731 assert!(rows[0].is_diagnostic);
732 // Next load should be 60 * 1.15 = 69, rounded to 2.5 -> 70.
733 let Some(SessionState { stage: Stage::Run { next_load, .. }, .. }) =
734 screen.session.as_ref()
735 else {
736 panic!("expected run stage");
737 };
738 assert_eq!(next_load.get(), 70.0);
739 }
740
741 #[test]
742 fn hard_set_completes_with_result_stage() {
743 let (db, _) = setup();
744 let mut screen = DiagnosticScreen::load(&db).unwrap();
745 for c in "squat".chars() {
746 screen.on_key(&db, key(KeyCode::Char(c)));
747 }
748 screen.on_key(&db, key(KeyCode::Enter));
749 for c in "100".chars() {
750 screen.on_key(&db, key(KeyCode::Char(c)));
751 }
752 screen.on_key(&db, key(KeyCode::Enter));
753 for c in "5".chars() {
754 screen.on_key(&db, key(KeyCode::Char(c)));
755 }
756 screen.on_key(&db, key(KeyCode::Tab));
757 for c in "4".chars() {
758 screen.on_key(&db, key(KeyCode::Char(c)));
759 }
760 screen.on_key(&db, key(KeyCode::Enter));
761 assert_eq!(stage_kind(&screen), "result");
762 }
763
764 #[test]
765 fn result_stage_returns_to_picker_on_enter() {
766 let (db, _) = setup();
767 let mut screen = DiagnosticScreen::load(&db).unwrap();
768 for c in "squat".chars() {
769 screen.on_key(&db, key(KeyCode::Char(c)));
770 }
771 screen.on_key(&db, key(KeyCode::Enter));
772 for c in "100".chars() {
773 screen.on_key(&db, key(KeyCode::Char(c)));
774 }
775 screen.on_key(&db, key(KeyCode::Enter));
776 for c in "5".chars() {
777 screen.on_key(&db, key(KeyCode::Char(c)));
778 }
779 screen.on_key(&db, key(KeyCode::Tab));
780 for c in "5".chars() {
781 screen.on_key(&db, key(KeyCode::Char(c)));
782 }
783 screen.on_key(&db, key(KeyCode::Enter));
784 assert_eq!(stage_kind(&screen), "result");
785 screen.on_key(&db, key(KeyCode::Enter));
786 assert_eq!(stage_kind(&screen), "picker");
787 }
788
789 #[test]
790 fn seed_prefills_from_prior_diagnostic() {
791 let (db, sq) = setup();
792 let d = chrono::NaiveDate::from_ymd_opt(2026, 7, 10).unwrap();
793 RepsKind::append(
794 &db,
795 sq,
796 d,
797 RepsPayload::new(Load::new(85.0).unwrap(), Reps::new(5).unwrap()),
798 Rpe::new(4).unwrap(),
799 true,
800 )
801 .unwrap();
802 let mut screen = DiagnosticScreen::load(&db).unwrap();
803 for c in "squat".chars() {
804 screen.on_key(&db, key(KeyCode::Char(c)));
805 }
806 screen.on_key(&db, key(KeyCode::Enter));
807 let Some(SessionState {
808 stage: Stage::Seed { seed, prefilled },
809 ..
810 }) = screen.session.as_ref()
811 else {
812 panic!("expected seed stage");
813 };
814 assert!(*prefilled);
815 assert_eq!(seed, "85");
816 }
817
818 #[test]
819 fn ctrl_backspace_removes_last_diagnostic_set() {
820 let (db, sq) = setup();
821 let mut screen = DiagnosticScreen::load(&db).unwrap();
822 for c in "squat".chars() {
823 screen.on_key(&db, key(KeyCode::Char(c)));
824 }
825 screen.on_key(&db, key(KeyCode::Enter));
826 for c in "60".chars() {
827 screen.on_key(&db, key(KeyCode::Char(c)));
828 }
829 screen.on_key(&db, key(KeyCode::Enter));
830 // Commit one set.
831 for c in "5".chars() {
832 screen.on_key(&db, key(KeyCode::Char(c)));
833 }
834 screen.on_key(&db, key(KeyCode::Tab));
835 for c in "2".chars() {
836 screen.on_key(&db, key(KeyCode::Char(c)));
837 }
838 screen.on_key(&db, key(KeyCode::Enter));
839 let today = chrono::Local::now().date_naive();
840 assert_eq!(RepsKind::list_sets_for_session(&db, sq, today).unwrap().len(), 1);
841 // Undo.
842 screen.on_key(&db, KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL));
843 assert_eq!(RepsKind::list_sets_for_session(&db, sq, today).unwrap().len(), 0);
844 }
845 }
846