Skip to main content

max / ripgrow

log screen: fuzzy pick, tight grid, tab bar ripgrow-core sets module: SessionSet, list_sets_for_session, append_set (auto-assigns set_index), delete_set. Rpe range and non-negative reps enforced in Rust before hitting the CHECK constraint. Chrono NaiveDate handling round-trips through the TEXT column. Log screen has two sub-modes: picker (type to filter by subsequence match; enter selects) and grid (load / reps / rpe cells; tab moves between; enter on rpe commits and starts a fresh row; ctrl-backspace removes the last committed set; esc returns to picker). Session date defaults to today; [ and ] step by a day. App now has a tab bar with Templates and Log; Alt+1/Alt+2 switch. Global Ctrl+C quits (leaves 'q' free for text entry). Creating a template refreshes the Log picker so newly-added exercises appear immediately. Error::ParseField added so parse failures surface an accurate field name instead of a bogus rpe/reps message. 40 tests total (23 core + 17 tui), all passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 16:32 UTC
Commit: 6dea4f1b5dc1934333ec101fd2f9927b10813ec4
Parent: 698bb8a
8 files changed, +824 insertions, -27 deletions
M Cargo.lock +1
@@ -636,6 +636,7 @@ dependencies = [
636 636 name = "ripgrow-tui"
637 637 version = "0.1.0"
638 638 dependencies = [
639 + "chrono",
639 640 "crossterm 0.29.0",
640 641 "dirs",
641 642 "ratatui",
@@ -22,4 +22,13 @@ pub enum Error {
22 22
23 23 #[error("not found: {0}")]
24 24 NotFound(String),
25 +
26 + #[error("rpe must be 1..=5, got {0}")]
27 + InvalidRpe(i32),
28 +
29 + #[error("reps must be non-negative, got {0}")]
30 + InvalidReps(i32),
31 +
32 + #[error("could not parse {field}: {value:?}")]
33 + ParseField { field: &'static str, value: String },
25 34 }
@@ -6,9 +6,11 @@
6 6 pub mod db;
7 7 pub mod error;
8 8 pub mod profiles;
9 + pub mod sets;
9 10 pub mod templates;
10 11
11 12 pub use db::{Db, Unit};
12 13 pub use error::Error;
13 14 pub use profiles::{Profile, create_profile_in, list_profiles, profiles_dir, slugify};
15 + pub use sets::SessionSet;
14 16 pub use templates::{Exercise, ResistanceType, Tag};
@@ -0,0 +1,177 @@
1 + //! Logged sets: the atomic unit of history.
2 + //!
3 + //! One row per set (date, exercise, set_index, load, reps, rpe). A "session"
4 + //! is a `SELECT ... WHERE session_date = ?`. Rpe is a 1-5 integer enforced
5 + //! by CHECK at the schema level.
6 +
7 + use chrono::NaiveDate;
8 + use rusqlite::params;
9 +
10 + use crate::db::Db;
11 + use crate::error::Error;
12 +
13 + #[derive(Debug, Clone, PartialEq)]
14 + pub struct SessionSet {
15 + pub id: i64,
16 + pub session_date: NaiveDate,
17 + pub exercise_id: i64,
18 + pub set_index: i32,
19 + pub load: f64,
20 + pub reps: i32,
21 + pub rpe: i32,
22 + }
23 +
24 + impl Db {
25 + /// List sets for an exercise on a given date, ordered by set_index.
26 + pub fn list_sets_for_session(
27 + &self,
28 + exercise_id: i64,
29 + date: NaiveDate,
30 + ) -> Result<Vec<SessionSet>, Error> {
31 + let mut stmt = self.conn().prepare(
32 + "SELECT id, session_date, exercise_id, set_index, load, reps, rpe \
33 + FROM sets WHERE exercise_id = ?1 AND session_date = ?2 \
34 + ORDER BY set_index",
35 + )?;
36 + let rows = stmt.query_map(
37 + params![exercise_id, date.to_string()],
38 + |row| {
39 + let date_str: String = row.get(1)?;
40 + let parsed = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d")
41 + .map_err(|e| rusqlite::Error::FromSqlConversionFailure(
42 + 1,
43 + rusqlite::types::Type::Text,
44 + Box::new(e),
45 + ))?;
46 + Ok(SessionSet {
47 + id: row.get(0)?,
48 + session_date: parsed,
49 + exercise_id: row.get(2)?,
50 + set_index: row.get(3)?,
51 + load: row.get(4)?,
52 + reps: row.get(5)?,
53 + rpe: row.get(6)?,
54 + })
55 + },
56 + )?;
57 + Ok(rows.collect::<Result<Vec<_>, _>>()?)
58 + }
59 +
60 + /// Append a new set to a session. `set_index` is auto-assigned as one
61 + /// past the current max for this (date, exercise). Returns the new row id.
62 + pub fn append_set(
63 + &self,
64 + exercise_id: i64,
65 + date: NaiveDate,
66 + load: f64,
67 + reps: i32,
68 + rpe: i32,
69 + ) -> Result<i64, Error> {
70 + if !(1..=5).contains(&rpe) {
71 + return Err(Error::InvalidRpe(rpe));
72 + }
73 + if reps < 0 {
74 + return Err(Error::InvalidReps(reps));
75 + }
76 + let next_index: i32 = self
77 + .conn()
78 + .query_row(
79 + "SELECT COALESCE(MAX(set_index), 0) + 1 FROM sets \
80 + WHERE exercise_id = ?1 AND session_date = ?2",
81 + params![exercise_id, date.to_string()],
82 + |row| row.get(0),
83 + )?;
84 + self.conn().execute(
85 + "INSERT INTO sets (session_date, exercise_id, set_index, load, reps, rpe) \
86 + VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
87 + params![date.to_string(), exercise_id, next_index, load, reps, rpe],
88 + )?;
89 + Ok(self.conn().last_insert_rowid())
90 + }
91 +
92 + /// Delete a specific set row by id. Returns `NotFound` if it did not
93 + /// exist. Subsequent `append_set` calls do NOT reuse the freed index;
94 + /// gaps are fine for the state machine (which reads ordered by index).
95 + pub fn delete_set(&self, id: i64) -> Result<(), Error> {
96 + let n = self
97 + .conn()
98 + .execute("DELETE FROM sets WHERE id = ?1", params![id])?;
99 + if n == 0 {
100 + return Err(Error::NotFound(format!("set {id}")));
101 + }
102 + Ok(())
103 + }
104 + }
105 +
106 + #[cfg(test)]
107 + mod tests {
108 + use super::*;
109 + use crate::db::Unit;
110 + use crate::templates::ResistanceType;
111 +
112 + fn setup() -> (Db, i64) {
113 + let db = Db::open_in_memory().unwrap();
114 + db.init_profile("self", Unit::Kg).unwrap();
115 + let id = db
116 + .create_exercise("squat", ResistanceType::Freeweight, "kg", 2.5, &[])
117 + .unwrap();
118 + (db, id)
119 + }
120 +
121 + #[test]
122 + fn append_and_list_round_trip() {
123 + let (db, ex) = setup();
124 + let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
125 + db.append_set(ex, date, 100.0, 5, 3).unwrap();
126 + db.append_set(ex, date, 100.0, 5, 3).unwrap();
127 + db.append_set(ex, date, 100.0, 5, 4).unwrap();
128 + let list = db.list_sets_for_session(ex, date).unwrap();
129 + assert_eq!(list.len(), 3);
130 + assert_eq!(list[0].set_index, 1);
131 + assert_eq!(list[1].set_index, 2);
132 + assert_eq!(list[2].rpe, 4);
133 + }
134 +
135 + #[test]
136 + fn append_rejects_rpe_out_of_range() {
137 + let (db, ex) = setup();
138 + let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
139 + assert!(db.append_set(ex, date, 100.0, 5, 0).is_err());
140 + assert!(db.append_set(ex, date, 100.0, 5, 6).is_err());
141 + }
142 +
143 + #[test]
144 + fn append_rejects_negative_reps() {
145 + let (db, ex) = setup();
146 + let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
147 + assert!(db.append_set(ex, date, 100.0, -1, 3).is_err());
148 + }
149 +
150 + #[test]
151 + fn list_isolates_by_date_and_exercise() {
152 + let (db, ex1) = setup();
153 + let ex2 = db
154 + .create_exercise("bench", ResistanceType::Freeweight, "kg", 2.5, &[])
155 + .unwrap();
156 + let d1 = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
157 + let d2 = NaiveDate::from_ymd_opt(2026, 7, 19).unwrap();
158 + db.append_set(ex1, d1, 100.0, 5, 3).unwrap();
159 + db.append_set(ex1, d2, 105.0, 5, 3).unwrap();
160 + db.append_set(ex2, d1, 80.0, 5, 3).unwrap();
161 + assert_eq!(db.list_sets_for_session(ex1, d1).unwrap().len(), 1);
162 + assert_eq!(db.list_sets_for_session(ex1, d2).unwrap().len(), 1);
163 + assert_eq!(db.list_sets_for_session(ex2, d1).unwrap().len(), 1);
164 + }
165 +
166 + #[test]
167 + fn delete_removes_and_gap_is_fine() {
168 + let (db, ex) = setup();
169 + let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
170 + let a = db.append_set(ex, date, 100.0, 5, 3).unwrap();
171 + db.append_set(ex, date, 100.0, 5, 3).unwrap();
172 + db.delete_set(a).unwrap();
173 + let list = db.list_sets_for_session(ex, date).unwrap();
174 + assert_eq!(list.len(), 1);
175 + assert_eq!(list[0].set_index, 2, "set_index preserved after delete");
176 + }
177 + }
@@ -16,3 +16,4 @@ ratatui = { workspace = true }
16 16 crossterm = { workspace = true }
17 17 dirs = { workspace = true }
18 18 thiserror = { workspace = true }
19 + chrono = { workspace = true }
@@ -1,14 +1,20 @@
1 1 //! Top-level app state and the render/event dispatch.
2 2 //!
3 - //! Two states so far: the profile picker (until a profile is opened) and the
4 - //! templates screen (after). More screens land on tabs later.
3 + //! While no profile is open the picker takes the whole screen. Once one is
4 + //! open, a tab bar sits under the header and `Alt+1`/`Alt+2` (or `Ctrl+Tab`)
5 + //! switch between Templates and Log. Screen keys never receive Alt-modified
6 + //! digits, so text entry keeps working.
5 7
6 8 use std::path::PathBuf;
7 9
8 - use crossterm::event::KeyEvent;
10 + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
9 11 use ratatui::Frame;
12 + use ratatui::style::{Modifier, Style};
13 + use ratatui::text::{Line, Span};
14 + use ratatui::widgets::{Block, Borders, Tabs};
10 15 use ripgrow_core::{Db, Unit, create_profile_in, list_profiles, profiles_dir};
11 16
17 + use crate::screens::log::LogScreen;
12 18 use crate::screens::profile_picker::{PickerAction, ProfilePicker};
13 19 use crate::screens::templates::{TemplatesAction, TemplatesScreen};
14 20
@@ -22,7 +28,26 @@ pub struct App {
22 28 struct Opened {
23 29 path: PathBuf,
24 30 db: Db,
31 + tab: Tab,
25 32 templates: TemplatesScreen,
33 + log: LogScreen,
34 + }
35 +
36 + #[derive(Clone, Copy, PartialEq, Eq)]
37 + enum Tab {
38 + Templates,
39 + Log,
40 + }
41 +
42 + impl Tab {
43 + const ALL: [Tab; 2] = [Tab::Templates, Tab::Log];
44 +
45 + fn title(self) -> &'static str {
46 + match self {
47 + Tab::Templates => "templates",
48 + Tab::Log => "log",
49 + }
50 + }
26 51 }
27 52
28 53 impl App {
@@ -40,40 +65,99 @@ impl App {
40 65 use ratatui::layout::{Constraint, Direction, Layout};
41 66
42 67 let area = frame.area();
43 - let chunks = Layout::default()
44 - .direction(Direction::Vertical)
45 - .constraints([Constraint::Length(1), Constraint::Min(1)])
46 - .split(area);
47 -
48 - let header = match &self.opened {
49 - Some(op) => format!(
50 - "ripgrow | {} | {}",
51 - op.db.profile_name().ok().flatten().unwrap_or_default(),
52 - op.path.display()
53 - ),
54 - None => "ripgrow".to_string(),
55 - };
56 - frame.render_widget(ratatui::widgets::Paragraph::new(header), chunks[0]);
57 68
58 69 match &mut self.opened {
59 - Some(op) => op.templates.render(frame, chunks[1]),
60 - None => self.picker.render(frame, chunks[1]),
70 + Some(op) => {
71 + let chunks = Layout::default()
72 + .direction(Direction::Vertical)
73 + .constraints([
74 + Constraint::Length(1),
75 + Constraint::Length(3),
76 + Constraint::Min(1),
77 + ])
78 + .split(area);
79 +
80 + let header = format!(
81 + "ripgrow | {} | {}",
82 + op.db.profile_name().ok().flatten().unwrap_or_default(),
83 + op.path.display()
84 + );
85 + frame.render_widget(ratatui::widgets::Paragraph::new(header), chunks[0]);
86 +
87 + let titles: Vec<Line> = Tab::ALL
88 + .iter()
89 + .map(|t| Line::from(t.title()))
90 + .collect();
91 + let idx = Tab::ALL.iter().position(|t| *t == op.tab).unwrap_or(0);
92 + let tabs = Tabs::new(titles)
93 + .block(Block::default().borders(Borders::ALL))
94 + .select(idx)
95 + .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
96 + frame.render_widget(tabs, chunks[1]);
97 +
98 + match op.tab {
99 + Tab::Templates => op.templates.render(frame, chunks[2]),
100 + Tab::Log => op.log.render(frame, chunks[2]),
101 + }
102 + }
103 + None => {
104 + let chunks = Layout::default()
105 + .direction(Direction::Vertical)
106 + .constraints([Constraint::Length(1), Constraint::Min(1)])
107 + .split(area);
108 + frame.render_widget(
109 + ratatui::widgets::Paragraph::new(Span::styled(
110 + format!("ripgrow {}", self.status),
111 + Style::default().add_modifier(Modifier::DIM),
112 + )),
113 + chunks[0],
114 + );
115 + self.picker.render(frame, chunks[1]);
116 + }
61 117 }
62 118 }
63 119
64 120 pub fn on_key(&mut self, key: KeyEvent) {
65 121 if let Some(op) = self.opened.as_mut() {
66 - let action = op.templates.on_key(&op.db, key);
67 - match action {
68 - TemplatesAction::None => {}
69 - TemplatesAction::Quit => self.should_quit = true,
70 - TemplatesAction::Refresh => match TemplatesScreen::load(&op.db) {
71 - Ok(next) => op.templates = next,
72 - Err(e) => self.status = format!("reload failed: {e}"),
122 + // Tab-switch keys handled at the app layer before dispatch.
123 + if key.modifiers.contains(KeyModifiers::ALT) {
124 + match key.code {
125 + KeyCode::Char('1') => {
126 + op.tab = Tab::Templates;
127 + return;
128 + }
129 + KeyCode::Char('2') => {
130 + op.tab = Tab::Log;
131 + return;
132 + }
133 + _ => {}
134 + }
135 + }
136 + if key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::CONTROL {
137 + self.should_quit = true;
138 + return;
139 + }
140 +
141 + match op.tab {
142 + Tab::Templates => match op.templates.on_key(&op.db, key) {
143 + TemplatesAction::None => {}
144 + TemplatesAction::Quit => self.should_quit = true,
145 + TemplatesAction::Refresh => {
146 + // Reload both screens so newly-created templates
147 + // appear in the log picker without extra plumbing.
148 + if let Ok(next) = TemplatesScreen::load(&op.db) {
149 + op.templates = next;
150 + }
151 + if let Ok(next) = LogScreen::load(&op.db) {
152 + op.log = next;
153 + }
154 + }
73 155 },
156 + Tab::Log => op.log.on_key(&op.db, key),
74 157 }
75 158 return;
76 159 }
160 +
77 161 match self.picker.on_key(key) {
78 162 PickerAction::None => {}
79 163 PickerAction::Quit => self.should_quit = true,
@@ -99,8 +183,21 @@ impl App {
99 183 return;
100 184 }
101 185 };
186 + let log = match LogScreen::load(&db) {
187 + Ok(s) => s,
188 + Err(e) => {
189 + self.status = format!("load log failed: {e}");
190 + return;
191 + }
192 + };
102 193 self.status = format!("opened {}", path.display());
103 - self.opened = Some(Opened { path, db, templates });
194 + self.opened = Some(Opened {
195 + path,
196 + db,
197 + tab: Tab::Templates,
198 + templates,
199 + log,
200 + });
104 201 }
105 202
106 203 fn create_profile(&mut self, display_name: &str, unit: Unit) {
@@ -120,11 +217,20 @@ impl App {
120 217 return;
121 218 }
122 219 };
220 + let log = match LogScreen::load(&db) {
221 + Ok(s) => s,
222 + Err(e) => {
223 + self.status = format!("load log failed: {e}");
224 + return;
225 + }
226 + };
123 227 self.status = format!("created {}", profile.slug);
124 228 self.opened = Some(Opened {
125 229 path: profile.path,
126 230 db,
231 + tab: Tab::Templates,
127 232 templates,
233 + log,
128 234 });
129 235 }
130 236 Err(e) => self.status = format!("create failed: {e}"),
@@ -0,0 +1,502 @@
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, so the flow is:
8 + //!
9 + //! 100 [tab] 5 [tab] 3 [enter] → row saved, cursor back at load.
10 + //!
11 + //! Session date defaults to today; `[`/`]` step by a day.
12 +
13 + use chrono::{Duration, Local, NaiveDate};
14 + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
15 + use ratatui::Frame;
16 + use ratatui::layout::{Constraint, Direction, Layout, Rect};
17 + use ratatui::style::{Modifier, Style};
18 + use ratatui::text::{Line, Span};
19 + use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
20 + use ripgrow_core::{Db, Exercise, SessionSet};
21 +
22 + pub struct LogScreen {
23 + date: NaiveDate,
24 + exercises: Vec<Exercise>,
25 + picker: PickState,
26 + grid: Option<GridState>,
27 + pub status: String,
28 + }
29 +
30 + struct PickState {
31 + query: String,
32 + list_state: ListState,
33 + }
34 +
35 + struct GridState {
36 + exercise: Exercise,
37 + committed: Vec<SessionSet>,
38 + pending: PendingRow,
39 + focus: GridField,
40 + }
41 +
42 + #[derive(Default)]
43 + struct PendingRow {
44 + load: String,
45 + reps: String,
46 + rpe: String,
47 + }
48 +
49 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
50 + enum GridField {
51 + Load,
52 + Reps,
53 + Rpe,
54 + }
55 +
56 + impl LogScreen {
57 + pub fn load(db: &Db) -> Result<Self, ripgrow_core::Error> {
58 + let exercises = db.list_exercises()?;
59 + let mut list_state = ListState::default();
60 + if !exercises.is_empty() {
61 + list_state.select(Some(0));
62 + }
63 + Ok(Self {
64 + date: Local::now().date_naive(),
65 + exercises,
66 + picker: PickState {
67 + query: String::new(),
68 + list_state,
69 + },
70 + grid: None,
71 + status: String::new(),
72 + })
73 + }
74 +
75 + pub fn on_key(&mut self, db: &Db, key: KeyEvent) {
76 + if self.grid.is_some() {
77 + self.on_grid_key(db, key);
78 + } else {
79 + self.on_pick_key(key);
80 + }
81 + }
82 +
83 + // ---- picker sub-mode ------------------------------------------------
84 +
85 + fn filtered_indices(&self) -> Vec<usize> {
86 + let q = self.picker.query.trim().to_lowercase();
87 + if q.is_empty() {
88 + (0..self.exercises.len()).collect()
89 + } else {
90 + self.exercises
91 + .iter()
92 + .enumerate()
93 + .filter(|(_, e)| subsequence_match(&e.name.to_lowercase(), &q))
94 + .map(|(i, _)| i)
95 + .collect()
96 + }
97 + }
98 +
99 + fn on_pick_key(&mut self, key: KeyEvent) {
100 + match key.code {
101 + KeyCode::Esc => {
102 + self.picker.query.clear();
103 + }
104 + KeyCode::Enter => {
105 + let filt = self.filtered_indices();
106 + if let Some(row) = self.picker.list_state.selected()
107 + && let Some(ex_idx) = filt.get(row)
108 + && let Some(ex) = self.exercises.get(*ex_idx)
109 + {
110 + self.enter_grid(ex.clone());
111 + }
112 + }
113 + KeyCode::Down => self.move_pick(1),
114 + KeyCode::Up => self.move_pick(-1),
115 + KeyCode::Char('[') if key.modifiers == KeyModifiers::NONE => {
116 + self.date -= Duration::days(1);
117 + }
118 + KeyCode::Char(']') if key.modifiers == KeyModifiers::NONE => {
119 + self.date += Duration::days(1);
120 + }
121 + KeyCode::Backspace => {
122 + self.picker.query.pop();
123 + self.picker.list_state.select(Some(0));
124 + }
125 + KeyCode::Char(c) => {
126 + self.picker.query.push(c);
127 + self.picker.list_state.select(Some(0));
128 + }
129 + _ => {}
130 + }
131 + }
132 +
133 + fn move_pick(&mut self, delta: isize) {
134 + let n = self.filtered_indices().len();
135 + if n == 0 {
136 + return;
137 + }
138 + let cur = self.picker.list_state.selected().unwrap_or(0) as isize;
139 + let next = (cur + delta).rem_euclid(n as isize) as usize;
140 + self.picker.list_state.select(Some(next));
141 + }
142 +
143 + 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 + });
153 + }
154 +
155 + fn refresh_grid(&mut self, db: &Db) {
156 + if let Some(g) = self.grid.as_mut()
157 + && let Ok(list) = db.list_sets_for_session(g.exercise.id, self.date)
158 + {
159 + g.committed = list;
160 + }
161 + }
162 +
163 + // ---- grid sub-mode --------------------------------------------------
164 +
165 + fn on_grid_key(&mut self, db: &Db, key: KeyEvent) {
166 + let Some(g) = self.grid.as_mut() else { return };
167 + match (key.code, key.modifiers) {
168 + (KeyCode::Esc, _) => {
169 + self.grid = None;
170 + return;
171 + }
172 + (KeyCode::Tab, _) => {
173 + g.focus = match g.focus {
174 + GridField::Load => GridField::Reps,
175 + GridField::Reps => GridField::Rpe,
176 + GridField::Rpe => GridField::Load,
177 + };
178 + return;
179 + }
180 + (KeyCode::BackTab, _) => {
181 + g.focus = match g.focus {
182 + GridField::Load => GridField::Rpe,
183 + GridField::Reps => GridField::Load,
184 + GridField::Rpe => GridField::Reps,
185 + };
186 + return;
187 + }
188 + (KeyCode::Enter, _) => {
189 + if let Err(e) = commit_pending(db, g, self.date) {
190 + self.status = format!("save failed: {e}");
191 + } else {
192 + self.status = "set saved".to_string();
193 + }
194 + self.refresh_grid(db);
195 + return;
196 + }
197 + (KeyCode::Backspace, KeyModifiers::CONTROL) => {
198 + if let Some(last) = g.committed.last().cloned() {
199 + match db.delete_set(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}"),
205 + }
206 + }
207 + return;
208 + }
209 + _ => {}
210 + }
211 +
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 + };
217 + match key.code {
218 + KeyCode::Backspace => {
219 + target.pop();
220 + }
221 + KeyCode::Char(c) => target.push(c),
222 + _ => {}
223 + }
224 + }
225 +
226 + // ---- rendering ------------------------------------------------------
227 +
228 + pub fn render(&mut self, frame: &mut Frame, area: Rect) {
229 + let chunks = Layout::default()
230 + .direction(Direction::Vertical)
231 + .constraints([
232 + Constraint::Length(1),
233 + Constraint::Min(3),
234 + Constraint::Length(1),
235 + ])
236 + .split(area);
237 +
238 + let date_hint = format!(
239 + "session: {} ([/] to change date)",
240 + self.date.format("%Y-%m-%d")
241 + );
242 + frame.render_widget(Paragraph::new(date_hint), chunks[0]);
243 +
244 + match &mut self.grid {
245 + Some(g) => render_grid(frame, chunks[1], g),
246 + None => render_picker(frame, chunks[1], &mut self.picker, &self.exercises),
247 + }
248 +
249 + let hint = match &self.grid {
250 + 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 + }
254 + };
255 + frame.render_widget(
256 + Paragraph::new(Line::from(vec![
257 + Span::raw(hint),
258 + Span::raw(" "),
259 + Span::styled(
260 + self.status.as_str(),
261 + Style::default().add_modifier(Modifier::DIM),
262 + ),
263 + ])),
264 + chunks[2],
265 + );
266 + }
267 + }
268 +
269 + fn commit_pending(
270 + db: &Db,
271 + g: &mut GridState,
272 + date: NaiveDate,
273 + ) -> Result<(), ripgrow_core::Error> {
274 + let load: f64 = parse_field("load", &g.pending.load)?;
275 + let reps: i32 = parse_field("reps", &g.pending.reps)?;
276 + let rpe: i32 = parse_field("rpe", &g.pending.rpe)?;
277 + db.append_set(g.exercise.id, date, load, reps, rpe)?;
278 + g.pending = PendingRow::default();
279 + g.focus = GridField::Load;
280 + Ok(())
281 + }
282 +
283 + fn parse_field<T: std::str::FromStr>(
284 + field: &'static str,
285 + raw: &str,
286 + ) -> Result<T, ripgrow_core::Error> {
287 + raw.trim()
288 + .parse()
289 + .map_err(|_| ripgrow_core::Error::ParseField {
290 + field,
291 + value: raw.to_string(),
292 + })
293 + }
294 +
295 + fn render_picker(
296 + frame: &mut Frame,
297 + area: Rect,
298 + picker: &mut PickState,
299 + exercises: &[Exercise],
300 + ) {
301 + let chunks = Layout::default()
302 + .direction(Direction::Vertical)
303 + .constraints([Constraint::Length(3), Constraint::Min(1)])
304 + .split(area);
305 +
306 + let query_block = Block::default().borders(Borders::ALL).title(" pick exercise ");
307 + let query = Paragraph::new(format!("> {}_", picker.query)).block(query_block);
308 + frame.render_widget(query, chunks[0]);
309 +
310 + let q = picker.query.trim().to_lowercase();
311 + let items: Vec<ListItem> = exercises
312 + .iter()
313 + .filter(|e| q.is_empty() || subsequence_match(&e.name.to_lowercase(), &q))
314 + .map(|e| ListItem::new(e.name.as_str()))
315 + .collect();
316 + let list = List::new(items)
317 + .block(Block::default().borders(Borders::ALL).title(" matches "))
318 + .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
319 + .highlight_symbol("> ");
320 + frame.render_stateful_widget(list, chunks[1], &mut picker.list_state);
321 + }
322 +
323 + fn render_grid(frame: &mut Frame, area: Rect, g: &GridState) {
324 + let block = Block::default()
325 + .borders(Borders::ALL)
326 + .title(format!(" {} ", g.exercise.name));
327 + let inner = block.inner(area);
328 + frame.render_widget(block, area);
329 +
330 + let header = format!(
331 + " # {:>8} {:>4} {:>3}",
332 + format!("load ({})", g.exercise.load_unit),
333 + "reps",
334 + "rpe"
335 + );
336 + let mut lines: Vec<Line> = vec![Line::from(Span::styled(
337 + header,
338 + Style::default().add_modifier(Modifier::BOLD),
339 + ))];
340 +
341 + for s in &g.committed {
342 + lines.push(Line::from(format!(
343 + " {:<3} {:>8} {:>4} {:>3}",
344 + s.set_index, s.load, s.reps, s.rpe
345 + )));
346 + }
347 +
348 + let load_span = focused_span(&g.pending.load, g.focus == GridField::Load, 8);
349 + let reps_span = focused_span(&g.pending.reps, g.focus == GridField::Reps, 4);
350 + let rpe_span = focused_span(&g.pending.rpe, g.focus == GridField::Rpe, 3);
351 + let next_idx = g.committed.last().map(|s| s.set_index + 1).unwrap_or(1);
352 + lines.push(Line::from(vec![
353 + Span::raw(format!(" {:<3} ", next_idx)),
354 + load_span,
355 + Span::raw(" "),
356 + reps_span,
357 + Span::raw(" "),
358 + rpe_span,
359 + ]));
360 +
361 + frame.render_widget(Paragraph::new(lines), inner);
362 + }
363 +
364 + fn focused_span(value: &str, focused: bool, width: usize) -> Span<'_> {
365 + let content = format!("{:>width$}", value, width = width);
366 + if focused {
367 + Span::styled(content, Style::default().add_modifier(Modifier::REVERSED))
368 + } else {
369 + Span::raw(content)
370 + }
371 + }
372 +
373 + /// Case-insensitive subsequence match. `"sq"` matches `"squat"` and
374 + /// `"back squat"` but not `"pushup"`.
375 + fn subsequence_match(haystack: &str, needle: &str) -> bool {
376 + let mut chars = haystack.chars();
377 + needle.chars().all(|nc| chars.any(|hc| hc == nc))
378 + }
379 +
380 + #[cfg(test)]
381 + mod tests {
382 + use super::*;
383 + use crossterm::event::KeyEvent;
384 + use ripgrow_core::{ResistanceType, Unit};
385 +
386 + fn key(c: KeyCode) -> KeyEvent {
387 + KeyEvent::new(c, KeyModifiers::empty())
388 + }
389 +
390 + fn setup() -> (Db, i64) {
391 + let db = Db::open_in_memory().unwrap();
392 + db.init_profile("self", Unit::Kg).unwrap();
393 + let sq = db
394 + .create_exercise("squat", ResistanceType::Freeweight, "kg", 2.5, &[])
395 + .unwrap();
396 + db.create_exercise("bench", ResistanceType::Freeweight, "kg", 2.5, &[])
397 + .unwrap();
398 + db.create_exercise("row", ResistanceType::Freeweight, "kg", 2.5, &[])
399 + .unwrap();
400 + (db, sq)
401 + }
402 +
403 + #[test]
404 + fn subsequence_match_examples() {
405 + assert!(subsequence_match("squat", "sq"));
406 + assert!(subsequence_match("back squat", "bsq"));
407 + assert!(!subsequence_match("pushup", "sq"));
408 + }
409 +
410 + #[test]
411 + fn typing_filters_and_enter_selects() {
412 + let (db, sq) = setup();
413 + let mut screen = LogScreen::load(&db).unwrap();
414 + for c in "sq".chars() {
415 + screen.on_key(&db, key(KeyCode::Char(c)));
416 + }
417 + assert_eq!(screen.filtered_indices().len(), 1);
418 + screen.on_key(&db, key(KeyCode::Enter));
419 + assert!(screen.grid.is_some());
420 + assert_eq!(screen.grid.as_ref().unwrap().exercise.id, sq);
421 + }
422 +
423 + #[test]
424 + fn enter_on_last_column_commits_set() {
425 + let (db, sq) = setup();
426 + let mut screen = LogScreen::load(&db).unwrap();
427 + for c in "squat".chars() {
428 + screen.on_key(&db, key(KeyCode::Char(c)));
429 + }
430 + screen.on_key(&db, key(KeyCode::Enter));
431 + // Now in grid. Type load, tab, reps, tab, rpe, enter.
432 + for c in "100".chars() {
433 + screen.on_key(&db, key(KeyCode::Char(c)));
434 + }
435 + screen.on_key(&db, key(KeyCode::Tab));
436 + for c in "5".chars() {
437 + screen.on_key(&db, key(KeyCode::Char(c)));
438 + }
439 + screen.on_key(&db, key(KeyCode::Tab));
440 + for c in "3".chars() {
441 + screen.on_key(&db, key(KeyCode::Char(c)));
442 + }
443 + screen.on_key(&db, key(KeyCode::Enter));
444 +
445 + let today = chrono::Local::now().date_naive();
446 + let list = db.list_sets_for_session(sq, today).unwrap();
447 + assert_eq!(list.len(), 1);
448 + assert_eq!(list[0].load, 100.0);
449 + assert_eq!(list[0].reps, 5);
450 + assert_eq!(list[0].rpe, 3);
451 + // Pending row should be cleared and focus back on Load.
452 + let g = screen.grid.as_ref().unwrap();
453 + assert!(g.pending.load.is_empty());
454 + assert_eq!(g.focus, GridField::Load);
455 + }
456 +
457 + #[test]
458 + fn ctrl_backspace_removes_last_committed_set() {
459 + let (db, sq) = setup();
460 + let today = chrono::Local::now().date_naive();
461 + db.append_set(sq, today, 100.0, 5, 3).unwrap();
462 + db.append_set(sq, today, 100.0, 5, 3).unwrap();
463 + let mut screen = LogScreen::load(&db).unwrap();
464 + for c in "squat".chars() {
465 + screen.on_key(&db, key(KeyCode::Char(c)));
466 + }
467 + screen.on_key(&db, key(KeyCode::Enter));
468 + screen.refresh_grid(&db);
469 + assert_eq!(screen.grid.as_ref().unwrap().committed.len(), 2);
470 + screen.on_key(
471 + &db,
472 + KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL),
473 + );
474 + assert_eq!(db.list_sets_for_session(sq, today).unwrap().len(), 1);
475 + }
476 +
477 + #[test]
478 + fn bracket_keys_change_date() {
479 + let db = Db::open_in_memory().unwrap();
480 + db.init_profile("self", Unit::Kg).unwrap();
481 + let mut screen = LogScreen::load(&db).unwrap();
482 + let start = screen.date;
483 + screen.on_key(&db, key(KeyCode::Char('[')));
484 + assert_eq!(screen.date, start - Duration::days(1));
485 + screen.on_key(&db, key(KeyCode::Char(']')));
486 + screen.on_key(&db, key(KeyCode::Char(']')));
487 + assert_eq!(screen.date, start + Duration::days(1));
488 + }
489 +
490 + #[test]
491 + fn esc_from_grid_returns_to_picker() {
492 + let (db, _sq) = setup();
493 + let mut screen = LogScreen::load(&db).unwrap();
494 + for c in "squat".chars() {
495 + screen.on_key(&db, key(KeyCode::Char(c)));
496 + }
497 + screen.on_key(&db, key(KeyCode::Enter));
498 + assert!(screen.grid.is_some());
499 + screen.on_key(&db, key(KeyCode::Esc));
500 + assert!(screen.grid.is_none());
Lines truncated
@@ -1,2 +1,3 @@
1 + pub mod log;
1 2 pub mod profile_picker;
2 3 pub mod templates;