Skip to main content

max / ripgrow

effort: TimedKind implementation Second Kind impl. TimedPayload is a bare Duration; TimedSet is a stored row with metadata + duration + rpe + is_diagnostic; TimedPrescription mirrors the reps one but with duration in place of load. Duration and Distance newtypes join the values module (Distance lands unused today, wired into slice 4d). The state machine stays generic. Timed's evaluate reduces a session by max RPE only (there's no rep-count target on a plank), and its prescription walker reuses progression::next_state and the DELOAD_RATIO heuristic from crate::heuristics. Increment is read from Exercise.increment reinterpreted as seconds per session; a zero increment (the default seed for CardioTime exercises) means "track but don't program," which is a reasonable default for plank-style holds. AnyPayload / AnySet / AnyPrescription grow a Timed variant; the dispatch functions cover it. Log/History/Generate screens still speak reps only — the UI wiring is slice 4e territory. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 19:07 UTC
Commit: 21a1d211e84c60883497d7f50e33a877e7c19bcf
Parent: c50200d
5 files changed, +545 insertions, -9 deletions
@@ -25,6 +25,7 @@ use crate::templates::Exercise;
25 25 use crate::values::Rpe;
26 26
27 27 pub mod reps;
28 + pub mod timed;
28 29
29 30 /// Discriminant carried on every exercise. Stored as text
30 31 /// (`'reps'` / `'timed'` / `'distance'`) in `exercises.effort_kind`,
@@ -156,7 +157,7 @@ pub trait Kind: 'static {
156 157 #[derive(Debug, Clone, PartialEq)]
157 158 pub enum AnyPayload {
158 159 Reps(<reps::RepsKind as Kind>::Payload),
159 - // Timed(TimedKind::Payload) — slice 4c
160 + Timed(<timed::TimedKind as Kind>::Payload),
160 161 // Distance(DistanceKind::Payload) — slice 4d
161 162 }
162 163
@@ -164,6 +165,7 @@ impl AnyPayload {
164 165 pub fn kind(&self) -> EffortKind {
165 166 match self {
166 167 AnyPayload::Reps(_) => EffortKind::Reps,
168 + AnyPayload::Timed(_) => EffortKind::Timed,
167 169 }
168 170 }
169 171 }
@@ -174,13 +176,15 @@ impl AnyPayload {
174 176 #[derive(Debug, Clone, PartialEq)]
175 177 pub enum AnySet {
176 178 Reps(<reps::RepsKind as Kind>::Set),
177 - // Timed / Distance follow in slice 4c/4d
179 + Timed(<timed::TimedKind as Kind>::Set),
180 + // Distance follows in slice 4d
178 181 }
179 182
180 183 impl AnySet {
181 184 pub fn kind(&self) -> EffortKind {
182 185 match self {
183 186 AnySet::Reps(_) => EffortKind::Reps,
187 + AnySet::Timed(_) => EffortKind::Timed,
184 188 }
185 189 }
186 190 }
@@ -191,13 +195,15 @@ impl AnySet {
191 195 #[derive(Debug, Clone, PartialEq)]
192 196 pub enum AnyPrescription {
193 197 Reps(<reps::RepsKind as Kind>::Prescription),
194 - // Timed / Distance follow in slice 4c/4d
198 + Timed(<timed::TimedKind as Kind>::Prescription),
199 + // Distance follows in slice 4d
195 200 }
196 201
197 202 impl AnyPrescription {
198 203 pub fn kind(&self) -> EffortKind {
199 204 match self {
200 205 AnyPrescription::Reps(_) => EffortKind::Reps,
206 + AnyPrescription::Timed(_) => EffortKind::Timed,
201 207 }
202 208 }
203 209 }
@@ -221,6 +227,9 @@ pub fn append_any(
221 227 AnyPayload::Reps(p) => {
222 228 reps::RepsKind::append(db, exercise.id, date, p, rpe, is_diagnostic)
223 229 }
230 + AnyPayload::Timed(p) => {
231 + timed::TimedKind::append(db, exercise.id, date, p, rpe, is_diagnostic)
232 + }
224 233 }
225 234 }
226 235
@@ -236,10 +245,15 @@ pub fn compute_prescription_any(
236 245 Ok(PrescriptionResult::Prescribed(AnyPrescription::Reps(p)))
237 246 }
238 247 },
239 - EffortKind::Timed | EffortKind::Distance => {
240 - // Kinds not yet implemented — treat as no history so the UI
241 - // shows "seed on first log" instead of crashing. Slice 4c/4d
242 - // wire in the real impls.
248 + EffortKind::Timed => match timed::TimedKind::compute_prescription(db, exercise)? {
249 + PrescriptionResult::NoHistory => Ok(PrescriptionResult::NoHistory),
250 + PrescriptionResult::Prescribed(p) => {
251 + Ok(PrescriptionResult::Prescribed(AnyPrescription::Timed(p)))
252 + }
253 + },
254 + EffortKind::Distance => {
255 + // Slice 4d wires in the real impl. Until then the UI shows
256 + // "seed on first log" instead of crashing.
243 257 Ok(PrescriptionResult::NoHistory)
244 258 }
245 259 }
@@ -0,0 +1,455 @@
1 + //! Timed effort kind.
2 + //!
3 + //! A single value per set — how long the position was held. Planks,
4 + //! dead hangs, isometric wall-sits, any timed cardio unit lands here.
5 + //! RPE still rides on every set (universal across kinds) as does the
6 + //! diagnostic flag.
7 + //!
8 + //! The prescription math is a stripped-down mirror of the reps state
9 + //! machine: each session's outcome is decided by max RPE (`<= 3` clean,
10 + //! `4` hold, `5` miss), and the next duration climbs by a per-exercise
11 + //! increment on clean, holds flat on hold, and deloads on miss. Same
12 + //! four states as reps (Progressing/Probation/Deloading/Rebuilding),
13 + //! same `SessionOutcome` -> `State` transitions, same
14 + //! `DELOAD_RATIO`. Only the *value* progresses differently: seconds
15 + //! instead of kilograms.
16 + //!
17 + //! Increment source: the exercise's [`Exercise::increment`] column,
18 + //! interpreted as **seconds per successful session** rather than
19 + //! kilograms per successful session. Bodyweight-holds with zero
20 + //! increment therefore never progress numerically; that's a valid
21 + //! choice for someone tracking their plank without programming it.
22 +
23 + use chrono::NaiveDate;
24 + use rusqlite::{Row, params};
25 +
26 + use crate::db::Db;
27 + use crate::error::Error;
28 + use crate::heuristics::DELOAD_RATIO;
29 + use crate::progression::{self, State};
30 + use crate::templates::Exercise;
31 + use crate::values::{Duration, Rpe};
32 +
33 + use super::{EffortKind, Kind, Outcome, PrescriptionResult};
34 +
35 + pub struct TimedKind;
36 +
37 + #[derive(Debug, Clone, Copy, PartialEq)]
38 + pub struct TimedPayload {
39 + pub duration: Duration,
40 + }
41 +
42 + impl TimedPayload {
43 + pub fn new(duration: Duration) -> Self {
44 + Self { duration }
45 + }
46 + }
47 +
48 + #[derive(Debug, Clone, PartialEq)]
49 + pub struct TimedSet {
50 + pub id: i64,
51 + pub session_date: NaiveDate,
52 + pub exercise_id: i64,
53 + pub set_index: i32,
54 + pub duration: Duration,
55 + pub rpe: Rpe,
56 + pub is_diagnostic: bool,
57 + }
58 +
59 + #[derive(Debug, Clone, PartialEq)]
60 + pub struct TimedPrescription {
61 + pub state: State,
62 + /// Number of holds to program. Echoes the last session's set count.
63 + pub sets: i32,
64 + /// Target hold duration.
65 + pub duration: Duration,
66 + }
67 +
68 + fn row_to_timed_set(row: &Row<'_>) -> rusqlite::Result<TimedSet> {
69 + let date_str: String = row.get(1)?;
70 + let session_date = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").map_err(|e| {
71 + rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
72 + })?;
73 + let secs: i32 = row.get(4)?;
74 + let rpe_raw: i32 = row.get(5)?;
75 + let diag: i64 = row.get(6)?;
76 + let duration = Duration::from_seconds(secs).map_err(|e| {
77 + rusqlite::Error::FromSqlConversionFailure(
78 + 4,
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 + 5,
86 + rusqlite::types::Type::Integer,
87 + Box::new(std::io::Error::other(e.to_string())),
88 + )
89 + })?;
90 + Ok(TimedSet {
91 + id: row.get(0)?,
92 + session_date,
93 + exercise_id: row.get(2)?,
94 + set_index: row.get(3)?,
95 + duration,
96 + rpe,
97 + is_diagnostic: diag != 0,
98 + })
99 + }
100 +
101 + impl Kind for TimedKind {
102 + type Payload = TimedPayload;
103 + type Set = TimedSet;
104 + type Prescription = TimedPrescription;
105 +
106 + const DISCRIMINANT: EffortKind = EffortKind::Timed;
107 + const TABLE: &'static str = "timed_sets";
108 +
109 + fn append(
110 + db: &Db,
111 + exercise_id: i64,
112 + date: NaiveDate,
113 + payload: Self::Payload,
114 + rpe: Rpe,
115 + is_diagnostic: bool,
116 + ) -> Result<i64, Error> {
117 + let next_index: i32 = db.conn().query_row(
118 + "SELECT COALESCE(MAX(set_index), 0) + 1 FROM timed_sets \
119 + WHERE exercise_id = ?1 AND session_date = ?2",
120 + params![exercise_id, date.to_string()],
121 + |row| row.get(0),
122 + )?;
123 + db.conn().execute(
124 + "INSERT INTO timed_sets \
125 + (session_date, exercise_id, set_index, duration_seconds, rpe, is_diagnostic) \
126 + VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
127 + params![
128 + date.to_string(),
129 + exercise_id,
130 + next_index,
131 + payload.duration.seconds(),
132 + rpe.get(),
133 + if is_diagnostic { 1 } else { 0 },
134 + ],
135 + )?;
136 + Ok(db.conn().last_insert_rowid())
137 + }
138 +
139 + fn list_sets_for_session(
140 + db: &Db,
141 + exercise_id: i64,
142 + date: NaiveDate,
143 + ) -> Result<Vec<Self::Set>, Error> {
144 + let mut stmt = db.conn().prepare(
145 + "SELECT id, session_date, exercise_id, set_index, duration_seconds, rpe, is_diagnostic \
146 + FROM timed_sets WHERE exercise_id = ?1 AND session_date = ?2 \
147 + ORDER BY set_index",
148 + )?;
149 + let rows = stmt
150 + .query_map(params![exercise_id, date.to_string()], |row| {
151 + row_to_timed_set(row)
152 + })?;
153 + Ok(rows.collect::<Result<Vec<_>, _>>()?)
154 + }
155 +
156 + fn list_sessions_for_exercise(
157 + db: &Db,
158 + exercise_id: i64,
159 + ) -> Result<Vec<Vec<Self::Set>>, Error> {
160 + let mut stmt = db.conn().prepare(
161 + "SELECT id, session_date, exercise_id, set_index, duration_seconds, rpe, is_diagnostic \
162 + FROM timed_sets WHERE exercise_id = ?1 AND is_diagnostic = 0 \
163 + ORDER BY session_date ASC, set_index ASC",
164 + )?;
165 + let rows = stmt.query_map(params![exercise_id], |row| row_to_timed_set(row))?;
166 + let mut sessions: Vec<Vec<TimedSet>> = Vec::new();
167 + for row in rows {
168 + let set = row?;
169 + match sessions.last_mut() {
170 + Some(last) if last[0].session_date == set.session_date => last.push(set),
171 + _ => sessions.push(vec![set]),
172 + }
173 + }
174 + Ok(sessions)
175 + }
176 +
177 + fn delete_last_diagnostic_on_date(
178 + db: &Db,
179 + exercise_id: i64,
180 + date: NaiveDate,
181 + ) -> Result<(), Error> {
182 + let list = Self::list_sets_for_session(db, exercise_id, date)?;
183 + if let Some(last) = list.iter().rev().find(|s| s.is_diagnostic) {
184 + db.conn()
185 + .execute("DELETE FROM timed_sets WHERE id = ?1", params![last.id])?;
186 + }
187 + Ok(())
188 + }
189 +
190 + fn evaluate(sets: &[Self::Set]) -> Outcome {
191 + if sets.is_empty() {
192 + return Outcome::Miss;
193 + }
194 + // Timed sessions have no rep-count target — outcome is decided
195 + // purely by max RPE. Same thresholds as reps for consistency.
196 + let max_rpe = sets.iter().map(|s| s.rpe.get()).max().unwrap_or(1);
197 + if max_rpe >= 5 {
198 + Outcome::Miss
199 + } else if max_rpe == 4 {
200 + Outcome::Hold
201 + } else {
202 + Outcome::CleanHit
203 + }
204 + }
205 +
206 + fn compute_prescription(
207 + db: &Db,
208 + exercise: &Exercise,
209 + ) -> Result<PrescriptionResult<Self::Prescription>, Error> {
210 + let sessions = Self::list_sessions_for_exercise(db, exercise.id)?;
211 + if sessions.is_empty() {
212 + return Ok(PrescriptionResult::NoHistory);
213 + }
214 + let increment_seconds = exercise.increment as i32;
215 + let walk = walk_timed_history(&sessions, increment_seconds);
216 + Ok(PrescriptionResult::Prescribed(TimedPrescription {
217 + state: walk.state,
218 + sets: walk.last_set_count,
219 + duration: walk.next_duration,
220 + }))
221 + }
222 + }
223 +
224 + /// Full walk state carried through the timed history, mirroring the
225 + /// reps walker. `next_duration` is what we'd prescribe if the most
226 + /// recent session were the last one.
227 + struct TimedWalk {
228 + state: State,
229 + next_duration: Duration,
230 + last_set_count: i32,
231 + last_top_seconds: i32,
232 + pre_deload_seconds: Option<i32>,
233 + }
234 +
235 + fn walk_timed_history(sessions: &[Vec<TimedSet>], increment_seconds: i32) -> TimedWalk {
236 + let first = &sessions[0];
237 + let first_top = top_duration_seconds(first);
238 + let mut walk = TimedWalk {
239 + state: State::Progressing,
240 + next_duration: Duration::from_seconds((first_top + increment_seconds).max(0))
241 + .expect("clamped to non-negative"),
242 + last_set_count: first.len() as i32,
243 + last_top_seconds: first_top,
244 + pre_deload_seconds: None,
245 + };
246 + for session in &sessions[1..] {
247 + let outcome = TimedKind::evaluate(session);
248 + let prev_state = walk.state;
249 + let new_state = progression::next_state(prev_state, outcome);
250 + let session_top = top_duration_seconds(session);
251 + if new_state == State::Deloading && prev_state != State::Deloading {
252 + walk.pre_deload_seconds = Some(session_top);
253 + }
254 + walk.next_duration = compute_next_duration(
255 + prev_state,
256 + new_state,
257 + outcome,
258 + session_top,
259 + increment_seconds,
260 + );
261 + walk.state = new_state;
262 + walk.last_set_count = session.len() as i32;
263 + walk.last_top_seconds = session_top;
264 + if walk.state == State::Rebuilding
265 + && let Some(pd) = walk.pre_deload_seconds
266 + && session_top >= pd
267 + {
268 + walk.state = State::Progressing;
269 + walk.pre_deload_seconds = None;
270 + }
271 + }
272 + walk
273 + }
274 +
275 + fn compute_next_duration(
276 + prev_state: State,
277 + new_state: State,
278 + outcome: Outcome,
279 + session_top_seconds: i32,
280 + increment_seconds: i32,
281 + ) -> Duration {
282 + if new_state == State::Deloading && prev_state != State::Deloading {
283 + let deloaded = ((session_top_seconds as f64) * DELOAD_RATIO).round() as i32;
284 + return Duration::from_seconds(deloaded.max(0))
285 + .expect("clamped to non-negative");
286 + }
287 + let next = match outcome {
288 + Outcome::CleanHit => session_top_seconds + increment_seconds,
289 + Outcome::Hold | Outcome::Miss => session_top_seconds,
290 + };
291 + Duration::from_seconds(next.max(0)).expect("clamped to non-negative")
292 + }
293 +
294 + fn top_duration_seconds(session: &[TimedSet]) -> i32 {
295 + session.iter().map(|s| s.duration.seconds()).max().unwrap_or(0)
296 + }
297 +
298 + #[cfg(test)]
299 + mod tests {
300 + use super::*;
301 + use crate::values::LoadUnit;
302 + use crate::ResistanceType;
303 +
304 + fn setup() -> (Db, i64) {
305 + let db = Db::open_in_memory().unwrap();
306 + db.init_profile("self", LoadUnit::Kg).unwrap();
307 + let id = db
308 + .create_exercise("plank", ResistanceType::CardioTime, LoadUnit::Kg, 10.0, &[])
309 + .unwrap();
310 + (db, id)
311 + }
312 +
313 + fn day(n: u32) -> NaiveDate {
314 + NaiveDate::from_ymd_opt(2026, 7, n).unwrap()
315 + }
316 +
317 + #[test]
318 + fn discriminant_and_table_agree_with_schema() {
319 + assert_eq!(TimedKind::DISCRIMINANT, EffortKind::Timed);
320 + assert_eq!(TimedKind::TABLE, "timed_sets");
321 + }
322 +
323 + #[test]
324 + fn append_and_list_round_trip() {
325 + let (db, ex) = setup();
326 + TimedKind::append(
327 + &db,
328 + ex,
329 + day(18),
330 + TimedPayload::new(Duration::from_seconds(60).unwrap()),
331 + Rpe::new(3).unwrap(),
332 + false,
333 + )
334 + .unwrap();
335 + let list = TimedKind::list_sets_for_session(&db, ex, day(18)).unwrap();
336 + assert_eq!(list.len(), 1);
337 + assert_eq!(list[0].duration.seconds(), 60);
338 + }
339 +
340 + #[test]
341 + fn diagnostic_sets_are_filtered_from_progression_history() {
342 + let (db, ex) = setup();
343 + TimedKind::append(
344 + &db,
345 + ex,
346 + day(10),
347 + TimedPayload::new(Duration::from_seconds(30).unwrap()),
348 + Rpe::new(3).unwrap(),
349 + true,
350 + )
351 + .unwrap();
352 + TimedKind::append(
353 + &db,
354 + ex,
355 + day(12),
356 + TimedPayload::new(Duration::from_seconds(60).unwrap()),
357 + Rpe::new(3).unwrap(),
358 + false,
359 + )
360 + .unwrap();
361 + let sessions = TimedKind::list_sessions_for_exercise(&db, ex).unwrap();
362 + assert_eq!(sessions.len(), 1);
363 + assert_eq!(sessions[0][0].session_date, day(12));
364 + }
365 +
366 + #[test]
367 + fn no_history_yields_no_prescription() {
368 + let (db, ex) = setup();
369 + let exercise = db
370 + .list_exercises()
371 + .unwrap()
372 + .into_iter()
373 + .find(|e| e.id == ex)
374 + .unwrap();
375 + assert!(matches!(
376 + TimedKind::compute_prescription(&db, &exercise).unwrap(),
377 + PrescriptionResult::NoHistory
378 + ));
379 + }
380 +
381 + #[test]
382 + fn clean_hit_climbs_by_increment_seconds() {
383 + let (db, ex) = setup();
384 + TimedKind::append(
385 + &db,
386 + ex,
387 + day(10),
388 + TimedPayload::new(Duration::from_seconds(60).unwrap()),
389 + Rpe::new(3).unwrap(),
390 + false,
391 + )
392 + .unwrap();
393 + let exercise = db
394 + .list_exercises()
395 + .unwrap()
396 + .into_iter()
397 + .find(|e| e.id == ex)
398 + .unwrap();
399 + let PrescriptionResult::Prescribed(pres) =
400 + TimedKind::compute_prescription(&db, &exercise).unwrap()
401 + else {
402 + panic!("expected prescription");
403 + };
404 + // Increment on this exercise is 10 sec.
405 + assert_eq!(pres.duration.seconds(), 70);
406 + assert_eq!(pres.state, State::Progressing);
407 + }
408 +
409 + #[test]
410 + fn miss_after_probation_deloads_by_ratio() {
411 + let (db, ex) = setup();
412 + // Session 1: clean at 60. Session 2: miss at 60 (probation).
413 + // Session 3: miss at 60 (deload). Next prescription: 0.9 * 60 = 54.
414 + TimedKind::append(
415 + &db,
416 + ex,
417 + day(1),
418 + TimedPayload::new(Duration::from_seconds(60).unwrap()),
419 + Rpe::new(3).unwrap(),
420 + false,
421 + )
422 + .unwrap();
423 + TimedKind::append(
424 + &db,
425 + ex,
426 + day(2),
427 + TimedPayload::new(Duration::from_seconds(60).unwrap()),
428 + Rpe::new(5).unwrap(),
429 + false,
430 + )
431 + .unwrap();
432 + TimedKind::append(
433 + &db,
434 + ex,
435 + day(3),
436 + TimedPayload::new(Duration::from_seconds(60).unwrap()),
437 + Rpe::new(5).unwrap(),
438 + false,
439 + )
440 + .unwrap();
441 + let exercise = db
442 + .list_exercises()
443 + .unwrap()
444 + .into_iter()
445 + .find(|e| e.id == ex)
446 + .unwrap();
447 + let PrescriptionResult::Prescribed(pres) =
448 + TimedKind::compute_prescription(&db, &exercise).unwrap()
449 + else {
450 + panic!()
451 + };
452 + assert_eq!(pres.state, State::Deloading);
453 + assert_eq!(pres.duration.seconds(), 54);
454 + }
455 + }
@@ -46,6 +46,12 @@ pub enum Error {
46 46 #[error("increment must be a finite non-negative number, got {0}")]
47 47 InvalidIncrement(f64),
48 48
49 + #[error("duration must be non-negative seconds, got {0}")]
50 + InvalidDuration(i32),
51 +
52 + #[error("distance must be a finite non-negative number of meters, got {0}")]
53 + InvalidDistance(f64),
54 +
49 55 #[error("could not parse {field}: {value:?}")]
50 56 ParseField { field: &'static str, value: String },
51 57 }
@@ -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,
24 + append_any, compute_prescription_any, reps, timed,
25 25 };
26 26 pub use error::Error;
27 27 pub use estimator::estimate_e1rm;
@@ -32,4 +32,4 @@ pub use progression::{Prescription, PrescriptionResult, SessionOutcome, State};
32 32 pub use seed::seed_starter_content;
33 33 pub use sets::SessionSet;
34 34 pub use templates::{Exercise, ResistanceType, Tag};
35 - pub use values::{Estimate, Increment, Load, LoadUnit, Reps, Rpe};
35 + pub use values::{Distance, Duration, Estimate, Increment, Load, LoadUnit, Reps, Rpe};
@@ -77,6 +77,67 @@ impl Load {
77 77 }
78 78 }
79 79
80 + /// A time span, stored as a non-negative integer count of seconds.
81 + /// Used by timed and distance efforts (plank hold, run duration).
82 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
83 + pub struct Duration(i32);
84 +
85 + impl Duration {
86 + pub const ZERO: Duration = Duration(0);
87 +
88 + pub fn from_seconds(n: i32) -> Result<Self, Error> {
89 + if n >= 0 {
90 + Ok(Duration(n))
91 + } else {
92 + Err(Error::InvalidDuration(n))
93 + }
94 + }
95 +
96 + pub fn seconds(self) -> i32 {
97 + self.0
98 + }
99 + }
100 +
101 + impl fmt::Display for Duration {
102 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 + let total = self.0;
104 + let m = total / 60;
105 + let s = total % 60;
106 + if m > 0 {
107 + write!(f, "{m}m{s:02}s")
108 + } else {
109 + write!(f, "{s}s")
110 + }
111 + }
112 + }
113 +
114 + /// A distance in meters. Always non-negative. Used by cardio distance
115 + /// efforts.
116 + #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
117 + pub struct Distance(f64);
118 +
119 + impl Distance {
120 + pub const ZERO: Distance = Distance(0.0);
121 +
122 + pub fn from_meters(v: f64) -> Result<Self, Error> {
123 + if v.is_finite() && v >= 0.0 {
124 + Ok(Distance(v))
125 + } else {
126 + Err(Error::InvalidDistance(v))
127 + }
128 + }
129 +
130 + pub fn meters(self) -> f64 {
131 + self.0
132 + }
133 + }
134 +
135 + impl fmt::Display for Distance {
136 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137 + write!(f, "{} m", self.0)
138 + }
139 + }
140 +
80 141 /// Smallest legal load change for an exercise. May be zero (bodyweight,
81 142 /// cardio). A real measurement of the equipment.
82 143 #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]