From c3870bcded591efe0df46d9b2695362c96fffc9f Mon Sep 17 00:00:00 2001 From: Joakim Hulthe Date: Fri, 30 Apr 2021 14:48:42 +0200 Subject: [PATCH] Display category children & Add calendar stats --- Cargo.lock | 10 + lib/src/category.rs | 27 --- lib/src/v2.rs | 4 +- server/Cargo.toml | 1 + server/src/database/mod.rs | 8 +- server/src/database/util/category.rs | 25 -- server/src/database/util/mod.rs | 1 - server/src/database/v2.rs | 87 ++++--- server/src/handlebars_util.rs | 13 ++ server/src/main.rs | 11 +- server/src/routes/api.rs | 68 ++---- server/src/routes/api/category.rs | 158 +++++++++++++ server/src/routes/pages.rs | 74 +++++- server/src/routes/pages/stats.rs | 338 +++++++++++++++++++-------- server/src/util.rs | 28 +++ server/static/styles/calendar.css | 43 ++++ server/static/styles/charts.css | 39 ---- server/static/styles/common.css | 75 +++++- server/templates/category_entry.hbs | 31 +++ server/templates/head.hbs | 2 + server/templates/index.hbs | 25 +- server/templates/stats_chart.hbs | 29 ++- 22 files changed, 759 insertions(+), 338 deletions(-) delete mode 100644 lib/src/category.rs delete mode 100644 server/src/database/util/category.rs delete mode 100644 server/src/database/util/mod.rs create mode 100644 server/src/routes/api/category.rs create mode 100644 server/static/styles/calendar.css create mode 100644 server/templates/category_entry.hbs diff --git a/Cargo.lock b/Cargo.lock index 6df7a16..8131352 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1030,6 +1030,15 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47be2f14c678be2fdcab04ab1171db51b2762ce6f0a8ee87c8dd4a04ed216135" +[[package]] +name = "itertools" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d572918e350e82412fe766d24b15e6682fb2ed2bbe018280caa810397cb319" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "0.4.7" @@ -2165,6 +2174,7 @@ dependencies = [ "duplicate", "futures", "handlebars", + "itertools", "log", "rocket", "rocket_contrib", diff --git a/lib/src/category.rs b/lib/src/category.rs deleted file mode 100644 index 08c994f..0000000 --- a/lib/src/category.rs +++ /dev/null @@ -1,27 +0,0 @@ -use chrono::{DateTime, Local}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -pub type CategoryKey = Uuid; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Category { - /// The name of the category - pub name: String, - - /// The description of the category - pub description: Option, - - /// The HTML color of the category in the rendered view - pub color: String, - - /// If the session is not active, this will be None - pub started: Option>, - - /// The parent category of this category - /// If none, the category has no parent - pub parent: Option, - - /// Whether the item has been "deleted", e.g. it shoudn't be shown in the view - pub deleted: bool, -} diff --git a/lib/src/v2.rs b/lib/src/v2.rs index f5a2a72..f670360 100644 --- a/lib/src/v2.rs +++ b/lib/src/v2.rs @@ -13,7 +13,7 @@ pub mod trees { pub type K = Uuid; - #[derive(Debug, Clone, Serialize, Deserialize)] + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub struct V { /// The name of the category pub name: String, @@ -44,7 +44,7 @@ pub mod trees { pub type K = Uuid; - #[derive(Debug, Clone, Serialize, Deserialize)] + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub struct V { /// The UUID of the category to which this session belongs pub category: trees::category::K, diff --git a/server/Cargo.toml b/server/Cargo.toml index ee7f74a..63095f6 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -20,6 +20,7 @@ uuid = { version = "0.8", features = ["serde", "v4"] } duplicate = "0.2" bincode = "1" handlebars = "3" +itertools = "0.10.0" [dependencies.stl_lib] path = "../lib" diff --git a/server/src/database/mod.rs b/server/src/database/mod.rs index fcdb823..9ac0bd8 100644 --- a/server/src/database/mod.rs +++ b/server/src/database/mod.rs @@ -1,9 +1,7 @@ -pub mod unversioned; -pub mod util; -pub mod v1; -pub use stl_lib::v2; - pub mod migrations; +pub mod unversioned; +pub mod v1; +pub mod v2; pub const SCHEMA_VERSION: unversioned::global::schema_version::V = 2; pub use v2 as latest; diff --git a/server/src/database/util/category.rs b/server/src/database/util/category.rs deleted file mode 100644 index c66261a..0000000 --- a/server/src/database/util/category.rs +++ /dev/null @@ -1,25 +0,0 @@ -use crate::database::latest::trees::category; -use crate::status_json::StatusJson; -use bincode::{deserialize, serialize}; -use std::collections::HashMap; - -pub fn get_category( - tree: &sled::Tree, - key: &category::K, -) -> Result, StatusJson> { - Ok(match tree.get(serialize(key)?)? { - Some(raw) => Some(deserialize(&raw)?), - None => None, - }) -} - -pub fn get_all_categories( - tree: &sled::Tree, -) -> Result, StatusJson> { - Ok(tree - .iter() - .map(|result| { - result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v)))) - }) - .collect::, _>>()??) -} diff --git a/server/src/database/util/mod.rs b/server/src/database/util/mod.rs deleted file mode 100644 index 7995330..0000000 --- a/server/src/database/util/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod category; diff --git a/server/src/database/v2.rs b/server/src/database/v2.rs index 8f078f2..d301604 100644 --- a/server/src/database/v2.rs +++ b/server/src/database/v2.rs @@ -1,63 +1,60 @@ -pub(self) use chrono::{DateTime, Local}; -pub(self) use serde_derive::{Deserialize, Serialize}; -pub(self) use uuid::Uuid; +pub(self) use crate::status_json::StatusJson; +pub(self) use bincode::{deserialize, serialize}; +pub(self) use rocket::http::Status; +pub(self) use std::collections::HashMap; /// Stuff in the default namespace pub mod global {} pub mod trees { - pub mod categories { + pub mod category { use super::super::*; + pub use stl_lib::v2::trees::category::*; - pub const NAME: &str = "CATEGORIES"; + pub fn get(tree: &sled::Tree, key: &K) -> Result { + match tree.get(serialize(key)?)? { + Some(raw) => Ok(deserialize(&raw)?), + None => Err(Status::NotFound.into()), + } + } - pub type K = Uuid; + pub fn get_all(tree: &sled::Tree) -> Result, StatusJson> { + Ok(tree + .iter() + .map(|result| { + result + .map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v)))) + }) + .collect::, _>>()??) + } - #[derive(Debug, Clone, Serialize, Deserialize)] - pub struct V { - /// The name of the category - pub name: String, - - /// The description of the category - pub description: Option, - - /// The HTML color of the category in the rendered view - pub color: String, - - /// If the session is not active, this will be None - pub started: Option>, - - /// The parent category of this category - /// If none, the category has no parent - pub parent: Option, - - // FIXME: this field is currently not used - /// Whether the item has been "deleted", e.g. it shoudn't be shown in the view - pub deleted: bool, + pub fn put(tree: &sled::Tree, key: &K, val: &V) -> Result<(), StatusJson> { + tree.insert(serialize(key)?, serialize(val)?)?; + Ok(()) } } - pub mod sessions { + pub mod session { use super::super::*; + pub use stl_lib::v2::trees::session::*; - pub const NAME: &str = "SESSIONS"; + /* + pub fn get(tree: &sled::Tree, key: &K) -> Result { + Ok(match tree.get(serialize(key)?)? { + Some(raw) => deserialize(&raw)?, + None => Err(Status::NotFound)?, + }) + } + */ - pub type K = Uuid; - - #[derive(Debug, Clone, Serialize, Deserialize)] - pub struct V { - /// The UUID of the category to which this session belongs - pub category: trees::categories::K, - - /// The time when this session was started - pub started: DateTime, - - /// The time when this session was ended - pub ended: DateTime, - - // FIXME: this field is currently not used - /// Whether the item has been "deleted", e.g. it shoudn't be shown in the view - pub deleted: bool, + pub fn get_all(tree: &sled::Tree) -> Result, StatusJson> { + Ok(tree + .iter() + .map(|result| { + result + .map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v)))) + }) + .collect::, _>>()??) } } } diff --git a/server/src/handlebars_util.rs b/server/src/handlebars_util.rs index eae09f3..1624b5f 100644 --- a/server/src/handlebars_util.rs +++ b/server/src/handlebars_util.rs @@ -11,6 +11,19 @@ pub fn register_helpers(engines: &mut Engines) { .handlebars .register_helper("pretty_datetime", Box::new(pretty_datetime)); + handlebars_helper!(pretty_compact_seconds: |secs: u64| { + let hours = secs / 60 / 60; + let minutes = secs / 60 % 60; + if hours == 0 { + format!("{}m", minutes) + } else { + format!("{}h", hours) + } + }); + engines + .handlebars + .register_helper("pretty_compact_seconds", Box::new(pretty_compact_seconds)); + handlebars_helper!(pretty_seconds: |secs: u64| { let hours = secs / 60 / 60; let minutes = secs / 60 % 60; diff --git a/server/src/main.rs b/server/src/main.rs index 8c6e686..74c9827 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -69,12 +69,17 @@ async fn main() -> io::Result<()> { .mount( "/api", rocket::routes![ - routes::api::edit_session, - routes::api::get_sessions, - routes::api::create_category, + routes::api::category::get, + routes::api::category::get_all, + routes::api::category::create, + routes::api::category::archive, + routes::api::category::unarchive, + routes::api::category::remove_parent, + routes::api::category::set_parent, routes::api::start_session, routes::api::end_session, routes::api::bump_session, + routes::api::edit_session, routes::api::delete_session, routes::api::wait_for_event, auth::login, diff --git a/server/src/routes/api.rs b/server/src/routes/api.rs index f3092f3..82c86c7 100644 --- a/server/src/routes/api.rs +++ b/server/src/routes/api.rs @@ -1,6 +1,6 @@ +pub mod category; + use crate::auth::Authorized; -use crate::database::latest::trees::{category, session}; -use crate::database::util::category::get_all_categories; use crate::routes::pages; use crate::status_json::StatusJson; use crate::util::EventNotifier; @@ -13,48 +13,8 @@ use rocket::{get, post, uri, State}; use rocket_contrib::json::Json; use rocket_contrib::uuid::Uuid; use sled::Transactional; -use std::collections::HashMap; use stl_lib::wfe::WaitForEvent; -#[get("/sessions")] -pub fn get_sessions( - _auth: Authorized, - db: State<'_, sled::Db>, -) -> Result>, StatusJson> { - let categories_tree = db.open_tree(category::NAME)?; - Ok(Json(get_all_categories(&categories_tree)?)) -} - -#[derive(FromForm)] -pub struct NewCategory { - name: String, - color: String, -} - -#[post("/create_category", data = "")] -pub fn create_category( - _auth: Authorized, - category: Form, - db: State<'_, sled::Db>, -) -> Result { - let category = category.into_inner(); - - let categories_tree = db.open_tree(category::NAME)?; - categories_tree.insert( - serialize(&uuid::Uuid::new_v4())?, - serialize(&category::V { - name: category.name, - description: None, - color: category.color, - started: None, - parent: None, - deleted: false, - })?, - )?; - - Ok(Status::Ok.into()) -} - #[post("/category//bump_session/minutes/")] pub fn bump_session( _auth: Authorized, @@ -62,14 +22,16 @@ pub fn bump_session( minutes: i64, db: State<'_, sled::Db>, ) -> Result { + use crate::database::latest::trees::category; + let duration = Duration::minutes(minutes); let category_uuid_s = sled::IVec::from(serialize(&category_uuid.into_inner())?); let categories_tree = db.open_tree(category::NAME)?; - Ok((&categories_tree).transaction(|tx_categories| { + (&categories_tree).transaction(|tx_categories| { match tx_categories.get(&category_uuid_s)? { - None => return Ok(Err(Status::NotFound.into())), + None => Ok(Err(Status::NotFound.into())), Some(data) => { let mut category: category::V = deserialize(&data).unwrap(); match category.started.as_mut() { @@ -87,16 +49,14 @@ pub fn bump_session( Ok(Ok(Redirect::to(uri!(pages::index)))) } - None => { - return Ok(Err(StatusJson::new( - Status::BadRequest, - "No active session", - ))) - } + None => Ok(Err(StatusJson::new( + Status::BadRequest, + "No active session", + ))), } } } - })??) + })? } #[post("/category//start_session")] @@ -125,6 +85,8 @@ pub fn toggle_category_session( event_notifier: State<'_, EventNotifier>, db: State<'_, sled::Db>, ) -> Result { + use crate::database::latest::trees::{category, session}; + let category_uuid_s = sled::IVec::from(serialize(&category_uuid.into_inner())?); let categories_tree = db.open_tree(category::NAME)?; @@ -187,6 +149,8 @@ pub fn edit_session( session: Form, db: State<'_, sled::Db>, ) -> Result { + use crate::database::latest::trees::session; + let session_uuid_s = sled::IVec::from(serialize(&session_uuid.into_inner())?); let session = session::V { @@ -227,6 +191,8 @@ pub fn delete_session( session_uuid: Uuid, db: State<'_, sled::Db>, ) -> Result { + use crate::database::latest::trees::session; + let session_uuid_s = sled::IVec::from(serialize(&session_uuid.into_inner())?); let sessions_tree = db.open_tree(session::NAME)?; diff --git a/server/src/routes/api/category.rs b/server/src/routes/api/category.rs new file mode 100644 index 0000000..103cc90 --- /dev/null +++ b/server/src/routes/api/category.rs @@ -0,0 +1,158 @@ +use crate::auth::Authorized; +use crate::database::latest::trees::category; +use crate::status_json::StatusJson; +use rocket::form::{Form, FromForm}; +use rocket::http::Status; +use rocket::{delete, get, post, State}; +use rocket_contrib::json::Json; +use rocket_contrib::uuid::Uuid; +use std::collections::HashMap; + +#[get("/category?")] +pub fn get_all( + _auth: Authorized, + include_archived: Option, + db: State<'_, sled::Db>, +) -> Result>, StatusJson> { + let categories_tree = db.open_tree(category::NAME)?; + let mut categories = category::get_all(&categories_tree)?; + if include_archived != Some(true) { + categories.retain(|_, category| !category.deleted); + } + Ok(Json(categories)) +} + +#[get("/category/")] +pub fn get( + _auth: Authorized, + id: Uuid, + db: State<'_, sled::Db>, +) -> Result, StatusJson> { + let categories_tree = db.open_tree(category::NAME)?; + Ok(Json(category::get(&categories_tree, &id.into_inner())?)) +} + +#[derive(FromForm)] +pub struct NewCategory { + name: String, + description: Option, + color: String, +} + +#[post("/category", data = "")] +pub fn create( + _auth: Authorized, + category: Form, + db: State<'_, sled::Db>, +) -> Result { + let category = category.into_inner(); + + let categories_tree = db.open_tree(category::NAME)?; + category::put( + &categories_tree, + &uuid::Uuid::new_v4(), + &category::V { + name: category.name, + description: category.description, + color: category.color, + started: None, + parent: None, + deleted: false, + }, + )?; + + Ok(Status::Ok.into()) +} + +#[delete("/category//parent")] +pub fn remove_parent( + _auth: Authorized, + id: Uuid, + db: State<'_, sled::Db>, +) -> Result { + let categories_tree = db.open_tree(category::NAME)?; + + let category = category::V { + parent: None, + ..category::get(&categories_tree, &id)? + }; + + category::put(&categories_tree, &id, &category)?; + + Ok(Status::Ok.into()) +} + +#[post("/category//parent", data = "")] +pub fn set_parent( + _auth: Authorized, + id: Uuid, + parent_id: Json, + db: State<'_, sled::Db>, +) -> Result { + let parent_id = *parent_id.into_inner(); + + let categories_tree = db.open_tree(category::NAME)?; + + // check for parent cycles + let mut next_parent_id = parent_id; + loop { + if next_parent_id == *id { + return Err(StatusJson::new(Status::BadRequest, "Parent cycle detected")); + } + + // this also makes sure that parent exists + let parent = category::get(&categories_tree, &&next_parent_id)?; + + match parent.parent { + Some(grandparent_id) => next_parent_id = grandparent_id, + None => break, + } + } + + let _parent = category::get(&categories_tree, &parent_id)?; + + let category = category::V { + parent: Some(parent_id), + ..category::get(&categories_tree, &id)? + }; + + category::put(&categories_tree, &id, &category)?; + + Ok(Status::Ok.into()) +} + +#[post("/category//archive")] +pub fn archive( + _auth: Authorized, + id: Uuid, + db: State<'_, sled::Db>, +) -> Result { + let categories_tree = db.open_tree(category::NAME)?; + + let category = category::V { + deleted: true, + ..category::get(&categories_tree, &id)? + }; + + category::put(&categories_tree, &id, &category)?; + + Ok(Status::Ok.into()) +} + +#[post("/category//unarchive")] +pub fn unarchive( + _auth: Authorized, + id: Uuid, + db: State<'_, sled::Db>, +) -> Result { + let categories_tree = db.open_tree(category::NAME)?; + + let category = category::V { + deleted: false, + ..category::get(&categories_tree, &id)? + }; + + category::put(&categories_tree, &id, &category)?; + + Ok(Status::Ok.into()) +} diff --git a/server/src/routes/pages.rs b/server/src/routes/pages.rs index 3524ae7..3536cab 100644 --- a/server/src/routes/pages.rs +++ b/server/src/routes/pages.rs @@ -9,25 +9,81 @@ 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("/")] pub fn index(_auth: Authorized, db: State<'_, sled::Db>) -> Result { + #[derive(Debug, Serialize, Deserialize, PartialOrd, Ord, PartialEq, Eq)] + struct Node { + category: category::V, + children: BTreeMap, + } + #[derive(Debug, Serialize, Deserialize)] struct TemplateContext { - categories: Vec<(category::K, category::V)>, + categories: BTreeMap, } let categories_tree = db.open_tree(category::NAME)?; + let mut categories = category::get_all(&categories_tree)?; + + // filter archived categories + categories.retain(|_, category| !category.deleted); + + // collect the top-level categories (those without a parent) + let mut top_level_nodes: BTreeMap = categories + .iter() + .filter(|(_, c)| c.parent.is_none()) + .map(|(&id, category)| { + let node = Node { + category: category.clone(), + children: Default::default(), + }; + (id, node) + }) + .collect(); + + // remove top-level categories from the list + for id in top_level_nodes.keys() { + categories.remove(id); + } + + /// populate `node.children with entries from `remaining` + fn populate_node( + node_id: category::K, + node: &mut Node, + remaining: &mut HashMap, + ) { + // make a list of the nodes children + let mut new_children = vec![]; + for (&id, category) in remaining.iter() { + if category.parent == Some(node_id) { + new_children.push(id); + } + } + + // move the children from `remaining` to `node.children` + for &id in &new_children { + let child_node = Node { + category: remaining.remove(&id).unwrap(), + children: Default::default(), + }; + node.children.insert(id, child_node); + } + + // recursively populate the childrens children + for (child_id, child) in node.children.iter_mut() { + populate_node(*child_id, child, remaining); + } + } + + for (id, node) in top_level_nodes.iter_mut() { + populate_node(*id, node, &mut categories); + } let context = TemplateContext { - categories: categories_tree - .iter() - .map(|result| { - result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v)))) - }) - .collect::, _>>()??, + categories: top_level_nodes, }; Ok(Template::render("index", &context)) @@ -49,7 +105,7 @@ pub fn session_edit( let sessions_tree = db.open_tree(session::NAME)?; match sessions_tree.get(session_uuid_s)? { - None => Err(Status::NotFound)?, + None => Err(Status::NotFound.into()), Some(data) => { let context = SessionPageContext { session: deserialize(&data).unwrap(), diff --git a/server/src/routes/pages/stats.rs b/server/src/routes/pages/stats.rs index 1cf6be2..5076d9f 100644 --- a/server/src/routes/pages/stats.rs +++ b/server/src/routes/pages/stats.rs @@ -1,19 +1,18 @@ use crate::auth::Authorized; use crate::database::latest::trees::{category, session}; -use crate::database::util::category::get_category; use crate::status_json::StatusJson; -use bincode::deserialize; -use chrono::{DateTime, Local, Timelike}; +use crate::util::OrdL; +use chrono::{Date, DateTime, Datelike, Duration, Local, Timelike}; +use itertools::Itertools; 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::cmp::Ordering; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; #[derive(Debug, Serialize, Deserialize)] -struct CategoryStatsContext { +struct CategoryStatsCtx { category_id: category::K, category: category::V, @@ -24,8 +23,12 @@ struct CategoryStatsContext { 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) @@ -33,35 +36,69 @@ fn sum_sessions<'a>(iter: impl IntoIterator) -> u64 { .sum() } -#[get("/stats/")] -pub fn single_stats( - _auth: Authorized, - category_uuid: Uuid, - db: State<'_, sled::Db>, -) -> Result { - let categories_tree = db.open_tree(category::NAME)?; - let sessions_tree = db.open_tree(session::NAME)?; +/// 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(); - let category: category::V = - get_category(&categories_tree, &category_uuid)?.ok_or(Status::NotFound)?; + const RECURSION_LIMIT: usize = 255; - 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 last_len = 0; + let mut new_child_buffer = vec![]; - let my_sessions = sessions - .values() - .filter(|session| session.category == *category_uuid); + // 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 now = Local::now(); - let secs_last_week = sum_sessions( my_sessions .clone() @@ -74,18 +111,14 @@ pub fn single_stats( .filter(|session| (now - session.started) <= chrono::Duration::days(30)), ); - let mut stats_per_hour = compute_percentage_per_hour(my_sessions); + let mut stats_per_hour = compute_percentage_per_hour(my_sessions.clone()); let biggest_hour = *stats_per_hour .values() - .max_by(|f1, f2| match () { - _ if f1 == f2 => Ordering::Equal, - _ if f1 > f2 => Ordering::Greater, - _ => Ordering::Less, - }) + .max_by_key(|&f| OrdL(f)) .unwrap_or(&1.0); - let context = CategoryStatsContext { - category_id: *category_uuid, + CategoryStatsCtx { + category_id, category, last_session_start: last_session.map(|session| session.started), @@ -100,16 +133,36 @@ pub fn single_stats( (hour, percentage, biggest_hour - percentage) }) .collect(), - }; - Ok(Template::render("stats_single", &context)) + calendar: compute_calendar_stats(my_sessions), + } +} + +#[get("/stats/")] +pub fn single_stats( + _auth: Authorized, + category_id: Uuid, + db: State<'_, sled::Db>, +) -> Result { + 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(Template::render("stats_single", dbg!(&ctx))) } #[get("/stats")] pub fn all_stats(_auth: Authorized, db: State<'_, sled::Db>) -> Result { #[derive(Debug, Serialize, Deserialize)] struct StatsContext { - categories_stats: Vec, + categories_stats: Vec, } let now = Local::now(); @@ -117,70 +170,16 @@ pub fn all_stats(_auth: Authorized, db: State<'_, sled::Db>) -> Result = categories_tree - .iter() - .map(|result| { - result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v)))) - }) - .collect::, _>>()??; + let categories = category::get_all(&categories_tree)?; + let sessions = session::get_all(&sessions_tree)?; - 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 child_map = calculate_child_map(&categories); let mut categories_stats: Vec<_> = categories .into_iter() + .filter(|(_, category)| !category.deleted) .map(|(category_id, category)| { - 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_max: biggest_hour, - bars: (0..24) - .map(|hour| { - let percentage = *stats_per_hour.entry(hour).or_default(); - (hour, percentage, biggest_hour - percentage) - }) - .collect(), - } + category_stats_ctx(now, category_id, category, &sessions, &child_map) }) .collect(); @@ -191,6 +190,44 @@ pub fn all_stats(_auth: Authorized, db: State<'_, sled::Db>) -> Result, + 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, @@ -247,10 +284,115 @@ where 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 { + days: 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()); + + CalendarCtx { + days: 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| match days.get(&other_day) { + Some(_) => other_day.month() != month, + None => true, + }; + + let month_or_week_border = |other_day| match days.get(&other_day) { + Some(_) => other_day.iso_week() != week || month_border(other_day), + None => true, + }; + + 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, + }; + + //(day.weekday(), Some(ctx)) + Some(ctx) + }) + .collect() + }) + .collect(), + } +} + #[cfg(test)] mod test { use super::*; - use crate::database::latest::trees::sessions; + use crate::database::latest::trees::session; use chrono::{DateTime, Local}; #[test] diff --git a/server/src/util.rs b/server/src/util.rs index 5f5e195..2265a28 100644 --- a/server/src/util.rs +++ b/server/src/util.rs @@ -1,3 +1,4 @@ +use std::cmp::{Eq, Ord, Ordering, PartialEq, PartialOrd}; use tokio::sync::Notify; pub struct EventNotifier { @@ -19,3 +20,30 @@ impl EventNotifier { self.notify.notify_waiters(); } } + +/// Make some type Ord if it is only PartialOrd, e.g. floats. +/// +/// Unorderable values (e.g. NaNs) are always compared to as being less than other values. +/// This messes with commutativity, but is fine for the simple case. +#[derive(PartialEq, Clone, Copy, Debug)] +pub struct OrdL(pub T); + +impl Eq for OrdL where T: PartialEq {} + +impl PartialOrd for OrdL +where + T: PartialOrd, +{ + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.0.partial_cmp(&other.0).unwrap_or(Ordering::Less)) + } +} + +impl Ord for OrdL +where + T: PartialOrd, +{ + fn cmp(&self, other: &Self) -> Ordering { + self.0.partial_cmp(&other.0).unwrap() + } +} diff --git a/server/static/styles/calendar.css b/server/static/styles/calendar.css new file mode 100644 index 0000000..0811629 --- /dev/null +++ b/server/static/styles/calendar.css @@ -0,0 +1,43 @@ +.cal { + display: flex; + flex-direction: row; +} + +.cal_col { + display: flex; + flex-direction: column; +} + +.cal_day { + width: 4em; + height: 1.5em; + padding-top: 0.5em; + padding-bottom: 0.5em; + text-align: center; + border: dotted 1px grey; +} + +.cal_day_non_empty { + background-color: #785ddc; + color: #fdab70; +} + +.cal_day_missing { + background-color: pink; +} + +.cal_day_border_top { + border-top: solid 1px white; +} + +.cal_day_border_bottom { + border-bottom: solid 1px white; +} + +.cal_day_border_left { + border-left: solid 1px white; +} + +.cal_day_border_right { + border-right: solid 1px white; +} diff --git a/server/static/styles/charts.css b/server/static/styles/charts.css index 22f9ac6..d6acd09 100644 --- a/server/static/styles/charts.css +++ b/server/static/styles/charts.css @@ -1,43 +1,4 @@ /* 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; diff --git a/server/static/styles/common.css b/server/static/styles/common.css index 8bfd516..bc3911d 100644 --- a/server/static/styles/common.css +++ b/server/static/styles/common.css @@ -28,21 +28,40 @@ a:hover { } ul.striped_list { - max-width: 40em; + max-width: 40rem; list-style-type: none; margin: auto; padding: 0; } -ul.striped_list > li:nth-of-type(odd) { - background-color: #3f4a53; -} +ul.striped_list > li:nth-child(even) { background-color:#302f3b } +ul.striped_list > li:nth-child(odd) { background-color:#3f4a53 } +ul.striped_list > li:nth-child(even) ul li:nth-child(even) { background-color:#302f3b } +ul.striped_list > li:nth-child(even) ul li:nth-child(odd) { background-color:#3f4a53 } +ul.striped_list > li:nth-child(odd) ul li:nth-child(even) { background-color:#3f4a53 } +ul.striped_list > li:nth-child(odd) ul li:nth-child(odd) { background-color:#302f3b;} .category_entry { +} + +.category_header { display: flex; flex-direction: row; } +.category_children { + display: flex; + flex-direction: column; + + margin-left: 2.5em !important; + + border-left: groove 1em; + border-top: groove 1em; + border-bottom: dotted; + border-color: #696969; + font-size: 0.8em; +} + .category_name { font-size: 3em; margin: auto; @@ -70,11 +89,11 @@ ul.striped_list > li:nth-of-type(odd) { } .category_button_container { - margin: 0.1em; + margin: 0.1rem; background-color: #a4829c; - border-radius: 3.5em; - width: 7em; - height: 7em; + border-radius: 3.5rem; + width: 7rem; + height: 7rem; } .category_button { @@ -131,3 +150,43 @@ ul.striped_list > li:nth-of-type(odd) { background-color: #aaaaaa; margin: auto; } + +.tooltip { + position: relative; + display: inline-block; + border-bottom: 1px dotted black; +} + +.tooltip .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; +} + +.tooltip .tooltiptext::after { + content: ""; + position: absolute; + top: 100%; + left: 50%; + margin-left: -5px; + border-width: 5px; + border-style: solid; + border-color: #000 transparent transparent transparent; +} + +.tooltip:hover .tooltiptext { + visibility: visible; + opacity: 1; +} + diff --git a/server/templates/category_entry.hbs b/server/templates/category_entry.hbs new file mode 100644 index 0000000..23a8414 --- /dev/null +++ b/server/templates/category_entry.hbs @@ -0,0 +1,31 @@ +
  • +
    +
    + {{this.category.name}} + {{#if this.category.started}} +
    + +
    + {{/if}} +
    + +
    +
    + {{#if this.children}} +
      + {{#each this.children}} + {{> category_entry}} + {{/each}} +
    + {{/if}} +
  • diff --git a/server/templates/head.hbs b/server/templates/head.hbs index 91aad2c..ac2abde 100644 --- a/server/templates/head.hbs +++ b/server/templates/head.hbs @@ -2,10 +2,12 @@ + + stl diff --git a/server/templates/index.hbs b/server/templates/index.hbs index 35c8e18..47f6983 100644 --- a/server/templates/index.hbs +++ b/server/templates/index.hbs @@ -34,29 +34,8 @@
      {{#each categories}} -
    • -
      - {{this.1.name}} - {{#if this.1.started}} -
      - -
      - {{/if}} -
      - -
      -
    • - {{/each}} + {{>category_entry}} + {{/each}}
    diff --git a/server/templates/stats_chart.hbs b/server/templates/stats_chart.hbs index f41484c..249473f 100644 --- a/server/templates/stats_chart.hbs +++ b/server/templates/stats_chart.hbs @@ -17,14 +17,39 @@ Senaste månaden: {{pretty_seconds secs_last_month}} +

    Senaste veckorna:

    +
    + {{#each calendar.days}} +
    + {{#each this}} + {{#if this}} +
    + {{#if this.duration}} + {{pretty_compact_seconds this.duration}} + {{pretty_seconds this.duration}} + {{/if}} +
    + {{else}} +
    + {{/if}} + {{/each}} +
    + {{/each}} +

    Andel per timme:

    {{bars_max}}%
    {{#each bars}}
    -
    - {{this.1}}% +
    + {{this.1}}%
    {{this.0}}