Display category children & Add calendar stats

This commit is contained in:
2021-04-30 14:48:42 +02:00
parent e8e8f535c2
commit c3870bcded
22 changed files with 759 additions and 338 deletions

10
Cargo.lock generated
View File

@ -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",

View File

@ -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<String>,
/// 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<DateTime<Local>>,
/// The parent category of this category
/// If none, the category has no parent
pub parent: Option<CategoryKey>,
/// Whether the item has been "deleted", e.g. it shoudn't be shown in the view
pub deleted: bool,
}

View File

@ -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,

View File

@ -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"

View File

@ -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;

View File

@ -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<Option<category::V>, StatusJson> {
Ok(match tree.get(serialize(key)?)? {
Some(raw) => Some(deserialize(&raw)?),
None => None,
})
}
pub fn get_all_categories(
tree: &sled::Tree,
) -> Result<HashMap<category::K, category::V>, StatusJson> {
Ok(tree
.iter()
.map(|result| {
result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v))))
})
.collect::<Result<Result<_, _>, _>>()??)
}

View File

@ -1 +0,0 @@
pub mod category;

View File

@ -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 type K = Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct V {
/// The name of the category
pub name: String,
/// The description of the category
pub description: Option<String>,
/// 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<DateTime<Local>>,
/// The parent category of this category
/// If none, the category has no parent
pub parent: Option<K>,
// 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(tree: &sled::Tree, key: &K) -> Result<V, StatusJson> {
match tree.get(serialize(key)?)? {
Some(raw) => Ok(deserialize(&raw)?),
None => Err(Status::NotFound.into()),
}
}
pub mod sessions {
pub fn get_all(tree: &sled::Tree) -> Result<HashMap<K, V>, StatusJson> {
Ok(tree
.iter()
.map(|result| {
result
.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v))))
})
.collect::<Result<Result<_, _>, _>>()??)
}
pub fn put(tree: &sled::Tree, key: &K, val: &V) -> Result<(), StatusJson> {
tree.insert(serialize(key)?, serialize(val)?)?;
Ok(())
}
}
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<V, StatusJson> {
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<Local>,
/// The time when this session was ended
pub ended: DateTime<Local>,
// 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<HashMap<K, V>, StatusJson> {
Ok(tree
.iter()
.map(|result| {
result
.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v))))
})
.collect::<Result<Result<_, _>, _>>()??)
}
}
}

View File

@ -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;

View File

@ -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,

View File

