max / ripgrow
7 files changed,
+101 insertions,
-50 deletions
| @@ -60,8 +60,8 @@ pub fn evaluate_session(sets: &[SessionSet], target_reps: i32) -> SessionOutcome | |||
| 60 | 60 | if sets.is_empty() { | |
| 61 | 61 | return SessionOutcome::Miss; | |
| 62 | 62 | } | |
| 63 | - | let all_hit_target = sets.iter().all(|s| s.reps >= target_reps); | |
| 64 | - | let max_rpe = sets.iter().map(|s| s.rpe).max().unwrap_or(1); | |
| 63 | + | let all_hit_target = sets.iter().all(|s| s.reps.get() >= target_reps); | |
| 64 | + | let max_rpe = sets.iter().map(|s| s.rpe.get()).max().unwrap_or(1); | |
| 65 | 65 | if !all_hit_target || max_rpe >= 5 { | |
| 66 | 66 | SessionOutcome::Miss | |
| 67 | 67 | } else if max_rpe == 4 { | |
| @@ -249,12 +249,12 @@ fn round_to_increment(value: f64, increment: f64) -> f64 { | |||
| 249 | 249 | fn top_load(session: &[SessionSet]) -> f64 { | |
| 250 | 250 | session | |
| 251 | 251 | .iter() | |
| 252 | - | .map(|s| s.load) | |
| 252 | + | .map(|s| s.load.get()) | |
| 253 | 253 | .fold(f64::NEG_INFINITY, f64::max) | |
| 254 | 254 | } | |
| 255 | 255 | ||
| 256 | 256 | fn top_reps(session: &[SessionSet]) -> i32 { | |
| 257 | - | session.iter().map(|s| s.reps).max().unwrap_or(0) | |
| 257 | + | session.iter().map(|s| s.reps.get()).max().unwrap_or(0) | |
| 258 | 258 | } | |
| 259 | 259 | ||
| 260 | 260 | /// Walk the exercise's session history and produce the next prescription. | |
| @@ -462,9 +462,9 @@ mod tests { | |||
| 462 | 462 | session_date: date, | |
| 463 | 463 | exercise_id: 1, | |
| 464 | 464 | set_index, | |
| 465 | - | load, | |
| 466 | - | reps, | |
| 467 | - | rpe, | |
| 465 | + | load: crate::values::Load::new(load).unwrap(), | |
| 466 | + | reps: crate::values::Reps::new(reps).unwrap(), | |
| 467 | + | rpe: crate::values::Rpe::new(rpe).unwrap(), | |
| 468 | 468 | is_diagnostic: false, | |
| 469 | 469 | } | |
| 470 | 470 | } | |
| @@ -483,15 +483,15 @@ mod tests { | |||
| 483 | 483 | assert_eq!(evaluate_session(&sets, 5), SessionOutcome::CleanHit); | |
| 484 | 484 | ||
| 485 | 485 | let mut mixed = session(day(1), 100.0, 5, 3, 3); | |
| 486 | - | mixed[2].rpe = 4; | |
| 486 | + | mixed[2].rpe = crate::values::Rpe::new(4).unwrap(); | |
| 487 | 487 | assert_eq!(evaluate_session(&mixed, 5), SessionOutcome::Hold); | |
| 488 | 488 | ||
| 489 | 489 | let mut missed = session(day(1), 100.0, 5, 3, 3); | |
| 490 | - | missed[2].reps = 4; | |
| 490 | + | missed[2].reps = crate::values::Reps::new(4).unwrap(); | |
| 491 | 491 | assert_eq!(evaluate_session(&missed, 5), SessionOutcome::Miss); | |
| 492 | 492 | ||
| 493 | 493 | let mut hard = session(day(1), 100.0, 5, 3, 3); | |
| 494 | - | hard[0].rpe = 5; | |
| 494 | + | hard[0].rpe = crate::values::Rpe::new(5).unwrap(); | |
| 495 | 495 | assert_eq!(evaluate_session(&hard, 5), SessionOutcome::Miss); | |
| 496 | 496 | } | |
| 497 | 497 | ||
| @@ -532,7 +532,7 @@ mod tests { | |||
| 532 | 532 | session(day(1), 100.0, 5, 3, 3), | |
| 533 | 533 | { | |
| 534 | 534 | let mut s = session(day(2), 102.5, 5, 3, 3); | |
| 535 | - | s[2].reps = 4; | |
| 535 | + | s[2].reps = crate::values::Reps::new(4).unwrap(); | |
| 536 | 536 | s | |
| 537 | 537 | }, | |
| 538 | 538 | ]; | |
| @@ -549,7 +549,7 @@ mod tests { | |||
| 549 | 549 | session(day(1), 100.0, 5, 3, 3), | |
| 550 | 550 | { | |
| 551 | 551 | let mut s = session(day(2), 102.5, 5, 3, 3); | |
| 552 | - | s[2].reps = 4; | |
| 552 | + | s[2].reps = crate::values::Reps::new(4).unwrap(); | |
| 553 | 553 | s | |
| 554 | 554 | }, | |
| 555 | 555 | session(day(3), 102.5, 5, 3, 3), | |
| @@ -567,12 +567,12 @@ mod tests { | |||
| 567 | 567 | session(day(1), 100.0, 5, 3, 3), | |
| 568 | 568 | { | |
| 569 | 569 | let mut s = session(day(2), 100.0, 5, 3, 3); | |
| 570 | - | s[2].reps = 4; | |
| 570 | + | s[2].reps = crate::values::Reps::new(4).unwrap(); | |
| 571 | 571 | s | |
| 572 | 572 | }, | |
| 573 | 573 | { | |
| 574 | 574 | let mut s = session(day(3), 100.0, 5, 3, 3); | |
| 575 | - | s[2].reps = 4; | |
| 575 | + | s[2].reps = crate::values::Reps::new(4).unwrap(); | |
| 576 | 576 | s | |
| 577 | 577 | }, | |
| 578 | 578 | ]; | |
| @@ -594,12 +594,12 @@ mod tests { | |||
| 594 | 594 | session(day(1), 100.0, 5, 3, 3), | |
| 595 | 595 | { | |
| 596 | 596 | let mut s = session(day(2), 100.0, 5, 3, 3); | |
| 597 | - | s[2].reps = 4; | |
| 597 | + | s[2].reps = crate::values::Reps::new(4).unwrap(); | |
| 598 | 598 | s | |
| 599 | 599 | }, | |
| 600 | 600 | { | |
| 601 | 601 | let mut s = session(day(3), 100.0, 5, 3, 3); | |
| 602 | - | s[2].reps = 4; | |
| 602 | + | s[2].reps = crate::values::Reps::new(4).unwrap(); | |
| 603 | 603 | s | |
| 604 | 604 | }, | |
| 605 | 605 | session(day(4), 90.0, 5, 3, 3), | |
| @@ -619,12 +619,12 @@ mod tests { | |||
| 619 | 619 | session(day(1), 100.0, 5, 3, 3), | |
| 620 | 620 | { | |
| 621 | 621 | let mut s = session(day(2), 100.0, 5, 3, 3); | |
| 622 | - | s[2].reps = 4; | |
| 622 | + | s[2].reps = crate::values::Reps::new(4).unwrap(); | |
| 623 | 623 | s | |
| 624 | 624 | }, | |
| 625 | 625 | { | |
| 626 | 626 | let mut s = session(day(3), 100.0, 5, 3, 3); | |
| 627 | - | s[2].reps = 4; | |
| 627 | + | s[2].reps = crate::values::Reps::new(4).unwrap(); | |
| 628 | 628 | s | |
| 629 | 629 | }, | |
| 630 | 630 | session(day(4), 90.0, 5, 3, 3), | |
| @@ -651,12 +651,12 @@ mod tests { | |||
| 651 | 651 | session(day(1), 82.5, 5, 3, 3), | |
| 652 | 652 | { | |
| 653 | 653 | let mut s = session(day(2), 82.5, 5, 3, 3); | |
| 654 | - | s[2].reps = 4; | |
| 654 | + | s[2].reps = crate::values::Reps::new(4).unwrap(); | |
| 655 | 655 | s | |
| 656 | 656 | }, | |
| 657 | 657 | { | |
| 658 | 658 | let mut s = session(day(3), 82.5, 5, 3, 3); | |
| 659 | - | s[2].reps = 4; | |
| 659 | + | s[2].reps = crate::values::Reps::new(4).unwrap(); | |
| 660 | 660 | s | |
| 661 | 661 | }, | |
| 662 | 662 | ]; |
| @@ -10,23 +10,37 @@ use rusqlite::{OptionalExtension, Row, params}; | |||
| 10 | 10 | use crate::db::Db; | |
| 11 | 11 | use crate::error::Error; | |
| 12 | 12 | use crate::estimator::estimate_e1rm; | |
| 13 | + | use crate::values::{Load, Reps, Rpe}; | |
| 14 | + | ||
| 15 | + | fn bad_column(field: &'static str, msg: String) -> rusqlite::Error { | |
| 16 | + | rusqlite::Error::FromSqlConversionFailure( | |
| 17 | + | 0, | |
| 18 | + | rusqlite::types::Type::Real, | |
| 19 | + | Box::new(std::io::Error::other(format!("{field}: {msg}"))), | |
| 20 | + | ) | |
| 21 | + | } | |
| 13 | 22 | ||
| 14 | 23 | /// Row -> SessionSet decoder shared by every set-fetching query. Column | |
| 15 | - | /// order must match the callers' SELECT lists. | |
| 24 | + | /// order must match the callers' SELECT lists. Validation happens here | |
| 25 | + | /// so a bad row (RPE 7, negative load) errors at read time instead of | |
| 26 | + | /// silently propagating through the progression walk. | |
| 16 | 27 | fn row_to_session_set(row: &Row<'_>) -> rusqlite::Result<SessionSet> { | |
| 17 | 28 | let date_str: String = row.get(1)?; | |
| 18 | 29 | let parsed = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").map_err(|e| { | |
| 19 | 30 | rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e)) | |
| 20 | 31 | })?; | |
| 21 | 32 | let flag: i64 = row.get(7)?; | |
| 33 | + | let load = Load::new(row.get(4)?).map_err(|e| bad_column("load", e.to_string()))?; | |
| 34 | + | let reps = Reps::new(row.get(5)?).map_err(|e| bad_column("reps", e.to_string()))?; | |
| 35 | + | let rpe = Rpe::new(row.get(6)?).map_err(|e| bad_column("rpe", e.to_string()))?; | |
| 22 | 36 | Ok(SessionSet { | |
| 23 | 37 | id: row.get(0)?, | |
| 24 | 38 | session_date: parsed, | |
| 25 | 39 | exercise_id: row.get(2)?, | |
| 26 | 40 | set_index: row.get(3)?, | |
| 27 | - | load: row.get(4)?, | |
| 28 | - | reps: row.get(5)?, | |
| 29 | - | rpe: row.get(6)?, | |
| 41 | + | load, | |
| 42 | + | reps, | |
| 43 | + | rpe, | |
| 30 | 44 | is_diagnostic: flag != 0, | |
| 31 | 45 | }) | |
| 32 | 46 | } | |
| @@ -37,9 +51,9 @@ pub struct SessionSet { | |||
| 37 | 51 | pub session_date: NaiveDate, | |
| 38 | 52 | pub exercise_id: i64, | |
| 39 | 53 | pub set_index: i32, | |
| 40 | - | pub load: f64, | |
| 41 | - | pub reps: i32, | |
| 42 | - | pub rpe: i32, | |
| 54 | + | pub load: Load, | |
| 55 | + | pub reps: Reps, | |
| 56 | + | pub rpe: Rpe, | |
| 43 | 57 | pub is_diagnostic: bool, | |
| 44 | 58 | } | |
| 45 | 59 | ||
| @@ -67,6 +81,11 @@ impl Db { | |||
| 67 | 81 | /// Append a normal (non-diagnostic) working set. `set_index` is | |
| 68 | 82 | /// auto-assigned as one past the current max for this (date, exercise). | |
| 69 | 83 | /// Returns the new row id. | |
| 84 | + | /// | |
| 85 | + | /// Primitive inputs are validated through the newtype constructors | |
| 86 | + | /// before any SQL runs. A follow-up commit will lift the signature to | |
| 87 | + | /// take `Load`/`Reps`/`Rpe` directly so parsing lives at the TUI | |
| 88 | + | /// boundary. | |
| 70 | 89 | pub fn append_set( | |
| 71 | 90 | &self, | |
| 72 | 91 | exercise_id: i64, | |
| @@ -75,6 +94,9 @@ impl Db { | |||
| 75 | 94 | reps: i32, | |
| 76 | 95 | rpe: i32, | |
| 77 | 96 | ) -> Result<i64, Error> { | |
| 97 | + | let load = Load::new(load)?; | |
| 98 | + | let reps = Reps::new(reps)?; | |
| 99 | + | let rpe = Rpe::new(rpe)?; | |
| 78 | 100 | self.append_set_inner(exercise_id, date, load, reps, rpe, false) | |
| 79 | 101 | } | |
| 80 | 102 | ||
| @@ -89,6 +111,9 @@ impl Db { | |||
| 89 | 111 | reps: i32, | |
| 90 | 112 | rpe: i32, | |
| 91 | 113 | ) -> Result<i64, Error> { | |
| 114 | + | let load = Load::new(load)?; | |
| 115 | + | let reps = Reps::new(reps)?; | |
| 116 | + | let rpe = Rpe::new(rpe)?; | |
| 92 | 117 | self.append_set_inner(exercise_id, date, load, reps, rpe, true) | |
| 93 | 118 | } | |
| 94 | 119 | ||
| @@ -96,17 +121,11 @@ impl Db { | |||
| 96 | 121 | &self, | |
| 97 | 122 | exercise_id: i64, | |
| 98 | 123 | date: NaiveDate, | |
| 99 | - | load: f64, | |
| 100 | - | reps: i32, | |
| 101 | - | rpe: i32, | |
| 124 | + | load: Load, | |
| 125 | + | reps: Reps, | |
| 126 | + | rpe: Rpe, | |
| 102 | 127 | is_diagnostic: bool, | |
| 103 | 128 | ) -> Result<i64, Error> { | |
| 104 | - | if !(1..=5).contains(&rpe) { | |
| 105 | - | return Err(Error::InvalidRpe(rpe)); | |
| 106 | - | } | |
| 107 | - | if reps < 0 { | |
| 108 | - | return Err(Error::InvalidReps(reps)); | |
| 109 | - | } | |
| 110 | 129 | let next_index: i32 = self | |
| 111 | 130 | .conn() | |
| 112 | 131 | .query_row( | |
| @@ -122,9 +141,9 @@ impl Db { | |||
| 122 | 141 | date.to_string(), | |
| 123 | 142 | exercise_id, | |
| 124 | 143 | next_index, | |
| 125 | - | load, | |
| 126 | - | reps, | |
| 127 | - | rpe, | |
| 144 | + | load.get(), | |
| 145 | + | reps.get(), | |
| 146 | + | rpe.get(), | |
| 128 | 147 | if is_diagnostic { 1 } else { 0 }, | |
| 129 | 148 | ], | |
| 130 | 149 | )?; | |
| @@ -268,7 +287,7 @@ mod tests { | |||
| 268 | 287 | assert_eq!(list.len(), 3); | |
| 269 | 288 | assert_eq!(list[0].set_index, 1); | |
| 270 | 289 | assert_eq!(list[1].set_index, 2); | |
| 271 | - | assert_eq!(list[2].rpe, 4); | |
| 290 | + | assert_eq!(list[2].rpe.get(), 4); | |
| 272 | 291 | } | |
| 273 | 292 | ||
| 274 | 293 | #[test] |
| @@ -9,6 +9,8 @@ | |||
| 9 | 9 | //! Every constructor is fallible and returns `crate::Error`. Accessors | |
| 10 | 10 | //! return the raw inner value. | |
| 11 | 11 | ||
| 12 | + | use std::fmt; | |
| 13 | + | ||
| 12 | 14 | use crate::error::Error; | |
| 13 | 15 | ||
| 14 | 16 | /// Rate of Perceived Exertion on ripgrow's 1-5 scale. RPE is a | |
| @@ -96,6 +98,30 @@ impl Increment { | |||
| 96 | 98 | } | |
| 97 | 99 | } | |
| 98 | 100 | ||
| 101 | + | impl fmt::Display for Rpe { | |
| 102 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 103 | + | write!(f, "{}", self.0) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | impl fmt::Display for Reps { | |
| 108 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 109 | + | write!(f, "{}", self.0) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | impl fmt::Display for Load { | |
| 114 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 115 | + | write!(f, "{}", self.0) | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | impl fmt::Display for Increment { | |
| 120 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 121 | + | write!(f, "{}", self.0) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 99 | 125 | /// Weight unit for the load column. Both the profile's default unit and | |
| 100 | 126 | /// each exercise's `load_unit` use this type. Timed and distance efforts | |
| 101 | 127 | /// have implicit units (seconds, meters) and do not carry a `LoadUnit`. | |
| @@ -122,6 +148,12 @@ impl LoadUnit { | |||
| 122 | 148 | } | |
| 123 | 149 | } | |
| 124 | 150 | ||
| 151 | + | impl fmt::Display for LoadUnit { | |
| 152 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 153 | + | f.write_str(self.as_str()) | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 125 | 157 | /// Wrapper around a value derived from a formula rather than measured. | |
| 126 | 158 | /// The `method` field carries a short human-readable label naming the | |
| 127 | 159 | /// formula so that UI code can render "e1RM (rpe-adjusted Epley)" instead |
| @@ -431,9 +431,9 @@ fn load_prior_diagnostic_today( | |||
| 431 | 431 | list.into_iter() | |
| 432 | 432 | .filter(|s| s.is_diagnostic) | |
| 433 | 433 | .map(|s: SessionSet| DiagnosticSet { | |
| 434 | - | load: s.load, | |
| 435 | - | reps: s.reps, | |
| 436 | - | rpe: s.rpe, | |
| 434 | + | load: s.load.get(), | |
| 435 | + | reps: s.reps.get(), | |
| 436 | + | rpe: s.rpe.get(), | |
| 437 | 437 | }) | |
| 438 | 438 | .collect() | |
| 439 | 439 | } |
| @@ -505,7 +505,7 @@ fn format_prescription(p: &Prescription, unit: &str) -> String { | |||
| 505 | 505 | fn direction_glyph(db: &Db, ex: &Exercise, next_load: f64) -> &'static str { | |
| 506 | 506 | let sessions = db.list_sessions_for_exercise(ex.id).unwrap_or_default(); | |
| 507 | 507 | let Some(last) = sessions.last() else { return " " }; | |
| 508 | - | let last_top = last.iter().map(|s| s.load).fold(f64::NEG_INFINITY, f64::max); | |
| 508 | + | let last_top = last.iter().map(|s| s.load.get()).fold(f64::NEG_INFINITY, f64::max); | |
| 509 | 509 | if next_load > last_top { | |
| 510 | 510 | "up" | |
| 511 | 511 | } else if next_load < last_top { |
| @@ -201,8 +201,8 @@ fn render_recent_table(frame: &mut Frame, area: Rect, db: &Db, ex: &Exercise) { | |||
| 201 | 201 | for session in &recent { | |
| 202 | 202 | let date = session[0].session_date; | |
| 203 | 203 | let top_load = top_load(session); | |
| 204 | - | let top_reps = session.iter().map(|s| s.reps).max().unwrap_or(0); | |
| 205 | - | let max_rpe = session.iter().map(|s| s.rpe).max().unwrap_or(0); | |
| 204 | + | let top_reps = session.iter().map(|s| s.reps.get()).max().unwrap_or(0); | |
| 205 | + | let max_rpe = session.iter().map(|s| s.rpe.get()).max().unwrap_or(0); | |
| 206 | 206 | lines.push(Line::from(format!( | |
| 207 | 207 | " {:<10} {:>4} {:>8} {:>4} {:>3}", | |
| 208 | 208 | date.format("%Y-%m-%d"), | |
| @@ -228,7 +228,7 @@ fn render_recent_table(frame: &mut Frame, area: Rect, db: &Db, ex: &Exercise) { | |||
| 228 | 228 | fn top_load(session: &[SessionSet]) -> f64 { | |
| 229 | 229 | session | |
| 230 | 230 | .iter() | |
| 231 | - | .map(|s| s.load) | |
| 231 | + | .map(|s| s.load.get()) | |
| 232 | 232 | .fold(f64::NEG_INFINITY, f64::max) | |
| 233 | 233 | } | |
| 234 | 234 |
| @@ -445,9 +445,9 @@ mod tests { | |||
| 445 | 445 | let today = chrono::Local::now().date_naive(); | |
| 446 | 446 | let list = db.list_sets_for_session(sq, today).unwrap(); | |
| 447 | 447 | assert_eq!(list.len(), 1); | |
| 448 | - | assert_eq!(list[0].load, 100.0); | |
| 449 | - | assert_eq!(list[0].reps, 5); | |
| 450 | - | assert_eq!(list[0].rpe, 3); | |
| 448 | + | assert_eq!(list[0].load.get(), 100.0); | |
| 449 | + | assert_eq!(list[0].reps.get(), 5); | |
| 450 | + | assert_eq!(list[0].rpe.get(), 3); | |
| 451 | 451 | // Pending row should be cleared and focus back on Load. | |
| 452 | 452 | let g = screen.grid.as_ref().unwrap(); | |
| 453 | 453 | assert!(g.pending.load.is_empty()); |