//! Timed effort kind. //! //! A single value per set — how long the position was held. Planks, //! dead hangs, isometric wall-sits, any timed cardio unit lands here. //! RPE still rides on every set (universal across kinds) as does the //! diagnostic flag. //! //! The prescription math is a stripped-down mirror of the reps state //! machine: each session's outcome is decided by max RPE (`<= 3` clean, //! `4` hold, `5` miss), and the next duration climbs by a per-exercise //! increment on clean, holds flat on hold, and deloads on miss. Same //! four states as reps (Progressing/Probation/Deloading/Rebuilding), //! same `SessionOutcome` -> `State` transitions, same //! `DELOAD_RATIO`. Only the *value* progresses differently: seconds //! instead of kilograms. //! //! Increment source: the exercise's [`Exercise::increment`] column, //! interpreted as **seconds per successful session** rather than //! kilograms per successful session. Bodyweight-holds with zero //! increment therefore never progress numerically; that's a valid //! choice for someone tracking their plank without programming it. use chrono::NaiveDate; use rusqlite::{Row, params}; use crate::db::Db; use crate::error::Error; use crate::heuristics::DELOAD_RATIO; use crate::progression::{self, State}; use crate::templates::Exercise; use crate::values::{Duration, Rpe}; use super::{EffortKind, Kind, Outcome, PrescriptionResult}; pub struct TimedKind; #[derive(Debug, Clone, Copy, PartialEq)] pub struct TimedPayload { pub duration: Duration, } impl TimedPayload { pub fn new(duration: Duration) -> Self { Self { duration } } } #[derive(Debug, Clone, PartialEq)] pub struct TimedSet { pub id: i64, pub session_date: NaiveDate, pub exercise_id: i64, pub set_index: i32, pub duration: Duration, pub rpe: Rpe, pub is_diagnostic: bool, } #[derive(Debug, Clone, PartialEq)] pub struct TimedPrescription { pub state: State, /// Number of holds to program. Echoes the last session's set count. pub sets: i32, /// Target hold duration. pub duration: Duration, } fn row_to_timed_set(row: &Row<'_>) -> rusqlite::Result { let date_str: String = row.get(1)?; let session_date = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").map_err(|e| { rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e)) })?; let secs: i32 = row.get(4)?; let rpe_raw: i32 = row.get(5)?; let diag: i64 = row.get(6)?; let duration = Duration::from_seconds(secs).map_err(|e| { rusqlite::Error::FromSqlConversionFailure( 4, rusqlite::types::Type::Integer, Box::new(std::io::Error::other(e.to_string())), ) })?; let rpe = Rpe::new(rpe_raw).map_err(|e| { rusqlite::Error::FromSqlConversionFailure( 5, rusqlite::types::Type::Integer, Box::new(std::io::Error::other(e.to_string())), ) })?; Ok(TimedSet { id: row.get(0)?, session_date, exercise_id: row.get(2)?, set_index: row.get(3)?, duration, rpe, is_diagnostic: diag != 0, }) } impl Kind for TimedKind { type Payload = TimedPayload; type Set = TimedSet; type Prescription = TimedPrescription; const DISCRIMINANT: EffortKind = EffortKind::Timed; const TABLE: &'static str = "timed_sets"; fn append( db: &Db, exercise_id: i64, date: NaiveDate, payload: Self::Payload, rpe: Rpe, is_diagnostic: bool, ) -> Result { let next_index: i32 = db.conn().query_row( "SELECT COALESCE(MAX(set_index), 0) + 1 FROM timed_sets \ WHERE exercise_id = ?1 AND session_date = ?2", params![exercise_id, date.to_string()], |row| row.get(0), )?; db.conn().execute( "INSERT INTO timed_sets \ (session_date, exercise_id, set_index, duration_seconds, rpe, is_diagnostic) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![ date.to_string(), exercise_id, next_index, payload.duration.seconds(), rpe.get(), if is_diagnostic { 1 } else { 0 }, ], )?; Ok(db.conn().last_insert_rowid()) } fn list_sets_for_session( db: &Db, exercise_id: i64, date: NaiveDate, ) -> Result, Error> { let mut stmt = db.conn().prepare( "SELECT id, session_date, exercise_id, set_index, duration_seconds, rpe, is_diagnostic \ FROM timed_sets WHERE exercise_id = ?1 AND session_date = ?2 \ ORDER BY set_index", )?; let rows = stmt .query_map(params![exercise_id, date.to_string()], |row| { row_to_timed_set(row) })?; Ok(rows.collect::, _>>()?) } fn list_sessions_for_exercise( db: &Db, exercise_id: i64, ) -> Result>, Error> { let mut stmt = db.conn().prepare( "SELECT id, session_date, exercise_id, set_index, duration_seconds, rpe, is_diagnostic \ FROM timed_sets WHERE exercise_id = ?1 AND is_diagnostic = 0 \ ORDER BY session_date ASC, set_index ASC", )?; let rows = stmt.query_map(params![exercise_id], |row| row_to_timed_set(row))?; let mut sessions: Vec> = Vec::new(); for row in rows { let set = row?; match sessions.last_mut() { Some(last) if last[0].session_date == set.session_date => last.push(set), _ => sessions.push(vec![set]), } } Ok(sessions) } fn delete_last_diagnostic_on_date( db: &Db, exercise_id: i64, date: NaiveDate, ) -> Result<(), Error> { let list = Self::list_sets_for_session(db, exercise_id, date)?; if let Some(last) = list.iter().rev().find(|s| s.is_diagnostic) { Self::delete(db, last.id)?; } Ok(()) } fn evaluate(sets: &[Self::Set]) -> Outcome { if sets.is_empty() { return Outcome::Miss; } // Timed sessions have no rep-count target — outcome is decided // purely by max RPE. Same thresholds as reps for consistency. let max_rpe = sets.iter().map(|s| s.rpe.get()).max().unwrap_or(1); if max_rpe >= 5 { Outcome::Miss } else if max_rpe == 4 { Outcome::Hold } else { Outcome::CleanHit } } fn compute_prescription( db: &Db, exercise: &Exercise, ) -> Result, Error> { let sessions = Self::list_sessions_for_exercise(db, exercise.id)?; if sessions.is_empty() { return Ok(PrescriptionResult::NoHistory); } let increment_seconds = exercise.increment as i32; let walk = walk_timed_history(&sessions, increment_seconds); Ok(PrescriptionResult::Prescribed(TimedPrescription { state: walk.state, sets: walk.last_set_count, duration: walk.next_duration, })) } } impl TimedKind { /// Delete a specific timed set by id. Returns `NotFound` if it did /// not exist. Symmetric with [`RepsKind::delete`](super::reps::RepsKind::delete). pub fn delete(db: &Db, id: i64) -> Result<(), Error> { let n = db .conn() .execute("DELETE FROM timed_sets WHERE id = ?1", params![id])?; if n == 0 { return Err(Error::NotFound(format!("timed set {id}"))); } Ok(()) } } /// Full walk state carried through the timed history, mirroring the /// reps walker. `next_duration` is what we'd prescribe if the most /// recent session were the last one. struct TimedWalk { state: State, next_duration: Duration, last_set_count: i32, last_top_seconds: i32, pre_deload_seconds: Option, } fn walk_timed_history(sessions: &[Vec], increment_seconds: i32) -> TimedWalk { let first = &sessions[0]; let first_top = top_duration_seconds(first); let mut walk = TimedWalk { state: State::Progressing, next_duration: Duration::from_seconds((first_top + increment_seconds).max(0)) .expect("clamped to non-negative"), last_set_count: first.len() as i32, last_top_seconds: first_top, pre_deload_seconds: None, }; for session in &sessions[1..] { let outcome = TimedKind::evaluate(session); let prev_state = walk.state; let new_state = progression::next_state(prev_state, outcome); let session_top = top_duration_seconds(session); if new_state == State::Deloading && prev_state != State::Deloading { walk.pre_deload_seconds = Some(session_top); } walk.next_duration = compute_next_duration( prev_state, new_state, outcome, session_top, increment_seconds, ); walk.state = new_state; walk.last_set_count = session.len() as i32; walk.last_top_seconds = session_top; if walk.state == State::Rebuilding && let Some(pd) = walk.pre_deload_seconds && session_top >= pd { walk.state = State::Progressing; walk.pre_deload_seconds = None; } } walk } fn compute_next_duration( prev_state: State, new_state: State, outcome: Outcome, session_top_seconds: i32, increment_seconds: i32, ) -> Duration { if new_state == State::Deloading && prev_state != State::Deloading { let deloaded = ((session_top_seconds as f64) * DELOAD_RATIO).round() as i32; return Duration::from_seconds(deloaded.max(0)) .expect("clamped to non-negative"); } let next = match outcome { Outcome::CleanHit => session_top_seconds + increment_seconds, Outcome::Hold | Outcome::Miss => session_top_seconds, }; Duration::from_seconds(next.max(0)).expect("clamped to non-negative") } fn top_duration_seconds(session: &[TimedSet]) -> i32 { session.iter().map(|s| s.duration.seconds()).max().unwrap_or(0) } #[cfg(test)] mod tests { use super::*; use crate::values::LoadUnit; use crate::ResistanceType; fn setup() -> (Db, i64) { let db = Db::open_in_memory().unwrap(); db.init_profile("self", LoadUnit::Kg).unwrap(); let id = db .create_exercise("plank", ResistanceType::CardioTime, LoadUnit::Kg, 10.0, &[]) .unwrap(); (db, id) } fn day(n: u32) -> NaiveDate { NaiveDate::from_ymd_opt(2026, 7, n).unwrap() } #[test] fn discriminant_and_table_agree_with_schema() { assert_eq!(TimedKind::DISCRIMINANT, EffortKind::Timed); assert_eq!(TimedKind::TABLE, "timed_sets"); } #[test] fn append_and_list_round_trip() { let (db, ex) = setup(); TimedKind::append( &db, ex, day(18), TimedPayload::new(Duration::from_seconds(60).unwrap()), Rpe::new(3).unwrap(), false, ) .unwrap(); let list = TimedKind::list_sets_for_session(&db, ex, day(18)).unwrap(); assert_eq!(list.len(), 1); assert_eq!(list[0].duration.seconds(), 60); } #[test] fn diagnostic_sets_are_filtered_from_progression_history() { let (db, ex) = setup(); TimedKind::append( &db, ex, day(10), TimedPayload::new(Duration::from_seconds(30).unwrap()), Rpe::new(3).unwrap(), true, ) .unwrap(); TimedKind::append( &db, ex, day(12), TimedPayload::new(Duration::from_seconds(60).unwrap()), Rpe::new(3).unwrap(), false, ) .unwrap(); let sessions = TimedKind::list_sessions_for_exercise(&db, ex).unwrap(); assert_eq!(sessions.len(), 1); assert_eq!(sessions[0][0].session_date, day(12)); } #[test] fn no_history_yields_no_prescription() { let (db, ex) = setup(); let exercise = db .list_exercises() .unwrap() .into_iter() .find(|e| e.id == ex) .unwrap(); assert!(matches!( TimedKind::compute_prescription(&db, &exercise).unwrap(), PrescriptionResult::NoHistory )); } #[test] fn clean_hit_climbs_by_increment_seconds() { let (db, ex) = setup(); TimedKind::append( &db, ex, day(10), TimedPayload::new(Duration::from_seconds(60).unwrap()), Rpe::new(3).unwrap(), false, ) .unwrap(); let exercise = db .list_exercises() .unwrap() .into_iter() .find(|e| e.id == ex) .unwrap(); let PrescriptionResult::Prescribed(pres) = TimedKind::compute_prescription(&db, &exercise).unwrap() else { panic!("expected prescription"); }; // Increment on this exercise is 10 sec. assert_eq!(pres.duration.seconds(), 70); assert_eq!(pres.state, State::Progressing); } #[test] fn miss_after_probation_deloads_by_ratio() { let (db, ex) = setup(); // Session 1: clean at 60. Session 2: miss at 60 (probation). // Session 3: miss at 60 (deload). Next prescription: 0.9 * 60 = 54. TimedKind::append( &db, ex, day(1), TimedPayload::new(Duration::from_seconds(60).unwrap()), Rpe::new(3).unwrap(), false, ) .unwrap(); TimedKind::append( &db, ex, day(2), TimedPayload::new(Duration::from_seconds(60).unwrap()), Rpe::new(5).unwrap(), false, ) .unwrap(); TimedKind::append( &db, ex, day(3), TimedPayload::new(Duration::from_seconds(60).unwrap()), Rpe::new(5).unwrap(), false, ) .unwrap(); let exercise = db .list_exercises() .unwrap() .into_iter() .find(|e| e.id == ex) .unwrap(); let PrescriptionResult::Prescribed(pres) = TimedKind::compute_prescription(&db, &exercise).unwrap() else { panic!() }; assert_eq!(pres.state, State::Deloading); assert_eq!(pres.duration.seconds(), 54); } }