//! Distance effort kind. //! //! Two numbers per set: how far, and how long. Running, cycling, rowing //! for distance land here. RPE and the diagnostic flag ride on top. //! //! **Track-only, no automatic prescription.** ripgrow's design brief is //! a resistance-training programmer that also happens to log; distance //! cardio has multiple valid progression axes (increase distance holding //! time; decrease time holding distance; hold both and drop RPE) and //! picking one imposes a training philosophy we haven't committed to. //! `compute_prescription` therefore always returns `NoHistory`, which //! makes the Generate screen render "seed on first log" — an honest //! "we don't program this for you." Distance sessions still show up in //! the History screen and the recency-based generator scores their //! exercises normally. use chrono::NaiveDate; use rusqlite::{Row, params}; use crate::db::Db; use crate::error::Error; use crate::templates::Exercise; use crate::values::{Distance, Duration, Rpe}; use super::{EffortKind, Kind, Outcome, PrescriptionResult}; pub struct DistanceKind; #[derive(Debug, Clone, Copy, PartialEq)] pub struct DistancePayload { pub distance: Distance, pub duration: Duration, } impl DistancePayload { pub fn new(distance: Distance, duration: Duration) -> Self { Self { distance, duration } } } #[derive(Debug, Clone, PartialEq)] pub struct DistanceSet { pub id: i64, pub session_date: NaiveDate, pub exercise_id: i64, pub set_index: i32, pub distance: Distance, pub duration: Duration, pub rpe: Rpe, pub is_diagnostic: bool, } /// Zero-sized marker to satisfy the trait's `Prescription` associated /// type. Distance never returns `Prescribed` today; the type only /// exists so `Kind` compiles. When we commit to a distance progression /// model this becomes a real struct. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct DistancePrescription; fn row_to_distance_set(row: &Row<'_>) -> rusqlite::Result { let date_str: String = row.get(1)?; let session_date = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").map_err(|e| { rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e)) })?; let meters: f64 = row.get(4)?; let secs: i32 = row.get(5)?; let rpe_raw: i32 = row.get(6)?; let diag: i64 = row.get(7)?; let distance = Distance::from_meters(meters).map_err(|e| { rusqlite::Error::FromSqlConversionFailure( 4, rusqlite::types::Type::Real, Box::new(std::io::Error::other(e.to_string())), ) })?; let duration = Duration::from_seconds(secs).map_err(|e| { rusqlite::Error::FromSqlConversionFailure( 5, rusqlite::types::Type::Integer, Box::new(std::io::Error::other(e.to_string())), ) })?; let rpe = Rpe::new(rpe_raw).map_err(|e| { rusqlite::Error::FromSqlConversionFailure( 6, rusqlite::types::Type::Integer, Box::new(std::io::Error::other(e.to_string())), ) })?; Ok(DistanceSet { id: row.get(0)?, session_date, exercise_id: row.get(2)?, set_index: row.get(3)?, distance, duration, rpe, is_diagnostic: diag != 0, }) } impl Kind for DistanceKind { type Payload = DistancePayload; type Set = DistanceSet; type Prescription = DistancePrescription; const DISCRIMINANT: EffortKind = EffortKind::Distance; const TABLE: &'static str = "distance_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 distance_sets \ WHERE exercise_id = ?1 AND session_date = ?2", params![exercise_id, date.to_string()], |row| row.get(0), )?; db.conn().execute( "INSERT INTO distance_sets \ (session_date, exercise_id, set_index, distance_meters, \ duration_seconds, rpe, is_diagnostic) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ date.to_string(), exercise_id, next_index, payload.distance.meters(), payload.duration.seconds(), 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, \ distance_meters, duration_seconds, rpe, is_diagnostic \ FROM distance_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_distance_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, \ distance_meters, duration_seconds, rpe, is_diagnostic \ FROM distance_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_distance_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 { if sets.is_empty() { return Outcome::Miss; } let max_rpe = sets.iter().map(|s| s.rpe.get()).max().unwrap_or(1); if max_rpe >= 5 { Outcome::Miss } else if max_rpe == 4 { Outcome::Hold } else { Outcome::CleanHit } } fn compute_prescription( _db: &Db, _exercise: &Exercise, ) -> Result, Error> { // Track-only. See the module docs for why. Ok(PrescriptionResult::NoHistory) } } impl DistanceKind { /// Delete a specific distance set by id. Returns `NotFound` if it /// did not exist. Symmetric with /// [`RepsKind::delete`](super::reps::RepsKind::delete). pub fn delete(db: &Db, id: i64) -> Result<(), Error> { let n = db .conn() .execute("DELETE FROM distance_sets WHERE id = ?1", params![id])?; if n == 0 { return Err(Error::NotFound(format!("distance 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( "5k run", ResistanceType::CardioDistance, LoadUnit::Kg, 0.0, &[], ) .unwrap(); (db, id) } fn day(n: u32) -> NaiveDate { NaiveDate::from_ymd_opt(2026, 7, n).unwrap() } #[test] fn discriminant_and_table_agree_with_schema() { assert_eq!(DistanceKind::DISCRIMINANT, EffortKind::Distance); assert_eq!(DistanceKind::TABLE, "distance_sets"); } #[test] fn append_and_list_round_trip() { let (db, ex) = setup(); DistanceKind::append( &db, ex, day(18), DistancePayload::new( Distance::from_meters(5000.0).unwrap(), Duration::from_seconds(1500).unwrap(), ), Rpe::new(3).unwrap(), false, ) .unwrap(); let list = DistanceKind::list_sets_for_session(&db, ex, day(18)).unwrap(); assert_eq!(list.len(), 1); assert_eq!(list[0].distance.meters(), 5000.0); assert_eq!(list[0].duration.seconds(), 1500); } #[test] fn compute_prescription_is_always_no_history() { let (db, ex) = setup(); // Log a real session; distance should still refuse to program. DistanceKind::append( &db, ex, day(10), DistancePayload::new( Distance::from_meters(5000.0).unwrap(), Duration::from_seconds(1500).unwrap(), ), Rpe::new(3).unwrap(), false, ) .unwrap(); let exercise = db .list_exercises() .unwrap() .into_iter() .find(|e| e.id == ex) .unwrap(); assert!(matches!( DistanceKind::compute_prescription(&db, &exercise).unwrap(), PrescriptionResult::NoHistory )); } }