max / ripgrow
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(), |