Skip to main content

max / ripgrow

18.0 KB · 479 lines History Blame Raw
1 //! Reps effort kind.
2 //!
3 //! Weight moved for a rep count at an RPE. This is the shape ripgrow
4 //! spoke natively before the phase-4 split, and it remains the primary
5 //! effort kind. Everything reps-shaped lives here: the row struct, the
6 //! SQL, the shared state-machine glue, and the ancillary queries
7 //! (`top_loads_over_time`, `best_e1rm`, `diagnostic_seed`, `delete`)
8 //! that don't fit on the [`Kind`] trait but are still reps-only.
9
10 use chrono::NaiveDate;
11 use rusqlite::{OptionalExtension, Row, params};
12
13 use crate::db::Db;
14 use crate::error::Error;
15 use crate::estimator::estimate_e1rm;
16 use crate::progression::{self, Prescription};
17 use crate::templates::Exercise;
18 use crate::values::{Load, Reps, Rpe};
19
20 use super::{EffortKind, Kind, Outcome, PrescriptionResult};
21
22 /// Marker type carrying the reps [`Kind`] impl. All actual state lives
23 /// in the associated types and the DB.
24 pub struct RepsKind;
25
26 /// Kind-specific input for inserting a reps set.
27 #[derive(Debug, Clone, Copy, PartialEq)]
28 pub struct RepsPayload {
29 pub load: Load,
30 pub reps: Reps,
31 }
32
33 impl RepsPayload {
34 pub fn new(load: Load, reps: Reps) -> Self {
35 Self { load, reps }
36 }
37 }
38
39 /// The full stored row for a reps set.
40 #[derive(Debug, Clone, PartialEq)]
41 pub struct RepsSet {
42 pub id: i64,
43 pub session_date: NaiveDate,
44 pub exercise_id: i64,
45 pub set_index: i32,
46 pub load: Load,
47 pub reps: Reps,
48 pub rpe: Rpe,
49 pub is_diagnostic: bool,
50 }
51
52 /// Prescription produced by walking a reps history through the state
53 /// machine. Alias of [`Prescription`](crate::Prescription); the
54 /// per-kind name gives symmetry with `TimedPrescription` /
55 /// `DistancePrescription` at the effort-dispatch boundary.
56 pub type RepsPrescription = Prescription;
57
58 fn bad_column(field: &'static str, msg: String) -> rusqlite::Error {
59 rusqlite::Error::FromSqlConversionFailure(
60 0,
61 rusqlite::types::Type::Real,
62 Box::new(std::io::Error::other(format!("{field}: {msg}"))),
63 )
64 }
65
66 /// Row -> RepsSet decoder shared by every set-fetching query. Column
67 /// order must match the callers' SELECT lists. Validation happens here
68 /// so a bad row (RPE 7, negative load) errors at read time instead of
69 /// silently propagating through the progression walk.
70 fn row_to_reps_set(row: &Row<'_>) -> rusqlite::Result<RepsSet> {
71 let date_str: String = row.get(1)?;
72 let parsed = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").map_err(|e| {
73 rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
74 })?;
75 let flag: i64 = row.get(7)?;
76 let load = Load::new(row.get(4)?).map_err(|e| bad_column("load", e.to_string()))?;
77 let reps = Reps::new(row.get(5)?).map_err(|e| bad_column("reps", e.to_string()))?;
78 let rpe = Rpe::new(row.get(6)?).map_err(|e| bad_column("rpe", e.to_string()))?;
79 Ok(RepsSet {
80 id: row.get(0)?,
81 session_date: parsed,
82 exercise_id: row.get(2)?,
83 set_index: row.get(3)?,
84 load,
85 reps,
86 rpe,
87 is_diagnostic: flag != 0,
88 })
89 }
90
91 impl Kind for RepsKind {
92 type Payload = RepsPayload;
93 type Set = RepsSet;
94 type Prescription = RepsPrescription;
95
96 const DISCRIMINANT: EffortKind = EffortKind::Reps;
97 const TABLE: &'static str = "reps_sets";
98
99 fn append(
100 db: &Db,
101 exercise_id: i64,
102 date: NaiveDate,
103 payload: Self::Payload,
104 rpe: Rpe,
105 is_diagnostic: bool,
106 ) -> Result<i64, Error> {
107 let next_index: i32 = db.conn().query_row(
108 "SELECT COALESCE(MAX(set_index), 0) + 1 FROM reps_sets \
109 WHERE exercise_id = ?1 AND session_date = ?2",
110 params![exercise_id, date.to_string()],
111 |row| row.get(0),
112 )?;
113 db.conn().execute(
114 "INSERT INTO reps_sets (session_date, exercise_id, set_index, load, reps, rpe, is_diagnostic) \
115 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
116 params![
117 date.to_string(),
118 exercise_id,
119 next_index,
120 payload.load.get(),
121 payload.reps.get(),
122 rpe.get(),
123 if is_diagnostic { 1 } else { 0 },
124 ],
125 )?;
126 Ok(db.conn().last_insert_rowid())
127 }
128
129 fn list_sets_for_session(
130 db: &Db,
131 exercise_id: i64,
132 date: NaiveDate,
133 ) -> Result<Vec<Self::Set>, Error> {
134 let mut stmt = db.conn().prepare(
135 "SELECT id, session_date, exercise_id, set_index, load, reps, rpe, is_diagnostic \
136 FROM reps_sets WHERE exercise_id = ?1 AND session_date = ?2 \
137 ORDER BY set_index",
138 )?;
139 let rows = stmt.query_map(
140 params![exercise_id, date.to_string()],
141 |row| row_to_reps_set(row),
142 )?;
143 Ok(rows.collect::<Result<Vec<_>, _>>()?)
144 }
145
146 fn list_sessions_for_exercise(
147 db: &Db,
148 exercise_id: i64,
149 ) -> Result<Vec<Vec<Self::Set>>, Error> {
150 let mut stmt = db.conn().prepare(
151 "SELECT id, session_date, exercise_id, set_index, load, reps, rpe, is_diagnostic \
152 FROM reps_sets WHERE exercise_id = ?1 AND is_diagnostic = 0 \
153 ORDER BY session_date ASC, set_index ASC",
154 )?;
155 let rows = stmt.query_map(params![exercise_id], |row| row_to_reps_set(row))?;
156
157 let mut sessions: Vec<Vec<RepsSet>> = Vec::new();
158 for row in rows {
159 let set = row?;
160 match sessions.last_mut() {
161 Some(last) if last[0].session_date == set.session_date => last.push(set),
162 _ => sessions.push(vec![set]),
163 }
164 }
165 Ok(sessions)
166 }
167
168 fn delete_last_diagnostic_on_date(
169 db: &Db,
170 exercise_id: i64,
171 date: NaiveDate,
172 ) -> Result<(), Error> {
173 let list = Self::list_sets_for_session(db, exercise_id, date)?;
174 if let Some(last) = list.iter().rev().find(|s| s.is_diagnostic) {
175 Self::delete(db, last.id)?;
176 }
177 Ok(())
178 }
179
180 fn evaluate(sets: &[Self::Set]) -> Outcome {
181 // Target reps carries over from the previous session; the state
182 // machine's own walker handles the seeding case. This helper is
183 // for callers that want to evaluate a single session under a
184 // known target elsewhere; for the walk-history path we delegate
185 // to progression::evaluate_session at a lower level.
186 let target = sets.iter().map(|s| s.reps.get()).max().unwrap_or(0);
187 progression::evaluate_session(sets, target)
188 }
189
190 fn compute_prescription(
191 db: &Db,
192 exercise: &Exercise,
193 ) -> Result<PrescriptionResult<Self::Prescription>, Error> {
194 use crate::progression::PrescriptionResult as OldResult;
195 match db.compute_prescription(exercise.id)? {
196 OldResult::NoHistory => Ok(PrescriptionResult::NoHistory),
197 OldResult::Prescribed(p) => Ok(PrescriptionResult::Prescribed(p)),
198 }
199 }
200 }
201
202 impl RepsKind {
203 /// One row per session for `exercise_id`: (session_date, top_load).
204 /// Oldest first. Empty when the exercise has no history. Diagnostic
205 /// sets are excluded so the sparkline reflects working-set progress.
206 pub fn top_loads_over_time(
207 db: &Db,
208 exercise_id: i64,
209 ) -> Result<Vec<(NaiveDate, f64)>, Error> {
210 let mut stmt = db.conn().prepare(
211 "SELECT session_date, MAX(load) FROM reps_sets \
212 WHERE exercise_id = ?1 AND is_diagnostic = 0 \
213 GROUP BY session_date ORDER BY session_date ASC",
214 )?;
215 let rows = stmt.query_map(params![exercise_id], |row| {
216 let date_str: String = row.get(0)?;
217 let load: f64 = row.get(1)?;
218 Ok((date_str, load))
219 })?;
220 let mut out = Vec::new();
221 for row in rows {
222 let (date_str, load) = row?;
223 if let Ok(d) = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") {
224 out.push((d, load));
225 }
226 }
227 Ok(out)
228 }
229
230 /// Best e1RM across every reps set logged for `exercise_id`. `None`
231 /// when the exercise has no sets, or when no set carries an e1RM
232 /// signal (all sets at warmup RPE, for instance).
233 pub fn best_e1rm(db: &Db, exercise_id: i64) -> Result<Option<f64>, Error> {
234 let mut stmt = db.conn().prepare(
235 "SELECT load, reps, rpe FROM reps_sets WHERE exercise_id = ?1",
236 )?;
237 let rows = stmt.query_map(params![exercise_id], |row| {
238 Ok((
239 row.get::<_, f64>(0)?,
240 row.get::<_, i32>(1)?,
241 row.get::<_, i32>(2)?,
242 ))
243 })?;
244 let mut best: Option<f64> = None;
245 for row in rows {
246 let (load, reps, rpe) = row?;
247 if let Some(e) = estimate_e1rm(load, reps, rpe) {
248 best = Some(best.map_or(e, |b| b.max(e)));
249 }
250 }
251 Ok(best)
252 }
253
254 /// Top load from the most recent diagnostic on this exercise. Used
255 /// by the Generate screen to seed a first working prescription when
256 /// there is no non-diagnostic history yet. `None` when no diagnostic
257 /// has been logged.
258 pub fn diagnostic_seed(db: &Db, exercise_id: i64) -> Result<Option<f64>, Error> {
259 let mut stmt = db.conn().prepare(
260 "SELECT MAX(load) FROM reps_sets \
261 WHERE exercise_id = ?1 AND is_diagnostic = 1 \
262 AND session_date = ( \
263 SELECT MAX(session_date) FROM reps_sets \
264 WHERE exercise_id = ?1 AND is_diagnostic = 1 \
265 )",
266 )?;
267 let out: Option<f64> = stmt
268 .query_row(params![exercise_id], |row| row.get(0))
269 .optional()?
270 .flatten();
271 Ok(out)
272 }
273
274 /// Delete a specific reps set by id. Returns `NotFound` if it did
275 /// not exist. Subsequent `append` calls do NOT reuse the freed
276 /// index; gaps are fine for the state machine (which reads ordered
277 /// by index).
278 pub fn delete(db: &Db, id: i64) -> Result<(), Error> {
279 let n = db
280 .conn()
281 .execute("DELETE FROM reps_sets WHERE id = ?1", params![id])?;
282 if n == 0 {
283 return Err(Error::NotFound(format!("set {id}")));
284 }
285 Ok(())
286 }
287 }
288
289 #[cfg(test)]
290 mod tests {
291 use super::*;
292 use crate::values::LoadUnit;
293 use crate::ResistanceType;
294
295 fn setup() -> (Db, i64) {
296 let db = Db::open_in_memory().unwrap();
297 db.init_profile("self", LoadUnit::Kg).unwrap();
298 let id = db
299 .create_exercise("squat", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
300 .unwrap();
301 (db, id)
302 }
303
304 fn payload(load: f64, reps: i32) -> RepsPayload {
305 RepsPayload::new(Load::new(load).unwrap(), Reps::new(reps).unwrap())
306 }
307
308 fn rpe(v: i32) -> Rpe {
309 Rpe::new(v).unwrap()
310 }
311
312 #[test]
313 fn kind_discriminant_matches_effort_kind() {
314 assert_eq!(RepsKind::DISCRIMINANT, EffortKind::Reps);
315 assert_eq!(RepsKind::TABLE, "reps_sets");
316 }
317
318 #[test]
319 fn append_and_list_round_trip() {
320 let (db, ex) = setup();
321 let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
322 RepsKind::append(&db, ex, date, payload(100.0, 5), rpe(3), false).unwrap();
323 RepsKind::append(&db, ex, date, payload(100.0, 5), rpe(3), false).unwrap();
324 RepsKind::append(&db, ex, date, payload(100.0, 5), rpe(4), false).unwrap();
325 let list = RepsKind::list_sets_for_session(&db, ex, date).unwrap();
326 assert_eq!(list.len(), 3);
327 assert_eq!(list[0].set_index, 1);
328 assert_eq!(list[1].set_index, 2);
329 assert_eq!(list[2].rpe.get(), 4);
330 }
331
332 #[test]
333 fn newtype_constructors_reject_out_of_range_values() {
334 // Validation lives on the value constructors; append can never
335 // be called with a bad Rpe or Reps because you can't construct
336 // them. These tests confirm the constructors themselves still
337 // guard.
338 assert!(Rpe::new(0).is_err());
339 assert!(Rpe::new(6).is_err());
340 assert!(Reps::new(-1).is_err());
341 assert!(Load::new(-1.0).is_err());
342 }
343
344 #[test]
345 fn list_isolates_by_date_and_exercise() {
346 let (db, ex1) = setup();
347 let ex2 = db
348 .create_exercise("bench", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
349 .unwrap();
350 let d1 = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
351 let d2 = NaiveDate::from_ymd_opt(2026, 7, 19).unwrap();
352 RepsKind::append(&db, ex1, d1, payload(100.0, 5), rpe(3), false).unwrap();
353 RepsKind::append(&db, ex1, d2, payload(105.0, 5), rpe(3), false).unwrap();
354 RepsKind::append(&db, ex2, d1, payload(80.0, 5), rpe(3), false).unwrap();
355 assert_eq!(RepsKind::list_sets_for_session(&db, ex1, d1).unwrap().len(), 1);
356 assert_eq!(RepsKind::list_sets_for_session(&db, ex1, d2).unwrap().len(), 1);
357 assert_eq!(RepsKind::list_sets_for_session(&db, ex2, d1).unwrap().len(), 1);
358 }
359
360 #[test]
361 fn top_loads_over_time_picks_max_per_date() {
362 let (db, ex) = setup();
363 let d1 = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
364 let d2 = NaiveDate::from_ymd_opt(2026, 7, 3).unwrap();
365 RepsKind::append(&db, ex, d1, payload(100.0, 5), rpe(3), false).unwrap();
366 RepsKind::append(&db, ex, d1, payload(105.0, 5), rpe(4), false).unwrap();
367 RepsKind::append(&db, ex, d2, payload(100.0, 5), rpe(3), false).unwrap();
368 let series = RepsKind::top_loads_over_time(&db, ex).unwrap();
369 assert_eq!(series, vec![(d1, 105.0), (d2, 100.0)]);
370 }
371
372 #[test]
373 fn best_e1rm_none_when_no_sets() {
374 let (db, ex) = setup();
375 assert_eq!(RepsKind::best_e1rm(&db, ex).unwrap(), None);
376 }
377
378 #[test]
379 fn best_e1rm_takes_max_across_sets() {
380 let (db, ex) = setup();
381 let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
382 // 100 x 5 @ RPE 5 -> e1RM 116.67
383 // 90 x 8 @ RPE 3 -> e1RM 90 * (1 + 10/30) = 120.0
384 // 120 x 3 @ RPE 5 -> e1RM 132.0 <- winner
385 RepsKind::append(&db, ex, date, payload(100.0, 5), rpe(5), false).unwrap();
386 RepsKind::append(&db, ex, date, payload(90.0, 8), rpe(3), false).unwrap();
387 RepsKind::append(&db, ex, date, payload(120.0, 3), rpe(5), false).unwrap();
388 let e = RepsKind::best_e1rm(&db, ex).unwrap().unwrap();
389 assert!((e - 132.0).abs() < 1e-6);
390 }
391
392 #[test]
393 fn best_e1rm_ignores_warmup_rpe_1_sets() {
394 let (db, ex) = setup();
395 let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
396 RepsKind::append(&db, ex, date, payload(200.0, 10), rpe(1), false).unwrap();
397 assert_eq!(RepsKind::best_e1rm(&db, ex).unwrap(), None);
398 }
399
400 #[test]
401 fn diagnostic_sets_excluded_from_progression_history() {
402 let (db, ex) = setup();
403 let d1 = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
404 let d2 = NaiveDate::from_ymd_opt(2026, 7, 3).unwrap();
405 RepsKind::append(&db, ex, d1, payload(60.0, 5), rpe(3), true).unwrap();
406 RepsKind::append(&db, ex, d1, payload(80.0, 5), rpe(4), true).unwrap();
407 RepsKind::append(&db, ex, d2, payload(72.0, 5), rpe(3), false).unwrap();
408 let sessions = RepsKind::list_sessions_for_exercise(&db, ex).unwrap();
409 assert_eq!(sessions.len(), 1, "diagnostic day should not appear");
410 assert_eq!(sessions[0][0].session_date, d2);
411 assert!(!sessions[0][0].is_diagnostic);
412 }
413
414 #[test]
415 fn diagnostic_sets_excluded_from_top_loads_over_time() {
416 let (db, ex) = setup();
417 let d1 = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
418 let d2 = NaiveDate::from_ymd_opt(2026, 7, 3).unwrap();
419 RepsKind::append(&db, ex, d1, payload(120.0, 5), rpe(4), true).unwrap();
420 RepsKind::append(&db, ex, d2, payload(80.0, 5), rpe(3), false).unwrap();
421 let series = RepsKind::top_loads_over_time(&db, ex).unwrap();
422 assert_eq!(series, vec![(d2, 80.0)]);
423 }
424
425 #[test]
426 fn diagnostic_sets_still_count_toward_e1rm() {
427 let (db, ex) = setup();
428 let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
429 RepsKind::append(&db, ex, date, payload(120.0, 3), rpe(4), true).unwrap();
430 assert!(RepsKind::best_e1rm(&db, ex).unwrap().is_some());
431 }
432
433 #[test]
434 fn diagnostic_seed_returns_top_of_last_diagnostic() {
435 let (db, ex) = setup();
436 let d1 = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
437 let d2 = NaiveDate::from_ymd_opt(2026, 7, 5).unwrap();
438 RepsKind::append(&db, ex, d1, payload(70.0, 5), rpe(3), true).unwrap();
439 RepsKind::append(&db, ex, d1, payload(80.0, 5), rpe(4), true).unwrap();
440 RepsKind::append(&db, ex, d2, payload(60.0, 5), rpe(3), true).unwrap();
441 RepsKind::append(&db, ex, d2, payload(90.0, 5), rpe(4), true).unwrap();
442 // Most recent diagnostic (d2) had a top of 90.
443 assert_eq!(RepsKind::diagnostic_seed(&db, ex).unwrap(), Some(90.0));
444 }
445
446 #[test]
447 fn diagnostic_seed_none_when_no_diagnostic_history() {
448 let (db, ex) = setup();
449 let date = NaiveDate::from_ymd_opt(2026, 7, 5).unwrap();
450 RepsKind::append(&db, ex, date, payload(100.0, 5), rpe(3), false).unwrap();
451 assert_eq!(RepsKind::diagnostic_seed(&db, ex).unwrap(), None);
452 }
453
454 #[test]
455 fn delete_removes_and_gap_is_fine() {
456 let (db, ex) = setup();
457 let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
458 let a = RepsKind::append(&db, ex, date, payload(100.0, 5), rpe(3), false).unwrap();
459 RepsKind::append(&db, ex, date, payload(100.0, 5), rpe(3), false).unwrap();
460 RepsKind::delete(&db, a).unwrap();
461 let list = RepsKind::list_sets_for_session(&db, ex, date).unwrap();
462 assert_eq!(list.len(), 1);
463 assert_eq!(list[0].set_index, 2, "set_index preserved after delete");
464 }
465
466 #[test]
467 fn compute_prescription_no_history_via_trait() {
468 let (db, ex) = setup();
469 let exercise = db
470 .list_exercises()
471 .unwrap()
472 .into_iter()
473 .find(|e| e.id == ex)
474 .unwrap();
475 let r = RepsKind::compute_prescription(&db, &exercise).unwrap();
476 assert!(matches!(r, PrescriptionResult::NoHistory));
477 }
478 }
479