Skip to main content

max / ripgrow

9.7 KB · 312 lines History Blame Raw
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 Self::delete(db, last.id)?;
190 }
191 Ok(())
192 }
193
194 fn evaluate(sets: &[Self::Set]) -> Outcome {
195 if sets.is_empty() {
196 return Outcome::Miss;
197 }
198 let max_rpe = sets.iter().map(|s| s.rpe.get()).max().unwrap_or(1);
199 if max_rpe >= 5 {
200 Outcome::Miss
201 } else if max_rpe == 4 {
202 Outcome::Hold
203 } else {
204 Outcome::CleanHit
205 }
206 }
207
208 fn compute_prescription(
209 _db: &Db,
210 _exercise: &Exercise,
211 ) -> Result<PrescriptionResult<Self::Prescription>, Error> {
212 // Track-only. See the module docs for why.
213 Ok(PrescriptionResult::NoHistory)
214 }
215 }
216
217 impl DistanceKind {
218 /// Delete a specific distance set by id. Returns `NotFound` if it
219 /// did not exist. Symmetric with
220 /// [`RepsKind::delete`](super::reps::RepsKind::delete).
221 pub fn delete(db: &Db, id: i64) -> Result<(), Error> {
222 let n = db
223 .conn()
224 .execute("DELETE FROM distance_sets WHERE id = ?1", params![id])?;
225 if n == 0 {
226 return Err(Error::NotFound(format!("distance set {id}")));
227 }
228 Ok(())
229 }
230 }
231
232 #[cfg(test)]
233 mod tests {
234 use super::*;
235 use crate::values::LoadUnit;
236 use crate::ResistanceType;
237
238 fn setup() -> (Db, i64) {
239 let db = Db::open_in_memory().unwrap();
240 db.init_profile("self", LoadUnit::Kg).unwrap();
241 let id = db
242 .create_exercise(
243 "5k run",
244 ResistanceType::CardioDistance,
245 LoadUnit::Kg,
246 0.0,
247 &[],
248 )
249 .unwrap();
250 (db, id)
251 }
252
253 fn day(n: u32) -> NaiveDate {
254 NaiveDate::from_ymd_opt(2026, 7, n).unwrap()
255 }
256
257 #[test]
258 fn discriminant_and_table_agree_with_schema() {
259 assert_eq!(DistanceKind::DISCRIMINANT, EffortKind::Distance);
260 assert_eq!(DistanceKind::TABLE, "distance_sets");
261 }
262
263 #[test]
264 fn append_and_list_round_trip() {
265 let (db, ex) = setup();
266 DistanceKind::append(
267 &db,
268 ex,
269 day(18),
270 DistancePayload::new(
271 Distance::from_meters(5000.0).unwrap(),
272 Duration::from_seconds(1500).unwrap(),
273 ),
274 Rpe::new(3).unwrap(),
275 false,
276 )
277 .unwrap();
278 let list = DistanceKind::list_sets_for_session(&db, ex, day(18)).unwrap();
279 assert_eq!(list.len(), 1);
280 assert_eq!(list[0].distance.meters(), 5000.0);
281 assert_eq!(list[0].duration.seconds(), 1500);
282 }
283
284 #[test]
285 fn compute_prescription_is_always_no_history() {
286 let (db, ex) = setup();
287 // Log a real session; distance should still refuse to program.
288 DistanceKind::append(
289 &db,
290 ex,
291 day(10),
292 DistancePayload::new(
293 Distance::from_meters(5000.0).unwrap(),
294 Duration::from_seconds(1500).unwrap(),
295 ),
296 Rpe::new(3).unwrap(),
297 false,
298 )
299 .unwrap();
300 let exercise = db
301 .list_exercises()
302 .unwrap()
303 .into_iter()
304 .find(|e| e.id == ex)
305 .unwrap();
306 assert!(matches!(
307 DistanceKind::compute_prescription(&db, &exercise).unwrap(),
308 PrescriptionResult::NoHistory
309 ));
310 }
311 }
312