Skip to main content

max / ripgrow

templates: Exercise.load_unit is a LoadUnit enum, not a raw string The exercise's load unit is now typed. list_exercises parses the column at decode time; create_exercise / update_exercise take LoadUnit. The templates edit-form still keeps a String buffer for the text field (that's what the user is typing) and parses on save, which is also where the "unknown unit" error surfaces. Cardio's placeholder unit ("min"/"km") stops passing here — LoadUnit is intentionally Kg/Lb only. That world moves to per-kind implicit units when phase 4 introduces timed_sets and distance_sets tables; the cardio pdf test now uses kg as a placeholder with a comment tying it to that follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 18:34 UTC
Commit: 5fcdf7d6f6c4b4815703eb59b13548989f546def
Parent: 0fe0dc9
11 files changed, +54 insertions, -47 deletions
@@ -254,7 +254,7 @@ mod tests {
254 254 id,
255 255 name: name.to_string(),
256 256 resistance_type: ResistanceType::Freeweight,
257 - load_unit: "kg".to_string(),
257 + load_unit: LoadUnit::Kg,
258 258 increment: 2.5,
259 259 notes: String::new(),
260 260 tag_ids: tags,
@@ -408,10 +408,10 @@ mod tests {
408 408 fn days_since_last_map_populated_only_for_logged_exercises() {
409 409 let db = setup();
410 410 let a = db
411 - .create_exercise("a", ResistanceType::Freeweight, "kg", 2.5, &[])
411 + .create_exercise("a", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
412 412 .unwrap();
413 413 let _b = db
414 - .create_exercise("b", ResistanceType::Freeweight, "kg", 2.5, &[])
414 + .create_exercise("b", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
415 415 .unwrap();
416 416 let d = NaiveDate::from_ymd_opt(2026, 7, 10).unwrap();
417 417 db.append_set(a, d, Load::new(100.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(3).unwrap()).unwrap();
@@ -372,7 +372,7 @@ mod db_tests {
372 372 let db = Db::open_in_memory().unwrap();
373 373 db.init_profile("self", LoadUnit::Kg).unwrap();
374 374 let id = db
375 - .create_exercise("squat", ResistanceType::Freeweight, "kg", 2.5, &[])
375 + .create_exercise("squat", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
376 376 .unwrap();
377 377 (db, id)
378 378 }
@@ -153,7 +153,6 @@ pub fn seed_starter_content(db: &Db, unit: LoadUnit) -> Result<(), Error> {
153 153 db.upsert_tag(tag)?;
154 154 }
155 155 let existing = db.list_exercises()?;
156 - let unit_str = unit.as_str();
157 156 for seed in SEED_EXERCISES {
158 157 if existing.iter().any(|e| e.name == seed.name) {
159 158 continue;
@@ -167,7 +166,7 @@ pub fn seed_starter_content(db: &Db, unit: LoadUnit) -> Result<(), Error> {
167 166 db.create_exercise(
168 167 seed.name,
169 168 seed.resistance_type,
170 - unit_str,
169 + unit,
171 170 increment,
172 171 &tag_ids,
173 172 )?;
@@ -226,7 +225,7 @@ mod tests {
226 225 .into_iter()
227 226 .find(|e| e.name == "back squat")
228 227 .unwrap();
229 - assert_eq!(squat.load_unit, "lb");
228 + assert_eq!(squat.load_unit, LoadUnit::Lb);
230 229 assert_eq!(squat.increment, 5.0);
231 230
232 231 let leg_press = db
@@ -260,7 +260,7 @@ mod tests {
260 260 let db = Db::open_in_memory().unwrap();
261 261 db.init_profile("self", LoadUnit::Kg).unwrap();
262 262 let id = db
263 - .create_exercise("squat", ResistanceType::Freeweight, "kg", 2.5, &[])
263 + .create_exercise("squat", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
264 264 .unwrap();
265 265 (db, id)
266 266 }
@@ -296,7 +296,7 @@ mod tests {
296 296 fn list_isolates_by_date_and_exercise() {
297 297 let (db, ex1) = setup();
298 298 let ex2 = db
299 - .create_exercise("bench", ResistanceType::Freeweight, "kg", 2.5, &[])
299 + .create_exercise("bench", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
300 300 .unwrap();
301 301 let d1 = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
302 302 let d2 = NaiveDate::from_ymd_opt(2026, 7, 19).unwrap();
@@ -8,6 +8,7 @@ use rusqlite::{OptionalExtension, params};
8 8
9 9 use crate::db::Db;
10 10 use crate::error::Error;
11 + use crate::values::LoadUnit;
11 12
12 13 /// Resistance type is a UI hint. It determines how the log screen renders
13 14 /// input, not how the schema stores rows.
@@ -62,7 +63,7 @@ pub struct Exercise {
62 63 pub id: i64,
63 64 pub name: String,
64 65 pub resistance_type: ResistanceType,
65 - pub load_unit: String,
66 + pub load_unit: LoadUnit,
66 67 pub increment: f64,
67 68 pub notes: String,
68 69 pub tag_ids: Vec<i64>,
@@ -141,7 +142,7 @@ impl Db {
141 142 id,
142 143 name,
143 144 resistance_type: ResistanceType::parse(&rt)?,
144 - load_unit,
145 + load_unit: LoadUnit::parse(&load_unit)?,
145 146 increment,
146 147 notes,
147 148 tag_ids: self.tag_ids_for(id)?,
@@ -162,7 +163,7 @@ impl Db {
162 163 &self,
163 164 name: &str,
164 165 resistance_type: ResistanceType,
165 - load_unit: &str,
166 + load_unit: LoadUnit,
166 167 increment: f64,
167 168 tag_ids: &[i64],
168 169 ) -> Result<i64, Error> {
@@ -173,7 +174,7 @@ impl Db {
173 174 self.conn().execute(
174 175 "INSERT INTO exercises (name, resistance_type, load_unit, increment) \
175 176 VALUES (?1, ?2, ?3, ?4)",
176 - params![name, resistance_type.as_str(), load_unit, increment],
177 + params![name, resistance_type.as_str(), load_unit.as_str(), increment],
177 178 )?;
178 179 let id = self.conn().last_insert_rowid();
179 180 self.write_tag_ids(id, tag_ids)?;
@@ -185,7 +186,7 @@ impl Db {
185 186 id: i64,
186 187 name: &str,
187 188 resistance_type: ResistanceType,
188 - load_unit: &str,
189 + load_unit: LoadUnit,
189 190 increment: f64,
190 191 tag_ids: &[i64],
191 192 ) -> Result<(), Error> {
@@ -196,7 +197,7 @@ impl Db {
196 197 let n = self.conn().execute(
197 198 "UPDATE exercises SET name = ?1, resistance_type = ?2, load_unit = ?3, \
198 199 increment = ?4 WHERE id = ?5",
199 - params![name, resistance_type.as_str(), load_unit, increment, id],
200 + params![name, resistance_type.as_str(), load_unit.as_str(), increment, id],
200 201 )?;
201 202 if n == 0 {
202 203 return Err(Error::NotFound(format!("exercise {id}")));
@@ -266,7 +267,7 @@ mod tests {
266 267 let t1 = db.upsert_tag("chest").unwrap();
267 268 let t2 = db.upsert_tag("triceps").unwrap();
268 269 let id = db
269 - .create_exercise("bench", ResistanceType::Freeweight, "kg", 2.5, &[t1.id, t2.id])
270 + .create_exercise("bench", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[t1.id, t2.id])
270 271 .unwrap();
271 272 let list = db.list_exercises().unwrap();
272 273 assert_eq!(list.len(), 1);
@@ -285,9 +286,9 @@ mod tests {
285 286 let t2 = db.upsert_tag("b").unwrap();
286 287 let t3 = db.upsert_tag("c").unwrap();
287 288 let id = db
288 - .create_exercise("x", ResistanceType::Machine, "kg", 5.0, &[t1.id, t2.id])
289 + .create_exercise("x", ResistanceType::Machine, LoadUnit::Kg, 5.0, &[t1.id, t2.id])
289 290 .unwrap();
290 - db.update_exercise(id, "x", ResistanceType::Machine, "kg", 5.0, &[t3.id])
291 + db.update_exercise(id, "x", ResistanceType::Machine, LoadUnit::Kg, 5.0, &[t3.id])
291 292 .unwrap();
292 293 let list = db.list_exercises().unwrap();
293 294 assert_eq!(list[0].tag_ids, vec![t3.id]);
@@ -298,7 +299,7 @@ mod tests {
298 299 let db = setup();
299 300 let t = db.upsert_tag("t").unwrap();
300 301 let id = db
301 - .create_exercise("y", ResistanceType::Bodyweight, "reps", 0.0, &[t.id])
302 + .create_exercise("y", ResistanceType::Bodyweight, LoadUnit::Kg, 0.0, &[t.id])
302 303 .unwrap();
303 304 db.delete_exercise(id).unwrap();
304 305 assert!(db.list_exercises().unwrap().is_empty());
@@ -314,7 +315,7 @@ mod tests {
314 315 fn delete_blocked_when_sets_exist() {
315 316 let db = setup();
316 317 let id = db
317 - .create_exercise("z", ResistanceType::Freeweight, "kg", 2.5, &[])
318 + .create_exercise("z", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
318 319 .unwrap();
319 320 db.conn()
320 321 .execute(
@@ -330,13 +331,13 @@ mod tests {
330 331 fn create_rejects_empty_name_and_duplicate() {
331 332 let db = setup();
332 333 assert!(
333 - db.create_exercise(" ", ResistanceType::Machine, "kg", 5.0, &[])
334 + db.create_exercise(" ", ResistanceType::Machine, LoadUnit::Kg, 5.0, &[])
334 335 .is_err()
335 336 );
336 - db.create_exercise("dup", ResistanceType::Machine, "kg", 5.0, &[])
337 + db.create_exercise("dup", ResistanceType::Machine, LoadUnit::Kg, 5.0, &[])
337 338 .unwrap();
338 339 assert!(
339 - db.create_exercise("dup", ResistanceType::Machine, "kg", 5.0, &[])
340 + db.create_exercise("dup", ResistanceType::Machine, LoadUnit::Kg, 5.0, &[])
340 341 .is_err(),
341 342 "UNIQUE(name) should block"
342 343 );
@@ -62,19 +62,19 @@ impl PrescriptionParts {
62 62 sets: p.sets.to_string(),
63 63 reps: p.reps.to_string(),
64 64 load: format!("BW+{}", trim(p.load.get())),
65 - unit: ex.load_unit.clone(),
65 + unit: ex.load_unit.as_str().to_string(),
66 66 },
67 67 ResistanceType::CardioTime | ResistanceType::CardioDistance => Self {
68 68 sets: "-".to_string(),
69 69 reps: "-".to_string(),
70 70 load: trim(p.load.get()),
71 - unit: ex.load_unit.clone(),
71 + unit: ex.load_unit.as_str().to_string(),
72 72 },
73 73 ResistanceType::Freeweight | ResistanceType::Machine => Self {
74 74 sets: p.sets.to_string(),
75 75 reps: p.reps.to_string(),
76 76 load: trim(p.load.get()),
77 - unit: ex.load_unit.clone(),
77 + unit: ex.load_unit.as_str().to_string(),
78 78 },
79 79 }
80 80 }
@@ -260,14 +260,14 @@ fn scratch_dir() -> Result<PathBuf, std::io::Error> {
260 260 #[cfg(test)]
261 261 mod tests {
262 262 use super::*;
263 - use ripgrow_core::{Prescription, ResistanceType, State};
263 + use ripgrow_core::{LoadUnit, Prescription, ResistanceType, State};
264 264
265 265 fn ex(name: &str, rt: ResistanceType, unit: &str) -> Exercise {
266 266 Exercise {
267 267 id: 0,
268 268 name: name.to_string(),
269 269 resistance_type: rt,
270 - load_unit: unit.to_string(),
270 + load_unit: LoadUnit::parse(unit).unwrap(),
271 271 increment: 2.5,
272 272 notes: String::new(),
273 273 tag_ids: vec![],
@@ -326,14 +326,20 @@ mod tests {
326 326
327 327 #[test]
328 328 fn parts_cardio() {
329 + // LoadUnit is currently Kg/Lb only. Cardio exercises will move to
330 + // their own effort kind in phase 4 (timed_sets / distance_sets),
331 + // at which point the "min"/"km" world moves off Exercise.load_unit
332 + // and lives on the kind itself. For now the placeholder unit is
333 + // kg; the test still verifies that cardio prescriptions elide the
334 + // set/rep count and just print load + unit.
329 335 let parts = PrescriptionParts::from(
330 - &ex("row", ResistanceType::CardioTime, "min"),
336 + &ex("row", ResistanceType::CardioTime, "kg"),
331 337 &p(1, 1, 12.0),
332 338 );
333 339 assert_eq!(parts.sets, "-");
334 340 assert_eq!(parts.reps, "-");
335 341 assert_eq!(parts.load, "12");
336 - assert_eq!(parts.unit, "min");
342 + assert_eq!(parts.unit, "kg");
337 343 }
338 344
339 345 #[test]
@@ -610,9 +610,9 @@ mod tests {
610 610 let db = Db::open_in_memory().unwrap();
611 611 db.init_profile("self", LoadUnit::Kg).unwrap();
612 612 let sq = db
613 - .create_exercise("squat", ResistanceType::Freeweight, "kg", 2.5, &[])
613 + .create_exercise("squat", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
614 614 .unwrap();
615 - db.create_exercise("bench", ResistanceType::Freeweight, "kg", 2.5, &[])
615 + db.create_exercise("bench", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
616 616 .unwrap();
617 617 (db, sq)
618 618 }
@@ -484,7 +484,7 @@ fn prescription_for(db: &Db, ex: &Exercise) -> (String, &'static str) {
484 484 match db.compute_prescription(ex.id) {
485 485 Ok(PrescriptionResult::NoHistory) => ("seed on first log".to_string(), " "),
486 486 Ok(PrescriptionResult::Prescribed(p)) => {
487 - let text = format_prescription(&p, &ex.load_unit);
487 + let text = format_prescription(&p, ex.load_unit.as_str());
488 488 let glyph = direction_glyph(db, ex, p.load.get());
489 489 (text, glyph)
490 490 }
@@ -532,13 +532,13 @@ mod tests {
532 532 let t2 = db.upsert_tag("back").unwrap();
533 533 let t3 = db.upsert_tag("legs").unwrap();
534 534 let a = db
535 - .create_exercise("bench", ResistanceType::Freeweight, "kg", 2.5, &[t1.id])
535 + .create_exercise("bench", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[t1.id])
536 536 .unwrap();
537 537 let b = db
538 - .create_exercise("row", ResistanceType::Freeweight, "kg", 2.5, &[t2.id])
538 + .create_exercise("row", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[t2.id])
539 539 .unwrap();
540 540 let c = db
541 - .create_exercise("squat", ResistanceType::Freeweight, "kg", 2.5, &[t3.id])
541 + .create_exercise("squat", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[t3.id])
542 542 .unwrap();
543 543 (db, vec![t1.id, t2.id, t3.id], vec![a, b, c])
544 544 }
@@ -255,7 +255,7 @@ mod tests {
255 255 let db = Db::open_in_memory().unwrap();
256 256 db.init_profile("self", LoadUnit::Kg).unwrap();
257 257 let id = db
258 - .create_exercise("squat", ResistanceType::Freeweight, "kg", 2.5, &[])
258 + .create_exercise("squat", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
259 259 .unwrap();
260 260 (db, id)
261 261 }
@@ -274,9 +274,9 @@ mod tests {
274 274 fn j_k_wraps_selection() {
275 275 let db = Db::open_in_memory().unwrap();
276 276 db.init_profile("self", LoadUnit::Kg).unwrap();
277 - db.create_exercise("a", ResistanceType::Freeweight, "kg", 2.5, &[])
277 + db.create_exercise("a", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
278 278 .unwrap();
279 - db.create_exercise("b", ResistanceType::Freeweight, "kg", 2.5, &[])
279 + db.create_exercise("b", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
280 280 .unwrap();
281 281 let mut screen = HistoryScreen::load(&db).unwrap();
282 282 assert_eq!(screen.list_state.selected(), Some(0));
@@ -391,11 +391,11 @@ mod tests {
391 391 let db = Db::open_in_memory().unwrap();
392 392 db.init_profile("self", LoadUnit::Kg).unwrap();
393 393 let sq = db
394 - .create_exercise("squat", ResistanceType::Freeweight, "kg", 2.5, &[])
394 + .create_exercise("squat", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
395 395 .unwrap();
396 - db.create_exercise("bench", ResistanceType::Freeweight, "kg", 2.5, &[])
396 + db.create_exercise("bench", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
397 397 .unwrap();
398 - db.create_exercise("row", ResistanceType::Freeweight, "kg", 2.5, &[])
398 + db.create_exercise("row", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
399 399 .unwrap();
400 400 (db, sq)
401 401 }
@@ -7,7 +7,7 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect};
7 7 use ratatui::style::{Modifier, Style};
8 8 use ratatui::text::{Line, Span};
9 9 use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
10 - use ripgrow_core::{Db, Exercise, ResistanceType, Tag};
10 + use ripgrow_core::{Db, Exercise, LoadUnit, ResistanceType, Tag};
11 11
12 12 pub struct TemplatesScreen {
13 13 pub exercises: Vec<Exercise>,
@@ -304,7 +304,7 @@ impl EditForm {
304 304 editing_id: Some(ex.id),
305 305 name: ex.name.clone(),
306 306 resistance_type: ex.resistance_type,
307 - load_unit: ex.load_unit.clone(),
307 + load_unit: ex.load_unit.as_str().to_string(),
308 308 increment: format!("{}", ex.increment),
309 309 selected_tag_ids: ex.tag_ids.clone(),
310 310 all_tags: Vec::new(),
@@ -428,12 +428,13 @@ impl EditForm {
428 428 .trim()
429 429 .parse()
430 430 .map_err(|_| ripgrow_core::Error::InvalidExerciseName)?;
431 + let load_unit = LoadUnit::parse(self.load_unit.trim())?;
431 432 match self.editing_id {
432 433 Some(id) => db.update_exercise(
433 434 id,
434 435 &self.name,
435 436 self.resistance_type,
436 - &self.load_unit,
437 + load_unit,
437 438 increment,
438 439 &self.selected_tag_ids,
439 440 ),
@@ -441,7 +442,7 @@ impl EditForm {
441 442 .create_exercise(
442 443 &self.name,
443 444 self.resistance_type,
444 - &self.load_unit,
445 + load_unit,
445 446 increment,
446 447 &self.selected_tag_ids,
447 448 )
@@ -639,7 +640,7 @@ mod tests {
639 640 #[test]
640 641 fn delete_removes_selected_exercise() {
641 642 let db = fresh_db();
642 - db.create_exercise("a", ResistanceType::Machine, "kg", 5.0, &[])
643 + db.create_exercise("a", ResistanceType::Machine, LoadUnit::Kg, 5.0, &[])
643 644 .unwrap();
644 645 let mut screen = TemplatesScreen::load(&db).unwrap();
645 646 let action = screen.on_key(&db, key(KeyCode::Char('d')));