Skip to main content

max / ripgrow

effort: consolidate reps into effort/reps.rs and migrate callsites Move all reps-specific SQL and the RepsSet struct into effort/reps.rs so the three effort kinds (reps, timed, distance) are structurally symmetric — each owns its row shape, its Kind trait impl, and its kind-only helpers. RepsKind gains inherent methods for the extras that don't fit on the Kind trait (top_loads_over_time, best_e1rm, diagnostic_seed, delete). Migrate every callsite off the old sets.rs surface onto RepsKind::* directly: progression.rs, generation.rs, and the four TUI screens (log, history, generate, diagnostic). Rename SessionSet to RepsSet throughout. Db::compute_prescription is the one legit higher-level Db method that stays. sets.rs is deleted.
Author: Max Johnson <me@maxj.phd> · 2026-07-18 20:05 UTC
Commit: 8d5dbdd6c2c6100bac3138541510e7607d152dc2
Parent: bc72780
10 files changed, +450 insertions, -501 deletions
@@ -30,9 +30,9 @@ use crate::values::{Estimate, Load, Reps, Rpe};
30 30 pub const TARGET_REPS: i32 = DIAGNOSTIC_TARGET_REPS;
31 31 pub const MAX_SETS: usize = DIAGNOSTIC_MAX_SETS;
32 32
33 - /// One completed set inside a diagnostic. Same shape as `SessionSet`'s
34 - /// working columns, kept separate so the protocol can be unit-tested
35 - /// without touching the DB layer.
33 + /// One completed set inside a diagnostic. Same shape as
34 + /// [`crate::RepsSet`]'s working columns, kept separate so the protocol
35 + /// can be unit-tested without touching the DB layer.
36 36 #[derive(Debug, Clone, Copy, PartialEq)]
37 37 pub struct DiagnosticSet {
38 38 pub load: Load,
@@ -1,18 +1,19 @@
1 1 //! Reps effort kind.
2 2 //!
3 3 //! Weight moved for a rep count at an RPE. This is the shape ripgrow
4 - //! spoke natively before phase 4; the types and DB paths here delegate
5 - //! to the pre-existing [`crate::sets`] and [`crate::progression`] modules
6 - //! rather than re-implementing them. Later slices may move the code in
7 - //! here for symmetry with `timed` and `distance`, but that's ordering
8 - //! churn, not new logic.
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 9
10 10 use chrono::NaiveDate;
11 + use rusqlite::{OptionalExtension, Row, params};
11 12
12 13 use crate::db::Db;
13 14 use crate::error::Error;
15 + use crate::estimator::estimate_e1rm;
14 16 use crate::progression::{self, Prescription};
15 - use crate::sets::SessionSet;
16 17 use crate::templates::Exercise;
17 18 use crate::values::{Load, Reps, Rpe};
18 19
@@ -29,16 +30,64 @@ pub struct RepsPayload {
29 30 pub reps: Reps,
30 31 }
31 32
32 - /// The full stored row for a reps set. Historically this type was named
33 - /// `SessionSet`; that alias lives on in [`crate::sets`] and is what the
34 - /// existing progression / diagnostic / UI code speaks.
35 - pub type RepsSet = SessionSet;
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 + }
36 51
37 52 /// Prescription produced by walking a reps history through the state
38 - /// machine. Alias for backward compatibility with existing code paths;
39 - /// [`Prescription`](crate::Prescription) is the canonical name today.
53 + /// machine. Alias of [`Prescription`](crate::Prescription); the
54 + /// per-kind name gives symmetry with `TimedPrescription` /
55 + /// `DistancePrescription` at the effort-dispatch boundary.
40 56 pub type RepsPrescription = Prescription;
41 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 +
42 91 impl Kind for RepsKind {
43 92 type Payload = RepsPayload;
44 93 type Set = RepsSet;
@@ -55,11 +104,26 @@ impl Kind for RepsKind {
55 104 rpe: Rpe,
56 105 is_diagnostic: bool,
57 106 ) -> Result<i64, Error> {
58 - if is_diagnostic {
59 - db.append_diagnostic_set(exercise_id, date, payload.load, payload.reps, rpe)
60 - } else {
61 - db.append_set(exercise_id, date, payload.load, payload.reps, rpe)
62 - }
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())
63 127 }
64 128
65 129 fn list_sets_for_session(
@@ -67,14 +131,38 @@ impl Kind for RepsKind {
67 131 exercise_id: i64,
68 132 date: NaiveDate,
69 133 ) -> Result<Vec<Self::Set>, Error> {
70 - db.list_sets_for_session(exercise_id, date)
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<_>, _>>()?)
71 144 }
72 145
73 146 fn list_sessions_for_exercise(
74 147 db: &Db,
75 148 exercise_id: i64,
76 149 ) -> Result<Vec<Vec<Self::Set>>, Error> {
77 - db.list_sessions_for_exercise(exercise_id)
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)
78 166 }
79 167
80 168 fn delete_last_diagnostic_on_date(
@@ -82,9 +170,9 @@ impl Kind for RepsKind {
82 170 exercise_id: i64,
83 171 date: NaiveDate,
84 172 ) -> Result<(), Error> {
85 - let list = db.list_sets_for_session(exercise_id, date)?;
173 + let list = Self::list_sets_for_session(db, exercise_id, date)?;
86 174 if let Some(last) = list.iter().rev().find(|s| s.is_diagnostic) {
87 - db.delete_set(last.id)?;
175 + Self::delete(db, last.id)?;
88 176 }
89 177 Ok(())
90 178 }
@@ -111,9 +199,90 @@ impl Kind for RepsKind {
111 199 }
112 200 }
113 201
114 - impl RepsPayload {
115 - pub fn new(load: Load, reps: Reps) -> Self {
116 - Self { load, reps }
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(())
117 286 }
118 287 }
119 288
@@ -132,6 +301,14 @@ mod tests {
132 301 (db, id)
133 302 }
134 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 +
135 312 #[test]
136 313 fn kind_discriminant_matches_effort_kind() {
137 314 assert_eq!(RepsKind::DISCRIMINANT, EffortKind::Reps);
@@ -139,14 +316,151 @@ mod tests {
139 316 }
140 317
141 318 #[test]
142 - fn append_via_trait_writes_to_reps_sets() {
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() {
143 456 let (db, ex) = setup();
144 - let date = chrono::NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
145 - let payload = RepsPayload::new(Load::new(100.0).unwrap(), Reps::new(5).unwrap());
146 - RepsKind::append(&db, ex, date, payload, Rpe::new(3).unwrap(), false).unwrap();
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();
147 461 let list = RepsKind::list_sets_for_session(&db, ex, date).unwrap();
148 462 assert_eq!(list.len(), 1);
149 - assert_eq!(list[0].load.get(), 100.0);
463 + assert_eq!(list[0].set_index, 2, "set_index preserved after delete");
150 464 }
151 465
152 466 #[test]
@@ -246,6 +246,8 @@ impl Db {
246 246 #[cfg(test)]
247 247 mod tests {
248 248 use super::*;
249 + use crate::effort::Kind;
250 + use crate::effort::reps::{RepsKind, RepsPayload};
249 251 use crate::templates::ResistanceType;
250 252 use crate::values::{Load, LoadUnit, Reps, Rpe};
251 253
@@ -415,7 +417,15 @@ mod tests {
415 417 .create_exercise("b", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
416 418 .unwrap();
417 419 let d = NaiveDate::from_ymd_opt(2026, 7, 10).unwrap();
418 - db.append_set(a, d, Load::new(100.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(3).unwrap()).unwrap();
420 + RepsKind::append(
421 + &db,
422 + a,
423 + d,
424 + RepsPayload::new(Load::new(100.0).unwrap(), Reps::new(5).unwrap()),
425 + Rpe::new(3).unwrap(),
426 + false,
427 + )
428 + .unwrap();
419 429 let map = db
420 430 .days_since_last_map(NaiveDate::from_ymd_opt(2026, 7, 15).unwrap())
421 431 .unwrap();
@@ -13,12 +13,12 @@ pub mod heuristics;
13 13 pub mod profiles;
14 14 pub mod progression;
15 15 pub mod seed;
16 - pub mod sets;
17 16 pub mod templates;
18 17 pub mod values;
19 18
20 19 pub use db::Db;
21 20 pub use diagnostic::{DiagnosticSet, DiagnosticStep, next_step as next_diagnostic_step};
21 + pub use effort::reps::{RepsKind, RepsPayload, RepsPrescription, RepsSet};
22 22 pub use effort::{
23 23 AnyPayload, AnyPrescription, AnySet, EffortKind, Kind, PrescriptionResult as AnyPrescriptionResult,
24 24 append_any, compute_prescription_any, distance, reps, timed,
@@ -30,6 +30,5 @@ pub use heuristics::rir_from_rpe;
30 30 pub use profiles::{Profile, create_profile_in, list_profiles, profiles_dir, slugify};
31 31 pub use progression::{Prescription, PrescriptionResult, SessionOutcome, State};
32 32 pub use seed::seed_starter_content;
33 - pub use sets::SessionSet;
34 33 pub use templates::{Exercise, ResistanceType, Tag};
35 34 pub use values::{Distance, Duration, Estimate, Increment, Load, LoadUnit, Reps, Rpe};
@@ -8,8 +8,8 @@
8 8 //! This module contains only pure functions. See `Db::compute_prescription`
9 9 //! for the glue that reads history, walks it, and updates the cache.
10 10
11 + use crate::effort::reps::{RepsKind, RepsSet};
11 12 use crate::heuristics::DELOAD_RATIO;
12 - use crate::sets::SessionSet;
13 13 use crate::values::{Load, Reps};
14 14
15 15 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -57,7 +57,7 @@ pub enum SessionOutcome {
57 57 /// `target_reps` is the max rep count from the previous session for this
58 58 /// exercise; on the first session there is no target so we consider it a
59 59 /// clean hit (which drives the initial +increment on the following one).
60 - pub fn evaluate_session(sets: &[SessionSet], target_reps: i32) -> SessionOutcome {
60 + pub fn evaluate_session(sets: &[RepsSet], target_reps: i32) -> SessionOutcome {
61 61 if sets.is_empty() {
62 62 return SessionOutcome::Miss;
63 63 }
@@ -124,7 +124,7 @@ struct Walk {
124 124 /// Walk sessions in chronological order and return the current state + the
125 125 /// numbers to prescribe next. `sessions` must already be grouped by date and
126 126 /// sorted oldest -> newest.
127 - pub fn walk_history(sessions: &[Vec<SessionSet>], increment: f64) -> Option<WalkResult> {
127 + pub fn walk_history(sessions: &[Vec<RepsSet>], increment: f64) -> Option<WalkResult> {
128 128 if sessions.is_empty() {
129 129 return None;
130 130 }
@@ -249,21 +249,21 @@ fn round_to_increment(value: f64, increment: f64) -> f64 {
249 249 (value / increment).round() * increment
250 250 }
251 251
252 - fn top_load(session: &[SessionSet]) -> f64 {
252 + fn top_load(session: &[RepsSet]) -> f64 {
253 253 session
254 254 .iter()
255 255 .map(|s| s.load.get())
256 256 .fold(f64::NEG_INFINITY, f64::max)
257 257 }
258 258
259 - fn top_reps(session: &[SessionSet]) -> i32 {
259 + fn top_reps(session: &[RepsSet]) -> i32 {
260 260 session.iter().map(|s| s.reps.get()).max().unwrap_or(0)
261 261 }
262 262
263 263 /// Walk the exercise's session history and produce the next prescription.
264 264 /// Returns `NoHistory` when nothing has been logged yet.
265 265 pub fn compute_prescription(
266 - sessions: &[Vec<SessionSet>],
266 + sessions: &[Vec<RepsSet>],
267 267 increment: f64,
268 268 ) -> PrescriptionResult {
269 269 let Some(walk) = walk_history(sessions, increment) else {
@@ -302,7 +302,10 @@ impl Db {
302 302 .optional()?
303 303 .ok_or_else(|| Error::NotFound(format!("exercise {exercise_id}")))?;
304 304
305 - let sessions = self.list_sessions_for_exercise(exercise_id)?;
305 + let sessions = <RepsKind as crate::effort::Kind>::list_sessions_for_exercise(
306 + self,
307 + exercise_id,
308 + )?;
306 309 let mut result = compute_prescription(&sessions, increment);
307 310
308 311 // Fall back to a diagnostic seed when there is no working-set
@@ -311,7 +314,7 @@ impl Db {
311 314 // the deload path uses, so a fresh calibration converges with a
312 315 // post-deload rebuild rather than diverging).
313 316 if matches!(result, PrescriptionResult::NoHistory)
314 - && let Some(seed) = self.diagnostic_seed(exercise_id)?
317 + && let Some(seed) = RepsKind::diagnostic_seed(self, exercise_id)?
315 318 {
316 319 let load_raw = round_to_increment(
317 320 seed * crate::heuristics::DIAGNOSTIC_WORKING_WEIGHT_RATIO,
@@ -364,6 +367,7 @@ impl Db {
364 367 #[cfg(test)]
365 368 mod db_tests {
366 369 use super::*;
370 + use crate::effort::reps::RepsPayload;
367 371 use crate::templates::ResistanceType;
368 372 use crate::values::{Load, LoadUnit, Reps, Rpe};
369 373 use chrono::NaiveDate;
@@ -377,6 +381,22 @@ mod db_tests {
377 381 (db, id)
378 382 }
379 383
384 + fn log_set(db: &Db, ex: i64, date: NaiveDate, load: f64, reps: i32, rpe: i32) {
385 + let payload = RepsPayload::new(Load::new(load).unwrap(), Reps::new(reps).unwrap());
386 + <RepsKind as crate::effort::Kind>::append(
387 + db, ex, date, payload, Rpe::new(rpe).unwrap(), false,
388 + )
389 + .unwrap();
390 + }
391 +
392 + fn log_diagnostic(db: &Db, ex: i64, date: NaiveDate, load: f64, reps: i32, rpe: i32) {
393 + let payload = RepsPayload::new(Load::new(load).unwrap(), Reps::new(reps).unwrap());
394 + <RepsKind as crate::effort::Kind>::append(
395 + db, ex, date, payload, Rpe::new(rpe).unwrap(), true,
396 + )
397 + .unwrap();
398 + }
399 +
380 400 #[test]
381 401 fn compute_prescription_no_history() {
382 402 let (db, id) = setup();
@@ -391,7 +411,7 @@ mod db_tests {
391 411 let (db, id) = setup();
392 412 let d = NaiveDate::from_ymd_opt(2026, 7, 10).unwrap();
393 413 // Diagnostic finished at 100. Working weight = 0.9 * 100 = 90.
394 - db.append_diagnostic_set(id, d, Load::new(100.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(4).unwrap()).unwrap();
414 + log_diagnostic(&db, id, d, 100.0, 5, 4);
395 415 let PrescriptionResult::Prescribed(pres) = db.compute_prescription(id).unwrap() else {
396 416 panic!("expected prescription");
397 417 };
@@ -406,8 +426,8 @@ mod db_tests {
406 426 let (db, id) = setup();
407 427 let d1 = NaiveDate::from_ymd_opt(2026, 7, 10).unwrap();
408 428 let d2 = NaiveDate::from_ymd_opt(2026, 7, 12).unwrap();
409 - db.append_diagnostic_set(id, d1, Load::new(100.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(4).unwrap()).unwrap();
410 - db.append_set(id, d2, Load::new(60.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(3).unwrap()).unwrap();
429 + log_diagnostic(&db, id, d1, 100.0, 5, 4);
430 + log_set(&db, id, d2, 60.0, 5, 3);
411 431 let PrescriptionResult::Prescribed(pres) = db.compute_prescription(id).unwrap() else {
412 432 panic!("expected prescription");
413 433 };
@@ -420,10 +440,10 @@ mod db_tests {
420 440 let (db, id) = setup();
421 441 let day = |n| NaiveDate::from_ymd_opt(2026, 7, n).unwrap();
422 442 for _ in 0..3 {
423 - db.append_set(id, day(1), Load::new(100.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(3).unwrap()).unwrap();
443 + log_set(&db, id, day(1), 100.0, 5, 3);
424 444 }
425 445 for _ in 0..3 {
426 - db.append_set(id, day(2), Load::new(102.5).unwrap(), Reps::new(5).unwrap(), Rpe::new(4).unwrap()).unwrap();
446 + log_set(&db, id, day(2), 102.5, 5, 4);
427 447 }
428 448 let PrescriptionResult::Prescribed(pres) = db.compute_prescription(id).unwrap() else {
429 449 panic!("expected prescription");
@@ -440,16 +460,16 @@ mod db_tests {
440 460 fn compute_prescription_updates_cache_across_calls() {
441 461 let (db, id) = setup();
442 462 let day = |n| NaiveDate::from_ymd_opt(2026, 7, n).unwrap();
443 - db.append_set(id, day(1), Load::new(100.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(3).unwrap()).unwrap();
463 + log_set(&db, id, day(1), 100.0, 5, 3);
444 464 db.compute_prescription(id).unwrap();
445 465 assert_eq!(
446 466 db.read_progression_state(id).unwrap(),
447 467 Some(State::Progressing)
448 468 );
449 469 // Add a miss to drop to probation.
450 - db.append_set(id, day(2), Load::new(100.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(3).unwrap()).unwrap();
451 - db.append_set(id, day(2), Load::new(100.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(3).unwrap()).unwrap();
452 - db.append_set(id, day(2), Load::new(100.0).unwrap(), Reps::new(4).unwrap(), Rpe::new(3).unwrap()).unwrap();
470 + log_set(&db, id, day(2), 100.0, 5, 3);
471 + log_set(&db, id, day(2), 100.0, 5, 3);
472 + log_set(&db, id, day(2), 100.0, 4, 3);
453 473 db.compute_prescription(id).unwrap();
454 474 assert_eq!(
455 475 db.read_progression_state(id).unwrap(),
@@ -463,8 +483,8 @@ mod tests {
463 483 use super::*;
464 484 use chrono::NaiveDate;
465 485
466 - fn s(date: NaiveDate, load: f64, reps: i32, rpe: i32, set_index: i32) -> SessionSet {
467 - SessionSet {
486 + fn s(date: NaiveDate, load: f64, reps: i32, rpe: i32, set_index: i32) -> RepsSet {
487 + RepsSet {
468 488 id: 0,
469 489 session_date: date,
470 490 exercise_id: 1,
@@ -480,7 +500,7 @@ mod tests {
480 500 NaiveDate::from_ymd_opt(2026, 7, n).unwrap()
481 501 }
482 502
483 - fn session(date: NaiveDate, load: f64, reps: i32, rpe: i32, sets: i32) -> Vec<SessionSet> {
503 + fn session(date: NaiveDate, load: f64, reps: i32, rpe: i32, sets: i32) -> Vec<RepsSet> {
484 504 (1..=sets).map(|i| s(date, load, reps, rpe, i)).collect()
485 505 }
486 506
@@ -1,416 +0,0 @@
1 - //! Logged sets: the atomic unit of history.
2 - //!
3 - //! One row per set (date, exercise, set_index, load, reps, rpe). A "session"
4 - //! is a `SELECT ... WHERE session_date = ?`. Rpe is a 1-5 integer enforced
5 - //! by CHECK at the schema level.
6 -
7 - use chrono::NaiveDate;
8 - use rusqlite::{OptionalExtension, Row, params};
9 -
10 - use crate::db::Db;
11 - use crate::error::Error;
12 - use crate::estimator::estimate_e1rm;
13 - use crate::values::{Load, Reps, Rpe};
14 -
15 - fn bad_column(field: &'static str, msg: String) -> rusqlite::Error {
16 - rusqlite::Error::FromSqlConversionFailure(
17 - 0,
18 - rusqlite::types::Type::Real,
19 - Box::new(std::io::Error::other(format!("{field}: {msg}"))),
20 - )
21 - }
22 -
23 - /// Row -> SessionSet decoder shared by every set-fetching query. Column
24 - /// order must match the callers' SELECT lists. Validation happens here
25 - /// so a bad row (RPE 7, negative load) errors at read time instead of
26 - /// silently propagating through the progression walk.
27 - fn row_to_session_set(row: &Row<'_>) -> rusqlite::Result<SessionSet> {
28 - let date_str: String = row.get(1)?;
29 - let parsed = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").map_err(|e| {
30 - rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
31 - })?;
32 - let flag: i64 = row.get(7)?;
33 - let load = Load::new(row.get(4)?).map_err(|e| bad_column("load", e.to_string()))?;
34 - let reps = Reps::new(row.get(5)?).map_err(|e| bad_column("reps", e.to_string()))?;
35 - let rpe = Rpe::new(row.get(6)?).map_err(|e| bad_column("rpe", e.to_string()))?;
36 - Ok(SessionSet {
37 - id: row.get(0)?,
38 - session_date: parsed,
39 - exercise_id: row.get(2)?,
40 - set_index: row.get(3)?,
41 - load,
42 - reps,
43 - rpe,
44 - is_diagnostic: flag != 0,
45 - })
46 - }
47 -
48 - #[derive(Debug, Clone, PartialEq)]
49 - pub struct SessionSet {
50 - pub id: i64,
51 - pub session_date: NaiveDate,
52 - pub exercise_id: i64,
53 - pub set_index: i32,
54 - pub load: Load,
55 - pub reps: Reps,
56 - pub rpe: Rpe,
57 - pub is_diagnostic: bool,
58 - }
59 -
60 - impl Db {
61 - /// List sets for an exercise on a given date, ordered by set_index.
62 - /// Includes diagnostic sets (the log screen shows every row that was
63 - /// actually recorded that day; filtering happens further downstream).
64 - pub fn list_sets_for_session(
65 - &self,
66 - exercise_id: i64,
67 - date: NaiveDate,
68 - ) -> Result<Vec<SessionSet>, Error> {
69 - let mut stmt = self.conn().prepare(
70 - "SELECT id, session_date, exercise_id, set_index, load, reps, rpe, is_diagnostic \
71 - FROM reps_sets WHERE exercise_id = ?1 AND session_date = ?2 \
72 - ORDER BY set_index",
73 - )?;
74 - let rows = stmt.query_map(
75 - params![exercise_id, date.to_string()],
76 - |row| row_to_session_set(row),
77 - )?;
78 - Ok(rows.collect::<Result<Vec<_>, _>>()?)
79 - }
80 -
81 - /// Append a normal (non-diagnostic) working set. `set_index` is
82 - /// auto-assigned as one past the current max for this (date, exercise).
83 - /// Returns the new row id.
84 - pub fn append_set(
85 - &self,
86 - exercise_id: i64,
87 - date: NaiveDate,
88 - load: Load,
89 - reps: Reps,
90 - rpe: Rpe,
91 - ) -> Result<i64, Error> {
92 - self.append_set_inner(exercise_id, date, load, reps, rpe, false)
93 - }
94 -
95 - /// Append a diagnostic set. Diagnostic sets feed e1RM and seed future
96 - /// prescriptions but are excluded from the progression state machine
97 - /// and the history sparkline.
98 - pub fn append_diagnostic_set(
99 - &self,
100 - exercise_id: i64,
101 - date: NaiveDate,
102 - load: Load,
103 - reps: Reps,
104 - rpe: Rpe,
105 - ) -> Result<i64, Error> {
106 - self.append_set_inner(exercise_id, date, load, reps, rpe, true)
107 - }
108 -
109 - fn append_set_inner(
110 - &self,
111 - exercise_id: i64,
112 - date: NaiveDate,
113 - load: Load,
114 - reps: Reps,
115 - rpe: Rpe,
116 - is_diagnostic: bool,
117 - ) -> Result<i64, Error> {
118 - let next_index: i32 = self
119 - .conn()
120 - .query_row(
121 - "SELECT COALESCE(MAX(set_index), 0) + 1 FROM reps_sets \
122 - WHERE exercise_id = ?1 AND session_date = ?2",
123 - params![exercise_id, date.to_string()],
124 - |row| row.get(0),
125 - )?;
126 - self.conn().execute(
127 - "INSERT INTO reps_sets (session_date, exercise_id, set_index, load, reps, rpe, is_diagnostic) \
128 - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
129 - params![
130 - date.to_string(),
131 - exercise_id,
132 - next_index,
133 - load.get(),
134 - reps.get(),
135 - rpe.get(),
136 - if is_diagnostic { 1 } else { 0 },
137 - ],
138 - )?;
139 - Ok(self.conn().last_insert_rowid())
140 - }
141 -
142 - /// List every non-diagnostic session for an exercise, oldest first,
143 - /// each session as a vec of its sets in `set_index` order. Empty vec
144 - /// when no history. Diagnostic sets are filtered so the progression
145 - /// state machine only walks real working sessions.
146 - pub fn list_sessions_for_exercise(
147 - &self,
148 - exercise_id: i64,
149 - ) -> Result<Vec<Vec<SessionSet>>, Error> {
150 - let mut stmt = self.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_session_set(row))?;
156 -
157 - let mut sessions: Vec<Vec<SessionSet>> = 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 - /// One row per session for an exercise: (session_date, top_load).
169 - /// Oldest first. Empty when the exercise has no history. Diagnostic
170 - /// sets are excluded so the sparkline reflects working-set progress.
171 - pub fn top_loads_over_time(
172 - &self,
173 - exercise_id: i64,
174 - ) -> Result<Vec<(NaiveDate, f64)>, Error> {
175 - let mut stmt = self.conn().prepare(
176 - "SELECT session_date, MAX(load) FROM reps_sets \
177 - WHERE exercise_id = ?1 AND is_diagnostic = 0 \
178 - GROUP BY session_date ORDER BY session_date ASC",
179 - )?;
180 - let rows = stmt.query_map(params![exercise_id], |row| {
181 - let date_str: String = row.get(0)?;
182 - let load: f64 = row.get(1)?;
183 - Ok((date_str, load))
184 - })?;
185 - let mut out = Vec::new();
186 - for row in rows {
187 - let (date_str, load) = row?;
188 - if let Ok(d) = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") {
189 - out.push((d, load));
190 - }
191 - }
192 - Ok(out)
193 - }
194 -
195 - /// Best e1RM across every set logged for `exercise_id`. `None` when
196 - /// the exercise has no sets, or when no set carries an e1RM signal
197 - /// (all sets at warmup RPE, for instance).
198 - pub fn best_e1rm(&self, exercise_id: i64) -> Result<Option<f64>, Error> {
199 - let mut stmt = self.conn().prepare(
200 - "SELECT load, reps, rpe FROM reps_sets WHERE exercise_id = ?1",
201 - )?;
202 - let rows = stmt.query_map(params![exercise_id], |row| {
203 - Ok((
204 - row.get::<_, f64>(0)?,
205 - row.get::<_, i32>(1)?,
206 - row.get::<_, i32>(2)?,
207 - ))
208 - })?;
209 - let mut best: Option<f64> = None;
210 - for row in rows {
211 - let (load, reps, rpe) = row?;
212 - if let Some(e) = estimate_e1rm(load, reps, rpe) {
213 - best = Some(best.map_or(e, |b| b.max(e)));
214 - }
215 - }
216 - Ok(best)
217 - }
218 -
219 - /// Top load from the most recent diagnostic on this exercise. Used by
220 - /// the Generate screen to seed a first working prescription when
221 - /// there is no non-diagnostic history yet. `None` when no diagnostic
222 - /// has been logged.
223 - pub fn diagnostic_seed(&self, exercise_id: i64) -> Result<Option<f64>, Error> {
224 - let mut stmt = self.conn().prepare(
225 - "SELECT MAX(load) FROM reps_sets \
226 - WHERE exercise_id = ?1 AND is_diagnostic = 1 \
227 - AND session_date = ( \
228 - SELECT MAX(session_date) FROM reps_sets \
229 - WHERE exercise_id = ?1 AND is_diagnostic = 1 \
230 - )",
231 - )?;
232 - let out: Option<f64> = stmt
233 - .query_row(params![exercise_id], |row| row.get(0))
234 - .optional()?
235 - .flatten();
236 - Ok(out)
237 - }
238 -
239 - /// Delete a specific set row by id. Returns `NotFound` if it did not
240 - /// exist. Subsequent `append_set` calls do NOT reuse the freed index;
241 - /// gaps are fine for the state machine (which reads ordered by index).
242 - pub fn delete_set(&self, id: i64) -> Result<(), Error> {
243 - let n = self
244 - .conn()
245 - .execute("DELETE FROM reps_sets WHERE id = ?1", params![id])?;
246 - if n == 0 {
247 - return Err(Error::NotFound(format!("set {id}")));
248 - }
249 - Ok(())
250 - }
251 - }
252 -
253 - #[cfg(test)]
254 - mod tests {
255 - use super::*;
256 - use crate::values::LoadUnit;
257 - use crate::templates::ResistanceType;
258 -
259 - fn setup() -> (Db, i64) {
260 - let db = Db::open_in_memory().unwrap();
261 - db.init_profile("self", LoadUnit::Kg).unwrap();
262 - let id = db
263 - .create_exercise("squat", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
264 - .unwrap();
265 - (db, id)
266 - }
267 -
268 - #[test]
269 - fn append_and_list_round_trip() {
270 - let (db, ex) = setup();
271 - let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
272 - db.append_set(ex, date, Load::new(100.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(3).unwrap()).unwrap();
273 - db.append_set(ex, date, Load::new(100.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(3).unwrap()).unwrap();
274 - db.append_set(ex, date, Load::new(100.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(4).unwrap()).unwrap();
275 - let list = db.list_sets_for_session(ex, date).unwrap();
276 - assert_eq!(list.len(), 3);
277 - assert_eq!(list[0].set_index, 1);
278 - assert_eq!(list[1].set_index, 2);
279 - assert_eq!(list[2].rpe.get(), 4);
280 - }
281 -
282 - #[test]
283 - fn newtype_constructors_reject_out_of_range_values() {
284 - // Validation now lives on the value constructors; append_set can
285 - // never be called with a bad Rpe or Reps because you can't
286 - // construct them. These tests confirm the constructors themselves
287 - // still guard.
288 - let _ = setup();
289 - assert!(Rpe::new(0).is_err());
290 - assert!(Rpe::new(6).is_err());
291 - assert!(Reps::new(-1).is_err());
292 - assert!(Load::new(-1.0).is_err());
293 - }
294 -
295 - #[test]
296 - fn list_isolates_by_date_and_exercise() {
297 - let (db, ex1) = setup();
298 - let ex2 = db
299 - .create_exercise("bench", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
300 - .unwrap();
301 - let d1 = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
302 - let d2 = NaiveDate::from_ymd_opt(2026, 7, 19).unwrap();
303 - db.append_set(ex1, d1, Load::new(100.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(3).unwrap()).unwrap();
304 - db.append_set(ex1, d2, Load::new(105.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(3).unwrap()).unwrap();
305 - db.append_set(ex2, d1, Load::new(80.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(3).unwrap()).unwrap();
306 - assert_eq!(db.list_sets_for_session(ex1, d1).unwrap().len(), 1);
307 - assert_eq!(db.list_sets_for_session(ex1, d2).unwrap().len(), 1);
308 - assert_eq!(db.list_sets_for_session(ex2, d1).unwrap().len(), 1);
309 - }
310 -
311 - #[test]
312 - fn top_loads_over_time_picks_max_per_date() {
313 - let (db, ex) = setup();
314 - let d1 = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
315 - let d2 = NaiveDate::from_ymd_opt(2026, 7, 3).unwrap();
316 - db.append_set(ex, d1, Load::new(100.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(3).unwrap()).unwrap();
317 - db.append_set(ex, d1, Load::new(105.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(4).unwrap()).unwrap();
318 - db.append_set(ex, d2, Load::new(100.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(3).unwrap()).unwrap();
319 - let series = db.top_loads_over_time(ex).unwrap();
320 - assert_eq!(series, vec![(d1, 105.0), (d2, 100.0)]);
321 - }
322 -
323 - #[test]
324 - fn best_e1rm_none_when_no_sets() {
325 - let (db, ex) = setup();
326 - assert_eq!(db.best_e1rm(ex).unwrap(), None);
327 - }
328 -
329 - #[test]
330 - fn best_e1rm_takes_max_across_sets() {
331 - let (db, ex) = setup();
332 - let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
333 - // 100 x 5 @ RPE 5 -> e1RM 116.67
334 - // 90 x 8 @ RPE 3 -> e1RM 90 * (1 + 10/30) = 120.0
335 - // 120 x 3 @ RPE 5 -> e1RM 132.0 <- winner
336 - db.append_set(ex, date, Load::new(100.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(5).unwrap()).unwrap();
337 - db.append_set(ex, date, Load::new(90.0).unwrap(), Reps::new(8).unwrap(), Rpe::new(3).unwrap()).unwrap();
338 - db.append_set(ex, date, Load::new(120.0).unwrap(), Reps::new(3).unwrap(), Rpe::new(5).unwrap()).unwrap();
339 - let e = db.best_e1rm(ex).unwrap().unwrap();
340 - assert!((e - 132.0).abs() < 1e-6);
341 - }
342 -
343 - #[test]
344 - fn best_e1rm_ignores_warmup_rpe_1_sets() {
345 - let (db, ex) = setup();
346 - let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
347 - db.append_set(ex, date, Load::new(200.0).unwrap(), Reps::new(10).unwrap(), Rpe::new(1).unwrap()).unwrap();
348 - assert_eq!(db.best_e1rm(ex).unwrap(), None);
349 - }
350 -
351 - #[test]
352 - fn diagnostic_sets_excluded_from_progression_history() {
353 - let (db, ex) = setup();
354 - let d1 = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
355 - let d2 = NaiveDate::from_ymd_opt(2026, 7, 3).unwrap();
356 - db.append_diagnostic_set(ex, d1, Load::new(60.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(3).unwrap()).unwrap();
357 - db.append_diagnostic_set(ex, d1, Load::new(80.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(4).unwrap()).unwrap();
358 - db.append_set(ex, d2, Load::new(72.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(3).unwrap()).unwrap();
359 - let sessions = db.list_sessions_for_exercise(ex).unwrap();
360 - assert_eq!(sessions.len(), 1, "diagnostic day should not appear");
361 - assert_eq!(sessions[0][0].session_date, d2);
362 - assert!(!sessions[0][0].is_diagnostic);
363 - }
364 -
365 - #[test]
366 - fn diagnostic_sets_excluded_from_top_loads_over_time() {
367 - let (db, ex) = setup();
368 - let d1 = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
369 - let d2 = NaiveDate::from_ymd_opt(2026, 7, 3).unwrap();
370 - db.append_diagnostic_set(ex, d1, Load::new(120.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(4).unwrap()).unwrap();
371 - db.append_set(ex, d2, Load::new(80.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(3).unwrap()).unwrap();
372 - let series = db.top_loads_over_time(ex).unwrap();
373 - assert_eq!(series, vec![(d2, 80.0)]);
374 - }
375 -
376 - #[test]
377 - fn diagnostic_sets_still_count_toward_e1rm() {
378 - let (db, ex) = setup();
379 - let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
380 - db.append_diagnostic_set(ex, date, Load::new(120.0).unwrap(), Reps::new(3).unwrap(), Rpe::new(4).unwrap()).unwrap();
381 - assert!(db.best_e1rm(ex).unwrap().is_some());
382 - }
383 -
384 - #[test]
385 - fn diagnostic_seed_returns_top_of_last_diagnostic() {
386 - let (db, ex) = setup();
387 - let d1 = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
388 - let d2 = NaiveDate::from_ymd_opt(2026, 7, 5).unwrap();
389 - db.append_diagnostic_set(ex, d1, Load::new(70.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(3).unwrap()).unwrap();
390 - db.append_diagnostic_set(ex, d1, Load::new(80.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(4).unwrap()).unwrap();
391 - db.append_diagnostic_set(ex, d2, Load::new(60.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(3).unwrap()).unwrap();
392 - db.append_diagnostic_set(ex, d2, Load::new(90.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(4).unwrap()).unwrap();
393 - // Most recent diagnostic (d2) had a top of 90.
394 - assert_eq!(db.diagnostic_seed(ex).unwrap(), Some(90.0));
395 - }
396 -
397 - #[test]
398 - fn diagnostic_seed_none_when_no_diagnostic_history() {
399 - let (db, ex) = setup();
400 - let date = NaiveDate::from_ymd_opt(2026, 7, 5).unwrap();
401 - db.append_set(ex, date, Load::new(100.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(3).unwrap()).unwrap();
402 - assert_eq!(db.diagnostic_seed(ex).unwrap(), None);
403 - }
404 -
405 - #[test]
406 - fn delete_removes_and_gap_is_fine() {
407 - let (db, ex) = setup();
408 - let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
409 - let a = db.append_set(ex, date, Load::new(100.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(3).unwrap()).unwrap();
410 - db.append_set(ex, date, Load::new(100.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(3).unwrap()).unwrap();
411 - db.delete_set(a).unwrap();
412 - let list = db.list_sets_for_session(ex, date).unwrap();
413 - assert_eq!(list.len(), 1);
414 - assert_eq!(list[0].set_index, 2, "set_index preserved after delete");
415 - }
416 - }
@@ -22,7 +22,7 @@ use ratatui::style::{Modifier, Style};
22 22 use ratatui::text::{Line, Span};
23 23 use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
24 24 use ripgrow_core::diagnostic::{DiagnosticSet, DiagnosticStep, next_step};
25 - use ripgrow_core::{Db, Estimate, Exercise, Load, Reps, Rpe, SessionSet};
25 + use ripgrow_core::{Db, Estimate, Exercise, Kind, Load, Reps, RepsKind, RepsPayload, RepsSet, Rpe};
26 26
27 27 pub struct DiagnosticScreen {
28 28 date: NaiveDate,
@@ -158,7 +158,7 @@ impl DiagnosticScreen {
158 158 }
159 159
160 160 fn enter_seed(&mut self, db: &Db, exercise: Exercise) {
161 - let prior = db.diagnostic_seed(exercise.id).ok().flatten();
161 + let prior = RepsKind::diagnostic_seed(db, exercise.id).ok().flatten();
162 162 let (seed, prefilled) = match prior {
163 163 Some(v) => (format!("{v}"), true),
164 164 None => (String::new(), false),
@@ -394,7 +394,14 @@ fn commit_diagnostic_set(
394 394 ) -> Result<(), ripgrow_core::Error> {
395 395 let reps = Reps::new(parse_field("reps", &pending.reps)?)?;
396 396 let rpe = Rpe::new(parse_field("rpe", &pending.rpe)?)?;
397 - db.append_diagnostic_set(exercise.id, date, prescribed_load, reps, rpe)?;
397 + RepsKind::append(
398 + db,
399 + exercise.id,
400 + date,
401 + RepsPayload::new(prescribed_load, reps),
402 + rpe,
403 + true,
404 + )?;
398 405 committed.push(DiagnosticSet {
399 406 load: prescribed_load,
400 407 reps,
@@ -425,12 +432,12 @@ fn load_prior_diagnostic_today(
425 432 exercise_id: i64,
426 433 date: NaiveDate,
427 434 ) -> Vec<DiagnosticSet> {
428 - let Ok(list) = db.list_sets_for_session(exercise_id, date) else {
435 + let Ok(list) = RepsKind::list_sets_for_session(db, exercise_id, date) else {
429 436 return Vec::new();
430 437 };
431 438 list.into_iter()
432 439 .filter(|s| s.is_diagnostic)
433 - .map(|s: SessionSet| DiagnosticSet {
440 + .map(|s: RepsSet| DiagnosticSet {
434 441 load: s.load,
435 442 reps: s.reps,
436 443 rpe: s.rpe,
@@ -443,9 +450,9 @@ fn delete_last_diagnostic_row_today(
443 450 exercise_id: i64,
444 451 date: NaiveDate,
445 452 ) -> Result<(), ripgrow_core::Error> {
446 - let list = db.list_sets_for_session(exercise_id, date)?;
453 + let list = RepsKind::list_sets_for_session(db, exercise_id, date)?;
447 454 if let Some(last) = list.iter().rev().find(|s| s.is_diagnostic) {
448 - db.delete_set(last.id)?;
455 + RepsKind::delete(db, last.id)?;
449 456 }
450 457 Ok(())
451 458 }
@@ -673,7 +680,7 @@ mod tests {
673 680 screen.on_key(&db, key(KeyCode::Enter));
674 681 // Set persisted with is_diagnostic = true.
675 682 let today = chrono::Local::now().date_naive();
676 - let rows = db.list_sets_for_session(sq, today).unwrap();
683 + let rows = RepsKind::list_sets_for_session(&db, sq, today).unwrap();
677 684 assert_eq!(rows.len(), 1);
678 685 assert!(rows[0].is_diagnostic);
679 686 // Next load should be 60 * 1.15 = 69, rounded to 2.5 -> 70.
@@ -737,7 +744,15 @@ mod tests {
737 744 fn seed_prefills_from_prior_diagnostic() {
738 745 let (db, sq) = setup();
739 746 let d = chrono::NaiveDate::from_ymd_opt(2026, 7, 10).unwrap();
740 - db.append_diagnostic_set(sq, d, Load::new(85.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(4).unwrap()).unwrap();
747 + RepsKind::append(
748 + &db,
749 + sq,
750 + d,
751 + RepsPayload::new(Load::new(85.0).unwrap(), Reps::new(5).unwrap()),
752 + Rpe::new(4).unwrap(),
753 + true,
754 + )
755 + .unwrap();
741 756 let mut screen = DiagnosticScreen::load(&db).unwrap();
742 757 for c in "squat".chars() {
743 758 screen.on_key(&db, key(KeyCode::Char(c)));
@@ -776,9 +791,9 @@ mod tests {
776 791 }
777 792 screen.on_key(&db, key(KeyCode::Enter));
778 793 let today = chrono::Local::now().date_naive();
779 - assert_eq!(db.list_sets_for_session(sq, today).unwrap().len(), 1);
794 + assert_eq!(RepsKind::list_sets_for_session(&db, sq, today).unwrap().len(), 1);
780 795 // Undo.
781 796 screen.on_key(&db, KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL));
782 - assert_eq!(db.list_sets_for_session(sq, today).unwrap().len(), 0);
797 + assert_eq!(RepsKind::list_sets_for_session(&db, sq, today).unwrap().len(), 0);
783 798 }
784 799 }
@@ -19,7 +19,7 @@ use ratatui::text::{Line, Span};
19 19 use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
20 20 use ripgrow_core::generation::{PickedSlot, pick_session, score, warmup_tag_ids};
21 21 use ripgrow_core::{
22 - Db, Exercise, Prescription, PrescriptionResult, Readiness, State, Tag,
22 + Db, Exercise, Kind, Prescription, PrescriptionResult, Readiness, RepsKind, State, Tag,
23 23 };
24 24
25 25 use crate::pdf::{self, PrescriptionParts, SessionExport, SlotExport};
@@ -503,7 +503,7 @@ fn format_prescription(p: &Prescription, unit: &str) -> String {
503 503 }
504 504
505 505 fn direction_glyph(db: &Db, ex: &Exercise, next_load: f64) -> &'static str {
506 - let sessions = db.list_sessions_for_exercise(ex.id).unwrap_or_default();
506 + let sessions = RepsKind::list_sessions_for_exercise(db, ex.id).unwrap_or_default();
507 507 let Some(last) = sessions.last() else { return " " };
508 508 let last_top = last.iter().map(|s| s.load.get()).fold(f64::NEG_INFINITY, f64::max);
509 509 if next_load > last_top {
@@ -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, Sparkline};
10 - use ripgrow_core::{Db, Exercise, SessionSet, State};
10 + use ripgrow_core::{Db, Exercise, Kind, RepsKind, RepsSet, State};
11 11
12 12 pub struct HistoryScreen {
13 13 exercises: Vec<Exercise>,
@@ -121,7 +121,7 @@ fn render_detail(frame: &mut Frame, area: Rect, db: &Db, ex: &Exercise) {
121 121 let inner = block.inner(area);
122 122 frame.render_widget(block, area);
123 123
124 - let series = db.top_loads_over_time(ex.id).unwrap_or_default();
124 + let series = RepsKind::top_loads_over_time(db, ex.id).unwrap_or_default();
125 125 let state_str = db
126 126 .read_progression_state(ex.id)
127 127 .ok()
@@ -131,7 +131,7 @@ fn render_detail(frame: &mut Frame, area: Rect, db: &Db, ex: &Exercise) {
131 131
132 132 let (header_area, spark_area, table_area) = split_detail(inner);
133 133
134 - let e1rm_str = match db.best_e1rm(ex.id).ok().flatten() {
134 + let e1rm_str = match RepsKind::best_e1rm(db, ex.id).ok().flatten() {
135 135 Some(v) => format!("{:.1} {}", v, ex.load_unit),
136 136 None => "-".to_string(),
137 137 };
@@ -185,10 +185,9 @@ fn split_detail(area: Rect) -> (Rect, Rect, Rect) {
185 185 }
186 186
187 187 fn render_recent_table(frame: &mut Frame, area: Rect, db: &Db, ex: &Exercise) {
188 - let sessions = db
189 - .list_sessions_for_exercise(ex.id)
190 - .unwrap_or_default();
191 - let recent: Vec<&Vec<SessionSet>> = sessions.iter().rev().take(10).collect();
188 + let sessions =
189 + RepsKind::list_sessions_for_exercise(db, ex.id).unwrap_or_default();
190 + let recent: Vec<&Vec<RepsSet>> = sessions.iter().rev().take(10).collect();
192 191
193 192 let mut lines: Vec<Line> = vec![Line::from(Span::styled(
194 193 format!(
@@ -225,7 +224,7 @@ fn render_recent_table(frame: &mut Frame, area: Rect, db: &Db, ex: &Exercise) {
225 224 );
226 225 }
227 226
228 - fn top_load(session: &[SessionSet]) -> f64 {
227 + fn top_load(session: &[RepsSet]) -> f64 {
229 228 session
230 229 .iter()
231 230 .map(|s| s.load.get())
@@ -17,7 +17,7 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect};
17 17 use ratatui::style::{Modifier, Style};
18 18 use ratatui::text::{Line, Span};
19 19 use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
20 - use ripgrow_core::{Db, Exercise, SessionSet};
20 + use ripgrow_core::{Db, Exercise, Kind, RepsKind, RepsPayload, RepsSet};
21 21
22 22 pub struct LogScreen {
23 23 date: NaiveDate,
@@ -34,7 +34,7 @@ struct PickState {
34 34
35 35 struct GridState {
36 36 exercise: Exercise,
37 - committed: Vec<SessionSet>,
37 + committed: Vec<RepsSet>,
38 38 pending: PendingRow,
39 39 focus: GridField,
40 40 }
@@ -154,7 +154,7 @@ impl LogScreen {
154 154
155 155 fn refresh_grid(&mut self, db: &Db) {
156 156 if let Some(g) = self.grid.as_mut()
157 - && let Ok(list) = db.list_sets_for_session(g.exercise.id, self.date)
157 + && let Ok(list) = RepsKind::list_sets_for_session(db, g.exercise.id, self.date)
158 158 {
159 159 g.committed = list;
160 160 }
@@ -196,7 +196,7 @@ impl LogScreen {
196 196 }
197 197 (KeyCode::Backspace, KeyModifiers::CONTROL) => {
198 198 if let Some(last) = g.committed.last().cloned() {
199 - match db.delete_set(last.id) {
199 + match RepsKind::delete(db, last.id) {
200 200 Ok(()) => {
201 201 self.status = "last set deleted".to_string();
202 202 self.refresh_grid(db);
@@ -274,7 +274,14 @@ fn commit_pending(
274 274 let load = ripgrow_core::Load::new(parse_field("load", &g.pending.load)?)?;
275 275 let reps = ripgrow_core::Reps::new(parse_field("reps", &g.pending.reps)?)?;
276 276 let rpe = ripgrow_core::Rpe::new(parse_field("rpe", &g.pending.rpe)?)?;
277 - db.append_set(g.exercise.id, date, load, reps, rpe)?;
277 + RepsKind::append(
278 + db,
279 + g.exercise.id,
280 + date,
281 + RepsPayload::new(load, reps),
282 + rpe,
283 + false,
284 + )?;
278 285 g.pending = PendingRow::default();
279 286 g.focus = GridField::Load;
280 287 Ok(())
@@ -443,7 +450,7 @@ mod tests {
443 450 screen.on_key(&db, key(KeyCode::Enter));
444 451
445 452 let today = chrono::Local::now().date_naive();
446 - let list = db.list_sets_for_session(sq, today).unwrap();
453 + let list = RepsKind::list_sets_for_session(&db, sq, today).unwrap();
447 454 assert_eq!(list.len(), 1);
448 455 assert_eq!(list[0].load.get(), 100.0);
449 456 assert_eq!(list[0].reps.get(), 5);
@@ -458,8 +465,9 @@ mod tests {
458 465 fn ctrl_backspace_removes_last_committed_set() {
459 466 let (db, sq) = setup();
460 467 let today = chrono::Local::now().date_naive();
461 - db.append_set(sq, today, Load::new(100.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(3).unwrap()).unwrap();
462 - db.append_set(sq, today, Load::new(100.0).unwrap(), Reps::new(5).unwrap(), Rpe::new(3).unwrap()).unwrap();
468 + let payload = RepsPayload::new(Load::new(100.0).unwrap(), Reps::new(5).unwrap());
469 + RepsKind::append(&db, sq, today, payload, Rpe::new(3).unwrap(), false).unwrap();
470 + RepsKind::append(&db, sq, today, payload, Rpe::new(3).unwrap(), false).unwrap();
463 471 let mut screen = LogScreen::load(&db).unwrap();
464 472 for c in "squat".chars() {
465 473 screen.on_key(&db, key(KeyCode::Char(c)));
@@ -471,7 +479,7 @@ mod tests {
471 479 &db,
472 480 KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL),
473 481 );
474 - assert_eq!(db.list_sets_for_session(sq, today).unwrap().len(), 1);
482 + assert_eq!(RepsKind::list_sets_for_session(&db, sq, today).unwrap().len(), 1);
475 483 }
476 484
477 485 #[test]