| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
|
| 15 |
|
| 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 |
|
| 81 |
|
| 82 |
|
| 83 |
|
| 84 |
|
| 85 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 319 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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; |
| 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 |
|
| 451 |
|
| 452 |
|
| 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)); |
| 718 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|