//! Reps effort kind. //! //! Weight moved for a rep count at an RPE. This is the shape ripgrow //! spoke natively before the phase-4 split, and it remains the primary //! effort kind. Everything reps-shaped lives here: the row struct, the //! SQL, the shared state-machine glue, and the ancillary queries //! (`top_loads_over_time`, `best_e1rm`, `diagnostic_seed`, `delete`) //! that don't fit on the [`Kind`] trait but are still reps-only. use chrono::NaiveDate; use rusqlite::{OptionalExtension, Row, params}; use crate::db::Db; use crate::error::Error; use crate::estimator::estimate_e1rm; use crate::progression::{self, Prescription}; use crate::templates::Exercise; use crate::values::{Load, Reps, Rpe}; use super::{EffortKind, Kind, Outcome, PrescriptionResult}; /// Marker type carrying the reps [`Kind`] impl. All actual state lives /// in the associated types and the DB. pub struct RepsKind; /// Kind-specific input for inserting a reps set. #[derive(Debug, Clone, Copy, PartialEq)] pub struct RepsPayload { pub load: Load, pub reps: Reps, } impl RepsPayload { pub fn new(load: Load, reps: Reps) -> Self { Self { load, reps } } } /// The full stored row for a reps set. #[derive(Debug, Clone, PartialEq)] pub struct RepsSet { pub id: i64, pub session_date: NaiveDate, pub exercise_id: i64, pub set_index: i32, pub load: Load, pub reps: Reps, pub rpe: Rpe, pub is_diagnostic: bool, } /// Prescription produced by walking a reps history through the state /// machine. Alias of [`Prescription`](crate::Prescription); the /// per-kind name gives symmetry with `TimedPrescription` / /// `DistancePrescription` at the effort-dispatch boundary. pub type RepsPrescription = Prescription; fn bad_column(field: &'static str, msg: String) -> rusqlite::Error { rusqlite::Error::FromSqlConversionFailure( 0, rusqlite::types::Type::Real, Box::new(std::io::Error::other(format!("{field}: {msg}"))), ) } /// Row -> RepsSet decoder shared by every set-fetching query. Column /// order must match the callers' SELECT lists. Validation happens here /// so a bad row (RPE 7, negative load) errors at read time instead of /// silently propagating through the progression walk. fn row_to_reps_set(row: &Row<'_>) -> rusqlite::Result { let date_str: String = row.get(1)?; let parsed = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").map_err(|e| { rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e)) })?; let flag: i64 = row.get(7)?; let load = Load::new(row.get(4)?).map_err(|e| bad_column("load", e.to_string()))?; let reps = Reps::new(row.get(5)?).map_err(|e| bad_column("reps", e.to_string()))?; let rpe = Rpe::new(row.get(6)?).map_err(|e| bad_column("rpe", e.to_string()))?; Ok(RepsSet { id: row.get(0)?, session_date: parsed, exercise_id: row.get(2)?, set_index: row.get(3)?, load, reps, rpe, is_diagnostic: flag != 0, }) } impl Kind for RepsKind { type Payload = RepsPayload; type Set = RepsSet; type Prescription = RepsPrescription; const DISCRIMINANT: EffortKind = EffortKind::Reps; const TABLE: &'static str = "reps_sets"; fn append( db: &Db, exercise_id: i64, date: NaiveDate, payload: Self::Payload, rpe: Rpe, is_diagnostic: bool, ) -> Result { let next_index: i32 = db.conn().query_row( "SELECT COALESCE(MAX(set_index), 0) + 1 FROM reps_sets \ WHERE exercise_id = ?1 AND session_date = ?2", params![exercise_id, date.to_string()], |row| row.get(0), )?; db.conn().execute( "INSERT INTO reps_sets (session_date, exercise_id, set_index, load, reps, rpe, is_diagnostic) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ date.to_string(), exercise_id, next_index, payload.load.get(), payload.reps.get(), rpe.get(), if is_diagnostic { 1 } else { 0 }, ], )?; Ok(db.conn().last_insert_rowid()) } fn list_sets_for_session( db: &Db, exercise_id: i64, date: NaiveDate, ) -> Result, Error> { let mut stmt = db.conn().prepare( "SELECT id, session_date, exercise_id, set_index, load, reps, rpe, is_diagnostic \ FROM reps_sets WHERE exercise_id = ?1 AND session_date = ?2 \ ORDER BY set_index", )?; let rows = stmt.query_map( params![exercise_id, date.to_string()], |row| row_to_reps_set(row), )?; Ok(rows.collect::, _>>()?) } fn list_sessions_for_exercise( db: &Db, exercise_id: i64, ) -> Result>, Error> { let mut stmt = db.conn().prepare( "SELECT id, session_date, exercise_id, set_index, load, reps, rpe, is_diagnostic \ FROM reps_sets WHERE exercise_id = ?1 AND is_diagnostic = 0 \ ORDER BY session_date ASC, set_index ASC", )?; let rows = stmt.query_map(params![exercise_id], |row| row_to_reps_set(row))?; let mut sessions: Vec> = Vec::new(); for row in rows { let set = row?; match sessions.last_mut() { Some(last) if last[0].session_date == set.session_date => last.push(set), _ => sessions.push(vec![set]), } } Ok(sessions) } fn delete_last_diagnostic_on_date( db: &Db, exercise_id: i64, date: NaiveDate, ) -> Result<(), Error> { let list = Self::list_sets_for_session(db, exercise_id, date)?; if let Some(last) = list.iter().rev().find(|s| s.is_diagnostic) { Self::delete(db, last.id)?; } Ok(()) } fn evaluate(sets: &[Self::Set]) -> Outcome { // Target reps carries over from the previous session; the state // machine's own walker handles the seeding case. This helper is // for callers that want to evaluate a single session under a // known target elsewhere; for the walk-history path we delegate // to progression::evaluate_session at a lower level. let target = sets.iter().map(|s| s.reps.get()).max().unwrap_or(0); progression::evaluate_session(sets, target) } fn compute_prescription( db: &Db, exercise: &Exercise, ) -> Result, Error> { use crate::progression::PrescriptionResult as OldResult; match db.compute_prescription(exercise.id)? { OldResult::NoHistory => Ok(PrescriptionResult::NoHistory), OldResult::Prescribed(p) => Ok(PrescriptionResult::Prescribed(p)), } } } impl RepsKind { /// One row per session for `exercise_id`: (session_date, top_load). /// Oldest first. Empty when the exercise has no history. Diagnostic /// sets are excluded so the sparkline reflects working-set progress. pub fn top_loads_over_time( db: &Db, exercise_id: i64, ) -> Result, Error> { let mut stmt = db.conn().prepare( "SELECT session_date, MAX(load) FROM reps_sets \ WHERE exercise_id = ?1 AND is_diagnostic = 0 \ GROUP BY session_date ORDER BY session_date ASC", )?; let rows = stmt.query_map(params![exercise_id], |row| { let date_str: String = row.get(0)?; let load: f64 = row.get(1)?; Ok((date_str, load)) })?; let mut out = Vec::new(); for row in rows { let (date_str, load) = row?; if let Ok(d) = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") { out.push((d, load)); } } Ok(out) } /// Best e1RM across every reps set logged for `exercise_id`. `None` /// when the exercise has no sets, or when no set carries an e1RM /// signal (all sets at warmup RPE, for instance). pub fn best_e1rm(db: &Db, exercise_id: i64) -> Result, Error> { let mut stmt = db.conn().prepare( "SELECT load, reps, rpe FROM reps_sets WHERE exercise_id = ?1", )?; let rows = stmt.query_map(params![exercise_id], |row| { Ok(( row.get::<_, f64>(0)?, row.get::<_, i32>(1)?, row.get::<_, i32>(2)?, )) })?; let mut best: Option = None; for row in rows { let (load, reps, rpe) = row?; if let Some(e) = estimate_e1rm(load, reps, rpe) { best = Some(best.map_or(e, |b| b.max(e))); } } Ok(best) } /// Top load from the most recent diagnostic on this exercise. Used /// by the Generate screen to seed a first working prescription when /// there is no non-diagnostic history yet. `None` when no diagnostic /// has been logged. pub fn diagnostic_seed(db: &Db, exercise_id: i64) -> Result, Error> { let mut stmt = db.conn().prepare( "SELECT MAX(load) FROM reps_sets \ WHERE exercise_id = ?1 AND is_diagnostic = 1 \ AND session_date = ( \ SELECT MAX(session_date) FROM reps_sets \ WHERE exercise_id = ?1 AND is_diagnostic = 1 \ )", )?; let out: Option = stmt .query_row(params![exercise_id], |row| row.get(0)) .optional()? .flatten(); Ok(out) } /// Delete a specific reps set by id. Returns `NotFound` if it did /// not exist. Subsequent `append` calls do NOT reuse the freed /// index; gaps are fine for the state machine (which reads ordered /// by index). pub fn delete(db: &Db, id: i64) -> Result<(), Error> { let n = db .conn() .execute("DELETE FROM reps_sets WHERE id = ?1", params![id])?; if n == 0 { return Err(Error::NotFound(format!("set {id}"))); } Ok(()) } } #[cfg(test)] mod tests { use super::*; use crate::values::LoadUnit; use crate::ResistanceType; fn setup() -> (Db, i64) { let db = Db::open_in_memory().unwrap(); db.init_profile("self", LoadUnit::Kg).unwrap(); let id = db .create_exercise("squat", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[]) .unwrap(); (db, id) } fn payload(load: f64, reps: i32) -> RepsPayload { RepsPayload::new(Load::new(load).unwrap(), Reps::new(reps).unwrap()) } fn rpe(v: i32) -> Rpe { Rpe::new(v).unwrap() } #[test] fn kind_discriminant_matches_effort_kind() { assert_eq!(RepsKind::DISCRIMINANT, EffortKind::Reps); assert_eq!(RepsKind::TABLE, "reps_sets"); } #[test] fn append_and_list_round_trip() { let (db, ex) = setup(); let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(); RepsKind::append(&db, ex, date, payload(100.0, 5), rpe(3), false).unwrap(); RepsKind::append(&db, ex, date, payload(100.0, 5), rpe(3), false).unwrap(); RepsKind::append(&db, ex, date, payload(100.0, 5), rpe(4), false).unwrap(); let list = RepsKind::list_sets_for_session(&db, ex, date).unwrap(); assert_eq!(list.len(), 3); assert_eq!(list[0].set_index, 1); assert_eq!(list[1].set_index, 2); assert_eq!(list[2].rpe.get(), 4); } #[test] fn newtype_constructors_reject_out_of_range_values() { // Validation lives on the value constructors; append can never // be called with a bad Rpe or Reps because you can't construct // them. These tests confirm the constructors themselves still // guard. assert!(Rpe::new(0).is_err()); assert!(Rpe::new(6).is_err()); assert!(Reps::new(-1).is_err()); assert!(Load::new(-1.0).is_err()); } #[test] fn list_isolates_by_date_and_exercise() { let (db, ex1) = setup(); let ex2 = db .create_exercise("bench", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[]) .unwrap(); let d1 = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(); let d2 = NaiveDate::from_ymd_opt(2026, 7, 19).unwrap(); RepsKind::append(&db, ex1, d1, payload(100.0, 5), rpe(3), false).unwrap(); RepsKind::append(&db, ex1, d2, payload(105.0, 5), rpe(3), false).unwrap(); RepsKind::append(&db, ex2, d1, payload(80.0, 5), rpe(3), false).unwrap(); assert_eq!(RepsKind::list_sets_for_session(&db, ex1, d1).unwrap().len(), 1); assert_eq!(RepsKind::list_sets_for_session(&db, ex1, d2).unwrap().len(), 1); assert_eq!(RepsKind::list_sets_for_session(&db, ex2, d1).unwrap().len(), 1); } #[test] fn top_loads_over_time_picks_max_per_date() { let (db, ex) = setup(); let d1 = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap(); let d2 = NaiveDate::from_ymd_opt(2026, 7, 3).unwrap(); RepsKind::append(&db, ex, d1, payload(100.0, 5), rpe(3), false).unwrap(); RepsKind::append(&db, ex, d1, payload(105.0, 5), rpe(4), false).unwrap(); RepsKind::append(&db, ex, d2, payload(100.0, 5), rpe(3), false).unwrap(); let series = RepsKind::top_loads_over_time(&db, ex).unwrap(); assert_eq!(series, vec![(d1, 105.0), (d2, 100.0)]); } #[test] fn best_e1rm_none_when_no_sets() { let (db, ex) = setup(); assert_eq!(RepsKind::best_e1rm(&db, ex).unwrap(), None); } #[test] fn best_e1rm_takes_max_across_sets() { let (db, ex) = setup(); let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(); // 100 x 5 @ RPE 5 -> e1RM 116.67 // 90 x 8 @ RPE 3 -> e1RM 90 * (1 + 10/30) = 120.0 // 120 x 3 @ RPE 5 -> e1RM 132.0 <- winner RepsKind::append(&db, ex, date, payload(100.0, 5), rpe(5), false).unwrap(); RepsKind::append(&db, ex, date, payload(90.0, 8), rpe(3), false).unwrap(); RepsKind::append(&db, ex, date, payload(120.0, 3), rpe(5), false).unwrap(); let e = RepsKind::best_e1rm(&db, ex).unwrap().unwrap(); assert!((e - 132.0).abs() < 1e-6); } #[test] fn best_e1rm_ignores_warmup_rpe_1_sets() { let (db, ex) = setup(); let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(); RepsKind::append(&db, ex, date, payload(200.0, 10), rpe(1), false).unwrap(); assert_eq!(RepsKind::best_e1rm(&db, ex).unwrap(), None); } #[test] fn diagnostic_sets_excluded_from_progression_history() { let (db, ex) = setup(); let d1 = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap(); let d2 = NaiveDate::from_ymd_opt(2026, 7, 3).unwrap(); RepsKind::append(&db, ex, d1, payload(60.0, 5), rpe(3), true).unwrap(); RepsKind::append(&db, ex, d1, payload(80.0, 5), rpe(4), true).unwrap(); RepsKind::append(&db, ex, d2, payload(72.0, 5), rpe(3), false).unwrap(); let sessions = RepsKind::list_sessions_for_exercise(&db, ex).unwrap(); assert_eq!(sessions.len(), 1, "diagnostic day should not appear"); assert_eq!(sessions[0][0].session_date, d2); assert!(!sessions[0][0].is_diagnostic); } #[test] fn diagnostic_sets_excluded_from_top_loads_over_time() { let (db, ex) = setup(); let d1 = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap(); let d2 = NaiveDate::from_ymd_opt(2026, 7, 3).unwrap(); RepsKind::append(&db, ex, d1, payload(120.0, 5), rpe(4), true).unwrap(); RepsKind::append(&db, ex, d2, payload(80.0, 5), rpe(3), false).unwrap(); let series = RepsKind::top_loads_over_time(&db, ex).unwrap(); assert_eq!(series, vec![(d2, 80.0)]); } #[test] fn diagnostic_sets_still_count_toward_e1rm() { let (db, ex) = setup(); let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(); RepsKind::append(&db, ex, date, payload(120.0, 3), rpe(4), true).unwrap(); assert!(RepsKind::best_e1rm(&db, ex).unwrap().is_some()); } #[test] fn diagnostic_seed_returns_top_of_last_diagnostic() { let (db, ex) = setup(); let d1 = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap(); let d2 = NaiveDate::from_ymd_opt(2026, 7, 5).unwrap(); RepsKind::append(&db, ex, d1, payload(70.0, 5), rpe(3), true).unwrap(); RepsKind::append(&db, ex, d1, payload(80.0, 5), rpe(4), true).unwrap(); RepsKind::append(&db, ex, d2, payload(60.0, 5), rpe(3), true).unwrap(); RepsKind::append(&db, ex, d2, payload(90.0, 5), rpe(4), true).unwrap(); // Most recent diagnostic (d2) had a top of 90. assert_eq!(RepsKind::diagnostic_seed(&db, ex).unwrap(), Some(90.0)); } #[test] fn diagnostic_seed_none_when_no_diagnostic_history() { let (db, ex) = setup(); let date = NaiveDate::from_ymd_opt(2026, 7, 5).unwrap(); RepsKind::append(&db, ex, date, payload(100.0, 5), rpe(3), false).unwrap(); assert_eq!(RepsKind::diagnostic_seed(&db, ex).unwrap(), None); } #[test] fn delete_removes_and_gap_is_fine() { let (db, ex) = setup(); let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(); let a = RepsKind::append(&db, ex, date, payload(100.0, 5), rpe(3), false).unwrap(); RepsKind::append(&db, ex, date, payload(100.0, 5), rpe(3), false).unwrap(); RepsKind::delete(&db, a).unwrap(); let list = RepsKind::list_sets_for_session(&db, ex, date).unwrap(); assert_eq!(list.len(), 1); assert_eq!(list[0].set_index, 2, "set_index preserved after delete"); } #[test] fn compute_prescription_no_history_via_trait() { let (db, ex) = setup(); let exercise = db .list_exercises() .unwrap() .into_iter() .find(|e| e.id == ex) .unwrap(); let r = RepsKind::compute_prescription(&db, &exercise).unwrap(); assert!(matches!(r, PrescriptionResult::NoHistory)); } }