| 5 |
5 |
|
//! by CHECK at the schema level.
|
| 6 |
6 |
|
|
| 7 |
7 |
|
use chrono::NaiveDate;
|
| 8 |
|
- |
use rusqlite::params;
|
|
8 |
+ |
use rusqlite::{OptionalExtension, Row, params};
|
| 9 |
9 |
|
|
| 10 |
10 |
|
use crate::db::Db;
|
| 11 |
11 |
|
use crate::error::Error;
|
| 12 |
12 |
|
use crate::estimator::estimate_e1rm;
|
| 13 |
13 |
|
|
|
14 |
+ |
/// Row -> SessionSet decoder shared by every set-fetching query. Column
|
|
15 |
+ |
/// order must match the callers' SELECT lists.
|
|
16 |
+ |
fn row_to_session_set(row: &Row<'_>) -> rusqlite::Result<SessionSet> {
|
|
17 |
+ |
let date_str: String = row.get(1)?;
|
|
18 |
+ |
let parsed = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").map_err(|e| {
|
|
19 |
+ |
rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
|
|
20 |
+ |
})?;
|
|
21 |
+ |
let flag: i64 = row.get(7)?;
|
|
22 |
+ |
Ok(SessionSet {
|
|
23 |
+ |
id: row.get(0)?,
|
|
24 |
+ |
session_date: parsed,
|
|
25 |
+ |
exercise_id: row.get(2)?,
|
|
26 |
+ |
set_index: row.get(3)?,
|
|
27 |
+ |
load: row.get(4)?,
|
|
28 |
+ |
reps: row.get(5)?,
|
|
29 |
+ |
rpe: row.get(6)?,
|
|
30 |
+ |
is_diagnostic: flag != 0,
|
|
31 |
+ |
})
|
|
32 |
+ |
}
|
|
33 |
+ |
|
| 14 |
34 |
|
#[derive(Debug, Clone, PartialEq)]
|
| 15 |
35 |
|
pub struct SessionSet {
|
| 16 |
36 |
|
pub id: i64,
|
| 20 |
40 |
|
pub load: f64,
|
| 21 |
41 |
|
pub reps: i32,
|
| 22 |
42 |
|
pub rpe: i32,
|
|
43 |
+ |
pub is_diagnostic: bool,
|
| 23 |
44 |
|
}
|
| 24 |
45 |
|
|
| 25 |
46 |
|
impl Db {
|
| 26 |
47 |
|
/// List sets for an exercise on a given date, ordered by set_index.
|
|
48 |
+ |
/// Includes diagnostic sets (the log screen shows every row that was
|
|
49 |
+ |
/// actually recorded that day; filtering happens further downstream).
|
| 27 |
50 |
|
pub fn list_sets_for_session(
|
| 28 |
51 |
|
&self,
|
| 29 |
52 |
|
exercise_id: i64,
|
| 30 |
53 |
|
date: NaiveDate,
|
| 31 |
54 |
|
) -> Result<Vec<SessionSet>, Error> {
|
| 32 |
55 |
|
let mut stmt = self.conn().prepare(
|
| 33 |
|
- |
"SELECT id, session_date, exercise_id, set_index, load, reps, rpe \
|
|
56 |
+ |
"SELECT id, session_date, exercise_id, set_index, load, reps, rpe, is_diagnostic \
|
| 34 |
57 |
|
FROM sets WHERE exercise_id = ?1 AND session_date = ?2 \
|
| 35 |
58 |
|
ORDER BY set_index",
|
| 36 |
59 |
|
)?;
|
| 37 |
60 |
|
let rows = stmt.query_map(
|
| 38 |
61 |
|
params![exercise_id, date.to_string()],
|
| 39 |
|
- |
|row| {
|
| 40 |
|
- |
let date_str: String = row.get(1)?;
|
| 41 |
|
- |
let parsed = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d")
|
| 42 |
|
- |
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
|
| 43 |
|
- |
1,
|
| 44 |
|
- |
rusqlite::types::Type::Text,
|
| 45 |
|
- |
Box::new(e),
|
| 46 |
|
- |
))?;
|
| 47 |
|
- |
Ok(SessionSet {
|
| 48 |
|
- |
id: row.get(0)?,
|
| 49 |
|
- |
session_date: parsed,
|
| 50 |
|
- |
exercise_id: row.get(2)?,
|
| 51 |
|
- |
set_index: row.get(3)?,
|
| 52 |
|
- |
load: row.get(4)?,
|
| 53 |
|
- |
reps: row.get(5)?,
|
| 54 |
|
- |
rpe: row.get(6)?,
|
| 55 |
|
- |
})
|
| 56 |
|
- |
},
|
|
62 |
+ |
|row| row_to_session_set(row),
|
| 57 |
63 |
|
)?;
|
| 58 |
64 |
|
Ok(rows.collect::<Result<Vec<_>, _>>()?)
|
| 59 |
65 |
|
}
|
| 60 |
66 |
|
|
| 61 |
|
- |
/// Append a new set to a session. `set_index` is auto-assigned as one
|
| 62 |
|
- |
/// past the current max for this (date, exercise). Returns the new row id.
|
|
67 |
+ |
/// Append a normal (non-diagnostic) working set. `set_index` is
|
|
68 |
+ |
/// auto-assigned as one past the current max for this (date, exercise).
|
|
69 |
+ |
/// Returns the new row id.
|
| 63 |
70 |
|
pub fn append_set(
|
| 64 |
71 |
|
&self,
|
| 65 |
72 |
|
exercise_id: i64,
|
| 68 |
75 |
|
reps: i32,
|
| 69 |
76 |
|
rpe: i32,
|
| 70 |
77 |
|
) -> Result<i64, Error> {
|
|
78 |
+ |
self.append_set_inner(exercise_id, date, load, reps, rpe, false)
|
|
79 |
+ |
}
|
|
80 |
+ |
|
|
81 |
+ |
/// Append a diagnostic set. Diagnostic sets feed e1RM and seed future
|
|
82 |
+ |
/// prescriptions but are excluded from the progression state machine
|
|
83 |
+ |
/// and the history sparkline.
|
|
84 |
+ |
pub fn append_diagnostic_set(
|
|
85 |
+ |
&self,
|
|
86 |
+ |
exercise_id: i64,
|
|
87 |
+ |
date: NaiveDate,
|
|
88 |
+ |
load: f64,
|
|
89 |
+ |
reps: i32,
|
|
90 |
+ |
rpe: i32,
|
|
91 |
+ |
) -> Result<i64, Error> {
|
|
92 |
+ |
self.append_set_inner(exercise_id, date, load, reps, rpe, true)
|
|
93 |
+ |
}
|
|
94 |
+ |
|
|
95 |
+ |
fn append_set_inner(
|
|
96 |
+ |
&self,
|
|
97 |
+ |
exercise_id: i64,
|
|
98 |
+ |
date: NaiveDate,
|
|
99 |
+ |
load: f64,
|
|
100 |
+ |
reps: i32,
|
|
101 |
+ |
rpe: i32,
|
|
102 |
+ |
is_diagnostic: bool,
|
|
103 |
+ |
) -> Result<i64, Error> {
|
| 71 |
104 |
|
if !(1..=5).contains(&rpe) {
|
| 72 |
105 |
|
return Err(Error::InvalidRpe(rpe));
|
| 73 |
106 |
|
}
|
| 83 |
116 |
|
|row| row.get(0),
|
| 84 |
117 |
|
)?;
|
| 85 |
118 |
|
self.conn().execute(
|
| 86 |
|
- |
"INSERT INTO sets (session_date, exercise_id, set_index, load, reps, rpe) \
|
| 87 |
|
- |
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
| 88 |
|
- |
params![date.to_string(), exercise_id, next_index, load, reps, rpe],
|
|
119 |
+ |
"INSERT INTO sets (session_date, exercise_id, set_index, load, reps, rpe, is_diagnostic) \
|
|
120 |
+ |
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
|
121 |
+ |
params![
|
|
122 |
+ |
date.to_string(),
|
|
123 |
+ |
exercise_id,
|
|
124 |
+ |
next_index,
|
|
125 |
+ |
load,
|
|
126 |
+ |
reps,
|
|
127 |
+ |
rpe,
|
|
128 |
+ |
if is_diagnostic { 1 } else { 0 },
|
|
129 |
+ |
],
|
| 89 |
130 |
|
)?;
|
| 90 |
131 |
|
Ok(self.conn().last_insert_rowid())
|
| 91 |
132 |
|
}
|
| 92 |
133 |
|
|
| 93 |
|
- |
/// List every session for an exercise, oldest first, each session as a
|
| 94 |
|
- |
/// vec of its sets in `set_index` order. Empty vec when no history.
|
|
134 |
+ |
/// List every non-diagnostic session for an exercise, oldest first,
|
|
135 |
+ |
/// each session as a vec of its sets in `set_index` order. Empty vec
|
|
136 |
+ |
/// when no history. Diagnostic sets are filtered so the progression
|
|
137 |
+ |
/// state machine only walks real working sessions.
|
| 95 |
138 |
|
pub fn list_sessions_for_exercise(
|
| 96 |
139 |
|
&self,
|
| 97 |
140 |
|
exercise_id: i64,
|
| 98 |
141 |
|
) -> Result<Vec<Vec<SessionSet>>, Error> {
|
| 99 |
142 |
|
let mut stmt = self.conn().prepare(
|
| 100 |
|
- |
"SELECT id, session_date, exercise_id, set_index, load, reps, rpe \
|
| 101 |
|
- |
FROM sets WHERE exercise_id = ?1 \
|
|
143 |
+ |
"SELECT id, session_date, exercise_id, set_index, load, reps, rpe, is_diagnostic \
|
|
144 |
+ |
FROM sets WHERE exercise_id = ?1 AND is_diagnostic = 0 \
|
| 102 |
145 |
|
ORDER BY session_date ASC, set_index ASC",
|
| 103 |
146 |
|
)?;
|
| 104 |
|
- |
let rows = stmt.query_map(params![exercise_id], |row| {
|
| 105 |
|
- |
let date_str: String = row.get(1)?;
|
| 106 |
|
- |
let parsed = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").map_err(|e| {
|
| 107 |
|
- |
rusqlite::Error::FromSqlConversionFailure(
|
| 108 |
|
- |
1,
|
| 109 |
|
- |
rusqlite::types::Type::Text,
|
| 110 |
|
- |
Box::new(e),
|
| 111 |
|
- |
)
|
| 112 |
|
- |
})?;
|
| 113 |
|
- |
Ok(SessionSet {
|
| 114 |
|
- |
id: row.get(0)?,
|
| 115 |
|
- |
session_date: parsed,
|
| 116 |
|
- |
exercise_id: row.get(2)?,
|
| 117 |
|
- |
set_index: row.get(3)?,
|
| 118 |
|
- |
load: row.get(4)?,
|
| 119 |
|
- |
reps: row.get(5)?,
|
| 120 |
|
- |
rpe: row.get(6)?,
|
| 121 |
|
- |
})
|
| 122 |
|
- |
})?;
|
|
147 |
+ |
let rows = stmt.query_map(params![exercise_id], |row| row_to_session_set(row))?;
|
| 123 |
148 |
|
|
| 124 |
149 |
|
let mut sessions: Vec<Vec<SessionSet>> = Vec::new();
|
| 125 |
150 |
|
for row in rows {
|
| 133 |
158 |
|
}
|
| 134 |
159 |
|
|
| 135 |
160 |
|
/// One row per session for an exercise: (session_date, top_load).
|
| 136 |
|
- |
/// Oldest first. Empty when the exercise has no history.
|
|
161 |
+ |
/// Oldest first. Empty when the exercise has no history. Diagnostic
|
|
162 |
+ |
/// sets are excluded so the sparkline reflects working-set progress.
|
| 137 |
163 |
|
pub fn top_loads_over_time(
|
| 138 |
164 |
|
&self,
|
| 139 |
165 |
|
exercise_id: i64,
|
| 140 |
166 |
|
) -> Result<Vec<(NaiveDate, f64)>, Error> {
|
| 141 |
167 |
|
let mut stmt = self.conn().prepare(
|
| 142 |
|
- |
"SELECT session_date, MAX(load) FROM sets WHERE exercise_id = ?1 \
|
|
168 |
+ |
"SELECT session_date, MAX(load) FROM sets \
|
|
169 |
+ |
WHERE exercise_id = ?1 AND is_diagnostic = 0 \
|
| 143 |
170 |
|
GROUP BY session_date ORDER BY session_date ASC",
|
| 144 |
171 |
|
)?;
|
| 145 |
172 |
|
let rows = stmt.query_map(params![exercise_id], |row| {
|
| 181 |
208 |
|
Ok(best)
|
| 182 |
209 |
|
}
|
| 183 |
210 |
|
|
|
211 |
+ |
/// Top load from the most recent diagnostic on this exercise. Used by
|
|
212 |
+ |
/// the Generate screen to seed a first working prescription when
|
|
213 |
+ |
/// there is no non-diagnostic history yet. `None` when no diagnostic
|
|
214 |
+ |
/// has been logged.
|
|
215 |
+ |
pub fn diagnostic_seed(&self, exercise_id: i64) -> Result<Option<f64>, Error> {
|
|
216 |
+ |
let mut stmt = self.conn().prepare(
|
|
217 |
+ |
"SELECT MAX(load) FROM sets \
|
|
218 |
+ |
WHERE exercise_id = ?1 AND is_diagnostic = 1 \
|
|
219 |
+ |
AND session_date = ( \
|
|
220 |
+ |
SELECT MAX(session_date) FROM sets \
|
|
221 |
+ |
WHERE exercise_id = ?1 AND is_diagnostic = 1 \
|
|
222 |
+ |
)",
|
|
223 |
+ |
)?;
|
|
224 |
+ |
let out: Option<f64> = stmt
|
|
225 |
+ |
.query_row(params![exercise_id], |row| row.get(0))
|
|
226 |
+ |
.optional()?
|
|
227 |
+ |
.flatten();
|
|
228 |
+ |
Ok(out)
|
|
229 |
+ |
}
|
|
230 |
+ |
|
| 184 |
231 |
|
/// Delete a specific set row by id. Returns `NotFound` if it did not
|
| 185 |
232 |
|
/// exist. Subsequent `append_set` calls do NOT reuse the freed index;
|
| 186 |
233 |
|
/// gaps are fine for the state machine (which reads ordered by index).
|
| 296 |
343 |
|
}
|
| 297 |
344 |
|
|
| 298 |
345 |
|
#[test]
|
|
346 |
+ |
fn diagnostic_sets_excluded_from_progression_history() {
|
|
347 |
+ |
let (db, ex) = setup();
|
|
348 |
+ |
let d1 = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
|
|
349 |
+ |
let d2 = NaiveDate::from_ymd_opt(2026, 7, 3).unwrap();
|
|
350 |
+ |
db.append_diagnostic_set(ex, d1, 60.0, 5, 3).unwrap();
|
|
351 |
+ |
db.append_diagnostic_set(ex, d1, 80.0, 5, 4).unwrap();
|
|
352 |
+ |
db.append_set(ex, d2, 72.0, 5, 3).unwrap();
|
|
353 |
+ |
let sessions = db.list_sessions_for_exercise(ex).unwrap();
|
|
354 |
+ |
assert_eq!(sessions.len(), 1, "diagnostic day should not appear");
|
|
355 |
+ |
assert_eq!(sessions[0][0].session_date, d2);
|
|
356 |
+ |
assert!(!sessions[0][0].is_diagnostic);
|
|
357 |
+ |
}
|
|
358 |
+ |
|
|
359 |
+ |
#[test]
|
|
360 |
+ |
fn diagnostic_sets_excluded_from_top_loads_over_time() {
|
|
361 |
+ |
let (db, ex) = setup();
|
|
362 |
+ |
let d1 = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
|
|
363 |
+ |
let d2 = NaiveDate::from_ymd_opt(2026, 7, 3).unwrap();
|
|
364 |
+ |
db.append_diagnostic_set(ex, d1, 120.0, 5, 4).unwrap();
|
|
365 |
+ |
db.append_set(ex, d2, 80.0, 5, 3).unwrap();
|
|
366 |
+ |
let series = db.top_loads_over_time(ex).unwrap();
|
|
367 |
+ |
assert_eq!(series, vec![(d2, 80.0)]);
|
|
368 |
+ |
}
|
|
369 |
+ |
|
|
370 |
+ |
#[test]
|
|
371 |
+ |
fn diagnostic_sets_still_count_toward_e1rm() {
|
|
372 |
+ |
let (db, ex) = setup();
|
|
373 |
+ |
let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
|
|
374 |
+ |
db.append_diagnostic_set(ex, date, 120.0, 3, 4).unwrap();
|
|
375 |
+ |
assert!(db.best_e1rm(ex).unwrap().is_some());
|
|
376 |
+ |
}
|
|
377 |
+ |
|
|
378 |
+ |
#[test]
|
|
379 |
+ |
fn diagnostic_seed_returns_top_of_last_diagnostic() {
|
|
380 |
+ |
let (db, ex) = setup();
|
|
381 |
+ |
let d1 = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
|
|
382 |
+ |
let d2 = NaiveDate::from_ymd_opt(2026, 7, 5).unwrap();
|
|
383 |
+ |
db.append_diagnostic_set(ex, d1, 70.0, 5, 3).unwrap();
|
|
384 |
+ |
db.append_diagnostic_set(ex, d1, 80.0, 5, 4).unwrap();
|
|
385 |
+ |
db.append_diagnostic_set(ex, d2, 60.0, 5, 3).unwrap();
|
|
386 |
+ |
db.append_diagnostic_set(ex, d2, 90.0, 5, 4).unwrap();
|
|
387 |
+ |
// Most recent diagnostic (d2) had a top of 90.
|
|
388 |
+ |
assert_eq!(db.diagnostic_seed(ex).unwrap(), Some(90.0));
|
|
389 |
+ |
}
|
|
390 |
+ |
|
|
391 |
+ |
#[test]
|
|
392 |
+ |
fn diagnostic_seed_none_when_no_diagnostic_history() {
|
|
393 |
+ |
let (db, ex) = setup();
|
|
394 |
+ |
let date = NaiveDate::from_ymd_opt(2026, 7, 5).unwrap();
|
|
395 |
+ |
db.append_set(ex, date, 100.0, 5, 3).unwrap();
|
|
396 |
+ |
assert_eq!(db.diagnostic_seed(ex).unwrap(), None);
|
|
397 |
+ |
}
|
|
398 |
+ |
|
|
399 |
+ |
#[test]
|
| 299 |
400 |
|
fn delete_removes_and_gap_is_fine() {
|
| 300 |
401 |
|
let (db, ex) = setup();
|
| 301 |
402 |
|
let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap();
|