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