Skip to main content

max / ripgrow

log: dispatch on effort_kind; per-kind input UI for reps/timed/distance Log screen grid state becomes a KindGrid enum with a Reps/Timed/Distance arm; each carries its own committed rows, pending buffer, focus field, commit logic, and render. Duration input accepts N (seconds) or M:SS. Persistence routes through the corresponding Kind's append / delete / list_sets_for_session, so a plank writes to timed_sets and a run writes to distance_sets. Also adds inherent TimedKind::delete and DistanceKind::delete symmetric with RepsKind::delete, and re-exports the Timed/Distance types at the crate root so the TUI can name them directly.
Author: Max Johnson <me@maxj.phd> · 2026-07-18 20:17 UTC
Commit: 30990a5efacbae92a73ee3cabd50303fdfe136cc
Parent: 8d5dbdd
4 files changed, +336 insertions, -79 deletions
@@ -186,10 +186,7 @@ impl Kind for DistanceKind {
186 186 ) -> Result<(), Error> {
187 187 let list = Self::list_sets_for_session(db, exercise_id, date)?;
188 188 if let Some(last) = list.iter().rev().find(|s| s.is_diagnostic) {
189 - db.conn().execute(
190 - "DELETE FROM distance_sets WHERE id = ?1",
191 - params![last.id],
192 - )?;
189 + Self::delete(db, last.id)?;
193 190 }
194 191 Ok(())
195 192 }
@@ -217,6 +214,21 @@ impl Kind for DistanceKind {
217 214 }
218 215 }
219 216
217 + impl DistanceKind {
218 + /// Delete a specific distance set by id. Returns `NotFound` if it
219 + /// did not exist. Symmetric with
220 + /// [`RepsKind::delete`](super::reps::RepsKind::delete).
221 + pub fn delete(db: &Db, id: i64) -> Result<(), Error> {
222 + let n = db
223 + .conn()
224 + .execute("DELETE FROM distance_sets WHERE id = ?1", params![id])?;
225 + if n == 0 {
226 + return Err(Error::NotFound(format!("distance set {id}")));
227 + }
228 + Ok(())
229 + }
230 + }
231 +
220 232 #[cfg(test)]
221 233 mod tests {
222 234 use super::*;
@@ -181,8 +181,7 @@ impl Kind for TimedKind {
181 181 ) -> Result<(), Error> {
182 182 let list = Self::list_sets_for_session(db, exercise_id, date)?;
183 183 if let Some(last) = list.iter().rev().find(|s| s.is_diagnostic) {
184 - db.conn()
185 - .execute("DELETE FROM timed_sets WHERE id = ?1", params![last.id])?;
184 + Self::delete(db, last.id)?;
186 185 }
187 186 Ok(())
188 187 }
@@ -221,6 +220,20 @@ impl Kind for TimedKind {
221 220 }
222 221 }
223 222
223 + impl TimedKind {
224 + /// Delete a specific timed set by id. Returns `NotFound` if it did
225 + /// not exist. Symmetric with [`RepsKind::delete`](super::reps::RepsKind::delete).
226 + pub fn delete(db: &Db, id: i64) -> Result<(), Error> {
227 + let n = db
228 + .conn()
229 + .execute("DELETE FROM timed_sets WHERE id = ?1", params![id])?;
230 + if n == 0 {
231 + return Err(Error::NotFound(format!("timed set {id}")));
232 + }
233 + Ok(())
234 + }
235 + }
236 +
224 237 /// Full walk state carried through the timed history, mirroring the
225 238 /// reps walker. `next_duration` is what we'd prescribe if the most
226 239 /// recent session were the last one.
@@ -18,7 +18,9 @@ pub mod values;
18 18
19 19 pub use db::Db;
20 20 pub use diagnostic::{DiagnosticSet, DiagnosticStep, next_step as next_diagnostic_step};
21 + pub use effort::distance::{DistanceKind, DistancePayload, DistancePrescription, DistanceSet};
21 22 pub use effort::reps::{RepsKind, RepsPayload, RepsPrescription, RepsSet};
23 + pub use effort::timed::{TimedKind, TimedPayload, TimedPrescription, TimedSet};
22 24 pub use effort::{
23 25 AnyPayload, AnyPrescription, AnySet, EffortKind, Kind, PrescriptionResult as AnyPrescriptionResult,
24 26 append_any, compute_prescription_any, distance, reps, timed,
@@ -4,20 +4,29 @@
4 4 //! subsequence match; Enter selects the highlighted one. **Grid** shows
5 5 //! the sets already logged for (date, exercise) plus a pending row being
6 6 //! typed. Enter on the last column commits the pending row and opens a
7 - //! fresh one, so the flow is:
7 + //! fresh one.
8 8 //!
9 - //! 100 [tab] 5 [tab] 3 [enter] → row saved, cursor back at load.
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.
10 16 //!
11 17 //! Session date defaults to today; `[`/`]` step by a day.
12 18
13 - use chrono::{Duration, Local, NaiveDate};
19 + use chrono::{Duration as ChronoDuration, Local, NaiveDate};
14 20 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
15 21 use ratatui::Frame;
16 22 use ratatui::layout::{Constraint, Direction, Layout, Rect};
17 23 use ratatui::style::{Modifier, Style};
18 24 use ratatui::text::{Line, Span};
19 25 use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
20 - use ripgrow_core::{Db, Exercise, Kind, RepsKind, RepsPayload, RepsSet};
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 + };
21 30
22 31 pub struct LogScreen {
23 32 date: NaiveDate,
@@ -34,25 +43,73 @@ struct PickState {
34 43
35 44 struct GridState {
36 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 {
37 56 committed: Vec<RepsSet>,
38 - pending: PendingRow,
39 - focus: GridField,
57 + pending: RepsPending,
58 + focus: RepsField,
40 59 }
41 60
42 61 #[derive(Default)]
43 - struct PendingRow {
62 + struct RepsPending {
44 63 load: String,
45 64 reps: String,
46 65 rpe: String,
47 66 }
48 67
49 68 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
50 - enum GridField {
69 + enum RepsField {
51 70 Load,
52 71 Reps,
53 72 Rpe,
54 73 }
55 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 +
56 113 impl LogScreen {
57 114 pub fn load(db: &Db) -> Result<Self, ripgrow_core::Error> {
58 115 let exercises = db.list_exercises()?;
@@ -113,10 +170,10 @@ impl LogScreen {
113 170 KeyCode::Down => self.move_pick(1),
114 171 KeyCode::Up => self.move_pick(-1),
115 172 KeyCode::Char('[') if key.modifiers == KeyModifiers::NONE => {
116 - self.date -= Duration::days(1);
173 + self.date -= ChronoDuration::days(1);
117 174 }
118 175 KeyCode::Char(']') if key.modifiers == KeyModifiers::NONE => {
119 - self.date += Duration::days(1);
176 + self.date += ChronoDuration::days(1);
120 177 }
121 178 KeyCode::Backspace => {
122 179 self.picker.query.pop();
@@ -141,22 +198,46 @@ impl LogScreen {
141 198 }
142 199
143 200 fn enter_grid(&mut self, exercise: Exercise) {
144 - // Committed sets are loaded fresh from the DB by the caller when
145 - // needed; construct the grid state with an empty vec here and let
146 - // refresh_grid() populate it.
147 - self.grid = Some(GridState {
148 - exercise,
149 - committed: Vec::new(),
150 - pending: PendingRow::default(),
151 - focus: GridField::Load,
152 - });
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 });
153 219 }
154 220
155 221 fn refresh_grid(&mut self, db: &Db) {
156 - if let Some(g) = self.grid.as_mut()
157 - && let Ok(list) = RepsKind::list_sets_for_session(db, g.exercise.id, self.date)
158 - {
159 - g.committed = list;
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 + }
160 241 }
161 242 }
162 243
@@ -170,19 +251,11 @@ impl LogScreen {
170 251 return;
171 252 }
172 253 (KeyCode::Tab, _) => {
173 - g.focus = match g.focus {
174 - GridField::Load => GridField::Reps,
175 - GridField::Reps => GridField::Rpe,
176 - GridField::Rpe => GridField::Load,
177 - };
254 + advance_focus(&mut g.kind);
178 255 return;
179 256 }
180 257 (KeyCode::BackTab, _) => {
181 - g.focus = match g.focus {
182 - GridField::Load => GridField::Rpe,
183 - GridField::Reps => GridField::Load,
184 - GridField::Rpe => GridField::Reps,
185 - };
258 + retreat_focus(&mut g.kind);
186 259 return;
187 260 }
188 261 (KeyCode::Enter, _) => {
@@ -195,25 +268,20 @@ impl LogScreen {
195 268 return;
196 269 }
197 270 (KeyCode::Backspace, KeyModifiers::CONTROL) => {
198 - if let Some(last) = g.committed.last().cloned() {
199 - match RepsKind::delete(db, last.id) {
200 - Ok(()) => {
201 - self.status = "last set deleted".to_string();
202 - self.refresh_grid(db);
203 - }
204 - Err(e) => self.status = format!("delete failed: {e}"),
271 + match delete_last(db, g) {
272 + Ok(true) => {
273 + self.status = "last set deleted".to_string();
274 + self.refresh_grid(db);
205 275 }
276 + Ok(false) => {}
277 + Err(e) => self.status = format!("delete failed: {e}"),
206 278 }
207 279 return;
208 280 }
209 281 _ => {}
210 282 }
211 283
212 - let target = match g.focus {
213 - GridField::Load => &mut g.pending.load,
214 - GridField::Reps => &mut g.pending.reps,
215 - GridField::Rpe => &mut g.pending.rpe,
216 - };
284 + let target = focused_field_mut(&mut g.kind);
217 285 match key.code {
218 286 KeyCode::Backspace => {
219 287 target.pop();
@@ -248,9 +316,7 @@ impl LogScreen {
248 316
249 317 let hint = match &self.grid {
250 318 None => "type to filter enter pick [/] date esc clear",
251 - Some(_) => {
252 - "tab move enter save ctrl-backspace remove last esc back to picker"
253 - }
319 + Some(_) => "tab move enter save ctrl-backspace remove last esc back to picker",
254 320 };
255 321 frame.render_widget(
256 322 Paragraph::new(Line::from(vec![
@@ -266,27 +332,152 @@ impl LogScreen {
266 332 }
267 333 }
268 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 +
269 404 fn commit_pending(
270 405 db: &Db,
271 406 g: &mut GridState,
272 407 date: NaiveDate,
273 408 ) -> Result<(), ripgrow_core::Error> {
274 - let load = ripgrow_core::Load::new(parse_field("load", &g.pending.load)?)?;
275 - let reps = ripgrow_core::Reps::new(parse_field("reps", &g.pending.reps)?)?;
276 - let rpe = ripgrow_core::Rpe::new(parse_field("rpe", &g.pending.rpe)?)?;
277 - RepsKind::append(
278 - db,
279 - g.exercise.id,
280 - date,
281 - RepsPayload::new(load, reps),
282 - rpe,
283 - false,
284 - )?;
285 - g.pending = PendingRow::default();
286 - g.focus = GridField::Load;
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 + }
287 455 Ok(())
288 456 }
289 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 +
290 481 fn parse_field<T: std::str::FromStr>(
291 482 field: &'static str,
292 483 raw: &str,
@@ -299,6 +490,31 @@ fn parse_field<T: std::str::FromStr>(
299 490 })
300 491 }
301 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 +
302 518 fn render_picker(
303 519 frame: &mut Frame,
304 520 area: Rect,
@@ -334,9 +550,18 @@ fn render_grid(frame: &mut Frame, area: Rect, g: &GridState) {
334 550 let inner = block.inner(area);
335 551 frame.render_widget(block, area);
336 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>> {
337 562 let header = format!(
338 563 " # {:>8} {:>4} {:>3}",
339 - format!("load ({})", g.exercise.load_unit),
564 + format!("load ({})", exercise.load_unit),
340 565 "reps",
341 566 "rpe"
342 567 );
@@ -344,28 +569,84 @@ fn render_grid(frame: &mut Frame, area: Rect, g: &GridState) {
344 569 header,
345 570 Style::default().add_modifier(Modifier::BOLD),
346 571 ))];
347 -
348 - for s in &g.committed {
572 + for s in &r.committed {
349 573 lines.push(Line::from(format!(
350 574 " {:<3} {:>8} {:>4} {:>3}",
351 575 s.set_index, s.load, s.reps, s.rpe
352 576 )));
353 577 }
354 -
355 - let load_span = focused_span(&g.pending.load, g.focus == GridField::Load, 8);
356 - let reps_span = focused_span(&g.pending.reps, g.focus == GridField::Reps, 4);
357 - let rpe_span = focused_span(&g.pending.rpe, g.focus == GridField::Rpe, 3);
358 - let next_idx = g.committed.last().map(|s| s.set_index + 1).unwrap_or(1);
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);
359 582 lines.push(Line::from(vec![
360 583 Span::raw(format!(" {:<3} ", next_idx)),
361 - load_span,
584 + load,
362 585 Span::raw(" "),
363 - reps_span,
586 + reps,
364 587 Span::raw(" "),
365 - rpe_span,
588 + rpe,
366 589 ]));
590 + lines
591 + }
367 592
368 - frame.render_widget(Paragraph::new(lines), inner);
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 + ))];
Lines truncated