Skip to main content

max / ripgrow

effort: DistanceKind (track-only) Third Kind impl closes the loop on schema-side phase 4. DistancePayload carries distance + duration; DistanceSet is the stored row. DistancePrescription is a zero-sized marker because distance never prescribes today — the module doc explains why: distance/pace progression has multiple defensible axes (grow distance, drop time, drop RPE at fixed pace), and picking one imposes a training philosophy outside ripgrow's resistance-training brief. Distance exercises still log, show in history, and score for generation via recency; they just don't come back from compute_prescription with a number. All three Kinds now implement the same trait shape. AnyPayload / AnySet / AnyPrescription cover every discriminant; the dispatch functions have no fallthrough arms. Callers migrating from Db::* to compute_prescription_any / append_any is a UI-layer commit (slice 4e), not part of the core wiring. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 19:09 UTC
Commit: bc72780361b154a5cd491b99c0253ccf4d9e848b
Parent: 21a1d21
3 files changed, +319 insertions, -9 deletions
@@ -0,0 +1,299 @@
1 + //! Distance effort kind.
2 + //!
3 + //! Two numbers per set: how far, and how long. Running, cycling, rowing
4 + //! for distance land here. RPE and the diagnostic flag ride on top.
5 + //!
6 + //! **Track-only, no automatic prescription.** ripgrow's design brief is
7 + //! a resistance-training programmer that also happens to log; distance
8 + //! cardio has multiple valid progression axes (increase distance holding
9 + //! time; decrease time holding distance; hold both and drop RPE) and
10 + //! picking one imposes a training philosophy we haven't committed to.
11 + //! `compute_prescription` therefore always returns `NoHistory`, which
12 + //! makes the Generate screen render "seed on first log" — an honest
13 + //! "we don't program this for you." Distance sessions still show up in
14 + //! the History screen and the recency-based generator scores their
15 + //! exercises normally.
16 +
17 + use chrono::NaiveDate;
18 + use rusqlite::{Row, params};
19 +
20 + use crate::db::Db;
21 + use crate::error::Error;
22 + use crate::templates::Exercise;
23 + use crate::values::{Distance, Duration, Rpe};
24 +
25 + use super::{EffortKind, Kind, Outcome, PrescriptionResult};
26 +
27 + pub struct DistanceKind;
28 +
29 + #[derive(Debug, Clone, Copy, PartialEq)]
30 + pub struct DistancePayload {
31 + pub distance: Distance,
32 + pub duration: Duration,
33 + }
34 +
35 + impl DistancePayload {
36 + pub fn new(distance: Distance, duration: Duration) -> Self {
37 + Self { distance, duration }
38 + }
39 + }
40 +
41 + #[derive(Debug, Clone, PartialEq)]
42 + pub struct DistanceSet {
43 + pub id: i64,
44 + pub session_date: NaiveDate,
45 + pub exercise_id: i64,
46 + pub set_index: i32,
47 + pub distance: Distance,
48 + pub duration: Duration,
49 + pub rpe: Rpe,
50 + pub is_diagnostic: bool,
51 + }
52 +
53 + /// Zero-sized marker to satisfy the trait's `Prescription` associated
54 + /// type. Distance never returns `Prescribed` today; the type only
55 + /// exists so `Kind` compiles. When we commit to a distance progression
56 + /// model this becomes a real struct.
57 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
58 + pub struct DistancePrescription;
59 +
60 + fn row_to_distance_set(row: &Row<'_>) -> rusqlite::Result<DistanceSet> {
61 + let date_str: String = row.get(1)?;
62 + let session_date = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").map_err(|e| {
63 + rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
64 + })?;
65 + let meters: f64 = row.get(4)?;
66 + let secs: i32 = row.get(5)?;
67 + let rpe_raw: i32 = row.get(6)?;
68 + let diag: i64 = row.get(7)?;
69 + let distance = Distance::from_meters(meters).map_err(|e| {
70 + rusqlite::Error::FromSqlConversionFailure(
71 + 4,
72 + rusqlite::types::Type::Real,
73 + Box::new(std::io::Error::other(e.to_string())),
74 + )
75 + })?;
76 + let duration = Duration::from_seconds(secs).map_err(|e| {
77 + rusqlite::Error::FromSqlConversionFailure(
78 + 5,
79 + rusqlite::types::Type::Integer,
80 + Box::new(std::io::Error::other(e.to_string())),
81 + )
82 + })?;
83 + let rpe = Rpe::new(rpe_raw).map_err(|e| {
84 + rusqlite::Error::FromSqlConversionFailure(
85 + 6,
86 + rusqlite::types::Type::Integer,
87 + Box::new(std::io::Error::other(e.to_string())),
88 + )
89 + })?;
90 + Ok(DistanceSet {
91 + id: row.get(0)?,
92 + session_date,
93 + exercise_id: row.get(2)?,
94 + set_index: row.get(3)?,
95 + distance,
96 + duration,
97 + rpe,
98 + is_diagnostic: diag != 0,
99 + })
100 + }
101 +
102 + impl Kind for DistanceKind {
103 + type Payload = DistancePayload;
104 + type Set = DistanceSet;
105 + type Prescription = DistancePrescription;
106 +
107 + const DISCRIMINANT: EffortKind = EffortKind::Distance;
108 + const TABLE: &'static str = "distance_sets";
109 +
110 + fn append(
111 + db: &Db,
112 + exercise_id: i64,
113 + date: NaiveDate,
114 + payload: Self::Payload,
115 + rpe: Rpe,
116 + is_diagnostic: bool,
117 + ) -> Result<i64, Error> {
118 + let next_index: i32 = db.conn().query_row(
119 + "SELECT COALESCE(MAX(set_index), 0) + 1 FROM distance_sets \
120 + WHERE exercise_id = ?1 AND session_date = ?2",
121 + params![exercise_id, date.to_string()],
122 + |row| row.get(0),
123 + )?;
124 + db.conn().execute(
125 + "INSERT INTO distance_sets \
126 + (session_date, exercise_id, set_index, distance_meters, \
127 + duration_seconds, 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 + payload.distance.meters(),
134 + payload.duration.seconds(),
135 + rpe.get(),
136 + if is_diagnostic { 1 } else { 0 },
137 + ],
138 + )?;
139 + Ok(db.conn().last_insert_rowid())
140 + }
141 +
142 + fn list_sets_for_session(
143 + db: &Db,
144 + exercise_id: i64,
145 + date: NaiveDate,
146 + ) -> Result<Vec<Self::Set>, Error> {
147 + let mut stmt = db.conn().prepare(
148 + "SELECT id, session_date, exercise_id, set_index, \
149 + distance_meters, duration_seconds, rpe, is_diagnostic \
150 + FROM distance_sets WHERE exercise_id = ?1 AND session_date = ?2 \
151 + ORDER BY set_index",
152 + )?;
153 + let rows = stmt
154 + .query_map(params![exercise_id, date.to_string()], |row| {
155 + row_to_distance_set(row)
156 + })?;
157 + Ok(rows.collect::<Result<Vec<_>, _>>()?)
158 + }
159 +
160 + fn list_sessions_for_exercise(
161 + db: &Db,
162 + exercise_id: i64,
163 + ) -> Result<Vec<Vec<Self::Set>>, Error> {
164 + let mut stmt = db.conn().prepare(
165 + "SELECT id, session_date, exercise_id, set_index, \
166 + distance_meters, duration_seconds, rpe, is_diagnostic \
167 + FROM distance_sets WHERE exercise_id = ?1 AND is_diagnostic = 0 \
168 + ORDER BY session_date ASC, set_index ASC",
169 + )?;
170 + let rows = stmt.query_map(params![exercise_id], |row| row_to_distance_set(row))?;
171 + let mut sessions: Vec<Vec<DistanceSet>> = Vec::new();
172 + for row in rows {
173 + let set = row?;
174 + match sessions.last_mut() {
175 + Some(last) if last[0].session_date == set.session_date => last.push(set),
176 + _ => sessions.push(vec![set]),
177 + }
178 + }
179 + Ok(sessions)
180 + }
181 +
182 + fn delete_last_diagnostic_on_date(
183 + db: &Db,
184 + exercise_id: i64,
185 + date: NaiveDate,
186 + ) -> Result<(), Error> {
187 + let list = Self::list_sets_for_session(db, exercise_id, date)?;
188 + if let Some(last) = list.iter().rev().find(|s| s.is_diagnostic) {
189 + db.conn().execute(
190 + "DELETE FROM distance_sets WHERE id = ?1",
191 + params![last.id],
192 + )?;
193 + }
194 + Ok(())
195 + }
196 +
197 + fn evaluate(sets: &[Self::Set]) -> Outcome {
198 + if sets.is_empty() {
199 + return Outcome::Miss;
200 + }
201 + let max_rpe = sets.iter().map(|s| s.rpe.get()).max().unwrap_or(1);
202 + if max_rpe >= 5 {
203 + Outcome::Miss
204 + } else if max_rpe == 4 {
205 + Outcome::Hold
206 + } else {
207 + Outcome::CleanHit
208 + }
209 + }
210 +
211 + fn compute_prescription(
212 + _db: &Db,
213 + _exercise: &Exercise,
214 + ) -> Result<PrescriptionResult<Self::Prescription>, Error> {
215 + // Track-only. See the module docs for why.
216 + Ok(PrescriptionResult::NoHistory)
217 + }
218 + }
219 +
220 + #[cfg(test)]
221 + mod tests {
222 + use super::*;
223 + use crate::values::LoadUnit;
224 + use crate::ResistanceType;
225 +
226 + fn setup() -> (Db, i64) {
227 + let db = Db::open_in_memory().unwrap();
228 + db.init_profile("self", LoadUnit::Kg).unwrap();
229 + let id = db
230 + .create_exercise(
231 + "5k run",
232 + ResistanceType::CardioDistance,
233 + LoadUnit::Kg,
234 + 0.0,
235 + &[],
236 + )
237 + .unwrap();
238 + (db, id)
239 + }
240 +
241 + fn day(n: u32) -> NaiveDate {
242 + NaiveDate::from_ymd_opt(2026, 7, n).unwrap()
243 + }
244 +
245 + #[test]
246 + fn discriminant_and_table_agree_with_schema() {
247 + assert_eq!(DistanceKind::DISCRIMINANT, EffortKind::Distance);
248 + assert_eq!(DistanceKind::TABLE, "distance_sets");
249 + }
250 +
251 + #[test]
252 + fn append_and_list_round_trip() {
253 + let (db, ex) = setup();
254 + DistanceKind::append(
255 + &db,
256 + ex,
257 + day(18),
258 + DistancePayload::new(
259 + Distance::from_meters(5000.0).unwrap(),
260 + Duration::from_seconds(1500).unwrap(),
261 + ),
262 + Rpe::new(3).unwrap(),
263 + false,
264 + )
265 + .unwrap();
266 + let list = DistanceKind::list_sets_for_session(&db, ex, day(18)).unwrap();
267 + assert_eq!(list.len(), 1);
268 + assert_eq!(list[0].distance.meters(), 5000.0);
269 + assert_eq!(list[0].duration.seconds(), 1500);
270 + }
271 +
272 + #[test]
273 + fn compute_prescription_is_always_no_history() {
274 + let (db, ex) = setup();
275 + // Log a real session; distance should still refuse to program.
276 + DistanceKind::append(
277 + &db,
278 + ex,
279 + day(10),
280 + DistancePayload::new(
281 + Distance::from_meters(5000.0).unwrap(),
282 + Duration::from_seconds(1500).unwrap(),
283 + ),
284 + Rpe::new(3).unwrap(),
285 + false,
286 + )
287 + .unwrap();
288 + let exercise = db
289 + .list_exercises()
290 + .unwrap()
291 + .into_iter()
292 + .find(|e| e.id == ex)
293 + .unwrap();
294 + assert!(matches!(
295 + DistanceKind::compute_prescription(&db, &exercise).unwrap(),
296 + PrescriptionResult::NoHistory
297 + ));
298 + }
299 + }
@@ -24,6 +24,7 @@ use crate::progression::SessionOutcome;
24 24 use crate::templates::Exercise;
25 25 use crate::values::Rpe;
26 26
27 + pub mod distance;
27 28 pub mod reps;
28 29 pub mod timed;
29 30
@@ -158,7 +159,7 @@ pub trait Kind: 'static {
158 159 pub enum AnyPayload {
159 160 Reps(<reps::RepsKind as Kind>::Payload),
160 161 Timed(<timed::TimedKind as Kind>::Payload),
161 - // Distance(DistanceKind::Payload) — slice 4d
162 + Distance(<distance::DistanceKind as Kind>::Payload),
162 163 }
163 164
164 165 impl AnyPayload {
@@ -166,6 +167,7 @@ impl AnyPayload {
166 167 match self {
167 168 AnyPayload::Reps(_) => EffortKind::Reps,
168 169 AnyPayload::Timed(_) => EffortKind::Timed,
170 + AnyPayload::Distance(_) => EffortKind::Distance,
169 171 }
170 172 }
171 173 }
@@ -177,7 +179,7 @@ impl AnyPayload {
177 179 pub enum AnySet {
178 180 Reps(<reps::RepsKind as Kind>::Set),
179 181 Timed(<timed::TimedKind as Kind>::Set),
180 - // Distance follows in slice 4d
182 + Distance(<distance::DistanceKind as Kind>::Set),
181 183 }
182 184
183 185 impl AnySet {
@@ -185,6 +187,7 @@ impl AnySet {
185 187 match self {
186 188 AnySet::Reps(_) => EffortKind::Reps,
187 189 AnySet::Timed(_) => EffortKind::Timed,
190 + AnySet::Distance(_) => EffortKind::Distance,
188 191 }
189 192 }
190 193 }
@@ -196,7 +199,7 @@ impl AnySet {
196 199 pub enum AnyPrescription {
197 200 Reps(<reps::RepsKind as Kind>::Prescription),
198 201 Timed(<timed::TimedKind as Kind>::Prescription),
199 - // Distance follows in slice 4d
202 + Distance(<distance::DistanceKind as Kind>::Prescription),
200 203 }
201 204
202 205 impl AnyPrescription {
@@ -204,6 +207,7 @@ impl AnyPrescription {
204 207 match self {
205 208 AnyPrescription::Reps(_) => EffortKind::Reps,
206 209 AnyPrescription::Timed(_) => EffortKind::Timed,
210 + AnyPrescription::Distance(_) => EffortKind::Distance,
207 211 }
208 212 }
209 213 }
@@ -230,10 +234,15 @@ pub fn append_any(
230 234 AnyPayload::Timed(p) => {
231 235 timed::TimedKind::append(db, exercise.id, date, p, rpe, is_diagnostic)
232 236 }
237 + AnyPayload::Distance(p) => {
238 + distance::DistanceKind::append(db, exercise.id, date, p, rpe, is_diagnostic)
239 + }
233 240 }
234 241 }
235 242
236 243 /// Dispatch: compute a prescription for the exercise regardless of kind.
244 + /// Distance kinds always return `NoHistory` today (see
245 + /// [`distance`](self::distance) module docs).
237 246 pub fn compute_prescription_any(
238 247 db: &Db,
239 248 exercise: &Exercise,
@@ -251,11 +260,13 @@ pub fn compute_prescription_any(
251 260 Ok(PrescriptionResult::Prescribed(AnyPrescription::Timed(p)))
252 261 }
253 262 },
254 - EffortKind::Distance => {
255 - // Slice 4d wires in the real impl. Until then the UI shows
256 - // "seed on first log" instead of crashing.
257 - Ok(PrescriptionResult::NoHistory)
258 - }
263 + EffortKind::Distance => match distance::DistanceKind::compute_prescription(db, exercise)?
264 + {
265 + PrescriptionResult::NoHistory => Ok(PrescriptionResult::NoHistory),
266 + PrescriptionResult::Prescribed(p) => Ok(PrescriptionResult::Prescribed(
267 + AnyPrescription::Distance(p),
268 + )),
269 + },
259 270 }
260 271 }
261 272
@@ -21,7 +21,7 @@ pub use db::Db;
21 21 pub use diagnostic::{DiagnosticSet, DiagnosticStep, next_step as next_diagnostic_step};
22 22 pub use effort::{
23 23 AnyPayload, AnyPrescription, AnySet, EffortKind, Kind, PrescriptionResult as AnyPrescriptionResult,
24 - append_any, compute_prescription_any, reps, timed,
24 + append_any, compute_prescription_any, distance, reps, timed,
25 25 };
26 26 pub use error::Error;
27 27 pub use estimator::estimate_e1rm;