diff --git a/CHANGELOG.md b/CHANGELOG.md index c0248fe..e9c1099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Option to prefer exercise in training session - Shortcut for inserting values of previous set into current set - Suggestion of exercises that train similar muscles when replacing exercise in training session +- Show 7 day average in set volume and volume load graphs for exercises +- Show 7 day average in set volume and RPE graphs for routines +- Show per day values in set volume graphs for muscles +- Show per day values in set volume and RPE graphs for trainings ### Changed @@ -28,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Hide empty columns in training tables - Consider sets without RPE value to be hard sets - Default interval on exercise, body weight, body fat and menstrual cycle page to three months +- Use 7 day interval instead of current week for RPE, set volume, and volume load graphs ### Fixed diff --git a/frontend/Cargo.lock b/frontend/Cargo.lock index 08277c9..36d11a7 100644 --- a/frontend/Cargo.lock +++ b/frontend/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "android-tzdata" version = "0.1.1" @@ -143,6 +152,12 @@ version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1056f553da426e9c025a662efa48b52e62e0a3a7648aa2d15aeaaf7f0d329357" +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + [[package]] name = "fnv" version = "1.0.7" @@ -151,9 +166,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "futures" -version = "0.3.29" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0290714b38af9b4a7b094b8a37086d1b4e61f2df9122c3cad2577669145335" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", @@ -166,9 +181,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.29" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", @@ -176,15 +191,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.29" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb1d22c66e66d9d72e1758f0bd7d4fd0bee04cad842ee34587d68c07e45d088c" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" -version = "0.3.29" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f4fb8693db0cf099eadcca0efe2a5a22e4550f98ed16aba6c48700da29597bc" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", @@ -193,38 +208,44 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.29" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bf34a163b5c4c52d0478a4d757da8fb65cabef42ba90515efee0f6f9fa45aaa" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-macro" -version = "0.3.29" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.87", ] [[package]] name = "futures-sink" -version = "0.3.29" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e36d3378ee38c2a36ad710c5d30c2911d752cb941c00c72dbabfb786a7970817" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.29" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-timer" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd193069b0ddadc69c46389b740bbccdd97203899b48d09c5f7969591d6bae2" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" [[package]] name = "futures-util" -version = "0.3.29" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a19526d624e703a3179b3d322efec918b6246ea0fa51d41124525f00f1cc8104" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", @@ -273,6 +294,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + [[package]] name = "gloo-console" version = "0.3.0" @@ -372,6 +399,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a9bfc1af68b1726ea47d3d5109de126281def866b33970e10fbab11b5dafab3" + [[package]] name = "http" version = "1.1.0" @@ -413,7 +446,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", - "hashbrown", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" +dependencies = [ + "equivalent", + "hashbrown 0.15.1", ] [[package]] @@ -527,6 +570,15 @@ dependencies = [ "yansi", ] +[[package]] +name = "proc-macro-crate" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro-hack" version = "0.5.20+deprecated" @@ -535,9 +587,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] name = "proc-macro2" -version = "1.0.70" +version = "1.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39278fbbf5fb4f646ce651690877f89d1c5811a3d4acb27700c1cb3cdb78fd3b" +checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" dependencies = [ "unicode-ident", ] @@ -556,9 +608,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.33" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] @@ -614,13 +666,87 @@ dependencies = [ "rand_core", ] +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "rstest" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a2c585be59b6b5dd66a9d2084aa1d8bd52fbdb806eafdeffb52791147862035" +dependencies = [ + "futures", + "futures-timer", + "rstest_macros", + "rustc_version 0.4.1", +] + +[[package]] +name = "rstest_macros" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825ea780781b15345a146be27eaefb05085e337e869bff01b4306a4fd4a9ad5a" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version 0.4.1", + "syn 2.0.87", + "unicode-ident", +] + [[package]] name = "rustc_version" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" dependencies = [ - "semver", + "semver 0.9.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver 1.0.23", ] [[package]] @@ -642,7 +768,7 @@ dependencies = [ "futures", "gloo-file", "gloo-timers", - "indexmap", + "indexmap 1.9.3", "js-sys", "pulldown-cmark", "rand", @@ -664,6 +790,12 @@ dependencies = [ "semver-parser", ] +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" + [[package]] name = "semver-parser" version = "0.7.0" @@ -687,7 +819,7 @@ checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.87", ] [[package]] @@ -747,7 +879,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d022496b16281348b52d0e30ae99e01a73d737b2f45d38fed4edf79f9325a1d5" dependencies = [ "discard", - "rustc_version", + "rustc_version 0.2.3", "stdweb-derive", "stdweb-internal-macros", "stdweb-internal-runtime", @@ -802,9 +934,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.41" +version = "2.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c8b28c477cc3bf0e7966561e3460130e1255f7a1cf71931075f1c5e7a7e269" +checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" dependencies = [ "proc-macro2", "quote", @@ -828,7 +960,7 @@ checksum = "01742297787513b79cf8e29d1056ede1313e2420b7b3b15d0a768b4921f549df" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.87", ] [[package]] @@ -869,6 +1001,23 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap 2.6.0", + "toml_datetime", + "winnow", +] + [[package]] name = "unicase" version = "2.7.0" @@ -880,9 +1029,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" [[package]] name = "unicode-width" @@ -910,6 +1059,7 @@ dependencies = [ "gloo-storage", "plotters", "pretty_assertions", + "rstest", "seed", "serde", "serde_json", @@ -1108,6 +1258,15 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] + [[package]] name = "yansi" version = "0.5.1" diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index ac7c465..96eace7 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -20,3 +20,4 @@ web-sys = { version = "0.3", features = ["AudioContext", "AudioDestinationNode", [dev-dependencies] assert_approx_eq = "1.1.0" pretty_assertions = "1.4.0" +rstest = "0.23.0" diff --git a/frontend/src/ui/common.rs b/frontend/src/ui/common.rs index fd165ef..cf5c466 100644 --- a/frontend/src/ui/common.rs +++ b/frontend/src/ui/common.rs @@ -1,6 +1,6 @@ use std::collections::{BTreeMap, HashMap}; -use chrono::{prelude::*, Duration}; +use chrono::{prelude::*, Days, Duration}; use plotters::prelude::*; use seed::{prelude::*, *}; @@ -17,8 +17,11 @@ pub const COLOR_LOAD: usize = 1; pub const COLOR_LONG_TERM_LOAD: usize = 2; pub const COLOR_LONG_TERM_LOAD_BOUNDS: usize = 13; pub const COLOR_RPE: usize = 0; +pub const COLOR_RPE_7DAY: usize = 2; pub const COLOR_SET_VOLUME: usize = 3; +pub const COLOR_SET_VOLUME_7DAY: usize = 2; pub const COLOR_VOLUME_LOAD: usize = 6; +pub const COLOR_VOLUME_LOAD_7DAY: usize = 2; pub const COLOR_TUT: usize = 2; pub const COLOR_REPS: usize = 3; pub const COLOR_REPS_RIR: usize = 4; @@ -1183,9 +1186,96 @@ pub fn post_message_to_service_worker(message: &ServiceWorkerMessage) -> Result< } } +pub fn centered_moving_grouping( + data: &Vec<(NaiveDate, f32)>, + interval: &Interval, + radius: u64, + group_day: impl Fn(Vec) -> f32, + group_range: impl Fn(Vec) -> f32, +) -> Vec<(NaiveDate, f32)> { + let mut date_map: BTreeMap<&NaiveDate, Vec> = BTreeMap::new(); + + for elem in data { + date_map.entry(&elem.0).or_default().push(elem.1); + } + + let mut grouped: BTreeMap<&NaiveDate, f32> = BTreeMap::new(); + + for elem in date_map { + grouped.insert(elem.0, group_day(elem.1)); + } + + interval + .first + .iter_days() + .take_while(|d| *d <= interval.last) + .map(|center| { + ( + center, + group_range( + center + .checked_sub_days(Days::new(radius)) + .unwrap_or(center) + .iter_days() + .take_while(|d| { + *d <= interval.last + && *d + <= center.checked_add_days(Days::new(radius)).unwrap_or(center) + }) + .filter_map(|d| grouped.get(&d)) + .copied() + .collect::>(), + ), + ) + }) + .collect::>() +} + +pub fn centered_moving_total( + data: &Vec<(NaiveDate, f32)>, + interval: &Interval, + radius: u64, +) -> Vec<(NaiveDate, f32)> { + centered_moving_grouping( + data, + interval, + radius, + |d| d.iter().sum(), + |d| d.iter().sum(), + ) +} + +pub fn centered_moving_average( + data: &Vec<(NaiveDate, f32)>, + interval: &Interval, + radius: u64, +) -> Vec<(NaiveDate, f32)> { + #[allow(clippy::cast_precision_loss)] + centered_moving_grouping( + data, + interval, + radius, + |d| { + if d.is_empty() { + 0.0 + } else { + d.iter().sum::() / d.len() as f32 + } + }, + |d| { + if d.is_empty() { + 0.0 + } else { + d.iter().sum::() / d.len() as f32 + } + }, + ) +} + #[cfg(test)] mod tests { use super::*; + use rstest::rstest; #[test] fn quartile_one() { @@ -1320,4 +1410,74 @@ mod tests { Duration::days(6) ); } + + #[rstest] + #[case((2020, 2, 3), (2020, 2, 5), 0, &[], &[(2020, 2, 3, 0.0), (2020, 2, 4, 0.0), (2020, 2, 5, 0.0)])] + #[case((2020, 2, 3), (2020, 2, 5), 0, &[(2020, 2, 3, 1.0)], &[(2020, 2, 3, 1.0), (2020, 2, 4, 0.0), (2020, 2, 5, 0.0)])] + #[case((2020, 3, 3), (2020, 3, 5), 0, &[(2020, 2, 3, 1.0)], &[(2020, 3, 3, 0.0), (2020, 3, 4, 0.0), (2020, 3, 5, 0.0)])] + #[case((2020, 2, 3), (2020, 2, 5), 0, &[(2020, 2, 3, 1.0), (2020, 2, 4, 1.0), (2020, 2, 5, 1.0)], &[(2020, 2, 3, 1.0), (2020, 2, 4, 1.0), (2020, 2, 5, 1.0)])] + #[case((2020, 2, 3), (2020, 2, 5), 0, &[(2020, 2, 3, 1.0), (2020, 2, 4, 1.0), (2020, 2, 5, 1.0), (2020, 2, 3, 3.0)], &[(2020, 2, 3, 2.0), (2020, 2, 4, 1.0), (2020, 2, 5, 1.0)])] + #[case((2020, 2, 3), (2020, 2, 5), 1, &[(2020, 2, 3, 1.0), (2020, 2, 4, 2.0), (2020, 2, 5, 3.0)], &[(2020, 2, 3, 1.5), (2020, 2, 4, 2.0), (2020, 2, 5, 2.5)])] + #[case((2020, 2, 2), (2020, 2, 6), 1, &[(2020, 2, 3, 1.0), (2020, 2, 4, 2.0), (2020, 2, 5, 3.0)], &[(2020, 2, 2, 1.0), (2020, 2, 3, 1.5), (2020, 2, 4, 2.0), (2020, 2, 5, 2.5), (2020, 2, 6, 3.0)])] + #[case((2020, 2, 3), (2020, 2, 7), 1, &[(2020, 2, 3, 1.0), (2020, 2, 7, 1.0)], &[(2020, 2, 3, 1.0), (2020, 2, 4, 1.0), (2020, 2, 5, 0.0), (2020, 2, 6, 1.0), (2020, 2, 7, 1.0)])] + fn centered_moving_average( + #[case] start: (i32, u32, u32), + #[case] end: (i32, u32, u32), + #[case] radius: u64, + #[case] input: &[(i32, u32, u32, f32)], + #[case] expected: &[(i32, u32, u32, f32)], + ) { + assert_eq!( + super::centered_moving_average( + &input + .iter() + .map(|(y, m, d, v)| (NaiveDate::from_ymd_opt(*y, *m, *d).unwrap(), *v)) + .collect::>(), + &Interval { + first: NaiveDate::from_ymd_opt(start.0, start.1, start.2).unwrap(), + last: NaiveDate::from_ymd_opt(end.0, end.1, end.2).unwrap(), + }, + radius, + ), + expected + .iter() + .map(|(y, m, d, v)| (NaiveDate::from_ymd_opt(*y, *m, *d).unwrap(), *v)) + .collect::>(), + ); + } + + #[rstest] + #[case((2020, 2, 3), (2020, 2, 5), 0, &[], &[(2020, 2, 3, 0.0), (2020, 2, 4, 0.0), (2020, 2, 5, 0.0)])] + #[case((2020, 3, 3), (2020, 3, 5), 0, &[(2020, 2, 3, 1.0)], &[(2020, 3, 3, 0.0), (2020, 3, 4, 0.0), (2020, 3, 5, 0.0)])] + #[case((2020, 2, 3), (2020, 2, 5), 0, &[(2020, 2, 3, 1.0)], &[(2020, 2, 3, 1.0), (2020, 2, 4, 0.0), (2020, 2, 5, 0.0)])] + #[case((2020, 2, 3), (2020, 2, 5), 0, &[(2020, 2, 3, 1.0), (2020, 2, 4, 2.0), (2020, 2, 5, 3.0)], &[(2020, 2, 3, 1.0), (2020, 2, 4, 2.0), (2020, 2, 5, 3.0)])] + #[case((2020, 2, 3), (2020, 2, 5), 0, &[(2020, 2, 3, 1.0), (2020, 2, 4, 2.0), (2020, 2, 5, 3.0), (2020, 2, 3, 1.0)], &[(2020, 2, 3, 2.0), (2020, 2, 4, 2.0), (2020, 2, 5, 3.0)])] + #[case((2020, 2, 3), (2020, 2, 5), 1, &[(2020, 2, 3, 1.0), (2020, 2, 4, 2.0), (2020, 2, 5, 3.0)], &[(2020, 2, 3, 3.0), (2020, 2, 4, 6.0), (2020, 2, 5, 5.0)])] + #[case((2020, 2, 2), (2020, 2, 6), 1, &[(2020, 2, 3, 1.0), (2020, 2, 4, 2.0), (2020, 2, 5, 3.0)], &[(2020, 2, 2, 1.0), (2020, 2, 3, 3.0), (2020, 2, 4, 6.0), (2020, 2, 5, 5.0), (2020, 2, 6, 3.0)])] + #[case((2020, 2, 3), (2020, 2, 7), 1, &[(2020, 2, 3, 1.0), (2020, 2, 7, 1.0)], &[(2020, 2, 3, 1.0), (2020, 2, 4, 1.0), (2020, 2, 5, 0.0), (2020, 2, 6, 1.0), (2020, 2, 7, 1.0)])] + fn centered_moving_total( + #[case] start: (i32, u32, u32), + #[case] end: (i32, u32, u32), + #[case] radius: u64, + #[case] input: &[(i32, u32, u32, f32)], + #[case] expected: &[(i32, u32, u32, f32)], + ) { + assert_eq!( + super::centered_moving_total( + &input + .iter() + .map(|(y, m, d, v)| (NaiveDate::from_ymd_opt(*y, *m, *d).unwrap(), *v)) + .collect::>(), + &Interval { + first: NaiveDate::from_ymd_opt(start.0, start.1, start.2).unwrap(), + last: NaiveDate::from_ymd_opt(end.0, end.1, end.2).unwrap(), + }, + radius, + ), + expected + .iter() + .map(|(y, m, d, v)| (NaiveDate::from_ymd_opt(*y, *m, *d).unwrap(), *v)) + .collect::>(), + ); + } } diff --git a/frontend/src/ui/data.rs b/frontend/src/ui/data.rs index dbf9a94..2560380 100644 --- a/frontend/src/ui/data.rs +++ b/frontend/src/ui/data.rs @@ -68,9 +68,7 @@ pub fn init(url: Url, _orders: &mut impl Orders) -> Model { training_stats: TrainingStats { short_term_load: Vec::new(), long_term_load: Vec::new(), - avg_rpe_per_week: Vec::new(), - total_set_volume_per_week: Vec::new(), - stimulus_for_each_muscle_per_week: BTreeMap::new(), + stimulus_per_muscle: BTreeMap::new(), }, settings, ongoing_training_session, @@ -274,9 +272,7 @@ pub struct CycleStats { pub struct TrainingStats { pub short_term_load: Vec<(NaiveDate, f32)>, pub long_term_load: Vec<(NaiveDate, f32)>, - pub avg_rpe_per_week: Vec<(NaiveDate, f32)>, - pub total_set_volume_per_week: Vec<(NaiveDate, f32)>, - pub stimulus_for_each_muscle_per_week: BTreeMap>, + pub stimulus_per_muscle: BTreeMap>, } impl TrainingStats { @@ -296,8 +292,6 @@ impl TrainingStats { pub fn clear(&mut self) { self.short_term_load.clear(); self.long_term_load.clear(); - self.avg_rpe_per_week.clear(); - self.total_set_volume_per_week.clear(); } } @@ -884,12 +878,7 @@ fn calculate_training_stats( TrainingStats { short_term_load, long_term_load, - total_set_volume_per_week: calculate_total_set_volume_per_week(training_sessions), - avg_rpe_per_week: calculate_avg_rpe_per_week(training_sessions), - stimulus_for_each_muscle_per_week: calculate_stimulus_for_each_muscle_per_week( - training_sessions, - exercises, - ), + stimulus_per_muscle: calculate_daily_stimulus_per_muscle(training_sessions, exercises), } } @@ -951,93 +940,29 @@ fn calculate_average_weighted_sum_of_load( .collect::>() } -fn calculate_total_set_volume_per_week( - training_sessions: &[&TrainingSession], -) -> Vec<(NaiveDate, f32)> { - let mut result: BTreeMap = training_session_weeks(training_sessions); - - #[allow(clippy::cast_precision_loss)] - for t in training_sessions { - result - .entry(t.date.week(Weekday::Mon).last_day()) - .and_modify(|e| *e += t.set_volume() as f32); - } - - result.into_iter().collect() -} - -fn calculate_avg_rpe_per_week(training_sessions: &[&TrainingSession]) -> Vec<(NaiveDate, f32)> { - let mut result: BTreeMap> = training_session_weeks(training_sessions); - - for t in training_sessions { - if let Some(avg_rpe) = t.avg_rpe() { - result - .entry(t.date.week(Weekday::Mon).last_day()) - .and_modify(|e| e.push(avg_rpe)); - } - } - - #[allow(clippy::cast_precision_loss)] - result - .into_iter() - .map(|(date, values)| { - ( - date, - if values.is_empty() { - 0.0 - } else { - values.iter().sum::() / values.len() as f32 - }, - ) - }) - .collect() -} - -fn calculate_stimulus_for_each_muscle_per_week( +fn calculate_daily_stimulus_per_muscle( training_sessions: &[&TrainingSession], exercises: &BTreeMap, -) -> BTreeMap> { - let mut result: BTreeMap> = BTreeMap::new(); +) -> BTreeMap> { + let mut result: BTreeMap> = BTreeMap::new(); for m in domain::Muscle::iter() { + #[allow(clippy::cast_precision_loss)] result.insert( domain::Muscle::id(*m), - training_session_weeks(training_sessions), + training_sessions + .iter() + .map(|s| { + ( + s.date, + *s.stimulus_per_muscle(exercises) + .get(&domain::Muscle::id(*m)) + .unwrap_or(&0) as f32, + ) + }) + .collect(), ); } - - for t in training_sessions { - for (id, stimulus) in t.stimulus_per_muscle(exercises) { - if let Some(stimulus_per_week) = result.get_mut(&id) { - stimulus_per_week - .entry(t.date.week(Weekday::Mon).last_day()) - .and_modify(|s| *s += stimulus); - } else { - error!(format!( - "failed to access stimulus per week for muscle with id {id}" - )); - } - } - } - - result - .into_iter() - .map(|(id, stimulus_per_week)| (id, stimulus_per_week.into_iter().collect())) - .collect() -} - -fn training_session_weeks( - training_sessions: &[&TrainingSession], -) -> BTreeMap { - let mut result: BTreeMap = BTreeMap::new(); - - let today = Local::now().date_naive(); - let mut day = training_sessions.first().map_or(today, |t| t.date); - while day <= today.week(Weekday::Mon).last_day() { - result.insert(day.week(Weekday::Mon).last_day(), T::default()); - day += Duration::days(7); - } - result } @@ -2228,15 +2153,30 @@ mod tests { let training_sessions = BTreeMap::from([ ( 1, - training_session(1, Some(3), NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()), + training_session( + 1, + Some(3), + NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(), + None, + ), ), ( 2, - training_session(2, Some(2), NaiveDate::from_ymd_opt(2020, 3, 3).unwrap()), + training_session( + 2, + Some(2), + NaiveDate::from_ymd_opt(2020, 3, 3).unwrap(), + None, + ), ), ( 3, - training_session(3, Some(3), NaiveDate::from_ymd_opt(2020, 2, 2).unwrap()), + training_session( + 3, + Some(3), + NaiveDate::from_ymd_opt(2020, 2, 2).unwrap(), + None, + ), ), ]); assert_eq!( @@ -2261,15 +2201,30 @@ mod tests { let training_sessions = BTreeMap::from([ ( 1, - training_session(1, Some(3), NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()), + training_session( + 1, + Some(3), + NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(), + None, + ), ), ( 2, - training_session(2, Some(2), NaiveDate::from_ymd_opt(2020, 3, 3).unwrap()), + training_session( + 2, + Some(2), + NaiveDate::from_ymd_opt(2020, 3, 3).unwrap(), + None, + ), ), ( 3, - training_session(3, Some(3), NaiveDate::from_ymd_opt(2020, 2, 2).unwrap()), + training_session( + 3, + Some(3), + NaiveDate::from_ymd_opt(2020, 2, 2).unwrap(), + None, + ), ), ]); assert_eq!( @@ -2289,15 +2244,30 @@ mod tests { let training_sessions = BTreeMap::from([ ( 1, - training_session(1, Some(3), NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()), + training_session( + 1, + Some(3), + NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(), + None, + ), ), ( 2, - training_session(2, Some(2), NaiveDate::from_ymd_opt(2020, 3, 3).unwrap()), + training_session( + 2, + Some(2), + NaiveDate::from_ymd_opt(2020, 3, 3).unwrap(), + None, + ), ), ( 3, - training_session(3, Some(3), NaiveDate::from_ymd_opt(2020, 2, 2).unwrap()), + training_session( + 3, + Some(3), + NaiveDate::from_ymd_opt(2020, 2, 2).unwrap(), + None, + ), ), ]); assert_eq!( @@ -2316,13 +2286,18 @@ mod tests { } } - fn training_session(id: u32, routine_id: Option, date: NaiveDate) -> TrainingSession { + fn training_session( + id: u32, + routine_id: Option, + date: NaiveDate, + elements: Option>, + ) -> TrainingSession { TrainingSession { id, routine_id, date, notes: None, - elements: vec![], + elements: elements.unwrap_or_default(), } } } diff --git a/frontend/src/ui/page/exercise.rs b/frontend/src/ui/page/exercise.rs index 037a57f..349bc9c 100644 --- a/frontend/src/ui/page/exercise.rs +++ b/frontend/src/ui/page/exercise.rs @@ -498,6 +498,16 @@ pub fn view_charts( .or_insert(vec![avg_time]); } } + let set_volume_7day_total = common::centered_moving_total( + &set_volume.clone().into_iter().collect::>(), + interval, + 3, + ); + let volume_load_7day_avg = common::centered_moving_average( + &volume_load.clone().into_iter().collect::>(), + interval, + 3, + ); let mut labels = vec![("Repetitions", common::COLOR_REPS)]; @@ -536,14 +546,20 @@ pub fn view_charts( nodes![ common::view_chart( - &[("Set volume", common::COLOR_SET_VOLUME)], - common::plot_line_chart( + &[ + ("Set volume (daily total)", common::COLOR_SET_VOLUME), + ("Set volume (7 day total)", common::COLOR_SET_VOLUME_7DAY) + ], + common::plot_bar_chart( &[( set_volume.into_iter().collect::>(), common::COLOR_SET_VOLUME, )], - interval.first, - interval.last, + &[( + set_volume_7day_total.into_iter().collect::>(), + common::COLOR_SET_VOLUME_7DAY, + )], + interval, Some(0.), Some(10.), true, @@ -552,14 +568,20 @@ pub fn view_charts( false, ), common::view_chart( - &[("Volume load", common::COLOR_VOLUME_LOAD)], - common::plot_line_chart( + &[ + ("Volume load (daily avg.)", common::COLOR_VOLUME_LOAD), + ("Volume load (7 day avg.)", common::COLOR_VOLUME_LOAD_7DAY) + ], + common::plot_bar_chart( &[( volume_load.into_iter().collect::>(), common::COLOR_VOLUME_LOAD, )], - interval.first, - interval.last, + &[( + volume_load_7day_avg.into_iter().collect::>(), + common::COLOR_VOLUME_LOAD_7DAY, + )], + interval, Some(0.), Some(10.), true, @@ -582,13 +604,7 @@ pub fn view_charts( ], common::view_chart( &labels, - common::plot_line_chart( - &data, - interval, - Some(0.), - Some(10.), - theme, - ), + common::plot_line_chart(&data, interval, Some(0.), Some(10.), theme,), false, ), common::view_chart( diff --git a/frontend/src/ui/page/muscles.rs b/frontend/src/ui/page/muscles.rs index 8a0032a..76e15a0 100644 --- a/frontend/src/ui/page/muscles.rs +++ b/frontend/src/ui/page/muscles.rs @@ -71,22 +71,29 @@ pub fn view(model: &Model, data_model: &data::Model) -> Node { domain::Muscle::iter().map(|m| { let set_volume = data_model .training_stats - .stimulus_for_each_muscle_per_week + .stimulus_per_muscle .get(&domain::Muscle::id(*m)) - .map(|stimulus_per_muscle| { - stimulus_per_muscle - .iter() - .filter(|(date, _)| { - *date >= model.interval.first - && *date <= model.interval.last.week(Weekday::Mon).last_day() - }) - .map( - #[allow(clippy::cast_precision_loss)] - |(date, stimulus)| (*date, *stimulus as f32 / 100.0), - ) - .collect() + .map_or(Vec::new(), std::clone::Clone::clone) + .into_iter() + .filter(|(date, _)| { + *date >= model.interval.first && *date <= model.interval.last }) - .unwrap_or_default(); + .map(|(date, stimulus)| (date, stimulus / 100.0)) + .collect(); + + let set_volume_7day_total = common::centered_moving_total( + &data_model + .training_stats + .stimulus_per_muscle + .get(&domain::Muscle::id(*m)) + .unwrap_or(&Vec::new()) + .iter() + .map(|(date, stimulus)| (*date, *stimulus / 100.0)) + .collect(), + &model.interval, + 3, + ); + div![ common::view_title(&span![domain::Muscle::name(*m)], 1), div![ @@ -96,11 +103,14 @@ pub fn view(model: &Model, data_model: &data::Model) -> Node { domain::Muscle::description(*m) ], common::view_chart( - &[("Set volume (weekly total)", common::COLOR_SET_VOLUME)], - common::plot_line_chart( + &[ + ("Set volume (daily total)", common::COLOR_SET_VOLUME), + ("Set volume (7 day total)", common::COLOR_SET_VOLUME_7DAY) + ], + common::plot_bar_chart( &[(set_volume, common::COLOR_SET_VOLUME)], - model.interval.first, - model.interval.last, + &[(set_volume_7day_total, common::COLOR_SET_VOLUME_7DAY)], + &model.interval, Some(0.), Some(10.), true, diff --git a/frontend/src/ui/page/routine.rs b/frontend/src/ui/page/routine.rs index 6be52e6..5e010b5 100644 --- a/frontend/src/ui/page/routine.rs +++ b/frontend/src/ui/page/routine.rs @@ -1317,24 +1317,46 @@ pub fn view_charts( show_rpe: bool, ) -> Vec> { let mut load: BTreeMap = BTreeMap::new(); - let mut set_volume: BTreeMap = BTreeMap::new(); - let mut rpe: BTreeMap> = BTreeMap::new(); for training_session in training_sessions { #[allow(clippy::cast_precision_loss)] load.entry(training_session.date) .and_modify(|e| *e += training_session.load() as f32) .or_insert(training_session.load() as f32); - #[allow(clippy::cast_precision_loss)] - set_volume - .entry(training_session.date) - .and_modify(|e| *e += training_session.set_volume() as f32) - .or_insert(training_session.set_volume() as f32); - if let Some(avg_rpe) = training_session.avg_rpe() { - rpe.entry(training_session.date) - .and_modify(|e| e.push(avg_rpe)) - .or_insert(vec![avg_rpe]); - } } + #[allow(clippy::cast_precision_loss)] + let set_volume = common::centered_moving_total( + &training_sessions + .iter() + .map(|s| (s.date, s.set_volume() as f32)) + .collect(), + interval, + 0, + ); + #[allow(clippy::cast_precision_loss)] + let set_volume_7day_total = common::centered_moving_total( + &training_sessions + .iter() + .map(|s| (s.date, s.set_volume() as f32)) + .collect(), + interval, + 3, + ); + let rpe = common::centered_moving_average( + &training_sessions + .iter() + .filter_map(|s| s.avg_rpe().map(|avg_rpe| (s.date, avg_rpe))) + .collect(), + interval, + 0, + ); + let rpe_7day_avg = common::centered_moving_average( + &training_sessions + .iter() + .filter_map(|s| s.avg_rpe().map(|avg_rpe| (s.date, avg_rpe))) + .collect(), + interval, + 3, + ); nodes![ common::view_chart( &[("Load", common::COLOR_LOAD)], @@ -1348,14 +1370,17 @@ pub fn view_charts( false, ), common::view_chart( - &[("Set volume", common::COLOR_SET_VOLUME)], - common::plot_line_chart( + &[ + ("Set volume (daily total)", common::COLOR_SET_VOLUME), + ("Set volume (7 day total)", common::COLOR_SET_VOLUME_7DAY) + ], + common::plot_bar_chart( &[( set_volume.into_iter().collect::>(), common::COLOR_SET_VOLUME, )], - interval.first, - interval.last, + &[(set_volume_7day_total, common::COLOR_SET_VOLUME_7DAY,)], + interval, Some(0.), Some(10.), true, @@ -1366,26 +1391,11 @@ pub fn view_charts( IF![ show_rpe => common::view_chart( - &[("RPE", common::COLOR_RPE)], - common::plot_line_chart( - &[( - rpe.into_iter() - .map(|(date, values)| { - #[allow(clippy::cast_precision_loss)] - ( - date, - if values.is_empty() { - 0. - } else { - values.iter().sum::() / values.len() as f32 - }, - ) - }) - .collect::>(), - common::COLOR_RPE, - )], - interval.first, - interval.last, + &[("RPE (daily avg.)", common::COLOR_RPE), ("RPE (7 day avg.)", common::COLOR_RPE_7DAY)], + common::plot_bar_chart( + &[(rpe, common::COLOR_RPE)], + &[(rpe_7day_avg, common::COLOR_RPE_7DAY)], + interval, Some(5.), Some(10.), true, diff --git a/frontend/src/ui/page/training.rs b/frontend/src/ui/page/training.rs index a4e9627..9d8f05b 100644 --- a/frontend/src/ui/page/training.rs +++ b/frontend/src/ui/page/training.rs @@ -232,26 +232,6 @@ pub fn view(model: &Model, data_model: &data::Model) -> Node { .filter(|(date, _)| *date >= model.interval.first && *date <= model.interval.last) .copied() .collect::>(); - let total_set_volume_per_week = data_model - .training_stats - .total_set_volume_per_week - .iter() - .filter(|(date, _)| { - *date >= model.interval.first - && *date <= model.interval.last.week(Weekday::Mon).last_day() - }) - .copied() - .collect::>(); - let avg_rpe_per_week = data_model - .training_stats - .avg_rpe_per_week - .iter() - .filter(|(date, _)| { - *date >= model.interval.first - && *date <= model.interval.last.week(Weekday::Mon).last_day() - }) - .copied() - .collect::>(); let training_sessions = data_model .training_sessions .values() @@ -323,10 +303,9 @@ pub fn view(model: &Model, data_model: &data::Model) -> Node { Msg::ChangeInterval ), view_charts( + &training_sessions, short_term_load, long_term_load, - total_set_volume_per_week, - avg_rpe_per_week, &model.interval, data_model.theme(), data_model.settings.show_rpe, @@ -504,10 +483,9 @@ fn view_training_sessions_dialog( } pub fn view_charts( + training_sessions: &[&data::TrainingSession], short_term_load: Vec<(NaiveDate, f32)>, long_term_load: Vec<(NaiveDate, f32)>, - total_set_volume_per_week: Vec<(NaiveDate, f32)>, - avg_rpe_per_week: Vec<(NaiveDate, f32)>, interval: &common::Interval, theme: &data::Theme, show_rpe: bool, @@ -522,6 +500,40 @@ pub fn view_charts( .copied() .map(|(d, l)| (d, l * data::TrainingStats::LOAD_RATIO_LOW)) .collect::>(); + #[allow(clippy::cast_precision_loss)] + let set_volume = common::centered_moving_total( + &training_sessions + .iter() + .map(|s| (s.date, s.set_volume() as f32)) + .collect::>(), + interval, + 0, + ); + #[allow(clippy::cast_precision_loss)] + let set_volume_7day_total = common::centered_moving_total( + &training_sessions + .iter() + .map(|s| (s.date, s.set_volume() as f32)) + .collect::>(), + interval, + 3, + ); + let rpe = common::centered_moving_total( + &training_sessions + .iter() + .filter_map(|s| s.avg_rpe().map(|avg_rpe| (s.date, avg_rpe))) + .collect::>(), + interval, + 0, + ); + let rpe_7day_avg = common::centered_moving_average( + &training_sessions + .iter() + .filter_map(|s| s.avg_rpe().map(|avg_rpe| (s.date, avg_rpe))) + .collect::>(), + interval, + 3, + ); nodes![ common::view_chart( &[ @@ -543,11 +555,14 @@ pub fn view_charts( false, ), common::view_chart( - &[("Set volume (weekly total)", common::COLOR_SET_VOLUME)], - common::plot_line_chart( - &[(total_set_volume_per_week, common::COLOR_SET_VOLUME)], - interval.first, - interval.last, + &[ + ("Set volume (daily total)", common::COLOR_SET_VOLUME), + ("Set volume (7 day total)", common::COLOR_SET_VOLUME_7DAY) + ], + common::plot_bar_chart( + &[(set_volume, common::COLOR_SET_VOLUME)], + &[(set_volume_7day_total, common::COLOR_SET_VOLUME_7DAY)], + interval, Some(0.), Some(10.), true, @@ -558,11 +573,10 @@ pub fn view_charts( IF![ show_rpe => common::view_chart( - &[("RPE (weekly average)", common::COLOR_RPE)], - common::plot_line_chart( - &[(avg_rpe_per_week, common::COLOR_RPE)], - interval.first, - interval.last, + &[("RPE (daily avg.)", common::COLOR_RPE), ("RPE (7 day average)", common::COLOR_RPE_7DAY)], + common::plot_bar_chart( + &[(rpe, common::COLOR_RPE)], &[(rpe_7day_avg, common::COLOR_RPE_7DAY)], + interval, Some(5.), Some(10.), true,