Skip to main content

max / ripgrow

28.9 KB · 893 lines History Blame Raw
1 //! Log screen: the fast keyboard-only path for entering a session's sets.
2 //!
3 //! Two sub-modes. **Pick** fuzzy-filters exercise templates by substring
4 //! subsequence match; Enter selects the highlighted one. **Grid** shows
5 //! the sets already logged for (date, exercise) plus a pending row being
6 //! typed. Enter on the last column commits the pending row and opens a
7 //! fresh one.
8 //!
9 //! Grid shape depends on the exercise's effort kind:
10 //!
11 //! - **Reps** `load [tab] reps [tab] rpe [enter]` → row saved.
12 //! - **Timed** `duration [tab] rpe [enter]`. Duration accepts `N`
13 //! (seconds) or `M:SS` (minutes:seconds).
14 //! - **Distance** `distance [tab] duration [tab] rpe [enter]`. Distance
15 //! is meters.
16 //!
17 //! Session date defaults to today; `[`/`]` step by a day.
18
19 use chrono::{Duration as ChronoDuration, Local, NaiveDate};
20 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
21 use ratatui::Frame;
22 use ratatui::layout::{Constraint, Direction, Layout, Rect};
23 use ratatui::style::{Modifier, Style};
24 use ratatui::text::{Line, Span};
25 use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
26 use ripgrow_core::{
27 Db, Distance, DistanceKind, DistancePayload, DistanceSet, Duration, EffortKind, Exercise,
28 Kind, Load, Reps, RepsKind, RepsPayload, RepsSet, Rpe, TimedKind, TimedPayload, TimedSet,
29 };
30
31 pub struct LogScreen {
32 date: NaiveDate,
33 exercises: Vec<Exercise>,
34 picker: PickState,
35 grid: Option<GridState>,
36 pub status: String,
37 }
38
39 struct PickState {
40 query: String,
41 list_state: ListState,
42 }
43
44 struct GridState {
45 exercise: Exercise,
46 kind: KindGrid,
47 }
48
49 enum KindGrid {
50 Reps(RepsGrid),
51 Timed(TimedGrid),
52 Distance(DistanceGrid),
53 }
54
55 struct RepsGrid {
56 committed: Vec<RepsSet>,
57 pending: RepsPending,
58 focus: RepsField,
59 }
60
61 #[derive(Default)]
62 struct RepsPending {
63 load: String,
64 reps: String,
65 rpe: String,
66 }
67
68 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
69 enum RepsField {
70 Load,
71 Reps,
72 Rpe,
73 }
74
75 struct TimedGrid {
76 committed: Vec<TimedSet>,
77 pending: TimedPending,
78 focus: TimedField,
79 }
80
81 #[derive(Default)]
82 struct TimedPending {
83 duration: String,
84 rpe: String,
85 }
86
87 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
88 enum TimedField {
89 Duration,
90 Rpe,
91 }
92
93 struct DistanceGrid {
94 committed: Vec<DistanceSet>,
95 pending: DistancePending,
96 focus: DistanceField,
97 }
98
99 #[derive(Default)]
100 struct DistancePending {
101 distance: String,
102 duration: String,
103 rpe: String,
104 }
105
106 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
107 enum DistanceField {
108 Distance,
109 Duration,
110 Rpe,
111 }
112
113 impl LogScreen {
114 pub fn load(db: &Db) -> Result<Self, ripgrow_core::Error> {
115 let exercises = db.list_exercises()?;
116 let mut list_state = ListState::default();
117 if !exercises.is_empty() {
118 list_state.select(Some(0));
119 }
120 Ok(Self {
121 date: Local::now().date_naive(),
122 exercises,
123 picker: PickState {
124 query: String::new(),
125 list_state,
126 },
127 grid: None,
128 status: String::new(),
129 })
130 }
131
132 pub fn on_key(&mut self, db: &Db, key: KeyEvent) {
133 if self.grid.is_some() {
134 self.on_grid_key(db, key);
135 } else {
136 self.on_pick_key(key);
137 }
138 }
139
140 // ---- picker sub-mode ------------------------------------------------
141
142 fn filtered_indices(&self) -> Vec<usize> {
143 let q = self.picker.query.trim().to_lowercase();
144 if q.is_empty() {
145 (0..self.exercises.len()).collect()
146 } else {
147 self.exercises
148 .iter()
149 .enumerate()
150 .filter(|(_, e)| subsequence_match(&e.name.to_lowercase(), &q))
151 .map(|(i, _)| i)
152 .collect()
153 }
154 }
155
156 fn on_pick_key(&mut self, key: KeyEvent) {
157 match key.code {
158 KeyCode::Esc => {
159 self.picker.query.clear();
160 }
161 KeyCode::Enter => {
162 let filt = self.filtered_indices();
163 if let Some(row) = self.picker.list_state.selected()
164 && let Some(ex_idx) = filt.get(row)
165 && let Some(ex) = self.exercises.get(*ex_idx)
166 {
167 self.enter_grid(ex.clone());
168 }
169 }
170 KeyCode::Down => self.move_pick(1),
171 KeyCode::Up => self.move_pick(-1),
172 KeyCode::Char('[') if key.modifiers == KeyModifiers::NONE => {
173 self.date -= ChronoDuration::days(1);
174 }
175 KeyCode::Char(']') if key.modifiers == KeyModifiers::NONE => {
176 self.date += ChronoDuration::days(1);
177 }
178 KeyCode::Backspace => {
179 self.picker.query.pop();
180 self.picker.list_state.select(Some(0));
181 }
182 KeyCode::Char(c) => {
183 self.picker.query.push(c);
184 self.picker.list_state.select(Some(0));
185 }
186 _ => {}
187 }
188 }
189
190 fn move_pick(&mut self, delta: isize) {
191 let n = self.filtered_indices().len();
192 if n == 0 {
193 return;
194 }
195 let cur = self.picker.list_state.selected().unwrap_or(0) as isize;
196 let next = (cur + delta).rem_euclid(n as isize) as usize;
197 self.picker.list_state.select(Some(next));
198 }
199
200 fn enter_grid(&mut self, exercise: Exercise) {
201 let kind = match exercise.effort_kind {
202 EffortKind::Reps => KindGrid::Reps(RepsGrid {
203 committed: Vec::new(),
204 pending: RepsPending::default(),
205 focus: RepsField::Load,
206 }),
207 EffortKind::Timed => KindGrid::Timed(TimedGrid {
208 committed: Vec::new(),
209 pending: TimedPending::default(),
210 focus: TimedField::Duration,
211 }),
212 EffortKind::Distance => KindGrid::Distance(DistanceGrid {
213 committed: Vec::new(),
214 pending: DistancePending::default(),
215 focus: DistanceField::Distance,
216 }),
217 };
218 self.grid = Some(GridState { exercise, kind });
219 }
220
221 fn refresh_grid(&mut self, db: &Db) {
222 let Some(g) = self.grid.as_mut() else { return };
223 match &mut g.kind {
224 KindGrid::Reps(r) => {
225 if let Ok(list) = RepsKind::list_sets_for_session(db, g.exercise.id, self.date) {
226 r.committed = list;
227 }
228 }
229 KindGrid::Timed(t) => {
230 if let Ok(list) = TimedKind::list_sets_for_session(db, g.exercise.id, self.date) {
231 t.committed = list;
232 }
233 }
234 KindGrid::Distance(d) => {
235 if let Ok(list) =
236 DistanceKind::list_sets_for_session(db, g.exercise.id, self.date)
237 {
238 d.committed = list;
239 }
240 }
241 }
242 }
243
244 // ---- grid sub-mode --------------------------------------------------
245
246 fn on_grid_key(&mut self, db: &Db, key: KeyEvent) {
247 let Some(g) = self.grid.as_mut() else { return };
248 match (key.code, key.modifiers) {
249 (KeyCode::Esc, _) => {
250 self.grid = None;
251 return;
252 }
253 (KeyCode::Tab, _) => {
254 advance_focus(&mut g.kind);
255 return;
256 }
257 (KeyCode::BackTab, _) => {
258 retreat_focus(&mut g.kind);
259 return;
260 }
261 (KeyCode::Enter, _) => {
262 if let Err(e) = commit_pending(db, g, self.date) {
263 self.status = format!("save failed: {e}");
264 } else {
265 self.status = "set saved".to_string();
266 }
267 self.refresh_grid(db);
268 return;
269 }
270 (KeyCode::Backspace, KeyModifiers::CONTROL) => {
271 match delete_last(db, g) {
272 Ok(true) => {
273 self.status = "last set deleted".to_string();
274 self.refresh_grid(db);
275 }
276 Ok(false) => {}
277 Err(e) => self.status = format!("delete failed: {e}"),
278 }
279 return;
280 }
281 _ => {}
282 }
283
284 let target = focused_field_mut(&mut g.kind);
285 match key.code {
286 KeyCode::Backspace => {
287 target.pop();
288 }
289 KeyCode::Char(c) => target.push(c),
290 _ => {}
291 }
292 }
293
294 // ---- rendering ------------------------------------------------------
295
296 pub fn render(&mut self, frame: &mut Frame, area: Rect) {
297 let chunks = Layout::default()
298 .direction(Direction::Vertical)
299 .constraints([
300 Constraint::Length(1),
301 Constraint::Min(3),
302 Constraint::Length(1),
303 ])
304 .split(area);
305
306 let date_hint = format!(
307 "session: {} ([/] to change date)",
308 self.date.format("%Y-%m-%d")
309 );
310 frame.render_widget(Paragraph::new(date_hint), chunks[0]);
311
312 match &mut self.grid {
313 Some(g) => render_grid(frame, chunks[1], g),
314 None => render_picker(frame, chunks[1], &mut self.picker, &self.exercises),
315 }
316
317 let hint = match &self.grid {
318 None => "type to filter enter pick [/] date esc clear",
319 Some(_) => "tab move enter save ctrl-backspace remove last esc back to picker",
320 };
321 frame.render_widget(
322 Paragraph::new(Line::from(vec![
323 Span::raw(hint),
324 Span::raw(" "),
325 Span::styled(
326 self.status.as_str(),
327 Style::default().add_modifier(Modifier::DIM),
328 ),
329 ])),
330 chunks[2],
331 );
332 }
333 }
334
335 fn advance_focus(kind: &mut KindGrid) {
336 match kind {
337 KindGrid::Reps(r) => {
338 r.focus = match r.focus {
339 RepsField::Load => RepsField::Reps,
340 RepsField::Reps => RepsField::Rpe,
341 RepsField::Rpe => RepsField::Load,
342 }
343 }
344 KindGrid::Timed(t) => {
345 t.focus = match t.focus {
346 TimedField::Duration => TimedField::Rpe,
347 TimedField::Rpe => TimedField::Duration,
348 }
349 }
350 KindGrid::Distance(d) => {
351 d.focus = match d.focus {
352 DistanceField::Distance => DistanceField::Duration,
353 DistanceField::Duration => DistanceField::Rpe,
354 DistanceField::Rpe => DistanceField::Distance,
355 }
356 }
357 }
358 }
359
360 fn retreat_focus(kind: &mut KindGrid) {
361 match kind {
362 KindGrid::Reps(r) => {
363 r.focus = match r.focus {
364 RepsField::Load => RepsField::Rpe,
365 RepsField::Reps => RepsField::Load,
366 RepsField::Rpe => RepsField::Reps,
367 }
368 }
369 KindGrid::Timed(t) => {
370 t.focus = match t.focus {
371 TimedField::Duration => TimedField::Rpe,
372 TimedField::Rpe => TimedField::Duration,
373 }
374 }
375 KindGrid::Distance(d) => {
376 d.focus = match d.focus {
377 DistanceField::Distance => DistanceField::Rpe,
378 DistanceField::Duration => DistanceField::Distance,
379 DistanceField::Rpe => DistanceField::Duration,
380 }
381 }
382 }
383 }
384
385 fn focused_field_mut(kind: &mut KindGrid) -> &mut String {
386 match kind {
387 KindGrid::Reps(r) => match r.focus {
388 RepsField::Load => &mut r.pending.load,
389 RepsField::Reps => &mut r.pending.reps,
390 RepsField::Rpe => &mut r.pending.rpe,
391 },
392 KindGrid::Timed(t) => match t.focus {
393 TimedField::Duration => &mut t.pending.duration,
394 TimedField::Rpe => &mut t.pending.rpe,
395 },
396 KindGrid::Distance(d) => match d.focus {
397 DistanceField::Distance => &mut d.pending.distance,
398 DistanceField::Duration => &mut d.pending.duration,
399 DistanceField::Rpe => &mut d.pending.rpe,
400 },
401 }
402 }
403
404 fn commit_pending(
405 db: &Db,
406 g: &mut GridState,
407 date: NaiveDate,
408 ) -> Result<(), ripgrow_core::Error> {
409 match &mut g.kind {
410 KindGrid::Reps(r) => {
411 let load = Load::new(parse_field("load", &r.pending.load)?)?;
412 let reps = Reps::new(parse_field("reps", &r.pending.reps)?)?;
413 let rpe = Rpe::new(parse_field("rpe", &r.pending.rpe)?)?;
414 RepsKind::append(
415 db,
416 g.exercise.id,
417 date,
418 RepsPayload::new(load, reps),
419 rpe,
420 false,
421 )?;
422 r.pending = RepsPending::default();
423 r.focus = RepsField::Load;
424 }
425 KindGrid::Timed(t) => {
426 let duration = Duration::from_seconds(parse_duration(&t.pending.duration)?)?;
427 let rpe = Rpe::new(parse_field("rpe", &t.pending.rpe)?)?;
428 TimedKind::append(
429 db,
430 g.exercise.id,
431 date,
432 TimedPayload::new(duration),
433 rpe,
434 false,
435 )?;
436 t.pending = TimedPending::default();
437 t.focus = TimedField::Duration;
438 }
439 KindGrid::Distance(d) => {
440 let distance = Distance::from_meters(parse_field("distance", &d.pending.distance)?)?;
441 let duration = Duration::from_seconds(parse_duration(&d.pending.duration)?)?;
442 let rpe = Rpe::new(parse_field("rpe", &d.pending.rpe)?)?;
443 DistanceKind::append(
444 db,
445 g.exercise.id,
446 date,
447 DistancePayload::new(distance, duration),
448 rpe,
449 false,
450 )?;
451 d.pending = DistancePending::default();
452 d.focus = DistanceField::Distance;
453 }
454 }
455 Ok(())
456 }
457
458 /// Delete the most recently committed set for the current grid. Returns
459 /// `Ok(true)` when a row was actually removed, `Ok(false)` when there
460 /// was nothing to delete.
461 fn delete_last(db: &Db, g: &GridState) -> Result<bool, ripgrow_core::Error> {
462 match &g.kind {
463 KindGrid::Reps(r) => {
464 let Some(last) = r.committed.last() else { return Ok(false) };
465 RepsKind::delete(db, last.id)?;
466 Ok(true)
467 }
468 KindGrid::Timed(t) => {
469 let Some(last) = t.committed.last() else { return Ok(false) };
470 TimedKind::delete(db, last.id)?;
471 Ok(true)
472 }
473 KindGrid::Distance(d) => {
474 let Some(last) = d.committed.last() else { return Ok(false) };
475 DistanceKind::delete(db, last.id)?;
476 Ok(true)
477 }
478 }
479 }
480
481 fn parse_field<T: std::str::FromStr>(
482 field: &'static str,
483 raw: &str,
484 ) -> Result<T, ripgrow_core::Error> {
485 raw.trim()
486 .parse()
487 .map_err(|_| ripgrow_core::Error::ParseField {
488 field,
489 value: raw.to_string(),
490 })
491 }
492
493 /// Parse a duration string into seconds. Accepts `N` (seconds) or `M:SS`
494 /// (minutes:seconds). `1:5` is 65s, `1:05` is 65s — the seconds field is
495 /// numeric, not lexical.
496 fn parse_duration(raw: &str) -> Result<i32, ripgrow_core::Error> {
497 let s = raw.trim();
498 let err = || ripgrow_core::Error::ParseField {
499 field: "duration",
500 value: raw.to_string(),
501 };
502 if let Some((m, sec)) = s.split_once(':') {
503 let m: i32 = m.parse().map_err(|_| err())?;
504 let sec: i32 = sec.parse().map_err(|_| err())?;
505 if m < 0 || sec < 0 {
506 return Err(err());
507 }
508 Ok(m * 60 + sec)
509 } else {
510 let n: i32 = s.parse().map_err(|_| err())?;
511 if n < 0 {
512 return Err(err());
513 }
514 Ok(n)
515 }
516 }
517
518 fn render_picker(
519 frame: &mut Frame,
520 area: Rect,
521 picker: &mut PickState,
522 exercises: &[Exercise],
523 ) {
524 let chunks = Layout::default()
525 .direction(Direction::Vertical)
526 .constraints([Constraint::Length(3), Constraint::Min(1)])
527 .split(area);
528
529 let query_block = Block::default().borders(Borders::ALL).title(" pick exercise ");
530 let query = Paragraph::new(format!("> {}_", picker.query)).block(query_block);
531 frame.render_widget(query, chunks[0]);
532
533 let q = picker.query.trim().to_lowercase();
534 let items: Vec<ListItem> = exercises
535 .iter()
536 .filter(|e| q.is_empty() || subsequence_match(&e.name.to_lowercase(), &q))
537 .map(|e| ListItem::new(e.name.as_str()))
538 .collect();
539 let list = List::new(items)
540 .block(Block::default().borders(Borders::ALL).title(" matches "))
541 .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
542 .highlight_symbol("> ");
543 frame.render_stateful_widget(list, chunks[1], &mut picker.list_state);
544 }
545
546 fn render_grid(frame: &mut Frame, area: Rect, g: &GridState) {
547 let block = Block::default()
548 .borders(Borders::ALL)
549 .title(format!(" {} ", g.exercise.name));
550 let inner = block.inner(area);
551 frame.render_widget(block, area);
552
553 let lines = match &g.kind {
554 KindGrid::Reps(r) => render_reps_lines(&g.exercise, r),
555 KindGrid::Timed(t) => render_timed_lines(t),
556 KindGrid::Distance(d) => render_distance_lines(d),
557 };
558 frame.render_widget(Paragraph::new(lines), inner);
559 }
560
561 fn render_reps_lines<'a>(exercise: &'a Exercise, r: &'a RepsGrid) -> Vec<Line<'a>> {
562 let header = format!(
563 " # {:>8} {:>4} {:>3}",
564 format!("load ({})", exercise.load_unit),
565 "reps",
566 "rpe"
567 );
568 let mut lines: Vec<Line> = vec![Line::from(Span::styled(
569 header,
570 Style::default().add_modifier(Modifier::BOLD),
571 ))];
572 for s in &r.committed {
573 lines.push(Line::from(format!(
574 " {:<3} {:>8} {:>4} {:>3}",
575 s.set_index, s.load, s.reps, s.rpe
576 )));
577 }
578 let load = focused_span(&r.pending.load, r.focus == RepsField::Load, 8);
579 let reps = focused_span(&r.pending.reps, r.focus == RepsField::Reps, 4);
580 let rpe = focused_span(&r.pending.rpe, r.focus == RepsField::Rpe, 3);
581 let next_idx = r.committed.last().map(|s| s.set_index + 1).unwrap_or(1);
582 lines.push(Line::from(vec![
583 Span::raw(format!(" {:<3} ", next_idx)),
584 load,
585 Span::raw(" "),
586 reps,
587 Span::raw(" "),
588 rpe,
589 ]));
590 lines
591 }
592
593 fn render_timed_lines(t: &TimedGrid) -> Vec<Line<'_>> {
594 let header = format!(" # {:>10} {:>3}", "duration", "rpe");
595 let mut lines: Vec<Line> = vec![Line::from(Span::styled(
596 header,
597 Style::default().add_modifier(Modifier::BOLD),
598 ))];
599 for s in &t.committed {
600 lines.push(Line::from(format!(
601 " {:<3} {:>10} {:>3}",
602 s.set_index,
603 s.duration,
604 s.rpe
605 )));
606 }
607 let dur = focused_span(&t.pending.duration, t.focus == TimedField::Duration, 10);
608 let rpe = focused_span(&t.pending.rpe, t.focus == TimedField::Rpe, 3);
609 let next_idx = t.committed.last().map(|s| s.set_index + 1).unwrap_or(1);
610 lines.push(Line::from(vec![
611 Span::raw(format!(" {:<3} ", next_idx)),
612 dur,
613 Span::raw(" "),
614 rpe,
615 ]));
616 lines
617 }
618
619 fn render_distance_lines(d: &DistanceGrid) -> Vec<Line<'_>> {
620 let header = format!(
621 " # {:>10} {:>10} {:>3}",
622 "distance", "duration", "rpe"
623 );
624 let mut lines: Vec<Line> = vec![Line::from(Span::styled(
625 header,
626 Style::default().add_modifier(Modifier::BOLD),
627 ))];
628 for s in &d.committed {
629 lines.push(Line::from(format!(
630 " {:<3} {:>10} {:>10} {:>3}",
631 s.set_index,
632 s.distance,
633 s.duration,
634 s.rpe
635 )));
636 }
637 let dist = focused_span(&d.pending.distance, d.focus == DistanceField::Distance, 10);
638 let dur = focused_span(&d.pending.duration, d.focus == DistanceField::Duration, 10);
639 let rpe = focused_span(&d.pending.rpe, d.focus == DistanceField::Rpe, 3);
640 let next_idx = d.committed.last().map(|s| s.set_index + 1).unwrap_or(1);
641 lines.push(Line::from(vec![
642 Span::raw(format!(" {:<3} ", next_idx)),
643 dist,
644 Span::raw(" "),
645 dur,
646 Span::raw(" "),
647 rpe,
648 ]));
649 lines
650 }
651
652 fn focused_span(value: &str, focused: bool, width: usize) -> Span<'_> {
653 let content = format!("{:>width$}", value, width = width);
654 if focused {
655 Span::styled(content, Style::default().add_modifier(Modifier::REVERSED))
656 } else {
657 Span::raw(content)
658 }
659 }
660
661 /// Case-insensitive subsequence match. `"sq"` matches `"squat"` and
662 /// `"back squat"` but not `"pushup"`.
663 fn subsequence_match(haystack: &str, needle: &str) -> bool {
664 let mut chars = haystack.chars();
665 needle.chars().all(|nc| chars.any(|hc| hc == nc))
666 }
667
668 #[cfg(test)]
669 mod tests {
670 use super::*;
671 use crossterm::event::KeyEvent;
672 use ripgrow_core::{LoadUnit, ResistanceType};
673
674 fn key(c: KeyCode) -> KeyEvent {
675 KeyEvent::new(c, KeyModifiers::empty())
676 }
677
678 fn setup() -> (Db, i64) {
679 let db = Db::open_in_memory().unwrap();
680 db.init_profile("self", LoadUnit::Kg).unwrap();
681 let sq = db
682 .create_exercise("squat", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
683 .unwrap();
684 db.create_exercise("bench", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
685 .unwrap();
686 db.create_exercise("row", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
687 .unwrap();
688 (db, sq)
689 }
690
691 fn type_str(screen: &mut LogScreen, db: &Db, text: &str) {
692 for c in text.chars() {
693 screen.on_key(db, key(KeyCode::Char(c)));
694 }
695 }
696
697 #[test]
698 fn subsequence_match_examples() {
699 assert!(subsequence_match("squat", "sq"));
700 assert!(subsequence_match("back squat", "bsq"));
701 assert!(!subsequence_match("pushup", "sq"));
702 }
703
704 #[test]
705 fn parse_duration_accepts_seconds_and_mm_ss() {
706 assert_eq!(parse_duration("60").unwrap(), 60);
707 assert_eq!(parse_duration("1:30").unwrap(), 90);
708 assert_eq!(parse_duration("1:05").unwrap(), 65);
709 assert_eq!(parse_duration("0:45").unwrap(), 45);
710 assert!(parse_duration("abc").is_err());
711 assert!(parse_duration("-1").is_err());
712 }
713
714 #[test]
715 fn typing_filters_and_enter_selects() {
716 let (db, sq) = setup();
717 let mut screen = LogScreen::load(&db).unwrap();
718 type_str(&mut screen, &db, "sq");
719 assert_eq!(screen.filtered_indices().len(), 1);
720 screen.on_key(&db, key(KeyCode::Enter));
721 assert!(screen.grid.is_some());
722 assert_eq!(screen.grid.as_ref().unwrap().exercise.id, sq);
723 }
724
725 #[test]
726 fn enter_on_last_column_commits_reps_set() {
727 let (db, sq) = setup();
728 let mut screen = LogScreen::load(&db).unwrap();
729 type_str(&mut screen, &db, "squat");
730 screen.on_key(&db, key(KeyCode::Enter));
731 // 100 [tab] 5 [tab] 3 [enter]
732 type_str(&mut screen, &db, "100");
733 screen.on_key(&db, key(KeyCode::Tab));
734 type_str(&mut screen, &db, "5");
735 screen.on_key(&db, key(KeyCode::Tab));
736 type_str(&mut screen, &db, "3");
737 screen.on_key(&db, key(KeyCode::Enter));
738
739 let today = chrono::Local::now().date_naive();
740 let list = RepsKind::list_sets_for_session(&db, sq, today).unwrap();
741 assert_eq!(list.len(), 1);
742 assert_eq!(list[0].load.get(), 100.0);
743 assert_eq!(list[0].reps.get(), 5);
744 assert_eq!(list[0].rpe.get(), 3);
745 // Pending row cleared, focus back on Load.
746 let KindGrid::Reps(r) = &screen.grid.as_ref().unwrap().kind else {
747 panic!("expected reps grid");
748 };
749 assert!(r.pending.load.is_empty());
750 assert_eq!(r.focus, RepsField::Load);
751 }
752
753 #[test]
754 fn commits_timed_set_to_timed_sets_table() {
755 let db = Db::open_in_memory().unwrap();
756 db.init_profile("self", LoadUnit::Kg).unwrap();
757 let plank = db
758 .create_exercise("plank", ResistanceType::CardioTime, LoadUnit::Kg, 10.0, &[])
759 .unwrap();
760 let mut screen = LogScreen::load(&db).unwrap();
761 type_str(&mut screen, &db, "plank");
762 screen.on_key(&db, key(KeyCode::Enter));
763 // 1:30 [tab] 3 [enter]
764 type_str(&mut screen, &db, "1:30");
765 screen.on_key(&db, key(KeyCode::Tab));
766 type_str(&mut screen, &db, "3");
767 screen.on_key(&db, key(KeyCode::Enter));
768
769 let today = chrono::Local::now().date_naive();
770 let list = TimedKind::list_sets_for_session(&db, plank, today).unwrap();
771 assert_eq!(list.len(), 1);
772 assert_eq!(list[0].duration.seconds(), 90);
773 assert_eq!(list[0].rpe.get(), 3);
774 // Reps table should NOT have received this row.
775 assert!(RepsKind::list_sets_for_session(&db, plank, today)
776 .unwrap()
777 .is_empty());
778 }
779
780 #[test]
781 fn commits_distance_set_to_distance_sets_table() {
782 let db = Db::open_in_memory().unwrap();
783 db.init_profile("self", LoadUnit::Kg).unwrap();
784 let run = db
785 .create_exercise(
786 "5k run",
787 ResistanceType::CardioDistance,
788 LoadUnit::Kg,
789 0.0,
790 &[],
791 )
792 .unwrap();
793 let mut screen = LogScreen::load(&db).unwrap();
794 type_str(&mut screen, &db, "5k");
795 screen.on_key(&db, key(KeyCode::Enter));
796 // 5000 [tab] 25:00 [tab] 3 [enter]
797 type_str(&mut screen, &db, "5000");
798 screen.on_key(&db, key(KeyCode::Tab));
799 type_str(&mut screen, &db, "25:00");
800 screen.on_key(&db, key(KeyCode::Tab));
801 type_str(&mut screen, &db, "3");
802 screen.on_key(&db, key(KeyCode::Enter));
803
804 let today = chrono::Local::now().date_naive();
805 let list = DistanceKind::list_sets_for_session(&db, run, today).unwrap();
806 assert_eq!(list.len(), 1);
807 assert_eq!(list[0].distance.meters(), 5000.0);
808 assert_eq!(list[0].duration.seconds(), 1500);
809 assert_eq!(list[0].rpe.get(), 3);
810 }
811
812 #[test]
813 fn ctrl_backspace_removes_last_reps_set() {
814 let (db, sq) = setup();
815 let today = chrono::Local::now().date_naive();
816 let payload = RepsPayload::new(Load::new(100.0).unwrap(), Reps::new(5).unwrap());
817 RepsKind::append(&db, sq, today, payload, Rpe::new(3).unwrap(), false).unwrap();
818 RepsKind::append(&db, sq, today, payload, Rpe::new(3).unwrap(), false).unwrap();
819 let mut screen = LogScreen::load(&db).unwrap();
820 type_str(&mut screen, &db, "squat");
821 screen.on_key(&db, key(KeyCode::Enter));
822 screen.refresh_grid(&db);
823 let KindGrid::Reps(r) = &screen.grid.as_ref().unwrap().kind else {
824 panic!("expected reps grid");
825 };
826 assert_eq!(r.committed.len(), 2);
827 screen.on_key(
828 &db,
829 KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL),
830 );
831 assert_eq!(
832 RepsKind::list_sets_for_session(&db, sq, today).unwrap().len(),
833 1
834 );
835 }
836
837 #[test]
838 fn ctrl_backspace_removes_last_timed_set() {
839 let db = Db::open_in_memory().unwrap();
840 db.init_profile("self", LoadUnit::Kg).unwrap();
841 let plank = db
842 .create_exercise("plank", ResistanceType::CardioTime, LoadUnit::Kg, 10.0, &[])
843 .unwrap();
844 let today = chrono::Local::now().date_naive();
845 TimedKind::append(
846 &db,
847 plank,
848 today,
849 TimedPayload::new(Duration::from_seconds(60).unwrap()),
850 Rpe::new(3).unwrap(),
851 false,
852 )
853 .unwrap();
854 let mut screen = LogScreen::load(&db).unwrap();
855 type_str(&mut screen, &db, "plank");
856 screen.on_key(&db, key(KeyCode::Enter));
857 screen.refresh_grid(&db);
858 screen.on_key(
859 &db,
860 KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL),
861 );
862 assert!(
863 TimedKind::list_sets_for_session(&db, plank, today)
864 .unwrap()
865 .is_empty()
866 );
867 }
868
869 #[test]
870 fn bracket_keys_change_date() {
871 let db = Db::open_in_memory().unwrap();
872 db.init_profile("self", LoadUnit::Kg).unwrap();
873 let mut screen = LogScreen::load(&db).unwrap();
874 let start = screen.date;
875 screen.on_key(&db, key(KeyCode::Char('[')));
876 assert_eq!(screen.date, start - ChronoDuration::days(1));
877 screen.on_key(&db, key(KeyCode::Char(']')));
878 screen.on_key(&db, key(KeyCode::Char(']')));
879 assert_eq!(screen.date, start + ChronoDuration::days(1));
880 }
881
882 #[test]
883 fn esc_from_grid_returns_to_picker() {
884 let (db, _sq) = setup();
885 let mut screen = LogScreen::load(&db).unwrap();
886 type_str(&mut screen, &db, "squat");
887 screen.on_key(&db, key(KeyCode::Enter));
888 assert!(screen.grid.is_some());
889 screen.on_key(&db, key(KeyCode::Esc));
890 assert!(screen.grid.is_none());
891 }
892 }
893