Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement 1RM calculator #72

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 94 additions & 18 deletions frontend/src/domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,16 @@ impl From<std::ops::RangeInclusive<NaiveDate>> for Interval {
}
}

impl Interval {
pub fn since(days: i64) -> Self {
let today = Local::now().date_naive();
Interval {
first: today - Duration::days(days),
last: today,
}
}
}

#[derive(Clone, Copy, PartialEq)]
pub enum DefaultInterval {
All,
Expand All @@ -869,6 +879,85 @@ pub fn init_interval(dates: &[NaiveDate], default_interval: DefaultInterval) ->
Interval { first, last }
}

// Calculate the one-rep max for a given exercise and interval.
//
// The formula by Epley is used for 1RM calculation. The function returns a tuple
// of minimum and maximum 1RM (min, max) for each date.
pub fn one_rep_max_values(
training_sessions: &[&TrainingSession],
exercise_id: u32,
interval: &Interval,
) -> BTreeMap<NaiveDate, (f32, f32)> {
group_days(
&training_sessions
.iter()
.flat_map(|s| {
s.elements
.iter()
.filter_map(|e| match e {
TrainingSessionElement::Set {
exercise_id: id,
reps,
weight,
rpe,
..
} if *id == exercise_id
&& interval.first <= s.date
&& s.date <= interval.last =>
{
reps.map_or(None, |reps| {
weight.map_or(None, |weight| {
#[allow(clippy::cast_precision_loss)]
Some((
s.date,
// Epley, B. (1985). “Poundage Chart”. Boyd Epley Workout.
// Lincoln, NE: Body Enterprises.
weight * (1. + (reps as f32 + (10. - rpe.unwrap_or(10.))) / 30.),
))
})
})
}
_ => None,
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>(),
|x| {
let min = x
.iter()
.reduce(|acc, val| if *val < *acc { val } else { acc });
let max = x
.iter()
.reduce(|acc, val| if *val < *acc { acc } else { val });
min.and_then(|min| max.map(|max| (*min, *max)))
},
)
}

// Group a series of (date, value) pairs by day.
//
/// The `group_day` is called to combine values of the *same* day.
pub fn group_days<E: Copy, F: Copy>(
data: &Vec<(NaiveDate, E)>,
group_day: impl Fn(Vec<E>) -> Option<F>,
) -> BTreeMap<NaiveDate, F> {
let mut date_map: BTreeMap<NaiveDate, Vec<E>> = BTreeMap::new();

for (date, value) in data {
date_map.entry(*date).or_default().push(*value);
}

let mut grouped: BTreeMap<NaiveDate, F> = BTreeMap::new();

for (date, values) in date_map {
if let Some(result) = group_day(values) {
grouped.insert(date, result);
}
}

grouped
}

/// Group a series of (date, value) pairs.
///
/// The `radius` parameter determines the number of days before and after the
Expand All @@ -886,27 +975,14 @@ pub fn init_interval(dates: &[NaiveDate], default_interval: DefaultInterval) ->
///
/// Return `None` in those functions to indicate the absence of a value.
///
pub fn centered_moving_grouping(
data: &Vec<(NaiveDate, f32)>,
pub fn centered_moving_grouping<E: Copy, F: Copy>(
data: &Vec<(NaiveDate, E)>,
interval: &Interval,
radius: u64,
group_day: impl Fn(Vec<f32>) -> Option<f32>,
group_range: impl Fn(Vec<f32>) -> Option<f32>,
group_day: impl Fn(Vec<E>) -> Option<F>,
group_range: impl Fn(Vec<F>) -> Option<f32>,
) -> Vec<Vec<(NaiveDate, f32)>> {
let mut date_map: BTreeMap<&NaiveDate, Vec<f32>> = BTreeMap::new();

for (date, value) in data {
date_map.entry(date).or_default().push(*value);
}

let mut grouped: BTreeMap<&NaiveDate, f32> = BTreeMap::new();

for (date, values) in date_map {
if let Some(result) = group_day(values) {
grouped.insert(date, result);
}
}

let grouped = group_days(data, group_day);
interval
.first
.iter_days()
Expand Down
33 changes: 24 additions & 9 deletions frontend/src/ui/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pub const COLOR_REPS: usize = 4;
pub const COLOR_REPS_RIR: usize = 4;
pub const COLOR_WEIGHT: usize = 8;
pub const COLOR_TIME: usize = 5;
pub const COLOR_1RM: usize = 7;

pub const OPACITY_LINE: f64 = 0.9;
pub const OPACITY_AREA: f64 = 0.3;
Expand All @@ -36,7 +37,6 @@ pub const FONT: (&str, u32) = ("Roboto", 11);

#[derive(Clone)]
pub enum PlotType {
#[allow(dead_code)]
Circle(usize, f64, u32),
Line(usize, f64, u32),
Histogram(usize, f64),
Expand Down Expand Up @@ -1130,28 +1130,43 @@ where
]
}

pub fn view_element_with_description<Ms>(element: Node<Ms>, description: &str) -> Node<Ms> {
pub fn view_element_with_tooltip<Ms>(
element: Node<Ms>,
tooltip: Node<Ms>,
right_aligned: bool,
) -> Node<Ms> {
div![
C!["dropdown"],
IF![right_aligned => C!["is-right"]],
C!["is-hoverable"],
div![
C!["dropdown-trigger"],
div![C!["control"], C!["is-clickable"], element]
],
IF![
not(description.is_empty()) =>
if let Node::Element(x) = tooltip {
div![
C!["dropdown-menu"],
C!["has-no-min-width"],
div![
C!["dropdown-content"],
div![C!["dropdown-item"], description]
]
div![C!["dropdown-content"], div![C!["dropdown-item"], x]]
]
]
} else {
div![]
}
]
}

pub fn view_element_with_description<Ms>(element: Node<Ms>, description: &str) -> Node<Ms> {
view_element_with_tooltip(
element,
if description.is_empty() {
Node::Empty
} else {
div![description]
},
false,
)
}

pub fn format_set(
reps: Option<u32>,
time: Option<u32>,
Expand Down
68 changes: 67 additions & 1 deletion frontend/src/ui/page/exercise.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use seed::{prelude::*, *};

use crate::{
domain,
ui::{self, common, data, page::training},
ui::{self, common, data, page::training, page::training_session},
};

// ------ ------
Expand Down Expand Up @@ -274,12 +274,18 @@ pub fn view(model: &Model, data_model: &data::Model) -> Node<Msg> {
),
view_charts(
&training_sessions,
model.exercise_id,
&model.interval,
data_model.theme(),
data_model.settings.show_rpe,
data_model.settings.show_tut,
),
view_calendar(&training_sessions, &model.interval),
training_session::view_1rm_table(
data_model,
model.exercise_id,
&model.interval
),
training::view_table(
&training_sessions,
&data_model.routines,
Expand Down Expand Up @@ -440,6 +446,7 @@ fn view_muscles(model: &Model) -> Node<Msg> {

pub fn view_charts<Ms>(
training_sessions: &[&domain::TrainingSession],
exercise_id: u32,
interval: &domain::Interval,
theme: &ui::Theme,
show_rpe: bool,
Expand Down Expand Up @@ -486,6 +493,57 @@ pub fn view_charts<Ms>(
})
.collect::<Vec<_>>();

let one_rep_values = domain::one_rep_max_values(training_sessions, exercise_id, interval);
let one_rep_max_values = one_rep_values
.iter()
.map(|(date, (_, max))| (*date, *max))
.collect::<Vec<_>>();

let mut one_rep_data = vec![];

one_rep_data.push(common::PlotData {
values_high: one_rep_max_values.clone(),
values_low: Some(
one_rep_values
.iter()
.map(|(date, (min, _))| (*date, *min))
.collect::<Vec<_>>(),
),
plots: common::plot_area(common::COLOR_1RM),
params: common::PlotParams::default(),
});

one_rep_data.extend(
domain::centered_moving_average(&one_rep_max_values, interval, 7)
.iter()
.map(|values| common::PlotData {
values_high: values.clone(),
values_low: None,
plots: common::plot_line(common::COLOR_1RM),
params: common::PlotParams::default(),
}),
);

one_rep_data.push(common::PlotData {
values_high: one_rep_max_values
.into_iter()
.reduce(|(acc_date, acc), (date, max)| {
if acc < max {
(date, max)
} else {
(acc_date, acc)
}
})
.map_or(vec![], |v| vec![v]),
values_low: None,
plots: vec![common::PlotType::Circle(
common::COLOR_1RM,
common::OPACITY_AREA,
3,
)],
params: common::PlotParams::default(),
});

let mut data = vec![];

if show_rpe {
Expand Down Expand Up @@ -638,6 +696,14 @@ pub fn view_charts<Ms>(
false,
)
],
common::view_chart(
&[
("1RM (kg)", common::COLOR_1RM, common::OPACITY_AREA),
("Avg. 1RM (kg)", common::COLOR_1RM, common::OPACITY_LINE)
],
common::plot_chart(&one_rep_data, interval, theme),
false,
),
]
}

Expand Down
72 changes: 68 additions & 4 deletions frontend/src/ui/page/training_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2182,6 +2182,63 @@ fn view_muscles(training_session: &domain::TrainingSession, data_model: &data::M
}
}

pub fn view_1rm_table<Ms: 'static>(
data_model: &data::Model,
exercise_id: u32,
interval: &domain::Interval,
) -> Node<Ms> {
let one_rep_max = domain::one_rep_max_values(
&data_model.training_sessions.values().collect::<Vec<_>>(),
exercise_id,
interval,
)
.into_iter()
.reduce(|(acc_date, (_, acc_max)), (date, (_, max))| {
if acc_max < max {
(date, (0.0, max))
} else {
(acc_date, (0.0, acc_max))
}
});

one_rep_max.map_or(div![], |(date, (_, kg))| {
div![
C!["mx-3"],
common::view_title(&span!["One-rep max"], 3),
div![
C!["block"],
C!["is-size-7"],
C!["has-text-centered"],
date.to_string()
],
table![
C!["table"],
C!["is-striped"],
C!["is-fullwidth"],
style![St::WhiteSpace => "nowrap"],
thead![tr![
th![C!["has-text-right"], "% 1RM"],
th![C!["has-text-right"], "Reps."],
th![C!["has-text-right"], "Weight (kg)"]
]],
tbody![[100.0f32, 90.0, 80.0, 70.0, 60.0, 50.0, 40.0, 30.0]
.iter()
.map(|percentage| tr![
td![C!["has-text-right"], format!("{percentage}")],
td![
C!["has-text-right"],
format!("{:.0}", 3000.0 / *percentage - 29.0)
],
td![
C!["has-text-right"],
format!("{:.1}", *percentage / 100.0 * kg)
]
]),],
]
]
})
}

fn view_training_session_form(model: &Model, data_model: &data::Model) -> Vec<Node<Msg>> {
let sections = determine_sections(&model.form.elements);
let valid = model.form.valid();
Expand Down Expand Up @@ -2368,10 +2425,17 @@ fn view_training_session_form(model: &Model, data_model: &data::Model) -> Vec<No
},
&s.exercise_name
],
div![a![
ev(Ev::Click, move |_| Msg::ShowOptionsDialog(element_idx, position)),
span![C!["icon"], i![C!["fas fa-ellipsis-vertical"]]]
]],
div![
common::view_element_with_tooltip(
span![C!["icon"], i![C!["fas fa-circle-info"]]],
view_1rm_table(data_model, s.exercise_id, &domain::Interval::since(31)),
true,
),
a![
ev(Ev::Click, move |_| Msg::ShowOptionsDialog(element_idx, position)),
span![C!["icon"], i![C!["fas fa-ellipsis-vertical"]]]
]
],
],
],
if let Some(guide) = &model.guide {
Expand Down
Loading