@ -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<Json<HashMap<category::K, category::V>>, 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 = "<category>")]
pub fn create_category(
_auth: Authorized,
category: Form<NewCategory>,
db: State<'_, sled::Db>,
) -> Result<StatusJson, StatusJson> {
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/<category_uuid>/bump_session/minutes/<minutes>")]
pub fn bump_session(
_auth: Authorized,
@ -62,14 +22,16 @@ pub fn bump_session(
minutes: i64,
db: State<'_, sled::Db>,
) -> Result<Redirect, StatusJson> {
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(
None => Ok(Err(StatusJson::new(
Status::BadRequest,
"No active session",
)))
))),
}
}
}
}
})??)
})?
}
#[post("/category/<category_uuid>/start_session")]
@ -125,6 +85,8 @@ pub fn toggle_category_session(
event_notifier: State<'_, EventNotifier>,
db: State<'_, sled::Db>,
) -> Result<StatusJson, StatusJson> {
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<EditSession>,
db: State<'_, sled::Db>,
) -> Result<Redirect, StatusJson> {
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<Redirect, StatusJson> {
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)?;

View File

@ -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?<include_archived>")]
pub fn get_all(
_auth: Authorized,
include_archived: Option<bool>,
db: State<'_, sled::Db>,
) -> Result<Json<HashMap<category::K, category::V>>, 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/<id>")]
pub fn get(
_auth: Authorized,
id: Uuid,
db: State<'_, sled::Db>,
) -> Result<Json<category::V>, 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<String>,
color: String,
}
#[post("/category", data = "<category>")]
pub fn create(
_auth: Authorized,
category: Form<NewCategory>,
db: State<'_, sled::Db>,
) -> Result<StatusJson, StatusJson> {
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/<id>/parent")]
pub fn remove_parent(
_auth: Authorized,
id: Uuid,
db: State<'_, sled::Db>,
) -> Result<StatusJson, StatusJson> {
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/<id>/parent", data = "<parent_id>")]
pub fn set_parent(
_auth: Authorized,
id: Uuid,
parent_id: Json<Uuid>,
db: State<'_, sled::Db>,
) -> Result<StatusJson, StatusJson> {
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/<id>/archive")]
pub fn archive(
_auth: Authorized,
id: Uuid,
db: State<'_, sled::Db>,
) -> Result<StatusJson, StatusJson> {
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/<id>/unarchive")]
pub fn unarchive(
_auth: Authorized,
id: Uuid,
db: State<'_, sled::Db>,
) -> Result<StatusJson, StatusJson> {
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())
}

View File

@ -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<Template, StatusJson> {
#[derive(Debug, Serialize, Deserialize, PartialOrd, Ord, PartialEq, Eq)]
struct Node {
category: category::V,
children: BTreeMap<category::K, Node>,
}
#[derive(Debug, Serialize, Deserialize)]
struct TemplateContext {
categories: Vec<(category::K, category::V)>,
categories: BTreeMap<category::K, Node>,
}
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<category::K, Node> = 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<category::K, category::V>,
) {
// 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::<Result<Result<_, _>, _>>()??,
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(),

View File

@ -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<category::K, HashSet<category::K>>;
fn sum_sessions<'a>(iter: impl IntoIterator<Item = &'a session::V>) -> u64 {
iter.into_iter()
.map(|session| session.ended - session.started)
@ -33,110 +36,64 @@ fn sum_sessions<'a>(iter: impl IntoIterator<Item = &'a session::V>) -> u64 {
.sum()
}
#[get("/stats/<category_uuid>")]
pub fn single_stats(
_auth: Authorized,
category_uuid: Uuid,
db: State<'_, sled::Db>,
) -> Result<Template, StatusJson> {
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<category::K, category::V>) -> 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<session::K, session::V> = sessions_tree
.iter()
.map(|result| {
result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v))))
})
.collect::<Result<Result<_, _>, _>>()??;
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);
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()
.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);
let context = CategoryStatsContext {
category_id: *category_uuid,
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(),
};
Ok(Template::render("stats_single", &context))
}
#[get("/stats")]
pub fn all_stats(_auth: Authorized, db: State<'_, sled::Db>) -> Result<Template, StatusJson> {
#[derive(Debug, Serialize, Deserialize)]
struct StatsContext {
categories_stats: Vec<CategoryStatsContext>,
for &child_id in child_map.entry(id).or_default().iter() {
new_child_buffer.push(child_id);
}
let now = Local::now();
for child_id in new_child_buffer.drain(..) {
child_map.entry(parent_id).or_default().insert(child_id);
}
}
}
let categories_tree = db.open_tree(category::NAME)?;
let sessions_tree = db.open_tree(session::NAME)?;
// 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;
}
let categories: HashMap<category::K, category::V> = categories_tree
.iter()
.map(|result| {
result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v))))
})
.collect::<Result<Result<_, _>, _>>()??;
panic!(
"Recursion-limit ({}) reached traversing category graph. Likely stuck in infinite loop.",
RECURSION_LIMIT
);
}
let sessions: HashMap<session::K, session::V> = sessions_tree
.iter()
.map(|result| {
result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v))))
})
.collect::<Result<Result<_, _>, _>>()??;
let mut categories_stats: Vec<_> = categories
.into_iter()
.map(|(category_id, category)| {
let my_sessions = sessions
.values()
.filter(|session| session.category == category_id);
/// Create a new CategoryStatsCtx
fn category_stats_ctx(
now: DateTime<Local>,
category_id: category::K,
category: category::V,
sessions: &HashMap<session::K, session::V>,
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);
@ -154,17 +111,13 @@ pub fn all_stats(_auth: Authorized, db: State<'_, sled::Db>) -> Result<Template,
.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);
CategoryStatsContext {
CategoryStatsCtx {
category_id,
category,
@ -180,7 +133,53 @@ pub fn all_stats(_auth: Authorized, db: State<'_, sled::Db>) -> Result<Template,
(hour, percentage, biggest_hour - percentage)
})
.collect(),
calendar: compute_calendar_stats(my_sessions),
}
}
#[get("/stats/<category_id>")]
pub fn single_stats(
_auth: Authorized,
category_id: Uuid,
db: State<'_, sled::Db>,
) -> Result<Template, 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::K, session::V> = 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<Template, StatusJson> {
#[derive(Debug, Serialize, Deserialize)]
struct StatsContext {
categories_stats: Vec<CategoryStatsCtx>,
}
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();
@ -191,6 +190,44 @@ pub fn all_stats(_auth: Authorized, db: State<'_, sled::Db>) -> Result<Template,
Ok(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<Local>,
end: DateTime<Local>,
day: Date<Local>,
) -> 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<u32, f64>
where
I: Iterator<Item = &'a session::V>,
@ -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<u64>,
}
#[derive(Debug, Serialize, Deserialize)]
struct CalendarCtx {
days: Vec<Vec<Option<CalendarDayCtx>>>,
}
fn compute_calendar_stats<'a, I>(sessions: I) -> CalendarCtx
where
I: Iterator<Item = &'a session::V>,
{
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<Date<Local>, 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]

View File

@ -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<T>(pub T);
impl<T> Eq for OrdL<T> where T: PartialEq {}
impl<T> PartialOrd for OrdL<T>
where
T: PartialOrd,
{
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.0.partial_cmp(&other.0).unwrap_or(Ordering::Less))
}
}
impl<T> Ord for OrdL<T>
where
T: PartialOrd,
{
fn cmp(&self, other: &Self) -> Ordering {
self.0.partial_cmp(&other.0).unwrap()
}
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
}

