Skip to main content

max / ripgrow

generate: dispatch on effort_kind via compute_prescription_any Generate screen prescriptions now route through compute_prescription_any instead of Db::compute_prescription, so a plank in the preview shows "3x 1m10s [prog]" and a run shows "seed on first log" (Distance is track-only). PrescriptionParts gains per-kind constructors (from_timed, from_distance, from_any) plus a no_history() factory shared by the preview and the PDF export. The direction glyph reads reps history for reps, timed history for timed, and stays neutral for distance.
Author: Max Johnson <me@maxj.phd> · 2026-07-18 20:26 UTC
Commit: 6e85aac1b05750828047b4d3b498ab6194b2f2c7
Parent: 30990a5
2 files changed, +258 insertions, -48 deletions
@@ -10,7 +10,10 @@ use std::process::Command;
10 10 use std::time::{SystemTime, UNIX_EPOCH};
11 11
12 12 use chrono::NaiveDate;
13 - use ripgrow_core::{Exercise, Prescription, ResistanceType};
13 + use ripgrow_core::{
14 + AnyPrescription, DistancePrescription, Exercise, Prescription, ResistanceType,
15 + TimedPrescription,
16 + };
14 17
15 18 const TEMPLATE: &str = include_str!("../templates/sheet.typ");
16 19
@@ -50,6 +53,9 @@ pub struct PrescriptionParts {
50 53 }
51 54
52 55 impl PrescriptionParts {
56 + /// Reps-shaped prescription. Bodyweight renders as `BW` (or `BW+N`
57 + /// for weighted bodyweight); everything else prints the numeric
58 + /// load with the exercise's load unit.
53 59 pub fn from(ex: &Exercise, p: &Prescription) -> Self {
54 60 match ex.resistance_type {
55 61 ResistanceType::Bodyweight if p.load.get() == 0.0 => Self {
@@ -64,12 +70,13 @@ impl PrescriptionParts {
64 70 load: format!("BW+{}", trim(p.load.get())),
65 71 unit: ex.load_unit.as_str().to_string(),
66 72 },
67 - ResistanceType::CardioTime | ResistanceType::CardioDistance => Self {
68 - sets: "-".to_string(),
69 - reps: "-".to_string(),
70 - load: trim(p.load.get()),
71 - unit: ex.load_unit.as_str().to_string(),
72 - },
73 + ResistanceType::CardioTime | ResistanceType::CardioDistance => {
74 + // Cardio exercises route through TimedKind / DistanceKind
75 + // now, so this branch is unreachable for real callers.
76 + // Kept defensive rather than panicking on an exotic mix
77 + // (e.g. a manually-crafted Prescription for a cardio ex).
78 + Self::no_history()
79 + }
73 80 ResistanceType::Freeweight | ResistanceType::Machine => Self {
74 81 sets: p.sets.to_string(),
75 82 reps: p.reps.to_string(),
@@ -78,6 +85,50 @@ impl PrescriptionParts {
78 85 },
79 86 }
80 87 }
88 +
89 + /// Timed prescription — plank holds and the like. Reps slot reads
90 + /// `-` (a timed set has no rep count); the duration lands in the
91 + /// load column with its own display (`1m30s`) and the unit column
92 + /// stays empty.
93 + pub fn from_timed(_ex: &Exercise, p: &TimedPrescription) -> Self {
94 + Self {
95 + sets: p.sets.to_string(),
96 + reps: "-".to_string(),
97 + load: p.duration.to_string(),
98 + unit: String::new(),
99 + }
100 + }
101 +
102 + /// Distance prescription. Distance is track-only today
103 + /// (`DistanceKind::compute_prescription` always returns `NoHistory`),
104 + /// so this factory exists purely for exhaustiveness at the dispatch
105 + /// boundary — it renders the same as [`Self::no_history`].
106 + pub fn from_distance(_ex: &Exercise, _p: &DistancePrescription) -> Self {
107 + Self::no_history()
108 + }
109 +
110 + /// Dispatch on kind. The reps-shaped branch is the only one that
111 + /// currently distinguishes bodyweight from freeweight; timed and
112 + /// distance render kind-uniformly.
113 + pub fn from_any(ex: &Exercise, p: &AnyPrescription) -> Self {
114 + match p {
115 + AnyPrescription::Reps(p) => Self::from(ex, p),
116 + AnyPrescription::Timed(p) => Self::from_timed(ex, p),
117 + AnyPrescription::Distance(p) => Self::from_distance(ex, p),
118 + }
119 + }
120 +
121 + /// "Seed on first log" placeholder for any kind's `NoHistory` case.
122 + /// The Generate screen uses this so the PDF and the on-screen
123 + /// preview render the same slot when there's nothing to prescribe.
124 + pub fn no_history() -> Self {
125 + Self {
126 + sets: "-".to_string(),
127 + reps: "-".to_string(),
128 + load: "seed".to_string(),
129 + unit: String::new(),
130 + }
131 + }
81 132 }
82 133
83 134 #[derive(Debug, thiserror::Error)]
@@ -326,21 +377,50 @@ mod tests {
326 377 }
327 378
328 379 #[test]
329 - fn parts_cardio() {
330 - // LoadUnit is currently Kg/Lb only. Cardio exercises will move to
331 - // their own effort kind in phase 4 (timed_sets / distance_sets),
332 - // at which point the "min"/"km" world moves off Exercise.load_unit
333 - // and lives on the kind itself. For now the placeholder unit is
334 - // kg; the test still verifies that cardio prescriptions elide the
335 - // set/rep count and just print load + unit.
336 - let parts = PrescriptionParts::from(
337 - &ex("row", ResistanceType::CardioTime, "kg"),
338 - &p(1, 1, 12.0),
380 + fn parts_timed_uses_duration_in_load_column() {
381 + use ripgrow_core::{Duration, TimedPrescription};
382 + let pres = TimedPrescription {
383 + state: State::Progressing,
384 + sets: 3,
385 + duration: Duration::from_seconds(90).unwrap(),
386 + };
387 + let parts = PrescriptionParts::from_timed(
388 + &ex("plank", ResistanceType::CardioTime, "kg"),
389 + &pres,
339 390 );
391 + assert_eq!(parts.sets, "3");
392 + assert_eq!(parts.reps, "-");
393 + assert_eq!(parts.load, "1m30s");
394 + assert_eq!(parts.unit, "");
395 + }
396 +
397 + #[test]
398 + fn no_history_renders_seed_placeholder() {
399 + let parts = PrescriptionParts::no_history();
340 400 assert_eq!(parts.sets, "-");
341 401 assert_eq!(parts.reps, "-");
342 - assert_eq!(parts.load, "12");
343 - assert_eq!(parts.unit, "kg");
402 + assert_eq!(parts.load, "seed");
403 + assert_eq!(parts.unit, "");
404 + }
405 +
406 + #[test]
407 + fn from_any_dispatches_by_kind() {
408 + use ripgrow_core::{AnyPrescription, Duration, TimedPrescription};
409 + let reps_parts = PrescriptionParts::from_any(
410 + &ex("bench", ResistanceType::Freeweight, "kg"),
411 + &AnyPrescription::Reps(p(3, 5, 82.5)),
412 + );
413 + assert_eq!(reps_parts.load, "82.5");
414 + let timed_parts = PrescriptionParts::from_any(
415 + &ex("plank", ResistanceType::CardioTime, "kg"),
416 + &AnyPrescription::Timed(TimedPrescription {
417 + state: State::Progressing,
418 + sets: 3,
419 + duration: Duration::from_seconds(60).unwrap(),
420 + }),
421 + );
422 + assert_eq!(timed_parts.load, "1m00s");
423 + assert_eq!(timed_parts.reps, "-");
344 424 }
345 425
346 426 #[test]
@@ -19,7 +19,8 @@ use ratatui::text::{Line, Span};
19 19 use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
20 20 use ripgrow_core::generation::{PickedSlot, pick_session, score, warmup_tag_ids};
21 21 use ripgrow_core::{
22 - Db, Exercise, Kind, Prescription, PrescriptionResult, Readiness, RepsKind, State, Tag,
22 + AnyPrescription, AnyPrescriptionResult, Db, EffortKind, Exercise, Kind, Prescription,
23 + Readiness, RepsKind, State, Tag, TimedKind, TimedPrescription, compute_prescription_any,
23 24 };
24 25
25 26 use crate::pdf::{self, PrescriptionParts, SessionExport, SlotExport};
@@ -249,21 +250,15 @@ impl GenerateScreen {
249 250 let mut slots: Vec<SlotExport> = Vec::new();
250 251 for slot in &p.slots {
251 252 let ex = slot.current();
252 - let (parts, glyph) = match db.compute_prescription(ex.id) {
253 - Ok(PrescriptionResult::Prescribed(pres)) => {
254 - let parts = PrescriptionParts::from(ex, &pres);
255 - let g = direction_glyph(db, ex, pres.load.get());
253 + let (parts, glyph) = match compute_prescription_any(db, ex) {
254 + Ok(AnyPrescriptionResult::Prescribed(pres)) => {
255 + let parts = PrescriptionParts::from_any(ex, &pres);
256 + let g = direction_glyph(db, ex, &pres);
256 257 (parts, g.to_string())
257 258 }
258 - Ok(PrescriptionResult::NoHistory) => (
259 - PrescriptionParts {
260 - sets: "-".to_string(),
261 - reps: "-".to_string(),
262 - load: "seed".to_string(),
263 - unit: String::new(),
264 - },
265 - String::new(),
266 - ),
259 + Ok(AnyPrescriptionResult::NoHistory) => {
260 + (PrescriptionParts::no_history(), String::new())
261 + }
267 262 Err(e) => {
268 263 self.status = format!("prescription failed: {e}");
269 264 return;
@@ -481,34 +476,82 @@ fn cycle_slot(p: &mut Preview, delta: i32) {
481 476 }
482 477
483 478 fn prescription_for(db: &Db, ex: &Exercise) -> (String, &'static str) {
484 - match db.compute_prescription(ex.id) {
485 - Ok(PrescriptionResult::NoHistory) => ("seed on first log".to_string(), " "),
486 - Ok(PrescriptionResult::Prescribed(p)) => {
487 - let text = format_prescription(&p, ex.load_unit.as_str());
488 - let glyph = direction_glyph(db, ex, p.load.get());
479 + match compute_prescription_any(db, ex) {
480 + Ok(AnyPrescriptionResult::NoHistory) => ("seed on first log".to_string(), " "),
481 + Ok(AnyPrescriptionResult::Prescribed(p)) => {
482 + let text = format_prescription_any(&p, ex.load_unit.as_str());
483 + let glyph = direction_glyph(db, ex, &p);
489 484 (text, glyph)
490 485 }
491 486 Err(_) => ("(error)".to_string(), " "),
492 487 }
493 488 }
494 489
495 - fn format_prescription(p: &Prescription, unit: &str) -> String {
496 - let state = match p.state {
490 + fn format_prescription_any(p: &AnyPrescription, unit: &str) -> String {
491 + match p {
492 + AnyPrescription::Reps(p) => format_reps_prescription(p, unit),
493 + AnyPrescription::Timed(p) => format_timed_prescription(p),
494 + // Distance is track-only; compute_prescription_any never returns
495 + // Prescribed for it today. Kept for exhaustiveness.
496 + AnyPrescription::Distance(_) => "(distance)".to_string(),
497 + }
498 + }
499 +
500 + fn format_reps_prescription(p: &Prescription, unit: &str) -> String {
501 + format!(
502 + "{}x{} @ {} {} [{}]",
503 + p.sets,
504 + p.reps,
505 + p.load,
506 + unit,
507 + state_tag(p.state),
508 + )
509 + }
510 +
511 + fn format_timed_prescription(p: &TimedPrescription) -> String {
512 + format!("{}x {} [{}]", p.sets, p.duration, state_tag(p.state))
513 + }
514 +
515 + fn state_tag(s: State) -> &'static str {
516 + match s {
497 517 State::Progressing => "prog",
498 518 State::Probation => "prob",
499 519 State::Deloading => "deld",
500 520 State::Rebuilding => "rebd",
501 - };
502 - format!("{}x{} @ {} {} [{}]", p.sets, p.reps, p.load, unit, state)
521 + }
503 522 }
504 523
505 - fn direction_glyph(db: &Db, ex: &Exercise, next_load: f64) -> &'static str {
506 - let sessions = RepsKind::list_sessions_for_exercise(db, ex.id).unwrap_or_default();
507 - let Some(last) = sessions.last() else { return " " };
508 - let last_top = last.iter().map(|s| s.load.get()).fold(f64::NEG_INFINITY, f64::max);
509 - if next_load > last_top {
524 + /// Direction arrow versus the most recent session for this exercise.
525 + /// Dispatches on effort kind: reps compares top load, timed compares
526 + /// top duration, distance is neutral (track-only, no notion of "up").
527 + fn direction_glyph(db: &Db, ex: &Exercise, next: &AnyPrescription) -> &'static str {
528 + match (ex.effort_kind, next) {
529 + (EffortKind::Reps, AnyPrescription::Reps(p)) => {
530 + let sessions =
531 + RepsKind::list_sessions_for_exercise(db, ex.id).unwrap_or_default();
532 + let Some(last) = sessions.last() else { return " " };
533 + let last_top = last
534 + .iter()
535 + .map(|s| s.load.get())
536 + .fold(f64::NEG_INFINITY, f64::max);
537 + compare(p.load.get(), last_top)
538 + }
539 + (EffortKind::Timed, AnyPrescription::Timed(p)) => {
540 + let sessions =
541 + TimedKind::list_sessions_for_exercise(db, ex.id).unwrap_or_default();
542 + let Some(last) = sessions.last() else { return " " };
543 + let last_top = last.iter().map(|s| s.duration.seconds()).max().unwrap_or(0);
544 + compare(p.duration.seconds() as f64, last_top as f64)
545 + }
546 + (EffortKind::Distance, _) => " ",
547 + _ => " ",
548 + }
549 + }
550 +
551 + fn compare(next: f64, last: f64) -> &'static str {
552 + if next > last {
510 553 "up"
511 - } else if next_load < last_top {
554 + } else if next < last {
512 555 "down"
513 556 } else {
514 557 "flat"
@@ -651,4 +694,91 @@ mod tests {
651 694 screen.on_key(&db, key(KeyCode::Esc));
652 695 assert!(screen.preview.is_none());
653 696 }
697 +
698 + #[test]
699 + fn prescription_for_reps_renders_load_and_state() {
700 + use ripgrow_core::{Load, RepsPayload, Reps as CoreReps, Rpe};
701 + let db = Db::open_in_memory().unwrap();
702 + db.init_profile("self", LoadUnit::Kg).unwrap();
703 + let ex_id = db
704 + .create_exercise("bench", ResistanceType::Freeweight, LoadUnit::Kg, 2.5, &[])
705 + .unwrap();
706 + let d = chrono::NaiveDate::from_ymd_opt(2026, 7, 10).unwrap();
707 + RepsKind::append(
708 + &db,
709 + ex_id,
710 + d,
711 + RepsPayload::new(Load::new(80.0).unwrap(), CoreReps::new(5).unwrap()),
712 + Rpe::new(3).unwrap(),
713 + false,
714 + )
715 + .unwrap();
716 + let ex = db.list_exercises().unwrap().into_iter().find(|e| e.id == ex_id).unwrap();
717 + let (text, glyph) = prescription_for(&db, &ex);
718 + // Clean-hit first session -> +increment -> 82.5.
719 + assert!(text.contains("82.5"), "text was: {text}");
720 + assert!(text.contains("prog"), "text was: {text}");
721 + assert_eq!(glyph, "up");
722 + }
723 +
724 + #[test]
725 + fn prescription_for_timed_renders_duration_and_state() {
726 + use ripgrow_core::{Duration as CoreDuration, Rpe, TimedPayload};
727 + let db = Db::open_in_memory().unwrap();
728 + db.init_profile("self", LoadUnit::Kg).unwrap();
729 + let ex_id = db
730 + .create_exercise("plank", ResistanceType::CardioTime, LoadUnit::Kg, 10.0, &[])
731 + .unwrap();
732 + let d = chrono::NaiveDate::from_ymd_opt(2026, 7, 10).unwrap();
733 + TimedKind::append(
734 + &db,
735 + ex_id,
736 + d,
737 + TimedPayload::new(CoreDuration::from_seconds(60).unwrap()),
738 + Rpe::new(3).unwrap(),
739 + false,
740 + )
741 + .unwrap();
742 + let ex = db.list_exercises().unwrap().into_iter().find(|e| e.id == ex_id).unwrap();
743 + let (text, glyph) = prescription_for(&db, &ex);
744 + // Clean-hit first session -> +increment (10 sec) -> 70 sec -> "1m10s".
745 + assert!(text.contains("1m10s"), "text was: {text}");
746 + assert!(text.contains("prog"), "text was: {text}");
747 + assert_eq!(glyph, "up");
748 + }
749 +
750 + #[test]
751 + fn prescription_for_distance_is_seed_regardless_of_history() {
752 + use ripgrow_core::{
753 + Distance as CoreDistance, DistanceKind, DistancePayload, Duration as CoreDuration, Rpe,
754 + };
755 + let db = Db::open_in_memory().unwrap();
756 + db.init_profile("self", LoadUnit::Kg).unwrap();
757 + let ex_id = db
758 + .create_exercise(
759 + "5k run",
760 + ResistanceType::CardioDistance,
761 + LoadUnit::Kg,
762 + 0.0,
763 + &[],
764 + )
765 + .unwrap();
766 + let d = chrono::NaiveDate::from_ymd_opt(2026, 7, 10).unwrap();
767 + DistanceKind::append(
768 + &db,
769 + ex_id,
770 + d,
771 + DistancePayload::new(
772 + CoreDistance::from_meters(5000.0).unwrap(),
773 + CoreDuration::from_seconds(1500).unwrap(),
774 + ),
775 + Rpe::new(3).unwrap(),
776 + false,
777 + )
778 + .unwrap();
779 + let ex = db.list_exercises().unwrap().into_iter().find(|e| e.id == ex_id).unwrap();
780 + let (text, _glyph) = prescription_for(&db, &ex);
781 + // Distance is track-only — always NoHistory.
782 + assert_eq!(text, "seed on first log");
783 + }
654 784 }