use crate::auth::Authorized; use crate::database::latest::trees::{category, session}; use crate::status_json::StatusJson; use crate::util::OrdL; use chrono::{Date, DateTime, Datelike, Duration, Local, Timelike}; use itertools::Itertools; use rocket::{get, http::Status, response::content::Html, serde::uuid::Uuid, State}; use rocket_dyn_templates::Template; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap, HashSet}; #[derive(Debug, Serialize, Deserialize)] struct CategoryStatsCtx { category_id: category::K, category: category::V, last_session_start: Option>, secs_last_session: u64, secs_last_week: u64, secs_last_month: u64, bars_max: f64, bars: Vec<(u32, f64, f64)>, calendar: CalendarCtx, } type ChildMap = HashMap>; 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() } /// Generate a flat child-map /// /// The result is a map from category to *every* category below it in the tree fn calculate_child_map(categories: &HashMap) -> ChildMap { let mut child_map: ChildMap = HashMap::new(); const RECURSION_LIMIT: usize = 255; let mut last_len = 0; let mut new_child_buffer = vec![]; // make sure we don't recurse forever // if RECURSION_LIMIT is less than the tree depth, the resulting map will be incomplete for _ in 0..RECURSION_LIMIT { for (&id, category) in categories.iter() { // find parent category and add *my* children to the list of *its* children if let Some(parent_id) = category.parent { child_map.entry(parent_id).or_default().insert(id); for &child_id in child_map.entry(id).or_default().iter() { new_child_buffer.push(child_id); } for child_id in new_child_buffer.drain(..) { child_map.entry(parent_id).or_default().insert(child_id); } } } // if the list didn't change, we're done let new_len = child_map.values().map(|children| children.len()).sum(); if new_len == last_len { return child_map; } last_len = new_len; } panic!( "Recursion-limit ({}) reached traversing category graph. Likely stuck in infinite loop.", RECURSION_LIMIT ); } /// Create a new CategoryStatsCtx fn category_stats_ctx( now: DateTime, category_id: category::K, category: category::V, sessions: &HashMap, child_map: &ChildMap, ) -> CategoryStatsCtx { let my_sessions = sessions.values().filter(|session| { session.category == category_id || child_map .get(&category_id) .map(|children| children.contains(&session.category)) .unwrap_or(false) }); 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.clone()); let biggest_hour = *stats_per_hour .values() .max_by_key(|&f| OrdL(f)) .unwrap_or(&1.0); CategoryStatsCtx { category_id, category, last_session_start: last_session.map(|session| session.started), secs_last_session, secs_last_week, secs_last_month, bars_max: biggest_hour, bars: (0..24) .map(|hour| { let percentage = *stats_per_hour.entry(hour).or_default(); (hour, percentage, biggest_hour - percentage) }) .collect(), calendar: compute_calendar_stats(my_sessions), } } #[get("/stats/")] pub fn single_stats( _auth: Authorized, category_id: Uuid, db: &State, ) -> Result, StatusJson> { let categories_tree = db.open_tree(category::NAME)?; let sessions_tree = db.open_tree(session::NAME)?; let mut categories = category::get_all(&categories_tree)?; let child_map = calculate_child_map(&categories); let category = categories.remove(&category_id).ok_or(Status::NotFound)?; let sessions: HashMap = session::get_all(&sessions_tree)?; let now = Local::now(); let ctx = category_stats_ctx(now, category_id, category, &sessions, &child_map); Ok(Html(Template::render("stats_single", dbg!(&ctx)))) } #[get("/stats")] pub fn all_stats(_auth: Authorized, db: &State) -> Result, StatusJson> { #[derive(Debug, Serialize, Deserialize)] struct StatsContext { categories_stats: Vec, } let now = Local::now(); let categories_tree = db.open_tree(category::NAME)?; let sessions_tree = db.open_tree(session::NAME)?; let categories = category::get_all(&categories_tree)?; let sessions = session::get_all(&sessions_tree)?; let child_map = calculate_child_map(&categories); let mut categories_stats: Vec<_> = categories .into_iter() .filter(|(_, category)| !category.deleted) .map(|(category_id, category)| { category_stats_ctx(now, category_id, category, &sessions, &child_map) }) .collect(); categories_stats.sort_by(|a, b| a.category.name.cmp(&b.category.name)); let context = StatsContext { categories_stats }; Ok(Html(Template::render("stats_all", &context))) } /// Compute the duration of `day` that is covered by the span `start..end` fn span_duration_of_day( start: DateTime, end: DateTime, day: Date, ) -> Duration { if end < start { panic!("start must come before end"); } // if the span is 0 // or if the day is not in the span // the duration is zero if end == start || start.date() > day || end.date() < day { return Duration::zero(); } if start.date() < day { if end.date() > day { Duration::days(1) } else { debug_assert_eq!(end.date(), day); end - day.and_hms(0, 0, 0) } } else if end.date() > day { debug_assert_eq!(start.date(), day); day.and_hms(0, 0, 0) + Duration::days(1) - start } else { debug_assert!(start < end); debug_assert_eq!(start.date(), day); debug_assert_eq!(end.date(), day); end - start } } 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); } } let sum_weight: f64 = 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 } #[derive(Debug, Serialize, Deserialize)] struct CalendarDayCtx { border_left: bool, border_right: bool, border_top: bool, border_bottom: bool, /// The visual weight of the day (between 0 and 1) weight: f32, /// Duration in seconds duration: Option, } #[derive(Debug, Serialize, Deserialize)] struct CalendarCtx { weeks: Vec>>, } fn compute_calendar_stats<'a, I>(sessions: I) -> CalendarCtx where I: Iterator, { const NUM_WEEKS: usize = 12; let today = Local::today(); let last_day = today // take at least NUM_WEEKS * 7 days - Duration::weeks(NUM_WEEKS as i64) // round up to nearest monday - Duration::days(today.weekday().num_days_from_monday() as i64); let mut days: BTreeMap, Duration> = Default::default(); // calculate the time spent logging this category for every day of the last NUM_WEEKS for session in sessions { let mut current_day = today; while current_day >= last_day { let day_stats = days.entry(current_day).or_insert_with(Duration::zero); *day_stats = *day_stats + span_duration_of_day(session.started, session.ended, current_day); current_day = current_day - Duration::days(1); } } let biggest_day_duration = days .values() .copied() .max() .unwrap_or_else(|| Duration::seconds(1)) .num_seconds() as f32; let weeks = days.iter().group_by(|(day, _)| day.iso_week()); let mut weeks: Vec<_> = weeks .into_iter() .map(|(week, weekdays)| { weekdays .map(|(&day, duration)| { let duration = if duration.is_zero() { None } else { Some(duration.num_seconds() as u64) }; let month = day.month(); let month_border = |other_day: Date<_>| other_day.month() != month; let month_or_week_border = |other_day: Date<_>| { other_day.iso_week() != week || month_border(other_day) }; const MIN_WEIGHT: f32 = 0.5; let ctx = CalendarDayCtx { border_left: month_border(day - Duration::weeks(1)), border_right: month_border(day + Duration::weeks(1)), border_top: month_or_week_border(day - Duration::days(1)), border_bottom: month_or_week_border(day + Duration::days(1)), weight: duration .map(|d| d as f32 / biggest_day_duration) .map(|w| (MIN_WEIGHT + w * (1.0 - MIN_WEIGHT)).clamp(0.0, 1.0)) .unwrap_or(1.0), duration, }; Some(ctx) }) .collect() }) .collect(); // calendar is shown as flex-direction: row-reverse // because it should be scrolled from the right weeks.reverse(); CalendarCtx { weeks } } #[cfg(test)] mod test { use super::*; use crate::database::latest::trees::session; 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() }; session::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())); } } }