Skip to main content

max / ripgrow

effort: migration 003 splits sets by kind; add EffortKind discriminant Migration 003 renames sets -> reps_sets, adds exercises.effort_kind (defaulted to 'reps' for every existing row), and creates empty timed_sets and distance_sets tables so the per-kind DB layers can land without another migration. crate::effort::EffortKind is the discriminant. Every Exercise now carries one; default_effort_kind() maps a UI-side ResistanceType to its data-model kind (Bodyweight/Machine/Freeweight -> Reps, cardio -> Timed or Distance). Existing behavior is preserved; only reps runs today. Follow-up slices introduce the Kind trait, per-kind protocols, and dispatch enums for the callers that need to speak across kinds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 18:55 UTC
Commit: ec35639b11ac6719f1630d65d8f133abb85c7c59
Parent: dee68bf
8 files changed, +225 insertions, -28 deletions
@@ -12,7 +12,7 @@ use rusqlite::{Connection, OptionalExtension, params};
12 12 use crate::error::Error;
13 13 use crate::values::LoadUnit;
14 14
15 - const MIGRATIONS: &[&str] = &[MIGRATION_001, MIGRATION_002];
15 + const MIGRATIONS: &[&str] = &[MIGRATION_001, MIGRATION_002, MIGRATION_003];
16 16
17 17 // Historically `Unit` lived here as the profile's weight-unit preference.
18 18 // It moved into `values` as `LoadUnit` (same type, better name — it also
@@ -169,6 +169,56 @@ ALTER TABLE sets ADD COLUMN is_diagnostic INTEGER NOT NULL DEFAULT 0
169 169 CREATE INDEX idx_sets_exercise_diagnostic ON sets(exercise_id, is_diagnostic);
170 170 "#;
171 171
172 + // Phase 4a: split effort into per-kind tables. Rename `sets` to
173 + // `reps_sets` (that's what all existing rows are), stamp every existing
174 + // exercise as effort_kind = 'reps', and create empty tables for the
175 + // timed and distance kinds so their DB layers can drop in without another
176 + // migration. The state machine, the log/history/diagnostic screens, and
177 + // the estimator continue to work off reps_sets exactly like they did
178 + // off sets; the only rewrite required here is s/sets/reps_sets/ in the
179 + // SQL of a handful of methods (done in the same commit).
180 + const MIGRATION_003: &str = r#"
181 + ALTER TABLE exercises ADD COLUMN effort_kind TEXT NOT NULL
182 + DEFAULT 'reps'
183 + CHECK (effort_kind IN ('reps', 'timed', 'distance'));
184 +
185 + ALTER TABLE sets RENAME TO reps_sets;
186 +
187 + CREATE TABLE timed_sets (
188 + id INTEGER PRIMARY KEY,
189 + session_date TEXT NOT NULL,
190 + exercise_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE RESTRICT,
191 + set_index INTEGER NOT NULL,
192 + duration_seconds INTEGER NOT NULL CHECK (duration_seconds >= 0),
193 + rpe INTEGER NOT NULL CHECK (rpe BETWEEN 1 AND 5),
194 + is_diagnostic INTEGER NOT NULL DEFAULT 0 CHECK (is_diagnostic IN (0, 1)),
195 + UNIQUE (session_date, exercise_id, set_index)
196 + );
197 +
198 + CREATE INDEX idx_timed_sets_session_date ON timed_sets(session_date);
199 + CREATE INDEX idx_timed_sets_exercise_date ON timed_sets(exercise_id, session_date);
200 + CREATE INDEX idx_timed_sets_exercise_diagnostic
201 + ON timed_sets(exercise_id, is_diagnostic);
202 +
203 + CREATE TABLE distance_sets (
204 + id INTEGER PRIMARY KEY,
205 + session_date TEXT NOT NULL,
206 + exercise_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE RESTRICT,
207 + set_index INTEGER NOT NULL,
208 + distance_meters REAL NOT NULL CHECK (distance_meters >= 0),
209 + duration_seconds INTEGER NOT NULL CHECK (duration_seconds >= 0),
210 + rpe INTEGER NOT NULL CHECK (rpe BETWEEN 1 AND 5),
211 + is_diagnostic INTEGER NOT NULL DEFAULT 0 CHECK (is_diagnostic IN (0, 1)),
212 + UNIQUE (session_date, exercise_id, set_index)
213 + );
214 +
215 + CREATE INDEX idx_distance_sets_session_date ON distance_sets(session_date);
216 + CREATE INDEX idx_distance_sets_exercise_date
217 + ON distance_sets(exercise_id, session_date);
218 + CREATE INDEX idx_distance_sets_exercise_diagnostic
219 + ON distance_sets(exercise_id, is_diagnostic);
220 + "#;
221 +
172 222 #[cfg(test)]
173 223 mod tests {
174 224 use super::*;
@@ -204,7 +254,7 @@ mod tests {
204 254 )
205 255 .unwrap();
206 256 let err = db.conn.execute(
207 - "INSERT INTO sets (session_date, exercise_id, set_index, load, reps, rpe) \
257 + "INSERT INTO reps_sets (session_date, exercise_id, set_index, load, reps, rpe) \
208 258 VALUES ('2026-07-18', 1, 1, 100.0, 5, 6)",
209 259 [],
210 260 );
@@ -235,13 +285,13 @@ mod tests {
235 285 .unwrap();
236 286 db.conn
237 287 .execute(
238 - "INSERT INTO sets (session_date, exercise_id, set_index, load, reps, rpe) \
288 + "INSERT INTO reps_sets (session_date, exercise_id, set_index, load, reps, rpe) \
239 289 VALUES ('2026-07-18', 1, 1, 80.0, 5, 3)",
240 290 [],
241 291 )
242 292 .unwrap();
243 293 let err = db.conn.execute(
244 - "INSERT INTO sets (session_date, exercise_id, set_index, load, reps, rpe) \
294 + "INSERT INTO reps_sets (session_date, exercise_id, set_index, load, reps, rpe) \
245 295 VALUES ('2026-07-18', 1, 1, 82.5, 5, 3)",
246 296 [],
247 297 );
@@ -0,0 +1,58 @@
1 + //! Per-kind effort layer.
2 + //!
3 + //! Phase 4 splits the single `sets` table into one table per effort kind
4 + //! (`reps_sets`, `timed_sets`, `distance_sets`). Each kind gets its own
5 + //! typed row shape, its own SQL, and its own protocol logic; the shared
6 + //! state machine reasons purely in terms of `SessionOutcome` and doesn't
7 + //! care what the numbers represent.
8 + //!
9 + //! This slice (4a) introduces only the discriminant enum. The `Kind`
10 + //! trait, per-kind modules, and dispatch enums land in follow-up slices.
11 +
12 + use crate::error::Error;
13 +
14 + /// Discriminant carried on every exercise. Stored as text
15 + /// (`'reps'` / `'timed'` / `'distance'`) in `exercises.effort_kind`,
16 + /// which decides which per-kind sets table holds that exercise's history.
17 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18 + pub enum EffortKind {
19 + Reps,
20 + Timed,
21 + Distance,
22 + }
23 +
24 + impl EffortKind {
25 + pub fn as_str(&self) -> &'static str {
26 + match self {
27 + EffortKind::Reps => "reps",
28 + EffortKind::Timed => "timed",
29 + EffortKind::Distance => "distance",
30 + }
31 + }
32 +
33 + pub fn parse(s: &str) -> Result<Self, Error> {
34 + match s {
35 + "reps" => Ok(EffortKind::Reps),
36 + "timed" => Ok(EffortKind::Timed),
37 + "distance" => Ok(EffortKind::Distance),
38 + other => Err(Error::InvalidEffortKind(other.to_string())),
39 + }
40 + }
41 + }
42 +
43 + #[cfg(test)]
44 + mod tests {
45 + use super::*;
46 +
47 + #[test]
48 + fn round_trip() {
49 + for k in [EffortKind::Reps, EffortKind::Timed, EffortKind::Distance] {
50 + assert_eq!(EffortKind::parse(k.as_str()).unwrap(), k);
51 + }
52 + }
53 +
54 + #[test]
55 + fn parse_rejects_unknown() {
56 + assert!(EffortKind::parse("velocity").is_err());
57 + }
58 + }
@@ -14,6 +14,9 @@ pub enum Error {
14 14 #[error("invalid resistance type: {0}")]
15 15 InvalidResistanceType(String),
16 16
17 + #[error("invalid effort kind: {0}")]
18 + InvalidEffortKind(String),
19 +
17 20 #[error("tag name cannot be empty")]
18 21 InvalidTagName,
19 22
@@ -224,7 +224,7 @@ impl Db {
224 224 today: NaiveDate,
225 225 ) -> Result<HashMap<i64, i64>, Error> {
226 226 let mut stmt = self.conn().prepare(
227 - "SELECT exercise_id, MAX(session_date) FROM sets GROUP BY exercise_id",
227 + "SELECT exercise_id, MAX(session_date) FROM reps_sets GROUP BY exercise_id",
228 228 )?;
229 229 let rows = stmt.query_map([], |row| {
230 230 let id: i64 = row.get(0)?;
@@ -254,6 +254,7 @@ mod tests {
254 254 id,
255 255 name: name.to_string(),
256 256 resistance_type: ResistanceType::Freeweight,
257 + effort_kind: crate::EffortKind::Reps,
257 258 load_unit: LoadUnit::Kg,
258 259 increment: 2.5,
259 260 notes: String::new(),
@@ -5,6 +5,7 @@
5 5
6 6 pub mod db;
7 7 pub mod diagnostic;
8 + pub mod effort;
8 9 pub mod error;
9 10 pub mod estimator;
10 11 pub mod generation;
@@ -18,6 +19,7 @@ pub mod values;
18 19
19 20 pub use db::Db;
20 21 pub use diagnostic::{DiagnosticSet, DiagnosticStep, next_step as next_diagnostic_step};
22 + pub use effort::EffortKind;
21 23 pub use error::Error;
22 24 pub use estimator::estimate_e1rm;
23 25 pub use generation::{PickedSlot, Readiness};
@@ -68,7 +68,7 @@ impl Db {
68 68 ) -> Result<Vec<SessionSet>, Error> {
69 69 let mut stmt = self.conn().prepare(
70 70 "SELECT id, session_date, exercise_id, set_index, load, reps, rpe, is_diagnostic \
71 - FROM sets WHERE exercise_id = ?1 AND session_date = ?2 \
71 + FROM reps_sets WHERE exercise_id = ?1 AND session_date = ?2 \
72 72 ORDER BY set_index",
73 73 )?;
74 74 let rows = stmt.query_map(
@@ -118,13 +118,13 @@ impl Db {
118 118 let next_index: i32 = self
119 119 .conn()
120 120 .query_row(
121 - "SELECT COALESCE(MAX(set_index), 0) + 1 FROM sets \
121 + "SELECT COALESCE(MAX(set_index), 0) + 1 FROM reps_sets \
122 122 WHERE exercise_id = ?1 AND session_date = ?2",
123 123 params![exercise_id, date.to_string()],
124 124 |row| row.get(0),
125 125 )?;
126 126 self.conn().execute(
127 - "INSERT INTO sets (session_date, exercise_id, set_index, load, reps, rpe, is_diagnostic) \
127 + "INSERT INTO reps_sets (session_date, exercise_id, set_index, load, reps, rpe, is_diagnostic) \
128 128 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
129 129 params![
130 130 date.to_string(),
@@ -149,7 +149,7 @@ impl Db {
149 149 ) -> Result<Vec<Vec<SessionSet>>, Error> {
150 150 let mut stmt = self.conn().prepare(
151 151 "SELECT id, session_date, exercise_id, set_index, load, reps, rpe, is_diagnostic \
152 - FROM sets WHERE exercise_id = ?1 AND is_diagnostic = 0 \
152 + FROM reps_sets WHERE exercise_id = ?1 AND is_diagnostic = 0 \
153 153 ORDER BY session_date ASC, set_index ASC",
154 154 )?;
155 155 let rows = stmt.query_map(params![exercise_id], |row| row_to_session_set(row))?;
@@ -173,7 +173,7 @@ impl Db {
173 173 exercise_id: i64,
174 174 ) -> Result<Vec<(NaiveDate, f64)>, Error> {
175 175 let mut stmt = self.conn().prepare(
176 - "SELECT session_date, MAX(load) FROM sets \
176 + "SELECT session_date, MAX(load) FROM reps_sets \
177 177 WHERE exercise_id = ?1 AND is_diagnostic = 0 \
178 178 GROUP BY session_date ORDER BY session_date ASC",
179 179 )?;
@@ -197,7 +197,7 @@ impl Db {
197 197 /// (all sets at warmup RPE, for instance).
198 198 pub fn best_e1rm(&self, exercise_id: i64) -> Result<Option<f64>, Error> {
199 199 let mut stmt = self.conn().prepare(
200 - "SELECT load, reps, rpe FROM sets WHERE exercise_id = ?1",
200 + "SELECT load, reps, rpe FROM reps_sets WHERE exercise_id = ?1",
201 201 )?;
202 202 let rows = stmt.query_map(params![exercise_id], |row| {
203 203 Ok((
@@ -222,10 +222,10 @@ impl Db {
222 222 /// has been logged.
223 223 pub fn diagnostic_seed(&self, exercise_id: i64) -> Result<Option<f64>, Error> {
224 224 let mut stmt = self.conn().prepare(
225 - "SELECT MAX(load) FROM sets \
225 + "SELECT MAX(load) FROM reps_sets \
226 226 WHERE exercise_id = ?1 AND is_diagnostic = 1 \
227 227 AND session_date = ( \
228 - SELECT MAX(session_date) FROM sets \
228 + SELECT MAX(session_date) FROM reps_sets \
229 229 WHERE exercise_id = ?1 AND is_diagnostic = 1 \
230 230 )",
231 231 )?;
@@ -242,7 +242,7 @@ impl Db {
242 242 pub fn delete_set(&self, id: i64) -> Result<(), Error> {
243 243 let n = self
244 244 .conn()
245 - .execute("DELETE FROM sets WHERE id = ?1", params![id])?;
245 + .execute("DELETE FROM reps_sets WHERE id = ?1", params![id])?;
246 246 if n == 0 {
247 247 return Err(Error::NotFound(format!("set {id}")));
248 248 }
@@ -7,6 +7,7 @@
7 7 use rusqlite::{OptionalExtension, params};
8 8
9 9 use crate::db::Db;
10 + use crate::effort::EffortKind;
10 11 use crate::error::Error;
11 12 use crate::values::LoadUnit;
12 13
@@ -52,6 +53,19 @@ impl ResistanceType {
52 53 }
53 54 }
54 55
56 + /// Which effort kind a new exercise inherits from its UI-side resistance
57 + /// type. Explicit override via `create_exercise_with_kind` will land when
58 + /// the template form learns to prompt for it in a later phase-4 slice.
59 + pub fn default_effort_kind(rt: ResistanceType) -> EffortKind {
60 + match rt {
61 + ResistanceType::Bodyweight
62 + | ResistanceType::Machine
63 + | ResistanceType::Freeweight => EffortKind::Reps,
64 + ResistanceType::CardioTime => EffortKind::Timed,
65 + ResistanceType::CardioDistance => EffortKind::Distance,
66 + }
67 + }
68 +
55 69 #[derive(Debug, Clone, PartialEq)]
56 70 pub struct Tag {
57 71 pub id: i64,
@@ -62,7 +76,13 @@ pub struct Tag {
62 76 pub struct Exercise {
63 77 pub id: i64,
64 78 pub name: String,
79 + /// UI hint for the log-screen input shape (dumbbell vs machine vs
80 + /// bodyweight, etc.). Orthogonal to the data model, which routes
81 + /// through [`EffortKind`].
65 82 pub resistance_type: ResistanceType,
83 + /// Discriminant on which per-kind sets table holds this exercise's
84 + /// history. Every exercise created before phase 4 is `Reps`.
85 + pub effort_kind: EffortKind,
66 86 pub load_unit: LoadUnit,
67 87 pub increment: f64,
68 88 pub notes: String,
@@ -120,28 +140,29 @@ impl Db {
120 140
121 141 pub fn list_exercises(&self) -> Result<Vec<Exercise>, Error> {
122 142 let mut stmt = self.conn().prepare(
123 - "SELECT id, name, resistance_type, load_unit, increment, notes \
143 + "SELECT id, name, resistance_type, effort_kind, load_unit, increment, notes \
124 144 FROM exercises ORDER BY name",
125 145 )?;
126 146 let rows = stmt.query_map([], |row| {
127 - let rt: String = row.get(2)?;
128 147 Ok((
129 148 row.get::<_, i64>(0)?,
130 149 row.get::<_, String>(1)?,
131 - rt,
150 + row.get::<_, String>(2)?,
132 151 row.get::<_, String>(3)?,
133 - row.get::<_, f64>(4)?,
134 - row.get::<_, String>(5)?,
152 + row.get::<_, String>(4)?,
153 + row.get::<_, f64>(5)?,
154 + row.get::<_, String>(6)?,
135 155 ))
136 156 })?;
137 157
138 158 let mut out = Vec::new();
139 159 for row in rows {
140 - let (id, name, rt, load_unit, increment, notes) = row?;
160 + let (id, name, rt, ek, load_unit, increment, notes) = row?;
141 161 out.push(Exercise {
142 162 id,
143 163 name,
144 164 resistance_type: ResistanceType::parse(&rt)?,
165 + effort_kind: EffortKind::parse(&ek)?,
145 166 load_unit: LoadUnit::parse(&load_unit)?,
146 167 increment,
147 168 notes,
@@ -171,10 +192,18 @@ impl Db {
171 192 if name.is_empty() {
172 193 return Err(Error::InvalidExerciseName);
173 194 }
195 + let effort_kind = default_effort_kind(resistance_type);
174 196 self.conn().execute(
175 - "INSERT INTO exercises (name, resistance_type, load_unit, increment) \
176 - VALUES (?1, ?2, ?3, ?4)",
177 - params![name, resistance_type.as_str(), load_unit.as_str(), increment],
197 + "INSERT INTO exercises \
198 + (name, resistance_type, effort_kind, load_unit, increment) \
199 + VALUES (?1, ?2, ?3, ?4, ?5)",
200 + params![
201 + name,
202 + resistance_type.as_str(),
203 + effort_kind.as_str(),
204 + load_unit.as_str(),
205 + increment
206 + ],
178 207 )?;
179 208 let id = self.conn().last_insert_rowid();
180 209 self.write_tag_ids(id, tag_ids)?;
@@ -194,10 +223,18 @@ impl Db {
194 223 if name.is_empty() {
195 224 return Err(Error::InvalidExerciseName);
196 225 }
226 + let effort_kind = default_effort_kind(resistance_type);
197 227 let n = self.conn().execute(
198 - "UPDATE exercises SET name = ?1, resistance_type = ?2, load_unit = ?3, \
199 - increment = ?4 WHERE id = ?5",
200 - params![name, resistance_type.as_str(), load_unit.as_str(), increment, id],
228 + "UPDATE exercises SET name = ?1, resistance_type = ?2, \
229 + effort_kind = ?3, load_unit = ?4, increment = ?5 WHERE id = ?6",
230 + params![
231 + name,
232 + resistance_type.as_str(),
233 + effort_kind.as_str(),
234 + load_unit.as_str(),
235 + increment,
236 + id
237 + ],
201 238 )?;
202 239 if n == 0 {
203 240 return Err(Error::NotFound(format!("exercise {id}")));
@@ -262,6 +299,51 @@ mod tests {
262 299 }
263 300
264 301 #[test]
302 + fn default_effort_kind_maps_from_resistance_type() {
303 + assert_eq!(
304 + default_effort_kind(ResistanceType::Freeweight),
305 + EffortKind::Reps
306 + );
307 + assert_eq!(
308 + default_effort_kind(ResistanceType::Machine),
309 + EffortKind::Reps
310 + );
311 + assert_eq!(
312 + default_effort_kind(ResistanceType::Bodyweight),
313 + EffortKind::Reps
314 + );
315 + assert_eq!(
316 + default_effort_kind(ResistanceType::CardioTime),
317 + EffortKind::Timed
318 + );
319 + assert_eq!(
320 + default_effort_kind(ResistanceType::CardioDistance),
321 + EffortKind::Distance
322 + );
323 + }
324 +
325 + #[test]
326 + fn newly_created_exercise_carries_effort_kind() {
327 + let db = setup();
328 + let id = db
329 + .create_exercise(
330 + "row",
331 + ResistanceType::CardioDistance,
332 + LoadUnit::Kg,
333 + 0.0,
334 + &[],
335 + )
336 + .unwrap();
337 + let ex = db
338 + .list_exercises()
339 + .unwrap()
340 + .into_iter()
341 + .find(|e| e.id == id)
342 + .unwrap();
343 + assert_eq!(ex.effort_kind, EffortKind::Distance);
344 + }
345 +
346 + #[test]
265 347 fn exercise_round_trip_with_tags() {
266 348 let db = setup();
267 349 let t1 = db.upsert_tag("chest").unwrap();
@@ -319,7 +401,7 @@ mod tests {
319 401 .unwrap();
320 402 db.conn()
321 403 .execute(
322 - "INSERT INTO sets (session_date, exercise_id, set_index, load, reps, rpe) \
404 + "INSERT INTO reps_sets (session_date, exercise_id, set_index, load, reps, rpe) \
323 405 VALUES ('2026-07-18', ?1, 1, 100.0, 5, 3)",
324 406 params![id],
325 407 )
@@ -260,13 +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::{LoadUnit, Prescription, ResistanceType, State};
263 + use ripgrow_core::{EffortKind, 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 + effort_kind: EffortKind::Reps,
270 271 load_unit: LoadUnit::parse(unit).unwrap(),
271 272 increment: 2.5,
272 273 notes: String::new(),