Skip to main content

max / ripgrow

templates: block an effort-kind change that would strand logged sets update_exercise derives effort_kind from resistance_type on every edit, so changing an exercise from freeweight to cardio_time flipped it from reps to timed while its rows stayed in reps_sets. The history screen dispatches on effort_kind, so the logged sets silently vanished and progression read an empty series against a table the exercise no longer pointed at. Reject the update when the kind would change and any effort row of any kind references the exercise, mirroring how delete_exercise is blocked by FK RESTRICT. Changing resistance type within a kind (freeweight -> machine) is still free, because no rows move. The two columns stay two axes on purpose: resistance_type drives log-screen input rendering, effort_kind selects the storage table, and the planned create_exercise_with_kind override needs them independent.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-27 15:26 UTC
Signed with PGP, not checked
Commit: 3210d59da7aee8eca59066f18270de46206b34c0
Parent: 0d71ef3
2 files changed, +121 insertions, -0 deletions
@@ -25,6 +25,16 @@
25 25 payload: &'static str,
26 26 },
27 27
28 + #[error(
29 + "cannot change exercise {exercise} from effort kind {from} to {to}: \
30 + logged sets exist and would be stranded in the {from} table"
31 + )]
32 + EffortKindChangeBlocked {
33 + exercise: i64,
34 + from: &'static str,
35 + to: &'static str,
36 + },
37 +
28 38 #[error("tag name cannot be empty")]
29 39 InvalidTagName,
30 40
@@ -172,6 +172,24 @@
172 172 Ok(out)
173 173 }
174 174
175 + /// True when any effort row of any kind references this exercise.
176 + /// Checked across all three tables rather than just the current kind's,
177 + /// so a row left behind by an earlier kind still counts as history.
178 + fn has_logged_sets(&self, exercise_id: i64) -> Result<bool, Error> {
179 + let exists: i64 = self.conn().query_row(
180 + "SELECT EXISTS(
181 + SELECT 1 FROM reps_sets WHERE exercise_id = ?1
182 + UNION ALL
183 + SELECT 1 FROM timed_sets WHERE exercise_id = ?1
184 + UNION ALL
185 + SELECT 1 FROM distance_sets WHERE exercise_id = ?1
186 + )",
187 + params![exercise_id],
188 + |row| row.get(0),
189 + )?;
190 + Ok(exists != 0)
191 + }
192 +
175 193 fn tag_ids_for(&self, exercise_id: i64) -> Result<Vec<i64>, Error> {
176 194 let mut stmt = self.conn().prepare(
177 195 "SELECT tag_id FROM exercise_tags WHERE exercise_id = ?1 ORDER BY tag_id",
@@ -224,6 +242,34 @@
224 242 return Err(Error::InvalidExerciseName);
225 243 }
226 244 let effort_kind = default_effort_kind(resistance_type);
245 +
246 + // `effort_kind` decides which per-kind table holds this exercise's
247 + // sets, and it is derived from `resistance_type`. So editing an
248 + // exercise across a kind boundary silently repoints it at a table its
249 + // history is not in: the history screen dispatches on `effort_kind`
250 + // and would show nothing, and progression would read an empty series
251 + // while the real rows sit in the old table. Block it the way
252 + // `delete_exercise` blocks deleting an exercise that has history.
253 + // Changing resistance type *within* a kind (freeweight -> machine)
254 + // stays free, because the rows do not move.
255 + let current_kind: EffortKind = self
256 + .conn()
257 + .query_row(
258 + "SELECT effort_kind FROM exercises WHERE id = ?1",
259 + params![id],
260 + |row| row.get::<_, String>(0),
261 + )
262 + .optional()?
263 + .ok_or_else(|| Error::NotFound(format!("exercise {id}")))
264 + .and_then(|s| EffortKind::parse(&s))?;
265 + if current_kind != effort_kind && self.has_logged_sets(id)? {
266 + return Err(Error::EffortKindChangeBlocked {
267 + exercise: id,
268 + from: current_kind.as_str(),
269 + to: effort_kind.as_str(),
270 + });
271 + }
272 +
227 273 let n = self.conn().execute(
228 274 "UPDATE exercises SET name = ?1, resistance_type = ?2, \
229 275 effort_kind = ?3, load_unit = ?4, increment = ?5 WHERE id = ?6",
@@ -409,6 +455,71 @@
409 455 assert!(db.delete_exercise(id).is_err(), "FK RESTRICT should block");
410 456 }
411 457
458 + #[test]
459 + fn update_blocked_when_effort_kind_would_change_with_history() {
460 + let db = setup();
461 + let id = db
462 + .create_exercise("row", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
463 + .unwrap();
464 + db.conn()
465 + .execute(
466 + "INSERT INTO reps_sets (session_date, exercise_id, set_index, load, reps, rpe) \
467 + VALUES ('2026-07-18', ?1, 1, 100.0, 5, 3)",
468 + params![id],
469 + )
470 + .unwrap();
471 +
472 + // Freeweight -> CardioTime crosses reps -> timed. The logged set stays
473 + // in reps_sets, so letting this through would strand the history in a
474 + // table the exercise no longer reads.
475 + let err = db
476 + .update_exercise(id, "row", ResistanceType::CardioTime, LoadUnit::Kg, 2.5, &[])
477 + .unwrap_err();
478 + assert!(
479 + matches!(err, Error::EffortKindChangeBlocked { .. }),
480 + "expected EffortKindChangeBlocked, got {err:?}"
481 + );
482 +
483 + // And the row is untouched.
484 + let ex = db.list_exercises().unwrap().into_iter().find(|e| e.id == id).unwrap();
485 + assert_eq!(ex.resistance_type, ResistanceType::Freeweight);
486 + assert_eq!(ex.effort_kind, EffortKind::Reps);
487 + }
488 +
489 + #[test]
490 + fn update_allowed_when_effort_kind_unchanged_despite_history() {
491 + let db = setup();
492 + let id = db
493 + .create_exercise("bench", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
494 + .unwrap();
495 + db.conn()
496 + .execute(
497 + "INSERT INTO reps_sets (session_date, exercise_id, set_index, load, reps, rpe) \
498 + VALUES ('2026-07-18', ?1, 1, 100.0, 5, 3)",
499 + params![id],
500 + )
501 + .unwrap();
502 +
503 + // Freeweight -> Machine is still reps, so history stays valid.
504 + db.update_exercise(id, "incline", ResistanceType::Machine, LoadUnit::Kg, 2.5, &[])
505 + .unwrap();
506 + let ex = db.list_exercises().unwrap().into_iter().find(|e| e.id == id).unwrap();
507 + assert_eq!(ex.resistance_type, ResistanceType::Machine);
508 + assert_eq!(ex.effort_kind, EffortKind::Reps);
509 + }
510 +
511 + #[test]
512 + fn update_allowed_across_kinds_when_no_history() {
513 + let db = setup();
514 + let id = db
515 + .create_exercise("erg", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
516 + .unwrap();
517 + db.update_exercise(id, "erg", ResistanceType::CardioTime, LoadUnit::Kg, 2.5, &[])
518 + .unwrap();
519 + let ex = db.list_exercises().unwrap().into_iter().find(|e| e.id == id).unwrap();
520 + assert_eq!(ex.effort_kind, EffortKind::Timed);
521 + }
522 +
412 523 #[test]
413 524 fn create_rejects_empty_name_and_duplicate() {
414 525 let db = setup();