View File

@ -0,0 +1,31 @@
<li class="category_entry">
<div class="category_header">
<div class="category_icon"
style="background-color: {{this.category.color}}"
></div>
<span class="category_name">{{this.category.name}}</span>
{{#if this.category.started}}
<form action="/api/category/{{@key}}/bump_session/minutes/5", method="post">
<button style="height: 100%; color: green;" type="submit">+5</button>
</form>
{{/if}}
<div class="category_button_container">
<button
id="toggle-button-{{@key}}"
onClick="toggle_category('{{@key}}')"
{{#if this.category.started}}
class="category_button category_button_toggled"
{{else}}
class="category_button"
{{/if}}>
</button>
</div>
</div>
{{#if this.children}}
<ul class="category_children striped_list">
{{#each this.children}}
{{> category_entry}}
{{/each}}
</ul>
{{/if}}
</li>

View File

@ -2,10 +2,12 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta http-equiv="Content-Security-Policy" content="script-src 'unsafe-inline'">
<link rel="icon" type="image/svg+xml" href="/static/icon.svg">
<link rel="stylesheet" href="/static/styles/common.css">
<link rel="stylesheet" href="/static/styles/charts.css">
<link rel="stylesheet" href="/static/styles/calendar.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Ubuntu|Ubuntu+Mono&display=swap">
<title>stl</title>

View File

@ -34,28 +34,7 @@
<ul class="striped_list">
{{#each categories}}
<li class="category_entry">
<div class="category_icon"
style="background-color: {{this.1.color}}"
></div>
<span class="category_name">{{this.1.name}}</span>
{{#if this.1.started}}
<form action="/api/category/{{this.0}}/bump_session/minutes/5", method="post">
<button style="height: 100%; color: green;" type="submit">+5</button>
</form>
{{/if}}
<div class="category_button_container">
<button
id="toggle-button-{{this.0}}"
onClick="toggle_category('{{this.0}}')"
{{#if this.1.started}}
class="category_button category_button_toggled"
{{else}}
class="category_button"
{{/if}}>
</button>
</div>
</li>
{{>category_entry}}
{{/each}}
</ul>
</body>

View File

@ -17,14 +17,39 @@
<span>Senaste månaden:</span>
<span class="history_entry_duration">{{pretty_seconds secs_last_month}}</span>
</h2>
<h2>Senaste veckorna:</h2>
<div class="cal">
{{#each calendar.days}}
<div class="cal_col">
{{#each this}}
{{#if this}}
<div class="cal_day tooltip
{{#if this.border_top}}cal_day_border_top{{/if}}
{{#if this.border_left}}cal_day_border_left{{/if}}
{{#if this.border_right}}cal_day_border_right{{/if}}
{{#if this.border_bottom}}cal_day_border_bottom{{/if}}
{{#if this.duration}}cal_day_non_empty{{/if}}"
style="opacity: {{this.weight}}">
{{#if this.duration}}
{{pretty_compact_seconds this.duration}}
<span class="tooltiptext">{{pretty_seconds this.duration}}</span>
{{/if}}
</div>
{{else}}
<div class="cal_day_missing"></div>
{{/if}}
{{/each}}
</div>
{{/each}}
</div>
<h2>Andel per timme:</h2>
<div class="chart_histogram">
<div class="chart_histogram_legend">{{bars_max}}&percnt;</div>
{{#each bars}}
<div class="chart_histogram_col">
<div style="flex-grow: {{this.2}};"></div>
<div class="chart_histogram_col_line chart_col_tooltip" style="flex-grow: {{this.1}};">
<span class="chart_col_tooltiptext">{{this.1}}&percnt;</span>
<div class="chart_histogram_col_line tooltip" style="flex-grow: {{this.1}};">
<span class="tooltiptext">{{this.1}}&percnt;</span>
</div>
<div class="chart_histogram_col_label">{{this.0}}</div>
</div>