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