From 243c5e6e5a5c141fab85215026c9f43d496278d0 Mon Sep 17 00:00:00 2001 From: Joakim Hulthe Date: Wed, 11 Nov 2020 22:54:17 +0100 Subject: [PATCH] Add stats page --- src/main.rs | 1 + src/routes/pages.rs | 121 +++++++++++++++++++++++++++++++++++++++++- static/charts.css | 124 ++++++++++++++++++++++++++++++++++++++++++++ static/styles.css | 7 +++ templates/stats.hbs | 51 ++++++++++++++++++ 5 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 static/charts.css create mode 100644 templates/stats.hbs diff --git a/src/main.rs b/src/main.rs index b82d19c..b55c097 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,6 +50,7 @@ fn main() -> io::Result<()> { routes::pages::index, routes::pages::history, routes::pages::session_edit, + routes::pages::stats, 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 8502f4f..88bf0aa 100644 --- a/src/routes/pages.rs +++ b/src/routes/pages.rs @@ -2,12 +2,13 @@ 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::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::time::Duration; #[get("/")] @@ -105,3 +106,121 @@ pub fn history(db: State<'_, sled::Db>) -> Result { Ok(Template::render("history", &context)) } + +#[get("/stats")] +pub fn stats(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, 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 + 1, 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/static/charts.css b/static/charts.css new file mode 100644 index 0000000..164e0ea --- /dev/null +++ b/static/charts.css @@ -0,0 +1,124 @@ +/* CSS rules for charts/plots */ +.chart_col_tooltip { + position: relative; + display: inline-block; + border-bottom: 1px dotted black; +} + +.chart_col_tooltip .chart_col_tooltiptext { + visibility: hidden; + width: 120px; + background-color: #000; + color: #fff; + text-align: center; + border-radius: 6px; + padding: 5px 0; + position: absolute; + z-index: 1; + bottom: 110%; + left: 50%; + margin-left: -60px; + opacity: 0; + transition: opacity 0.3s; +} + +.chart_col_tooltip .chart_col_tooltiptext::after { + content: ""; + position: absolute; + top: 100%; + left: 50%; + margin-left: -5px; + border-width: 5px; + border-style: solid; + border-color: #000 transparent transparent transparent; +} + +.chart_col_tooltip:hover .chart_col_tooltiptext { + visibility: visible; + opacity: 1; +} + +.chart_histogram { + height: 250px; /* TODO: possibly remove this */ + padding: 0.75em; + padding-top: 1.5em; + margin: auto; + + display: flex; + flex-direction: row; + + /*** lined-paper background ***/ + position: relative; + + background: #fff; + background: -webkit-gradient(linear, 0 0, 0 100%, from(#d1d1d1), color-stop(4%, #fff)) 0 0; + background: -webkit-linear-gradient(top, #d1d1d1 0%, #fff 4%) 0 0; + background: -moz-linear-gradient(top, #d1d1d1 0%, #fff 4%) 0 0; + background: -ms-linear-gradient(top, #d1d1d1 0%, #fff 4%) 0 0; + background: -o-linear-gradient(top, #d1d1d1 0%, #fff 4%) 0 0; + background: linear-gradient(top, #d1d1d1 0%, #fff 4%) 0 0; + + background-size: 100% 40px; + + border-radius: 3px; +} + +.chart_histogram_col { + max-width: 2em; + + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.chart_histogram_col_line { + display: flex; + width: 1em; + margin: auto; + + background-color: #785ddc; + + border-color: #e2e8f0; + border-style: solid; + border-width: 0.1em; + border-radius: 1em; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + + transition: all 0.2s linear; +} + +.chart_histogram_col_line:hover { + background-color: #5538ba; + + transition: all 0.2s linear; +} + +.chart_histogram_col_label { + display: flex; + max-width: 100%; + height: 4em; + font-size: 0.5em; + text-align: center; + margin: auto; + overflow: hidden; + padding-left: 0.2em; + padding-right: 0.2em; +} + +.paper { + /* font: normal 12px/1.5 "Lucida Grande", arial, sans-serif; */ +} + +/* +.paper::before { + content: ''; + position: absolute; + width: 4px; + top: 0; + left: 30px; + bottom: 0; + border: 1px solid; + border-color: transparent #efe4e4; +} +*/ diff --git a/static/styles.css b/static/styles.css index 16f2e7c..e0d6cbd 100644 --- a/static/styles.css +++ b/static/styles.css @@ -103,3 +103,10 @@ ul.striped_list > li:nth-of-type(odd) { .history_entry_delete_button { color: #aa0000; } + +.hline { + width: 99%; + height: 3px; + background-color: #aaaaaa; + margin: auto; +} diff --git a/templates/stats.hbs b/templates/stats.hbs new file mode 100644 index 0000000..7f210a2 --- /dev/null +++ b/templates/stats.hbs @@ -0,0 +1,51 @@ + + + + + + + + + + + + + stl + + + +

stl

+ + {{#each categories_stats}} + {{#if this.last_session_start}} +
+

+ Senaste session: + {{pretty_datetime this.last_session_start}} + i + {{pretty_seconds this.secs_last_session}} +

+

+ Denna veckan: + {{pretty_seconds this.secs_last_week}} +

+

+ Denna månaden: + {{pretty_seconds this.secs_last_month}} +

+

Andel per timme:

+
+ {{#each this.bars}} +
+
+
+ {{this.1}}% +
+
{{this.0}}
+
+ {{/each}} +
+ {{/if}} + {{/each}} + +