diff --git a/Cargo.lock b/Cargo.lock index 08277c9..36d11a7 100644 --- a/Cargo.lock +++ b/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 f00e625..f6d60a5 100644 --- a/frontend/src/ui/common.rs +++ b/frontend/src/ui/common.rs @@ -3,7 +3,7 @@ use std::{ collections::{BTreeMap, HashMap}, }; -use chrono::{prelude::*, Duration}; +use chrono::{prelude::*, Days, Duration}; use plotters::prelude::*; use seed::{prelude::*, *}; @@ -39,6 +39,10 @@ pub fn plot_line_with_dots(color: usize) -> Vec { [PlotType::Line(color, 2), PlotType::Circle(color, 2)].to_vec() } +pub fn plot_line(color: usize) -> Vec { + [PlotType::Line(color, 2)].to_vec() +} + #[derive(Default)] pub struct PlotParams { pub y_min_opt: Option, @@ -1109,9 +1113,149 @@ pub fn post_message_to_service_worker(message: &ServiceWorkerMessage) -> Result< } } +/// Group a series of (date, value) pairs. +/// +/// The `radius` parameter determines the number of days before and after the +/// center value to include in the calculation. +/// +/// Only values which have a date within `interval` are used as a center value +/// for the calculation. Values outside the interval are included in the +/// calculation if they fall within the radius of a center value. +/// +/// Two user-provided functions determine how values are combined: +/// +/// - `group_day` is called to combine values of the *same* day. +/// - `group_range` is called to combine values of multiple days after all +/// values for the same day have been combined by `group_day`. +/// +/// Return `None` in those functions to indicate the absence of a value. +/// +pub fn centered_moving_grouping( + data: &Vec<(NaiveDate, f32)>, + interval: &Interval, + radius: u64, + group_day: impl Fn(Vec) -> Option, + group_range: impl Fn(Vec) -> Option, +) -> Vec> { + let mut date_map: BTreeMap<&NaiveDate, Vec> = 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); + } + } + + interval + .first + .iter_days() + .take_while(|d| *d <= interval.last) + .fold( + vec![vec![]], + |mut result: Vec>, center| { + let value = 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::>(), + ); + if let Some(last) = result.last_mut() { + match value { + Some(v) => { + last.push((center, v)); + } + None => { + if !last.is_empty() { + result.push(vec![]); + } + } + } + } + result + }, + ) + .into_iter() + .filter(|v| !v.is_empty()) + .collect::>() +} + +/// Calculate a series of moving totals from a given series of (date, value) pairs. +/// +/// The radius argument determines the number of days to include into the calculated +/// total before and after each value within the interval. +/// +/// Multiple values for the same date will be summed up. +/// +/// An empty result vector may be returned if there is no data within the interval. +pub fn centered_moving_total( + data: &Vec<(NaiveDate, f32)>, + interval: &Interval, + radius: u64, +) -> Vec<(NaiveDate, f32)> { + centered_moving_grouping( + data, + interval, + radius, + |d| Some(d.iter().sum()), + |d| Some(d.iter().sum()), + )[0] + .clone() +} + +/// Calculate a series of moving averages from a given series of (date, value) pairs. +/// +/// The radius argument determines the number of days to include into the calculated +/// average before and after each value within the interval. +/// +/// Multiple values for the same date will be averaged. +/// +/// An empty result vector may be returned if there is no data within the interval. +/// Multiple result vectors may be returned in cases where there are gaps of more than +/// 2*radius+1 days in the input data within the interval. +pub fn centered_moving_average( + data: &Vec<(NaiveDate, f32)>, + interval: &Interval, + radius: u64, +) -> Vec> { + #[allow(clippy::cast_precision_loss)] + centered_moving_grouping( + data, + interval, + radius, + |d| { + if d.is_empty() { + None + } else { + Some(d.iter().sum::() / d.len() as f32) + } + }, + |d| { + if d.is_empty() { + None + } else { + Some(d.iter().sum::() / d.len() as f32) + } + }, + ) +} + #[cfg(test)] mod tests { use super::*; + use rstest::rstest; #[test] fn quartile_one() { @@ -1246,4 +1390,187 @@ mod tests { Duration::days(6) ); } + + #[rstest] + #[case::empty_series( + (2020, 2, 3), + (2020, 2, 5), + 0, + &[], + vec![] + )] + #[case::value_outside_interval( + (2020, 3, 3), + (2020, 3, 5), + 0, + &[(2020, 2, 3, 1.0)], + vec![] + )] + #[case::zero_radius_single_value( + (2020, 2, 3), + (2020, 2, 5), + 0, + &[(2020, 2, 3, 1.0)], + vec![vec![(2020, 2, 3, 1.0)]] + )] + #[case::zero_radius_multiple_days( + (2020, 2, 3), + (2020, 2, 5), + 0, + &[(2020, 2, 3, 1.0), (2020, 2, 4, 1.0), (2020, 2, 5, 1.0)], + vec![vec![(2020, 2, 3, 1.0), (2020, 2, 4, 1.0), (2020, 2, 5, 1.0)]] + )] + #[case::zero_radius_multiple_values_per_day( + (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)], + vec![vec![(2020, 2, 3, 2.0), (2020, 2, 4, 1.0), (2020, 2, 5, 1.0)]] + )] + #[case::nonzero_radius_multiple_days( + (2020, 2, 3), + (2020, 2, 5), + 1, + &[(2020, 2, 3, 1.0), (2020, 2, 4, 2.0), (2020, 2, 5, 3.0)], + vec![vec![(2020, 2, 3, 1.5), (2020, 2, 4, 2.0), (2020, 2, 5, 2.5)]] + )] + #[case::nonzero_radius_missing_day( + (2020, 2, 2), + (2020, 2, 6), + 1, + &[(2020, 2, 3, 1.0), (2020, 2, 4, 2.0), (2020, 2, 5, 3.0)], + vec![vec![(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::nonzero_radius_with_gap_1( + (2020, 2, 3), + (2020, 2, 7), + 1, + &[(2020, 2, 3, 1.0), (2020, 2, 7, 1.0)], + vec![vec![(2020, 2, 3, 1.0), (2020, 2, 4, 1.0)], vec![(2020, 2, 6, 1.0), (2020, 2, 7, 1.0)]] + )] + #[case::nonzero_radius_with_gap_2( + (2020, 2, 3), + (2020, 2, 9), + 1, + &[(2020, 2, 3, 1.0), (2020, 2, 9, 1.0)], + vec![vec![(2020, 2, 3, 1.0), (2020, 2, 4, 1.0)], vec![(2020, 2, 8, 1.0), (2020, 2, 9, 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: Vec>, + ) { + 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(|v| v + .iter() + .map(|(y, m, d, v)| (NaiveDate::from_ymd_opt(*y, *m, *d).unwrap(), *v)) + .collect::>()) + .collect::>(), + ); + } + + #[rstest] + #[case::empty_series( + (2020, 2, 3), + (2020, 2, 5), + 0, + &[], + &[(2020, 2, 3, 0.0), (2020, 2, 4, 0.0), (2020, 2, 5, 0.0)], + )] + #[case::value_outside_interval( + (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::zero_radius_single_day( + (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::zero_radius_multiple_days( + (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::zero_radius_multiple_values_per_day( + (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::nonzero_radius_multiple_days( + (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::nonzero_radius_missing_day( + (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::nonzero_radius_multiple_missing_days_1( + (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)], + )] + #[case::nonzero_radius_multiple_missing_days_2( + (2020, 2, 3), + (2020, 2, 9), + 1, + &[(2020, 2, 3, 1.0), (2020, 2, 9, 1.0)], + &[(2020, 2, 3, 1.0), (2020, 2, 4, 1.0), (2020, 2, 5, 0.0), (2020, 2, 6, 0.0), (2020, 2, 7, 0.0), (2020, 2, 8, 1.0), (2020, 2, 9, 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..8a15755 100644 --- a/frontend/src/ui/data.rs +++ b/frontend/src/ui/data.rs @@ -68,9 +68,6 @@ 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(), }, settings, ongoing_training_session, @@ -274,9 +271,6 @@ 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>, } impl TrainingStats { @@ -296,8 +290,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(); } } @@ -875,21 +867,12 @@ pub fn calculate_cycle_stats(cycles: &[&Cycle]) -> CycleStats { } } -fn calculate_training_stats( - training_sessions: &[&TrainingSession], - exercises: &BTreeMap, -) -> TrainingStats { +fn calculate_training_stats(training_sessions: &[&TrainingSession]) -> TrainingStats { let short_term_load = calculate_weighted_sum_of_load(training_sessions, 7); let long_term_load = calculate_average_weighted_sum_of_load(&short_term_load, 28); 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, - ), } } @@ -951,96 +934,6 @@ 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( - training_sessions: &[&TrainingSession], - exercises: &BTreeMap, -) -> BTreeMap> { - let mut result: BTreeMap> = BTreeMap::new(); - - for m in domain::Muscle::iter() { - result.insert( - domain::Muscle::id(*m), - training_session_weeks(training_sessions), - ); - } - - 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 -} - // ------ ------ // Update // ------ ------ @@ -1759,10 +1652,8 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { } Msg::ExerciseReplaced(Ok(exercise)) => { model.exercises.insert(exercise.id, exercise); - model.training_stats = calculate_training_stats( - &model.training_sessions.values().collect::>(), - &model.exercises, - ); + model.training_stats = + calculate_training_stats(&model.training_sessions.values().collect::>()); orders.notify(Event::ExerciseReplacedOk); } Msg::ExerciseReplaced(Err(message)) => { @@ -1917,10 +1808,8 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { let training_sessions = training_sessions.into_iter().map(|t| (t.id, t)).collect(); if model.training_sessions != training_sessions { model.training_sessions = training_sessions; - model.training_stats = calculate_training_stats( - &model.training_sessions.values().collect::>(), - &model.exercises, - ); + model.training_stats = + calculate_training_stats(&model.training_sessions.values().collect::>()); orders.notify(Event::DataChanged); } model.loading_training_sessions = false; @@ -1951,10 +1840,8 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { model .training_sessions .insert(training_session.id, training_session); - model.training_stats = calculate_training_stats( - &model.training_sessions.values().collect::>(), - &model.exercises, - ); + model.training_stats = + calculate_training_stats(&model.training_sessions.values().collect::>()); orders.notify(Event::TrainingSessionCreatedOk); } Msg::TrainingSessionCreated(Err(message)) => { @@ -1985,10 +1872,8 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { model .training_sessions .insert(training_session.id, training_session); - model.training_stats = calculate_training_stats( - &model.training_sessions.values().collect::>(), - &model.exercises, - ); + model.training_stats = + calculate_training_stats(&model.training_sessions.values().collect::>()); orders.notify(Event::TrainingSessionModifiedOk); } Msg::TrainingSessionModified(Err(message)) => { @@ -2011,10 +1896,8 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { } Msg::TrainingSessionDeleted(Ok(id)) => { model.training_sessions.remove(&id); - model.training_stats = calculate_training_stats( - &model.training_sessions.values().collect::>(), - &model.exercises, - ); + model.training_stats = + calculate_training_stats(&model.training_sessions.values().collect::>()); orders.notify(Event::TrainingSessionDeletedOk); } Msg::TrainingSessionDeleted(Err(message)) => { diff --git a/frontend/src/ui/page/muscles.rs b/frontend/src/ui/page/muscles.rs index b88feec..4e60aa0 100644 --- a/frontend/src/ui/page/muscles.rs +++ b/frontend/src/ui/page/muscles.rs @@ -69,24 +69,21 @@ pub fn view(model: &Model, data_model: &data::Model) -> Node { Msg::ChangeInterval ), domain::Muscle::iter().map(|m| { - let set_volume = data_model - .training_stats - .stimulus_for_each_muscle_per_week - .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() - }) - .unwrap_or_default(); + #[allow(clippy::cast_precision_loss)] + let total_7day_set_volume = common::centered_moving_total( + &data_model + .training_sessions + .values() + .filter_map(|s| { + s.stimulus_per_muscle(&data_model.exercises) + .get(&domain::Muscle::id(*m)) + .map(|stimulus| (s.date, *stimulus as f32 / 100.)) + }) + .collect::>(), + &model.interval, + 3, + ); + div![ common::view_title(&span![domain::Muscle::name(*m)], 1), div![ @@ -96,11 +93,11 @@ 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)], + &[("Set volume (7 day total)", common::COLOR_SET_VOLUME)], common::plot_chart( &[common::PlotData { - values: set_volume, - plots: common::plot_line_with_dots(common::COLOR_SET_VOLUME), + values: total_7day_set_volume, + plots: common::plot_line(common::COLOR_SET_VOLUME), params: common::PlotParams::primary_range(0., 10.), }], model.interval.first, diff --git a/frontend/src/ui/page/training.rs b/frontend/src/ui/page/training.rs index 2f51fa8..60a6bbe 100644 --- a/frontend/src/ui/page/training.rs +++ b/frontend/src/ui/page/training.rs @@ -232,26 +232,26 @@ 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::>(); + #[allow(clippy::cast_precision_loss)] + let total_7day_set_volume = common::centered_moving_total( + &data_model + .training_sessions + .values() + .map(|s| (s.date, s.set_volume() as f32)) + .collect::>(), + &model.interval, + 3, + ); + + let average_7day_rpe = common::centered_moving_average( + &data_model + .training_sessions + .values() + .filter_map(|s| s.avg_rpe().map(|v| (s.date, v))) + .collect::>(), + &model.interval, + 3, + ); let mut training_sessions = data_model .training_sessions .values() @@ -326,8 +326,8 @@ pub fn view(model: &Model, data_model: &data::Model) -> Node { view_charts( short_term_load, long_term_load, - total_set_volume_per_week, - avg_rpe_per_week, + total_7day_set_volume, + &average_7day_rpe, &model.interval, data_model.theme(), data_model.settings.show_rpe, @@ -507,8 +507,8 @@ fn view_training_sessions_dialog( pub fn view_charts( 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)>, + total_7day_set_volume: Vec<(NaiveDate, f32)>, + average_7day_rpe: &[Vec<(NaiveDate, f32)>], interval: &common::Interval, theme: &data::Theme, show_rpe: bool, @@ -533,22 +533,22 @@ pub fn view_charts( &[ common::PlotData { values: long_term_load_low, - plots: common::plot_line_with_dots(common::COLOR_LONG_TERM_LOAD_BOUNDS), + plots: common::plot_line(common::COLOR_LONG_TERM_LOAD_BOUNDS), params: common::PlotParams::primary_range(0., 10.), }, common::PlotData { values: long_term_load_high, - plots: common::plot_line_with_dots(common::COLOR_LONG_TERM_LOAD_BOUNDS), + plots: common::plot_line(common::COLOR_LONG_TERM_LOAD_BOUNDS), params: common::PlotParams::primary_range(0., 10.), }, common::PlotData { values: long_term_load, - plots: common::plot_line_with_dots(common::COLOR_LONG_TERM_LOAD), + plots: common::plot_line(common::COLOR_LONG_TERM_LOAD), params: common::PlotParams::primary_range(0., 10.), }, common::PlotData { values: short_term_load, - plots: common::plot_line_with_dots(common::COLOR_LOAD), + plots: common::plot_line(common::COLOR_LOAD), params: common::PlotParams::primary_range(0., 10.), } ], @@ -559,11 +559,11 @@ pub fn view_charts( false, ), common::view_chart( - &[("Set volume (weekly total)", common::COLOR_SET_VOLUME)], + &[("Set volume (7 day total)", common::COLOR_SET_VOLUME)], common::plot_chart( &[common::PlotData { - values: total_set_volume_per_week, - plots: common::plot_line_with_dots(common::COLOR_SET_VOLUME), + values: total_7day_set_volume, + plots: common::plot_line(common::COLOR_SET_VOLUME), params: common::PlotParams::primary_range(0., 10.), }], interval.first, @@ -575,12 +575,12 @@ pub fn view_charts( IF![ show_rpe => common::view_chart( - &[("RPE (weekly average)", common::COLOR_RPE)], + &[("RPE (7 day average)", common::COLOR_RPE)], common::plot_chart( - &[common::PlotData{values: avg_rpe_per_week, - plots: common::plot_line_with_dots(common::COLOR_RPE), + &average_7day_rpe.iter().map(|values| common::PlotData{values: values.clone(), + plots: common::plot_line(common::COLOR_RPE), params: common::PlotParams::primary_range(5., 10.) - }], + }).collect::>(), interval.first, interval.last, theme,