|
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 |
+ |
}
|