Display category children & Add calendar stats
This commit is contained in:
10
Cargo.lock
generated
10
Cargo.lock
generated
@ -1030,6 +1030,15 @@ version = "2.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47be2f14c678be2fdcab04ab1171db51b2762ce6f0a8ee87c8dd4a04ed216135"
|
checksum = "47be2f14c678be2fdcab04ab1171db51b2762ce6f0a8ee87c8dd4a04ed216135"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "37d572918e350e82412fe766d24b15e6682fb2ed2bbe018280caa810397cb319"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "0.4.7"
|
version = "0.4.7"
|
||||||
@ -2165,6 +2174,7 @@ dependencies = [
|
|||||||
"duplicate",
|
"duplicate",
|
||||||
"futures",
|
"futures",
|
||||||
"handlebars",
|
"handlebars",
|
||||||
|
"itertools",
|
||||||
"log",
|
"log",
|
||||||
"rocket",
|
"rocket",
|
||||||
"rocket_contrib",
|
"rocket_contrib",
|
||||||
|
|||||||
@ -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,
|
|
||||||
}
|
|
||||||
@ -13,7 +13,7 @@ pub mod trees {
|
|||||||
|
|
||||||
pub type K = Uuid;
|
pub type K = Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub struct V {
|
pub struct V {
|
||||||
/// The name of the category
|
/// The name of the category
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@ -44,7 +44,7 @@ pub mod trees {
|
|||||||
|
|
||||||
pub type K = Uuid;
|
pub type K = Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub struct V {
|
pub struct V {
|
||||||
/// The UUID of the category to which this session belongs
|
/// The UUID of the category to which this session belongs
|
||||||
pub category: trees::category::K,
|
pub category: trees::category::K,
|
||||||
|
|||||||
@ -20,6 +20,7 @@ uuid = { version = "0.8", features = ["serde", "v4"] }
|
|||||||
duplicate = "0.2"
|
duplicate = "0.2"
|
||||||
bincode = "1"
|
bincode = "1"
|
||||||
handlebars = "3"
|
handlebars = "3"
|
||||||
|
itertools = "0.10.0"
|
||||||
|
|
||||||
[dependencies.stl_lib]
|
[dependencies.stl_lib]
|
||||||
path = "../lib"
|
path = "../lib"
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
pub mod unversioned;
|
|
||||||
pub mod util;
|
|
||||||
pub mod v1;
|
|
||||||
pub use stl_lib::v2;
|
|
||||||
|
|
||||||
pub mod migrations;
|
pub mod migrations;
|
||||||
|
pub mod unversioned;
|
||||||
|
pub mod v1;
|
||||||
|
pub mod v2;
|
||||||
|
|
||||||
pub const SCHEMA_VERSION: unversioned::global::schema_version::V = 2;
|
pub const SCHEMA_VERSION: unversioned::global::schema_version::V = 2;
|
||||||
pub use v2 as latest;
|
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 crate::status_json::StatusJson;
|
||||||
pub(self) use serde_derive::{Deserialize, Serialize};
|
pub(self) use bincode::{deserialize, serialize};
|
||||||
pub(self) use uuid::Uuid;
|
pub(self) use rocket::http::Status;
|
||||||
|
pub(self) use std::collections::HashMap;
|
||||||
|
|
||||||
/// Stuff in the default namespace
|
/// Stuff in the default namespace
|
||||||
pub mod global {}
|
pub mod global {}
|
||||||
|
|
||||||
pub mod trees {
|
pub mod trees {
|
||||||
pub mod categories {
|
pub mod category {
|
||||||
use super::super::*;
|
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 fn put(tree: &sled::Tree, key: &K, val: &V) -> Result<(), StatusJson> {
|
||||||
pub struct V {
|
tree.insert(serialize(key)?, serialize(val)?)?;
|
||||||
/// The name of the category
|
Ok(())
|
||||||
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 mod sessions {
|
pub mod session {
|
||||||
use super::super::*;
|
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;
|
pub fn get_all(tree: &sled::Tree) -> Result<HashMap<K, V>, StatusJson> {
|
||||||
|
Ok(tree
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
.iter()
|
||||||
pub struct V {
|
.map(|result| {
|
||||||
/// The UUID of the category to which this session belongs
|
result
|
||||||
pub category: trees::categories::K,
|
.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v))))
|
||||||
|
})
|
||||||
/// The time when this session was started
|
.collect::<Result<Result<_, _>, _>>()??)
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,19 @@ pub fn register_helpers(engines: &mut Engines) {
|
|||||||
.handlebars
|
.handlebars
|
||||||
.register_helper("pretty_datetime", Box::new(pretty_datetime));
|
.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| {
|
handlebars_helper!(pretty_seconds: |secs: u64| {
|
||||||
let hours = secs / 60 / 60;
|
let hours = secs / 60 / 60;
|
||||||
let minutes = secs / 60 % 60;
|
let minutes = secs / 60 % 60;
|
||||||
|
|||||||
@ -69,12 +69,17 @@ async fn main() -> io::Result<()> {
|
|||||||
.mount(
|
.mount(
|
||||||
"/api",
|
"/api",
|
||||||
rocket::routes![
|
rocket::routes![
|
||||||
routes::api::edit_session,
|
routes::api::category::get,
|
||||||
routes::api::get_sessions,
|
routes::api::category::get_all,
|
||||||
routes::api::create_category,
|
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::start_session,
|
||||||
routes::api::end_session,
|
routes::api::end_session,
|
||||||
routes::api::bump_session,
|
routes::api::bump_session,
|
||||||
|
routes::api::edit_session,
|
||||||
routes::api::delete_session,
|
routes::api::delete_session,
|
||||||
routes::api::wait_for_event,
|
routes::api::wait_for_event,
|
||||||
auth::login,
|
auth::login,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
|
pub mod category;
|
||||||
|
|
||||||
use crate::auth::Authorized;
|
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::routes::pages;
|
||||||
use crate::status_json::StatusJson;
|
use crate::status_json::StatusJson;
|
||||||
use crate::util::EventNotifier;
|
use crate::util::EventNotifier;
|
||||||
@ -13,48 +13,8 @@ use rocket::{get, post, uri, State};
|
|||||||
use rocket_contrib::json::Json;
|
use rocket_contrib::json::Json;
|
||||||
use rocket_contrib::uuid::Uuid;
|
use rocket_contrib::uuid::Uuid;
|
||||||
use sled::Transactional;
|
use sled::Transactional;
|
||||||
use std::collections::HashMap;
|
|
||||||
use stl_lib::wfe::WaitForEvent;
|
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>")]
|
#[post("/category/<category_uuid>/bump_session/minutes/<minutes>")]
|
||||||
pub fn bump_session(
|
pub fn bump_session(
|
||||||
_auth: Authorized,
|
_auth: Authorized,
|
||||||
@ -62,14 +22,16 @@ pub fn bump_session(
|
|||||||
minutes: i64,
|
minutes: i64,
|
||||||
db: State<'_, sled::Db>,
|
db: State<'_, sled::Db>,
|
||||||
) -> Result<Redirect, StatusJson> {
|
) -> Result<Redirect, StatusJson> {
|
||||||
|
use crate::database::latest::trees::category;
|
||||||
|
|
||||||
let duration = Duration::minutes(minutes);
|
let duration = Duration::minutes(minutes);
|
||||||
let category_uuid_s = sled::IVec::from(serialize(&category_uuid.into_inner())?);
|
let category_uuid_s = sled::IVec::from(serialize(&category_uuid.into_inner())?);
|
||||||
|
|
||||||
let categories_tree = db.open_tree(category::NAME)?;
|
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)? {
|
match tx_categories.get(&category_uuid_s)? {
|
||||||
None => return Ok(Err(Status::NotFound.into())),
|
None => Ok(Err(Status::NotFound.into())),
|
||||||
Some(data) => {
|
Some(data) => {
|
||||||
let mut category: category::V = deserialize(&data).unwrap();
|
let mut category: category::V = deserialize(&data).unwrap();
|
||||||
match category.started.as_mut() {
|
match category.started.as_mut() {
|
||||||
@ -87,16 +49,14 @@ pub fn bump_session(
|
|||||||
|
|
||||||
Ok(Ok(Redirect::to(uri!(pages::index))))
|
Ok(Ok(Redirect::to(uri!(pages::index))))
|
||||||
}
|
}
|
||||||
None => {
|
None => Ok(Err(StatusJson::new(
|
||||||
return Ok(Err(StatusJson::new(
|
Status::BadRequest,
|
||||||
Status::BadRequest,
|
"No active session",
|
||||||
"No active session",
|
))),
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})??)
|
})?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/category/<category_uuid>/start_session")]
|
#[post("/category/<category_uuid>/start_session")]
|
||||||
@ -125,6 +85,8 @@ pub fn toggle_category_session(
|
|||||||
event_notifier: State<'_, EventNotifier>,
|
event_notifier: State<'_, EventNotifier>,
|
||||||
db: State<'_, sled::Db>,
|
db: State<'_, sled::Db>,
|
||||||
) -> Result<StatusJson, StatusJson> {
|
) -> Result<StatusJson, StatusJson> {
|
||||||
|
use crate::database::latest::trees::{category, session};
|
||||||
|
|
||||||
let category_uuid_s = sled::IVec::from(serialize(&category_uuid.into_inner())?);
|
let category_uuid_s = sled::IVec::from(serialize(&category_uuid.into_inner())?);
|
||||||
|
|
||||||
let categories_tree = db.open_tree(category::NAME)?;
|
let categories_tree = db.open_tree(category::NAME)?;
|
||||||
@ -187,6 +149,8 @@ pub fn edit_session(
|
|||||||
session: Form<EditSession>,
|
session: Form<EditSession>,
|
||||||
db: State<'_, sled::Db>,
|
db: State<'_, sled::Db>,
|
||||||
) -> Result<Redirect, StatusJson> {
|
) -> Result<Redirect, StatusJson> {
|
||||||
|
use crate::database::latest::trees::session;
|
||||||
|
|
||||||
let session_uuid_s = sled::IVec::from(serialize(&session_uuid.into_inner())?);
|
let session_uuid_s = sled::IVec::from(serialize(&session_uuid.into_inner())?);
|
||||||
|
|
||||||
let session = session::V {
|
let session = session::V {
|
||||||
@ -227,6 +191,8 @@ pub fn delete_session(
|
|||||||
session_uuid: Uuid,
|
session_uuid: Uuid,
|
||||||
db: State<'_, sled::Db>,
|
db: State<'_, sled::Db>,
|
||||||
) -> Result<Redirect, StatusJson> {
|
) -> Result<Redirect, StatusJson> {
|
||||||
|
use crate::database::latest::trees::session;
|
||||||
|
|
||||||
let session_uuid_s = sled::IVec::from(serialize(&session_uuid.into_inner())?);
|
let session_uuid_s = sled::IVec::from(serialize(&session_uuid.into_inner())?);
|
||||||
|
|
||||||
let sessions_tree = db.open_tree(session::NAME)?;
|
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::templates::Template;
|
||||||
use rocket_contrib::uuid::Uuid;
|
use rocket_contrib::uuid::Uuid;
|
||||||
use serde_derive::{Deserialize, Serialize};
|
use serde_derive::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[get("/")]
|
#[get("/")]
|
||||||
pub fn index(_auth: Authorized, db: State<'_, sled::Db>) -> Result<Template, StatusJson> {
|
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)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
struct TemplateContext {
|
struct TemplateContext {
|
||||||
categories: Vec<(category::K, category::V)>,
|
categories: BTreeMap<category::K, Node>,
|
||||||
}
|
}
|
||||||
|
|
||||||
let categories_tree = db.open_tree(category::NAME)?;
|
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 {
|
let context = TemplateContext {
|
||||||
categories: categories_tree
|
categories: top_level_nodes,
|
||||||
.iter()
|
|
||||||
.map(|result| {
|
|
||||||
result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v))))
|
|
||||||
})
|
|
||||||
.collect::<Result<Result<_, _>, _>>()??,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Template::render("index", &context))
|
Ok(Template::render("index", &context))
|
||||||
@ -49,7 +105,7 @@ pub fn session_edit(
|
|||||||
|
|
||||||
let sessions_tree = db.open_tree(session::NAME)?;
|
let sessions_tree = db.open_tree(session::NAME)?;
|
||||||
match sessions_tree.get(session_uuid_s)? {
|
match sessions_tree.get(session_uuid_s)? {
|
||||||
None => Err(Status::NotFound)?,
|
None => Err(Status::NotFound.into()),
|
||||||
Some(data) => {
|
Some(data) => {
|
||||||
let context = SessionPageContext {
|
let context = SessionPageContext {
|
||||||
session: deserialize(&data).unwrap(),
|
session: deserialize(&data).unwrap(),
|
||||||
|
|||||||
@ -1,19 +1,18 @@
|
|||||||
use crate::auth::Authorized;
|
use crate::auth::Authorized;
|
||||||
use crate::database::latest::trees::{category, session};
|
use crate::database::latest::trees::{category, session};
|
||||||
use crate::database::util::category::get_category;
|
|
||||||
use crate::status_json::StatusJson;
|
use crate::status_json::StatusJson;
|
||||||
use bincode::deserialize;
|
use crate::util::OrdL;
|
||||||
use chrono::{DateTime, Local, Timelike};
|
use chrono::{Date, DateTime, Datelike, Duration, Local, Timelike};
|
||||||
|
use itertools::Itertools;
|
||||||
use rocket::http::Status;
|
use rocket::http::Status;
|
||||||
use rocket::{get, State};
|
use rocket::{get, State};
|
||||||
use rocket_contrib::templates::Template;
|
use rocket_contrib::templates::Template;
|
||||||
use rocket_contrib::uuid::Uuid;
|
use rocket_contrib::uuid::Uuid;
|
||||||
use serde_derive::{Deserialize, Serialize};
|
use serde_derive::{Deserialize, Serialize};
|
||||||
use std::cmp::Ordering;
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::collections::{BTreeMap, HashMap};
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
struct CategoryStatsContext {
|
struct CategoryStatsCtx {
|
||||||
category_id: category::K,
|
category_id: category::K,
|
||||||
category: category::V,
|
category: category::V,
|
||||||
|
|
||||||
@ -24,8 +23,12 @@ struct CategoryStatsContext {
|
|||||||
|
|
||||||
bars_max: f64,
|
bars_max: f64,
|
||||||
bars: Vec<(u32, f64, 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 {
|
fn sum_sessions<'a>(iter: impl IntoIterator<Item = &'a session::V>) -> u64 {
|
||||||
iter.into_iter()
|
iter.into_iter()
|
||||||
.map(|session| session.ended - session.started)
|
.map(|session| session.ended - session.started)
|
||||||
@ -33,35 +36,69 @@ fn sum_sessions<'a>(iter: impl IntoIterator<Item = &'a session::V>) -> u64 {
|
|||||||
.sum()
|
.sum()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/stats/<category_uuid>")]
|
/// Generate a flat child-map
|
||||||
pub fn single_stats(
|
///
|
||||||
_auth: Authorized,
|
/// The result is a map from category to *every* category below it in the tree
|
||||||
category_uuid: Uuid,
|
fn calculate_child_map(categories: &HashMap<category::K, category::V>) -> ChildMap {
|
||||||
db: State<'_, sled::Db>,
|
let mut child_map: ChildMap = HashMap::new();
|
||||||
) -> Result<Template, StatusJson> {
|
|
||||||
let categories_tree = db.open_tree(category::NAME)?;
|
|
||||||
let sessions_tree = db.open_tree(session::NAME)?;
|
|
||||||
|
|
||||||
let category: category::V =
|
const RECURSION_LIMIT: usize = 255;
|
||||||
get_category(&categories_tree, &category_uuid)?.ok_or(Status::NotFound)?;
|
|
||||||
|
|
||||||
let sessions: HashMap<session::K, session::V> = sessions_tree
|
let mut last_len = 0;
|
||||||
.iter()
|
let mut new_child_buffer = vec![];
|
||||||
.map(|result| {
|
|
||||||
result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v))))
|
|
||||||
})
|
|
||||||
.collect::<Result<Result<_, _>, _>>()??;
|
|
||||||
|
|
||||||
let my_sessions = sessions
|
// make sure we don't recurse forever
|
||||||
.values()
|
// if RECURSION_LIMIT is less than the tree depth, the resulting map will be incomplete
|
||||||
.filter(|session| session.category == *category_uuid);
|
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 last_session = my_sessions.clone().max_by_key(|session| &session.started);
|
||||||
|
|
||||||
let secs_last_session = sum_sessions(last_session);
|
let secs_last_session = sum_sessions(last_session);
|
||||||
|
|
||||||
let now = Local::now();
|
|
||||||
|
|
||||||
let secs_last_week = sum_sessions(
|
let secs_last_week = sum_sessions(
|
||||||
my_sessions
|
my_sessions
|
||||||
.clone()
|
.clone()
|
||||||
@ -74,18 +111,14 @@ pub fn single_stats(
|
|||||||
.filter(|session| (now - session.started) <= chrono::Duration::days(30)),
|
.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
|
let biggest_hour = *stats_per_hour
|
||||||
.values()
|
.values()
|
||||||
.max_by(|f1, f2| match () {
|
.max_by_key(|&f| OrdL(f))
|
||||||
_ if f1 == f2 => Ordering::Equal,
|
|
||||||
_ if f1 > f2 => Ordering::Greater,
|
|
||||||
_ => Ordering::Less,
|
|
||||||
})
|
|
||||||
.unwrap_or(&1.0);
|
.unwrap_or(&1.0);
|
||||||
|
|
||||||
let context = CategoryStatsContext {
|
CategoryStatsCtx {
|
||||||
category_id: *category_uuid,
|
category_id,
|
||||||
category,
|
category,
|
||||||
|
|
||||||
last_session_start: last_session.map(|session| session.started),
|
last_session_start: last_session.map(|session| session.started),
|
||||||
@ -100,16 +133,36 @@ pub fn single_stats(
|
|||||||
(hour, percentage, biggest_hour - percentage)
|
(hour, percentage, biggest_hour - percentage)
|
||||||
})
|
})
|
||||||
.collect(),
|
.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")]
|
#[get("/stats")]
|
||||||
pub fn all_stats(_auth: Authorized, db: State<'_, sled::Db>) -> Result<Template, StatusJson> {
|
pub fn all_stats(_auth: Authorized, db: State<'_, sled::Db>) -> Result<Template, StatusJson> {
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
struct StatsContext {
|
struct StatsContext {
|
||||||
categories_stats: Vec<CategoryStatsContext>,
|
categories_stats: Vec<CategoryStatsCtx>,
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = Local::now();
|
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 categories_tree = db.open_tree(category::NAME)?;
|
||||||
let sessions_tree = db.open_tree(session::NAME)?;
|
let sessions_tree = db.open_tree(session::NAME)?;
|
||||||
|
|
||||||
let categories: HashMap<category::K, category::V> = categories_tree
|
let categories = category::get_all(&categories_tree)?;
|
||||||
.iter()
|
let sessions = session::get_all(&sessions_tree)?;
|
||||||
.map(|result| {
|
|
||||||
result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v))))
|
|
||||||
})
|
|
||||||
.collect::<Result<Result<_, _>, _>>()??;
|
|
||||||
|
|
||||||
let sessions: HashMap<session::K, session::V> = sessions_tree
|
let child_map = calculate_child_map(&categories);
|
||||||
.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
|
let mut categories_stats: Vec<_> = categories
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
.filter(|(_, category)| !category.deleted)
|
||||||
.map(|(category_id, category)| {
|
.map(|(category_id, category)| {
|
||||||
let my_sessions = sessions
|
category_stats_ctx(now, category_id, category, &sessions, &child_map)
|
||||||
.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(),
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@ -191,6 +190,44 @@ pub fn all_stats(_auth: Authorized, db: State<'_, sled::Db>) -> Result<Template,
|
|||||||
Ok(Template::render("stats_all", &context))
|
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>
|
fn compute_percentage_per_hour<'a, I>(sessions: I) -> BTreeMap<u32, f64>
|
||||||
where
|
where
|
||||||
I: Iterator<Item = &'a session::V>,
|
I: Iterator<Item = &'a session::V>,
|
||||||
@ -247,10 +284,115 @@ where
|
|||||||
stats_per_hour
|
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)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::database::latest::trees::sessions;
|
use crate::database::latest::trees::session;
|
||||||
use chrono::{DateTime, Local};
|
use chrono::{DateTime, Local};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
use std::cmp::{Eq, Ord, Ordering, PartialEq, PartialOrd};
|
||||||
use tokio::sync::Notify;
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
pub struct EventNotifier {
|
pub struct EventNotifier {
|
||||||
@ -19,3 +20,30 @@ impl EventNotifier {
|
|||||||
self.notify.notify_waiters();
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
43
server/static/styles/calendar.css
Normal file
43
server/static/styles/calendar.css
Normal 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;
|
||||||
|
}
|
||||||
@ -1,43 +1,4 @@
|
|||||||
/* CSS rules for charts/plots */
|
/* 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 {
|
.chart_histogram {
|
||||||
height: 250px; /* TODO: possibly remove this */
|
height: 250px; /* TODO: possibly remove this */
|
||||||
padding: 0.75em;
|
padding: 0.75em;
|
||||||
|
|||||||
@ -28,21 +28,40 @@ a:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ul.striped_list {
|
ul.striped_list {
|
||||||
max-width: 40em;
|
max-width: 40rem;
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul.striped_list > li:nth-of-type(odd) {
|
ul.striped_list > li:nth-child(even) { background-color:#302f3b }
|
||||||
background-color: #3f4a53;
|
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_entry {
|
||||||
|
}
|
||||||
|
|
||||||
|
.category_header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
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 {
|
.category_name {
|
||||||
font-size: 3em;
|
font-size: 3em;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
@ -70,11 +89,11 @@ ul.striped_list > li:nth-of-type(odd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.category_button_container {
|
.category_button_container {
|
||||||
margin: 0.1em;
|
margin: 0.1rem;
|
||||||
background-color: #a4829c;
|
background-color: #a4829c;
|
||||||
border-radius: 3.5em;
|
border-radius: 3.5rem;
|
||||||
width: 7em;
|
width: 7rem;
|
||||||
height: 7em;
|
height: 7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category_button {
|
.category_button {
|
||||||
@ -131,3 +150,43 @@ ul.striped_list > li:nth-of-type(odd) {
|
|||||||
background-color: #aaaaaa;
|
background-color: #aaaaaa;
|
||||||
margin: auto;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
31
server/templates/category_entry.hbs
Normal file
31
server/templates/category_entry.hbs
Normal 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>
|
||||||
@ -2,10 +2,12 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<meta name="description" content="">
|
<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="icon" type="image/svg+xml" href="/static/icon.svg">
|
||||||
<link rel="stylesheet" href="/static/styles/common.css">
|
<link rel="stylesheet" href="/static/styles/common.css">
|
||||||
<link rel="stylesheet" href="/static/styles/charts.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">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Ubuntu|Ubuntu+Mono&display=swap">
|
||||||
|
|
||||||
<title>stl</title>
|
<title>stl</title>
|
||||||
|
|||||||
@ -34,29 +34,8 @@
|
|||||||
|
|
||||||
<ul class="striped_list">
|
<ul class="striped_list">
|
||||||
{{#each categories}}
|
{{#each categories}}
|
||||||
<li class="category_entry">
|
{{>category_entry}}
|
||||||
<div class="category_icon"
|
{{/each}}
|
||||||
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>
|
|
||||||
{{/each}}
|
|
||||||
</ul>
|
</ul>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -17,14 +17,39 @@
|
|||||||
<span>Senaste månaden:</span>
|
<span>Senaste månaden:</span>
|
||||||
<span class="history_entry_duration">{{pretty_seconds secs_last_month}}</span>
|
<span class="history_entry_duration">{{pretty_seconds secs_last_month}}</span>
|
||||||
</h2>
|
</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>
|
<h2>Andel per timme:</h2>
|
||||||
<div class="chart_histogram">
|
<div class="chart_histogram">
|
||||||
<div class="chart_histogram_legend">{{bars_max}}%</div>
|
<div class="chart_histogram_legend">{{bars_max}}%</div>
|
||||||
{{#each bars}}
|
{{#each bars}}
|
||||||
<div class="chart_histogram_col">
|
<div class="chart_histogram_col">
|
||||||
<div style="flex-grow: {{this.2}};"></div>
|
<div style="flex-grow: {{this.2}};"></div>
|
||||||
<div class="chart_histogram_col_line chart_col_tooltip" style="flex-grow: {{this.1}};">
|
<div class="chart_histogram_col_line tooltip" style="flex-grow: {{this.1}};">
|
||||||
<span class="chart_col_tooltiptext">{{this.1}}%</span>
|
<span class="tooltiptext">{{this.1}}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart_histogram_col_label">{{this.0}}</div>
|
<div class="chart_histogram_col_label">{{this.0}}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user