From 62cb06838b5a2cf749bb1ec4dd9f2c8bfbf214b5 Mon Sep 17 00:00:00 2001 From: Joakim Hulthe Date: Mon, 16 Nov 2020 15:59:45 +0100 Subject: [PATCH] Fix per-hour stats view The computation now accounts for partial hours --- src/main.rs | 2 +- src/routes/pages.rs | 123 +-------------------- src/routes/pages/stats.rs | 223 ++++++++++++++++++++++++++++++++++++++ templates/stats.hbs | 4 +- 4 files changed, 229 insertions(+), 123 deletions(-) create mode 100644 src/routes/pages/stats.rs diff --git a/src/main.rs b/src/main.rs index 7bc08b6..4c2cf80 100644 --- a/src/main.rs +++ b/src/main.rs @@ -59,7 +59,7 @@ async fn main() -> io::Result<()> { routes::pages::index, routes::pages::history, routes::pages::session_edit, - routes::pages::stats, + routes::pages::stats::view, routes::api::edit_session, routes::api::create_category, routes::api::start_session, diff --git a/src/routes/pages.rs b/src/routes/pages.rs index 30b614e..2b23068 100644 --- a/src/routes/pages.rs +++ b/src/routes/pages.rs @@ -1,15 +1,16 @@ +pub mod stats; + use crate::auth::Authorized; use crate::database::latest::trees::{categories, sessions}; use crate::status_json::StatusJson; use bincode::deserialize; use bincode::serialize; -use chrono::{DateTime, Local, Timelike}; use rocket::http::Status; use rocket::{get, State}; use rocket_contrib::templates::Template; use rocket_contrib::uuid::Uuid; use serde_derive::{Deserialize, Serialize}; -use std::collections::{BTreeMap, HashMap}; +use std::collections::HashMap; use std::time::Duration; #[get("/")] @@ -111,121 +112,3 @@ pub fn history(_auth: Authorized, db: State<'_, sled::Db>) -> Result) -> Result { - #[derive(Debug, Serialize, Deserialize)] - struct StatsContext { - categories_stats: Vec, - } - - #[derive(Debug, Serialize, Deserialize)] - struct CategoryStatsContext { - category_id: categories::K, - category: categories::V, - - last_session_start: Option>, - secs_last_session: u64, - secs_last_week: u64, - secs_last_month: u64, - - bars: Vec<(u32, u32, u32)>, - } - - let now = Local::now(); - - let categories_tree = db.open_tree(categories::NAME)?; - let sessions_tree = db.open_tree(sessions::NAME)?; - - let categories: HashMap = categories_tree - .iter() - .map(|result| { - result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v)))) - }) - .collect::, _>>()??; - - let sessions: HashMap = sessions_tree - .iter() - .map(|result| { - result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v)))) - }) - .collect::, _>>()??; - - let mut categories_stats: Vec<_> = categories - .into_iter() - .map(|(category_id, category)| { - fn sum_sessions<'a>(iter: impl IntoIterator) -> u64 { - iter.into_iter() - .map(|session| session.ended - session.started) - .map(|duration| duration.num_seconds() as u64) - .sum() - } - - let my_sessions = sessions - .values() - .filter(|session| session.category == category_id); - - let last_session = my_sessions.clone().max_by_key(|session| &session.started); - - let secs_last_session = sum_sessions(last_session); - - let secs_last_week = sum_sessions( - my_sessions - .clone() - .filter(|session| (now - session.started) <= chrono::Duration::days(7)), - ); - - let secs_last_month = sum_sessions( - my_sessions - .clone() - .filter(|session| (now - session.started) <= chrono::Duration::days(30)), - ); - - let mut stats_per_hour = BTreeMap::new(); - for session in my_sessions { - let step_size = chrono::Duration::minutes(60); - let started_hour = session - .started - .with_minute(0) - .unwrap() - .with_second(0) - .unwrap() - .with_nanosecond(0) - .unwrap(); - - let mut time = started_hour; - while time < session.ended { - *stats_per_hour.entry(time.hour()).or_default() += 1; - time = time.checked_add_signed(step_size).unwrap(); - } - } - let max_weight = *stats_per_hour.values().max().unwrap_or(&0); - for weight in stats_per_hour.values_mut() { - *weight = *weight * 100 / max_weight; - } - - CategoryStatsContext { - category_id, - category, - - last_session_start: last_session.map(|session| session.started), - secs_last_session, - secs_last_week, - secs_last_month, - - bars: (0..24) - .map(|hour| { - let percentage = *stats_per_hour.entry(hour).or_default(); - (hour, percentage, 100 - percentage) - }) - .collect(), - } - }) - .collect(); - - categories_stats.sort_by(|a, b| a.category.name.cmp(&b.category.name)); - - let context = StatsContext { categories_stats }; - - Ok(Template::render("stats", &context)) -} diff --git a/src/routes/pages/stats.rs b/src/routes/pages/stats.rs new file mode 100644 index 0000000..30bab36 --- /dev/null +++ b/src/routes/pages/stats.rs @@ -0,0 +1,223 @@ +use crate::auth::Authorized; +use crate::database::latest::trees::{categories, sessions}; +use crate::status_json::StatusJson; +use bincode::deserialize; +use chrono::{DateTime, Local, Timelike}; +use rocket::{get, State}; +use rocket_contrib::templates::Template; +use serde_derive::{Deserialize, Serialize}; +use std::cmp::Ordering; +use std::collections::{BTreeMap, HashMap}; + +#[get("/stats")] +pub fn view(_auth: Authorized, db: State<'_, sled::Db>) -> Result { + #[derive(Debug, Serialize, Deserialize)] + struct StatsContext { + categories_stats: Vec, + } + + #[derive(Debug, Serialize, Deserialize)] + struct CategoryStatsContext { + category_id: categories::K, + category: categories::V, + + last_session_start: Option>, + secs_last_session: u64, + secs_last_week: u64, + secs_last_month: u64, + + bars: Vec<(u32, f64, f64)>, + } + + let now = Local::now(); + + let categories_tree = db.open_tree(categories::NAME)?; + let sessions_tree = db.open_tree(sessions::NAME)?; + + let categories: HashMap = categories_tree + .iter() + .map(|result| { + result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v)))) + }) + .collect::, _>>()??; + + let sessions: HashMap = sessions_tree + .iter() + .map(|result| { + result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v)))) + }) + .collect::, _>>()??; + + let mut categories_stats: Vec<_> = categories + .into_iter() + .map(|(category_id, category)| { + fn sum_sessions<'a>(iter: impl IntoIterator) -> u64 { + iter.into_iter() + .map(|session| session.ended - session.started) + .map(|duration| duration.num_seconds() as u64) + .sum() + } + + let my_sessions = sessions + .values() + .filter(|session| session.category == category_id); + + let last_session = my_sessions.clone().max_by_key(|session| &session.started); + + let secs_last_session = sum_sessions(last_session); + + let secs_last_week = sum_sessions( + my_sessions + .clone() + .filter(|session| (now - session.started) <= chrono::Duration::days(7)), + ); + + let secs_last_month = sum_sessions( + my_sessions + .clone() + .filter(|session| (now - session.started) <= chrono::Duration::days(30)), + ); + + let mut stats_per_hour = compute_percentage_per_hour(my_sessions); + let biggest_hour = *stats_per_hour + .values() + .max_by(|f1, f2| match () { + _ if f1 == f2 => Ordering::Equal, + _ if f1 > f2 => Ordering::Greater, + _ => Ordering::Less, + }) + .unwrap_or(&1.0); + + CategoryStatsContext { + category_id, + category, + + last_session_start: last_session.map(|session| session.started), + secs_last_session, + secs_last_week, + secs_last_month, + + bars: (0..24) + .map(|hour| { + let percentage = *stats_per_hour.entry(hour).or_default(); + (hour, percentage, biggest_hour - percentage) + }) + .collect(), + } + }) + .collect(); + + categories_stats.sort_by(|a, b| a.category.name.cmp(&b.category.name)); + + let context = StatsContext { categories_stats }; + + Ok(Template::render("stats", &context)) +} + +fn compute_percentage_per_hour<'a, I>(sessions: I) -> BTreeMap +where + I: Iterator, +{ + let mut stats_per_hour = BTreeMap::new(); + for session in sessions { + let an_hour = chrono::Duration::minutes(60); + + let hour_of = |time: DateTime| { + time.with_minute(0) + .and_then(|time| time.with_second(0)) + .and_then(|time| time.with_nanosecond(0)) + .unwrap() + }; + + let next_hour_of = + |time: DateTime| hour_of(time).checked_add_signed(an_hour).unwrap(); + + let mut add_hour_stats = + |time: DateTime, hours| *stats_per_hour.entry(time.hour()).or_default() += hours; + + let mut hour = hour_of(session.started); + loop { + if hour_of(session.started) == hour { + let minutes_started = (session.started - hour).num_minutes() as u32; + + if hour_of(session.ended) == hour { + let minutes_ended = (session.ended - hour).num_minutes() as u32; + let minutes_last_hour = minutes_ended - minutes_started; + add_hour_stats(hour, minutes_last_hour as f64); + + break; + } else { + let minutes_first_hour = 60 - minutes_started; + add_hour_stats(hour, minutes_first_hour as f64); + } + } else if hour_of(session.ended) == hour { + let minutes_last_hour = (session.ended - hour).num_minutes() as u32; + add_hour_stats(hour, minutes_last_hour as f64); + break; + } else { + add_hour_stats(hour, 60.0); + } + hour = next_hour_of(hour); + } + } + + dbg!(&stats_per_hour); + let sum_weight: f64 = dbg!(stats_per_hour.values().sum()); + for weight in stats_per_hour.values_mut() { + *weight = *weight * 100.0 / sum_weight; + *weight = (*weight * 10.0).trunc() / 10.0; + } + + stats_per_hour +} + +#[cfg(test)] +mod test { + use super::*; + use crate::database::latest::trees::sessions; + use chrono::{DateTime, Local}; + + #[test] + fn test_compute_percentage_per_hour() { + let today = Local::now(); + + let test_data = vec![ + ( + vec![((11, 20), (13, 20))], + vec![(11, 33.3), (12, 50.0), (13, 16.6)], + ), + (vec![((09, 00), (09, 01))], vec![(09, 100.0)]), + (vec![((09, 00), (09, 59))], vec![(09, 100.0)]), + ( + vec![((13, 00), (16, 00))], + vec![(13, 33.3), (14, 33.3), (15, 33.3), (16, 0.0)], + ), + ]; + + for (sessions, expected) in test_data { + let sessions: Vec<_> = sessions + .into_iter() + .map(|((h1, m1), (h2, m2))| { + let set_hm = |t: DateTime, h, m| { + t.with_hour(h) + .and_then(|t| t.with_minute(m)) + .and_then(|t| t.with_second(0)) + .and_then(|t| t.with_nanosecond(0)) + .unwrap() + }; + + sessions::V { + category: Default::default(), + deleted: false, + started: set_hm(today, h1, m1), + ended: set_hm(today, h2, m2), + } + }) + .collect(); + + let percentages = compute_percentage_per_hour(sessions.iter()); + println!("{:#?}", percentages); + assert!(percentages.into_iter().eq(expected.into_iter())); + } + } +} diff --git a/templates/stats.hbs b/templates/stats.hbs index 1fff451..f3b8397 100644 --- a/templates/stats.hbs +++ b/templates/stats.hbs @@ -40,8 +40,8 @@
{{#each this.bars}}
-
-
+
+
{{this.1}}%
{{this.0}}