Display category children & Add calendar stats
This commit is contained in:
@ -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;
|
||||
|
||||
@ -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<_, _>, _>>()??)
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
pub mod category;
|
||||
@ -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<V, StatusJson> {
|
||||
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<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<_, _>, _>>()??)
|
||||
}
|
||||
|
||||
#[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 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<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<_, _>, _>>()??)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
Status::BadRequest,
|
||||
"No active session",
|
||||
)))
|
||||
}
|
||||
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)?;
|
||||
|
||||
158
server/src/routes/api/category.rs
Normal file
158
server/src/routes/api/category.rs
Normal 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())
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -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,35 +36,69 @@ 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);
|
||||
|
||||
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<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);
|
||||
|
||||
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/<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<CategoryStatsContext>,
|
||||
categories_stats: Vec<CategoryStatsCtx>,
|
||||
}
|
||||
|
||||
let now = Local::now();
|
||||
@ -117,70 +170,16 @@ pub fn all_stats(_auth: Authorized, db: State<'_, sled::Db>) -> Result<Template,
|
||||
let categories_tree = db.open_tree(category::NAME)?;
|
||||
let sessions_tree = db.open_tree(session::NAME)?;
|
||||
|
||||
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<_, _>, _>>()??;
|
||||
let categories = category::get_all(&categories_tree)?;
|
||||
let sessions = session::get_all(&sessions_tree)?;
|
||||
|
||||
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 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<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]
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user