Add cli & lib crates
This commit is contained in:
2281
server/Cargo.lock
generated
Normal file
2281
server/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
server/Cargo.toml
Normal file
41
server/Cargo.toml
Normal file
@ -0,0 +1,41 @@
|
||||
[package]
|
||||
name = "stl"
|
||||
description = "studielogg aka scuffed toggl"
|
||||
version = "2.5.0"
|
||||
authors = ["Joakim Hulthe <joakim@hulthe.net>"]
|
||||
license = "MPL-2.0"
|
||||
edition = "2018"
|
||||
|
||||
[dependencies]
|
||||
dotenv = "0.13.0"
|
||||
serde = "1"
|
||||
serde_json = "1"
|
||||
serde_derive = "1"
|
||||
log = "0.4.8"
|
||||
futures = "0.3"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
sled = "0.34"
|
||||
semver = "0.11"
|
||||
uuid = { version = "0.8", features = ["serde", "v4"] }
|
||||
duplicate = "0.2"
|
||||
bincode = "1"
|
||||
handlebars = "3"
|
||||
|
||||
[dependencies.stl_lib]
|
||||
path = "../lib"
|
||||
|
||||
[dependencies.tokio]
|
||||
version = "1"
|
||||
features = ["sync", "time"]
|
||||
|
||||
[dependencies.rocket]
|
||||
#version = "0.4"
|
||||
git = "https://github.com/SergioBenitez/Rocket"
|
||||
branch = "master"
|
||||
features = ["secrets"]
|
||||
|
||||
[dependencies.rocket_contrib]
|
||||
#version = "0.4"
|
||||
git = "https://github.com/SergioBenitez/Rocket"
|
||||
branch = "master"
|
||||
features = ["handlebars_templates", "uuid"]
|
||||
4
server/database/conf
Normal file
4
server/database/conf
Normal file
@ -0,0 +1,4 @@
|
||||
segment_size: 524288
|
||||
use_compression: false
|
||||
version: 0.34
|
||||
vQ<>
|
||||
BIN
server/database/db
Normal file
BIN
server/database/db
Normal file
Binary file not shown.
BIN
server/database/snap.0000000000014880
Normal file
BIN
server/database/snap.0000000000014880
Normal file
Binary file not shown.
16
server/example.env
Normal file
16
server/example.env
Normal file
@ -0,0 +1,16 @@
|
||||
DB_PATH=./database
|
||||
|
||||
# Tests are required to run sequentially so that the database state won't interfere
|
||||
# TODO: we don't have tests yet :)
|
||||
#RUST_TEST_THREADS=1
|
||||
|
||||
## Rocket Web Server Configuration ##
|
||||
#ROCKET_ADDRESS="localhost"
|
||||
#ROCKET_PORT=8000
|
||||
#ROCKET_WORKERS=[number of cpus * 2]
|
||||
#ROCKET_LOG="normal"
|
||||
#ROCKET_SECRET_KEY=[randomly generated at launch]
|
||||
#ROCKET_LIMITS="{ forms = 32768 }"
|
||||
ROCKET_TEMPLATE_DIR="templates"
|
||||
## =============================== ##
|
||||
|
||||
63
server/src/auth.rs
Normal file
63
server/src/auth.rs
Normal file
@ -0,0 +1,63 @@
|
||||
use crate::routes::pages;
|
||||
use rocket::{
|
||||
catch,
|
||||
form::{Form, FromForm},
|
||||
http::{Cookie, CookieJar, Status},
|
||||
post,
|
||||
request::{FromRequest, Outcome, Request},
|
||||
response::Redirect,
|
||||
uri, State,
|
||||
};
|
||||
use rocket_contrib::templates::Template;
|
||||
|
||||
pub struct MasterPassword(String);
|
||||
impl From<String> for MasterPassword {
|
||||
fn from(pass: String) -> Self {
|
||||
Self(pass)
|
||||
}
|
||||
}
|
||||
|
||||
const AUTH_COOKIE_KEY: &str = "authorized";
|
||||
const AUTH_COOKIE_VAL: &str = "true";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Authorized;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Unauthorized;
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'a> FromRequest<'a> for Authorized {
|
||||
type Error = Unauthorized;
|
||||
|
||||
async fn from_request(request: &'a Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let cookies = request.cookies();
|
||||
|
||||
match cookies.get_private(AUTH_COOKIE_KEY) {
|
||||
Some(cookie) if cookie.value() == AUTH_COOKIE_VAL => Outcome::Success(Authorized),
|
||||
_ => Outcome::Failure((Status::Unauthorized, Unauthorized)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[catch(401)]
|
||||
pub fn login_page(_req: &Request) -> Template {
|
||||
Template::render("login", &())
|
||||
}
|
||||
|
||||
#[derive(FromForm)]
|
||||
pub struct Login {
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[post("/login", data = "<login>")]
|
||||
pub fn login(
|
||||
cookies: &CookieJar,
|
||||
login: Form<Login>,
|
||||
master_pass: State<MasterPassword>,
|
||||
) -> Redirect {
|
||||
if login.password == master_pass.0 {
|
||||
cookies.add_private(Cookie::new(AUTH_COOKIE_KEY, AUTH_COOKIE_VAL));
|
||||
}
|
||||
Redirect::to(uri!(pages::index))
|
||||
}
|
||||
43
server/src/database/migrations/mod.rs
Normal file
43
server/src/database/migrations/mod.rs
Normal file
@ -0,0 +1,43 @@
|
||||
pub mod v1_to_v2;
|
||||
|
||||
use crate::database::unversioned::global::schema_version;
|
||||
use duplicate::duplicate;
|
||||
use sled::Db;
|
||||
|
||||
pub fn migrate(
|
||||
db: &mut Db,
|
||||
from: schema_version::V,
|
||||
to: schema_version::V,
|
||||
) -> Result<(), MigrationError> {
|
||||
for current in from..to {
|
||||
let next = current + 1;
|
||||
|
||||
println!("Will migrate from {} to {}", current, next);
|
||||
match (current, next) {
|
||||
(1, 2) => v1_to_v2::migrate(db)?,
|
||||
_ => panic!(
|
||||
"No valid migration from version {} to version {}",
|
||||
current, next
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MigrationError {
|
||||
Serde(bincode::Error),
|
||||
Sled(sled::Error),
|
||||
}
|
||||
|
||||
#[duplicate(
|
||||
Variant Error;
|
||||
[ Sled ] [ sled::Error ];
|
||||
[ Serde ] [ bincode::Error ];
|
||||
)]
|
||||
impl From<Error> for MigrationError {
|
||||
fn from(e: Error) -> MigrationError {
|
||||
MigrationError::Variant(e)
|
||||
}
|
||||
}
|
||||
85
server/src/database/migrations/v1_to_v2.rs
Normal file
85
server/src/database/migrations/v1_to_v2.rs
Normal file
@ -0,0 +1,85 @@
|
||||
use super::MigrationError;
|
||||
use crate::database::v1;
|
||||
use crate::database::v2;
|
||||
use bincode::{deserialize, serialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
use sled::Db;
|
||||
|
||||
/// Migrate from database version 1 to version 2
|
||||
pub fn migrate(db: &mut Db) -> Result<(), MigrationError> {
|
||||
{
|
||||
// Migrate sessions
|
||||
|
||||
// Open old & new trees
|
||||
let v1_past_sessions = db.open_tree(v1::trees::past_sessions::NAME)?;
|
||||
let v2_sessions = db.open_tree(v2::trees::session::NAME)?;
|
||||
|
||||
// Iterate over old tree
|
||||
for r in v1_past_sessions.iter() {
|
||||
let (k, v) = r?;
|
||||
|
||||
// Deserialize old value
|
||||
let v1::trees::past_sessions::V {
|
||||
category,
|
||||
started,
|
||||
ended,
|
||||
} = deserialize(&v)?;
|
||||
|
||||
// Convert to new value
|
||||
let v2_value = v2::trees::session::V {
|
||||
category,
|
||||
started: DateTime::<Utc>::from_utc(started, Utc).into(),
|
||||
ended: DateTime::<Utc>::from_utc(ended, Utc).into(),
|
||||
deleted: false,
|
||||
};
|
||||
|
||||
// Insert into new tree
|
||||
v2_sessions.insert(k, serialize(&v2_value)?)?;
|
||||
}
|
||||
|
||||
// Remove old tree
|
||||
v1_past_sessions.clear()?;
|
||||
}
|
||||
|
||||
{
|
||||
// Migrate categories
|
||||
// Open the old tree, and a TMP tree since the old tree name hasn't changed
|
||||
let categories = db.open_tree(v1::trees::categories::NAME)?;
|
||||
let v2_categories_tmp = db.open_tree("TEMP")?;
|
||||
|
||||
// Iterate over old tree
|
||||
for r in categories.iter() {
|
||||
let (k, v) = r?;
|
||||
|
||||
// Deserialize old value
|
||||
let v1::trees::categories::V {
|
||||
name,
|
||||
color,
|
||||
started,
|
||||
} = deserialize(&v)?;
|
||||
|
||||
// Convert to new value
|
||||
let v2_value = v2::trees::category::V {
|
||||
name,
|
||||
description: None,
|
||||
color,
|
||||
started: started.map(|ndt| DateTime::<Utc>::from_utc(ndt, Utc).into()),
|
||||
parent: None,
|
||||
deleted: false,
|
||||
};
|
||||
|
||||
// Insert into temporary tree
|
||||
v2_categories_tmp.insert(k, serialize(&v2_value)?)?;
|
||||
}
|
||||
|
||||
// Copy data from temp-tree back into old tree
|
||||
categories.clear()?;
|
||||
for r in v2_categories_tmp.iter() {
|
||||
let (k, v) = r?;
|
||||
categories.insert(k, v)?;
|
||||
}
|
||||
v2_categories_tmp.clear()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
9
server/src/database/mod.rs
Normal file
9
server/src/database/mod.rs
Normal file
@ -0,0 +1,9 @@
|
||||
pub mod unversioned;
|
||||
pub mod util;
|
||||
pub mod v1;
|
||||
pub use stl_lib::v2;
|
||||
|
||||
pub mod migrations;
|
||||
|
||||
pub const SCHEMA_VERSION: unversioned::global::schema_version::V = 2;
|
||||
pub use v2 as latest;
|
||||
6
server/src/database/unversioned.rs
Normal file
6
server/src/database/unversioned.rs
Normal file
@ -0,0 +1,6 @@
|
||||
pub mod global {
|
||||
pub mod schema_version {
|
||||
pub const K: &str = "SCHEMA_VERSION";
|
||||
pub type V = u32;
|
||||
}
|
||||
}
|
||||
25
server/src/database/util/category.rs
Normal file
25
server/src/database/util/category.rs
Normal file
@ -0,0 +1,25 @@
|
||||
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
server/src/database/util/mod.rs
Normal file
1
server/src/database/util/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod category;
|
||||
50
server/src/database/v1.rs
Normal file
50
server/src/database/v1.rs
Normal file
@ -0,0 +1,50 @@
|
||||
#![allow(dead_code)] // old schema, not used anymore
|
||||
|
||||
pub(self) use chrono::NaiveDateTime;
|
||||
pub(self) use serde_derive::{Deserialize, Serialize};
|
||||
pub(self) use uuid::Uuid;
|
||||
|
||||
/// Stuff in the default namespace
|
||||
pub mod global {}
|
||||
|
||||
pub mod trees {
|
||||
pub mod categories {
|
||||
use super::super::*;
|
||||
|
||||
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 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<NaiveDateTime>,
|
||||
}
|
||||
}
|
||||
|
||||
pub mod past_sessions {
|
||||
use super::super::*;
|
||||
|
||||
pub const NAME: &str = "PAST_SESSIONS";
|
||||
|
||||
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: NaiveDateTime,
|
||||
|
||||
/// The time when this session was ended
|
||||
pub ended: NaiveDateTime,
|
||||
}
|
||||
}
|
||||
}
|
||||
63
server/src/database/v2.rs
Normal file
63
server/src/database/v2.rs
Normal file
@ -0,0 +1,63 @@
|
||||
pub(self) use chrono::{DateTime, Local};
|
||||
pub(self) use serde_derive::{Deserialize, Serialize};
|
||||
pub(self) use uuid::Uuid;
|
||||
|
||||
/// Stuff in the default namespace
|
||||
pub mod global {}
|
||||
|
||||
pub mod trees {
|
||||
pub mod categories {
|
||||
use super::super::*;
|
||||
|
||||
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 mod sessions {
|
||||
use super::super::*;
|
||||
|
||||
pub const NAME: &str = "SESSIONS";
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
28
server/src/handlebars_util.rs
Normal file
28
server/src/handlebars_util.rs
Normal file
@ -0,0 +1,28 @@
|
||||
use chrono::{DateTime, Local};
|
||||
use handlebars::handlebars_helper;
|
||||
use rocket_contrib::templates::Engines;
|
||||
|
||||
pub fn register_helpers(engines: &mut Engines) {
|
||||
handlebars_helper!(pretty_datetime: |dt: str| {
|
||||
let dt: DateTime<Local> = dt.parse().unwrap();
|
||||
format!("{}", dt.format("%Y-%m-%d %H:%M"))
|
||||
});
|
||||
engines
|
||||
.handlebars
|
||||
.register_helper("pretty_datetime", Box::new(pretty_datetime));
|
||||
|
||||
handlebars_helper!(pretty_seconds: |secs: u64| {
|
||||
let hours = secs / 60 / 60;
|
||||
let minutes = secs / 60 % 60;
|
||||
if hours == 0 {
|
||||
format!("{}m", minutes)
|
||||
} else if minutes == 0 {
|
||||
format!("{}h", hours)
|
||||
} else {
|
||||
format!("{}h {}m", hours, minutes)
|
||||
}
|
||||
});
|
||||
engines
|
||||
.handlebars
|
||||
.register_helper("pretty_seconds", Box::new(pretty_seconds));
|
||||
}
|
||||
88
server/src/main.rs
Normal file
88
server/src/main.rs
Normal file
@ -0,0 +1,88 @@
|
||||
mod auth;
|
||||
mod database;
|
||||
mod handlebars_util;
|
||||
mod routes;
|
||||
mod status_json;
|
||||
mod util;
|
||||
|
||||
use crate::auth::MasterPassword;
|
||||
use crate::database::migrations::migrate;
|
||||
use crate::database::unversioned::global::schema_version;
|
||||
use crate::database::SCHEMA_VERSION;
|
||||
use crate::util::EventNotifier;
|
||||
use bincode::{deserialize, serialize};
|
||||
use dotenv::dotenv;
|
||||
use rocket_contrib::serve::StaticFiles;
|
||||
use rocket_contrib::templates::Template;
|
||||
use std::{env, io};
|
||||
|
||||
#[rocket::main]
|
||||
async fn main() -> io::Result<()> {
|
||||
dotenv().ok();
|
||||
|
||||
let db_path = env::var("DB_PATH").expect("DB_PATH not set");
|
||||
|
||||
let master_pass: MasterPassword = env::var("STL_PASSWORD")
|
||||
.unwrap_or_else(|_| {
|
||||
eprintln!(r#"STL_PASSWORD not set, defaulting to "password""#);
|
||||
"password".into()
|
||||
})
|
||||
.into();
|
||||
|
||||
let mut sled = sled::open(db_path)?;
|
||||
match sled.insert(
|
||||
serialize(schema_version::K).unwrap(),
|
||||
serialize(&SCHEMA_VERSION).unwrap(),
|
||||
)? {
|
||||
Some(prev_schema_version) => {
|
||||
let prev_schema_version: schema_version::V = deserialize(&prev_schema_version).unwrap();
|
||||
println!(
|
||||
"Schema version: {}, previously: {}",
|
||||
SCHEMA_VERSION, prev_schema_version
|
||||
);
|
||||
|
||||
migrate(&mut sled, prev_schema_version, SCHEMA_VERSION).expect("Migration failed")
|
||||
}
|
||||
None => {
|
||||
println!("Schema version: {}, previously: None", SCHEMA_VERSION);
|
||||
}
|
||||
}
|
||||
|
||||
let rocket = rocket::build()
|
||||
.attach(Template::custom(|engines| {
|
||||
handlebars_util::register_helpers(engines)
|
||||
}))
|
||||
.manage(sled)
|
||||
.manage(master_pass)
|
||||
.manage(EventNotifier::new())
|
||||
.mount("/static", StaticFiles::from("static"))
|
||||
.mount(
|
||||
"/",
|
||||
rocket::routes![
|
||||
routes::pages::index,
|
||||
routes::pages::history,
|
||||
routes::pages::session_edit,
|
||||
routes::pages::stats::single_stats,
|
||||
routes::pages::stats::all_stats,
|
||||
],
|
||||
)
|
||||
.mount(
|
||||
"/api",
|
||||
rocket::routes![
|
||||
routes::api::edit_session,
|
||||
routes::api::get_sessions,
|
||||
routes::api::create_category,
|
||||
routes::api::start_session,
|
||||
routes::api::end_session,
|
||||
routes::api::bump_session,
|
||||
routes::api::delete_session,
|
||||
routes::api::wait_for_event,
|
||||
auth::login,
|
||||
],
|
||||
)
|
||||
.register("/", rocket::catchers![auth::login_page,]);
|
||||
|
||||
rocket.launch().await.expect("rocket failed to launch");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
267
server/src/routes/api.rs
Normal file
267
server/src/routes/api.rs
Normal file
@ -0,0 +1,267 @@
|
||||
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;
|
||||
use bincode::{deserialize, serialize};
|
||||
use chrono::{Duration, Local, NaiveDateTime, TimeZone};
|
||||
use rocket::form::{Form, FromForm};
|
||||
use rocket::http::Status;
|
||||
use rocket::response::Redirect;
|
||||
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,
|
||||
category_uuid: Uuid,
|
||||
minutes: i64,
|
||||
db: State<'_, sled::Db>,
|
||||
) -> Result<Redirect, StatusJson> {
|
||||
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| {
|
||||
match tx_categories.get(&category_uuid_s)? {
|
||||
None => return Ok(Err(Status::NotFound.into())),
|
||||
Some(data) => {
|
||||
let mut category: category::V = deserialize(&data).unwrap();
|
||||
match category.started.as_mut() {
|
||||
Some(started) => {
|
||||
if let Some(new_started) = started.checked_sub_signed(duration) {
|
||||
*started = new_started;
|
||||
tx_categories
|
||||
.insert(&category_uuid_s, serialize(&category).unwrap())?;
|
||||
} else {
|
||||
return Ok(Err(StatusJson::new(
|
||||
Status::BadRequest,
|
||||
"Duration subtract resulted in overflow",
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Ok(Redirect::to(uri!(pages::index))))
|
||||
}
|
||||
None => {
|
||||
return Ok(Err(StatusJson::new(
|
||||
Status::BadRequest,
|
||||
"No active session",
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})??)
|
||||
}
|
||||
|
||||
#[post("/category/<category_uuid>/start_session")]
|
||||
pub fn start_session(
|
||||
_auth: Authorized,
|
||||
category_uuid: Uuid,
|
||||
event_notifier: State<'_, EventNotifier>,
|
||||
db: State<'_, sled::Db>,
|
||||
) -> Result<StatusJson, StatusJson> {
|
||||
toggle_category_session(category_uuid, true, event_notifier, db)
|
||||
}
|
||||
|
||||
#[post("/category/<category_uuid>/end_session")]
|
||||
pub fn end_session(
|
||||
_auth: Authorized,
|
||||
category_uuid: Uuid,
|
||||
event_notifier: State<'_, EventNotifier>,
|
||||
db: State<'_, sled::Db>,
|
||||
) -> Result<StatusJson, StatusJson> {
|
||||
toggle_category_session(category_uuid, false, event_notifier, db)
|
||||
}
|
||||
|
||||
pub fn toggle_category_session(
|
||||
category_uuid: Uuid,
|
||||
set_active: bool,
|
||||
event_notifier: State<'_, EventNotifier>,
|
||||
db: State<'_, sled::Db>,
|
||||
) -> Result<StatusJson, StatusJson> {
|
||||
let category_uuid_s = sled::IVec::from(serialize(&category_uuid.into_inner())?);
|
||||
|
||||
let categories_tree = db.open_tree(category::NAME)?;
|
||||
let sessions_tree = db.open_tree(session::NAME)?;
|
||||
|
||||
Ok(
|
||||
(&categories_tree, &sessions_tree).transaction(|(tx_categories, tx_sessions)| {
|
||||
match tx_categories.get(&category_uuid_s)? {
|
||||
None => return Ok(Err(Status::NotFound)),
|
||||
Some(data) => {
|
||||
let mut category: category::V = deserialize(&data).unwrap();
|
||||
let now = Local::now();
|
||||
|
||||
match (set_active, category.started.take()) {
|
||||
(false, Some(started)) => {
|
||||
// only save sessions longer than 5 minutes
|
||||
let duration = now - started;
|
||||
if duration > Duration::minutes(5) {
|
||||
let session_uuid = serialize(&uuid::Uuid::new_v4()).unwrap();
|
||||
let session = session::V {
|
||||
category: category_uuid.into_inner(),
|
||||
started,
|
||||
ended: now,
|
||||
deleted: category.deleted,
|
||||
};
|
||||
tx_sessions.insert(session_uuid, serialize(&session).unwrap())?;
|
||||
}
|
||||
}
|
||||
(true, None) => {
|
||||
category.started = Some(now);
|
||||
}
|
||||
_ => {
|
||||
// Category is already in the correct state
|
||||
return Ok(Ok(Status::Ok.into()));
|
||||
}
|
||||
}
|
||||
|
||||
tx_categories.insert(&category_uuid_s, serialize(&category).unwrap())?;
|
||||
event_notifier.notify_event();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Ok(Status::Ok.into()))
|
||||
})??,
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, FromForm)]
|
||||
pub struct EditSession {
|
||||
category: Uuid,
|
||||
started: String,
|
||||
ended: String,
|
||||
deleted: bool,
|
||||
}
|
||||
|
||||
#[post("/session/<session_uuid>/edit", data = "<session>")]
|
||||
pub fn edit_session(
|
||||
_auth: Authorized,
|
||||
session_uuid: Uuid,
|
||||
session: Form<EditSession>,
|
||||
db: State<'_, sled::Db>,
|
||||
) -> Result<Redirect, StatusJson> {
|
||||
let session_uuid_s = sled::IVec::from(serialize(&session_uuid.into_inner())?);
|
||||
|
||||
let session = session::V {
|
||||
category: session.category.into_inner(),
|
||||
started: Local
|
||||
.from_local_datetime(&NaiveDateTime::parse_from_str(
|
||||
&session.started,
|
||||
"%Y-%m-%d %H:%M",
|
||||
)?)
|
||||
.unwrap(),
|
||||
ended: Local
|
||||
.from_local_datetime(&NaiveDateTime::parse_from_str(
|
||||
&session.ended,
|
||||
"%Y-%m-%d %H:%M",
|
||||
)?)
|
||||
.unwrap(),
|
||||
deleted: session.deleted,
|
||||
};
|
||||
|
||||
if session.started >= session.ended {
|
||||
return Err(StatusJson::new(
|
||||
Status::BadRequest,
|
||||
"started must be earlier than ended",
|
||||
));
|
||||
}
|
||||
|
||||
db.open_tree(session::NAME)?
|
||||
.insert(session_uuid_s, serialize(&session)?)?;
|
||||
|
||||
// FIXME: Uuid does not implement FromUriParam for some reason... File an issue?
|
||||
//Ok(Redirect::to(uri!(pages::session_edit: session_uuid)))
|
||||
Ok(Redirect::to(format!("/session/{}/edit", session_uuid)))
|
||||
}
|
||||
|
||||
#[post("/session/<session_uuid>/delete")]
|
||||
pub fn delete_session(
|
||||
_auth: Authorized,
|
||||
session_uuid: Uuid,
|
||||
db: State<'_, sled::Db>,
|
||||
) -> Result<Redirect, StatusJson> {
|
||||
let session_uuid_s = sled::IVec::from(serialize(&session_uuid.into_inner())?);
|
||||
|
||||
let sessions_tree = db.open_tree(session::NAME)?;
|
||||
|
||||
match sessions_tree.remove(session_uuid_s)? {
|
||||
Some(_) => Ok(Redirect::to(uri!(pages::history))),
|
||||
None => Err(Status::NotFound.into()),
|
||||
}
|
||||
|
||||
// TODO: mark as deleted instead of removing
|
||||
// Ok(sessions_tree.transaction(|tx_sessions| {
|
||||
// match tx_sessions.get(&session_uuid_s)? {
|
||||
// None => return Ok(Err(Status::NotFound)),
|
||||
// Some(data) => {
|
||||
// let mut session: session::V = deserialize(&data).unwrap();
|
||||
// }
|
||||
// }
|
||||
// Ok(Ok(Redirect::to(uri!(pages::history))))
|
||||
// })??)
|
||||
}
|
||||
|
||||
#[get("/wait_for_event?<timeout>")]
|
||||
pub async fn wait_for_event(
|
||||
_auth: Authorized,
|
||||
timeout: Option<u64>,
|
||||
event_notifier: State<'_, EventNotifier>,
|
||||
) -> Json<WaitForEvent> {
|
||||
use std::time::Duration;
|
||||
match tokio::time::timeout(
|
||||
Duration::from_secs(timeout.unwrap_or(30)),
|
||||
event_notifier.wait_for_event(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(WaitForEvent { timeout: false }),
|
||||
Err(_) => Json(WaitForEvent { timeout: true }),
|
||||
}
|
||||
}
|
||||
2
server/src/routes/mod.rs
Normal file
2
server/src/routes/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod api;
|
||||
pub mod pages;
|
||||
113
server/src/routes/pages.rs
Normal file
113
server/src/routes/pages.rs
Normal file
@ -0,0 +1,113 @@
|
||||
pub mod stats;
|
||||
|
||||
use crate::auth::Authorized;
|
||||
use crate::database::latest::trees::{category, session};
|
||||
use crate::status_json::StatusJson;
|
||||
use bincode::{deserialize, serialize};
|
||||
use rocket::http::Status;
|
||||
use rocket::{get, State};
|
||||
use rocket_contrib::templates::Template;
|
||||
use rocket_contrib::uuid::Uuid;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
#[get("/")]
|
||||
pub fn index(_auth: Authorized, db: State<'_, sled::Db>) -> Result<Template, StatusJson> {
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct TemplateContext {
|
||||
categories: Vec<(category::K, category::V)>,
|
||||
}
|
||||
|
||||
let categories_tree = db.open_tree(category::NAME)?;
|
||||
|
||||
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<_, _>, _>>()??,
|
||||
};
|
||||
|
||||
Ok(Template::render("index", &context))
|
||||
}
|
||||
|
||||
#[get("/session/<session_uuid>/edit")]
|
||||
pub fn session_edit(
|
||||
_auth: Authorized,
|
||||
session_uuid: Uuid,
|
||||
db: State<'_, sled::Db>,
|
||||
) -> Result<Template, StatusJson> {
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct SessionPageContext {
|
||||
session: session::V,
|
||||
session_id: uuid::Uuid,
|
||||
}
|
||||
|
||||
let session_uuid_s = sled::IVec::from(serialize(&session_uuid.into_inner())?);
|
||||
|
||||
let sessions_tree = db.open_tree(session::NAME)?;
|
||||
match sessions_tree.get(session_uuid_s)? {
|
||||
None => Err(Status::NotFound)?,
|
||||
Some(data) => {
|
||||
let context = SessionPageContext {
|
||||
session: deserialize(&data).unwrap(),
|
||||
session_id: session_uuid.into_inner(),
|
||||
};
|
||||
|
||||
Ok(Template::render("edit_session", &context))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/history")]
|
||||
pub fn history(_auth: Authorized, db: State<'_, sled::Db>) -> Result<Template, StatusJson> {
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct HistoryEntryContext {
|
||||
category: category::V,
|
||||
session: session::V,
|
||||
session_id: uuid::Uuid,
|
||||
duration: Duration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct TemplateContext {
|
||||
entries: Vec<HistoryEntryContext>,
|
||||
}
|
||||
|
||||
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 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 context = TemplateContext {
|
||||
entries: sessions
|
||||
.into_iter()
|
||||
.map(|(session_id, session)| HistoryEntryContext {
|
||||
duration: (session.ended - session.started).to_std().unwrap(),
|
||||
category: categories.get(&session.category).unwrap().clone(),
|
||||
session,
|
||||
session_id,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
// Newest entries first
|
||||
context.entries.sort_by_key(|entry| entry.session.started);
|
||||
context.entries.reverse();
|
||||
|
||||
Ok(Template::render("history", &context))
|
||||
}
|
||||
299
server/src/routes/pages/stats.rs
Normal file
299
server/src/routes/pages/stats.rs
Normal file
@ -0,0 +1,299 @@
|
||||
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 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};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CategoryStatsContext {
|
||||
category_id: category::K,
|
||||
category: category::V,
|
||||
|
||||
last_session_start: Option<DateTime<Local>>,
|
||||
secs_last_session: u64,
|
||||
secs_last_week: u64,
|
||||
secs_last_month: u64,
|
||||
|
||||
bars_max: f64,
|
||||
bars: Vec<(u32, f64, f64)>,
|
||||
}
|
||||
|
||||
fn sum_sessions<'a>(iter: impl IntoIterator<Item = &'a session::V>) -> u64 {
|
||||
iter.into_iter()
|
||||
.map(|session| session.ended - session.started)
|
||||
.map(|duration| duration.num_seconds() as 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)?;
|
||||
|
||||
let category: category::V =
|
||||
get_category(&categories_tree, &category_uuid)?.ok_or(Status::NotFound)?;
|
||||
|
||||
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 my_sessions = sessions
|
||||
.values()
|
||||
.filter(|session| session.category == *category_uuid);
|
||||
|
||||
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>,
|
||||
}
|
||||
|
||||
let now = Local::now();
|
||||
|
||||
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 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);
|
||||
|
||||
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();
|
||||
|
||||
categories_stats.sort_by(|a, b| a.category.name.cmp(&b.category.name));
|
||||
|
||||
let context = StatsContext { categories_stats };
|
||||
|
||||
Ok(Template::render("stats_all", &context))
|
||||
}
|
||||
|
||||
fn compute_percentage_per_hour<'a, I>(sessions: I) -> BTreeMap<u32, f64>
|
||||
where
|
||||
I: Iterator<Item = &'a session::V>,
|
||||
{
|
||||
let mut stats_per_hour = BTreeMap::new();
|
||||
for session in sessions {
|
||||
let an_hour = chrono::Duration::minutes(60);
|
||||
|
||||
let hour_of = |time: DateTime<Local>| {
|
||||
time.with_minute(0)
|
||||
.and_then(|time| time.with_second(0))
|
||||
.and_then(|time| time.with_nanosecond(0))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let next_hour_of =
|
||||
|time: DateTime<Local>| hour_of(time).checked_add_signed(an_hour).unwrap();
|
||||
|
||||
let mut add_hour_stats =
|
||||
|time: DateTime<Local>, hours| *stats_per_hour.entry(time.hour()).or_default() += hours;
|
||||
|
||||
let mut hour = hour_of(session.started);
|
||||
loop {
|
||||
if hour_of(session.started) == hour {
|
||||
let minutes_started = (session.started - hour).num_minutes() as u32;
|
||||
|
||||
if hour_of(session.ended) == hour {
|
||||
let minutes_ended = (session.ended - hour).num_minutes() as u32;
|
||||
let minutes_last_hour = minutes_ended - minutes_started;
|
||||
add_hour_stats(hour, minutes_last_hour as f64);
|
||||
|
||||
break;
|
||||
} else {
|
||||
let minutes_first_hour = 60 - minutes_started;
|
||||
add_hour_stats(hour, minutes_first_hour as f64);
|
||||
}
|
||||
} else if hour_of(session.ended) == hour {
|
||||
let minutes_last_hour = (session.ended - hour).num_minutes() as u32;
|
||||
add_hour_stats(hour, minutes_last_hour as f64);
|
||||
break;
|
||||
} else {
|
||||
add_hour_stats(hour, 60.0);
|
||||
}
|
||||
hour = next_hour_of(hour);
|
||||
}
|
||||
}
|
||||
|
||||
let sum_weight: f64 = stats_per_hour.values().sum();
|
||||
for weight in stats_per_hour.values_mut() {
|
||||
*weight = *weight * 100.0 / sum_weight;
|
||||
*weight = (*weight * 10.0).trunc() / 10.0;
|
||||
}
|
||||
|
||||
stats_per_hour
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::database::latest::trees::sessions;
|
||||
use chrono::{DateTime, Local};
|
||||
|
||||
#[test]
|
||||
fn test_compute_percentage_per_hour() {
|
||||
let today = Local::now();
|
||||
|
||||
let test_data = vec![
|
||||
(
|
||||
vec![((11, 20), (13, 20))],
|
||||
vec![(11, 33.3), (12, 50.0), (13, 16.6)],
|
||||
),
|
||||
(vec![((09, 00), (09, 01))], vec![(09, 100.0)]),
|
||||
(vec![((09, 00), (09, 59))], vec![(09, 100.0)]),
|
||||
(
|
||||
vec![((13, 00), (16, 00))],
|
||||
vec![(13, 33.3), (14, 33.3), (15, 33.3), (16, 0.0)],
|
||||
),
|
||||
];
|
||||
|
||||
for (sessions, expected) in test_data {
|
||||
let sessions: Vec<_> = sessions
|
||||
.into_iter()
|
||||
.map(|((h1, m1), (h2, m2))| {
|
||||
let set_hm = |t: DateTime<Local>, h, m| {
|
||||
t.with_hour(h)
|
||||
.and_then(|t| t.with_minute(m))
|
||||
.and_then(|t| t.with_second(0))
|
||||
.and_then(|t| t.with_nanosecond(0))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
session::V {
|
||||
category: Default::default(),
|
||||
deleted: false,
|
||||
started: set_hm(today, h1, m1),
|
||||
ended: set_hm(today, h2, m2),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let percentages = compute_percentage_per_hour(sessions.iter());
|
||||
println!("{:#?}", percentages);
|
||||
assert!(percentages.into_iter().eq(expected.into_iter()));
|
||||
}
|
||||
}
|
||||
}
|
||||
88
server/src/status_json.rs
Normal file
88
server/src/status_json.rs
Normal file
@ -0,0 +1,88 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use duplicate::duplicate;
|
||||
use log::{info, warn};
|
||||
use rocket::http::Status;
|
||||
use rocket::response::{Responder, Response};
|
||||
use rocket::Request;
|
||||
use rocket_contrib::json;
|
||||
use rocket_contrib::json::Json; // macro
|
||||
|
||||
/// An error message which can be serialized as JSON.
|
||||
///
|
||||
/// #### Example JSON
|
||||
/// ```json
|
||||
/// {
|
||||
/// "status": 404,
|
||||
/// "description": "Not Found"
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StatusJson {
|
||||
pub status: Status,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
impl StatusJson {
|
||||
pub fn new<S: ToString>(status: Status, description: S) -> Self {
|
||||
StatusJson {
|
||||
status,
|
||||
description: description.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn describe<S: ToString>(mut self, description: S) -> Self {
|
||||
self.description = description.to_string();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<'r> Responder<'r, 'static> for StatusJson {
|
||||
fn respond_to(self, req: &Request) -> Result<Response<'static>, Status> {
|
||||
if self.status.code >= 400 {
|
||||
warn!(
|
||||
"Responding with status {}.\n\
|
||||
Description: {}",
|
||||
self.status, self.description,
|
||||
);
|
||||
} else {
|
||||
info!("Responding with status {}", self.status);
|
||||
}
|
||||
|
||||
let mut response = Json(json!({
|
||||
"status": self.status.code,
|
||||
"description": self.description,
|
||||
}))
|
||||
.respond_to(req)?;
|
||||
|
||||
response.set_status(self.status);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
#[duplicate(
|
||||
status_code T;
|
||||
[ Status::InternalServerError ] [ sled::Error ];
|
||||
[ Status::InternalServerError ] [ sled::transaction::TransactionError ];
|
||||
[ Status::InternalServerError ] [ bincode::Error ];
|
||||
[ Status::BadRequest ] [ uuid::Error ];
|
||||
[ Status::BadRequest ] [ chrono::ParseError ];
|
||||
)]
|
||||
impl From<T> for StatusJson {
|
||||
fn from(e: T) -> StatusJson {
|
||||
StatusJson {
|
||||
status: status_code,
|
||||
description: e.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Status> for StatusJson {
|
||||
fn from(status: Status) -> StatusJson {
|
||||
StatusJson {
|
||||
description: status.reason.to_string(),
|
||||
status,
|
||||
}
|
||||
}
|
||||
}
|
||||
21
server/src/util.rs
Normal file
21
server/src/util.rs
Normal file
@ -0,0 +1,21 @@
|
||||
use tokio::sync::Notify;
|
||||
|
||||
pub struct EventNotifier {
|
||||
notify: Notify,
|
||||
}
|
||||
|
||||
impl EventNotifier {
|
||||
pub fn new() -> Self {
|
||||
EventNotifier {
|
||||
notify: Notify::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn wait_for_event(&self) {
|
||||
self.notify.notified().await;
|
||||
}
|
||||
|
||||
pub fn notify_event(&self) {
|
||||
self.notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
552
server/static/icon.svg
Normal file
552
server/static/icon.svg
Normal file
@ -0,0 +1,552 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="256"
|
||||
height="256"
|
||||
viewBox="0 0 67.733332 67.733335"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)"
|
||||
sodipodi:docname="icon.svg">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="4.3242188"
|
||||
inkscape:cx="128"
|
||||
inkscape:cy="87.207841"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer3"
|
||||
inkscape:document-rotation="0"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
inkscape:window-width="1702"
|
||||
inkscape:window-height="912"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:groupmode="layer"
|
||||
id="layer3"
|
||||
inkscape:label="Pillars Back"
|
||||
style="display:inline"
|
||||
sodipodi:insensitive="true">
|
||||
<g
|
||||
id="g1091-2"
|
||||
transform="translate(30.347669,-2.086268e-4)"
|
||||
style="stroke:#782121">
|
||||
<path
|
||||
style="fill:none;stroke:#782121;stroke-width:0.661458;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 20.775512,11.047657 c 0,0 -0.449722,2.39498 -0.540157,3.290627 -0.09044,0.895648 -0.06275,0.914293 -0.06275,0.914293 0,0 -0.272088,1.156905 -0.115668,2.614035 0.15642,1.45713 0.598996,6.086427 0.598996,6.086427 0,0 0.271195,1.640122 0.341422,3.273546 0.07023,1.633424 0.117678,3.510463 -0.22821,4.237297 -0.345888,0.726834 -0.579346,1.374174 -0.582584,1.924381 -0.0032,0.550206 0.0901,1.498663 0.343767,2.22918 0.253666,0.73052 0.474843,1.092039 0.631598,2.358583 0.156755,1.266545 0.162561,4.692156 0.04946,5.481291 -0.1131,0.789133 -0.84775,6.16313 -0.926685,7.325842 -0.07894,1.162711 -0.06431,6.608161 -0.06431,6.608161"
|
||||
id="path1041-28" />
|
||||
<path
|
||||
style="fill:none;stroke:#782121;stroke-width:1.32292;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 16.750851,59.206934 c 0,0 -0.193699,-1.936867 0.15288,-2.270057 0.346579,-0.333187 1.130607,-0.151709 1.236687,-1.658405 0.10608,-1.506696 -0.02041,-4.908137 -0.260519,-6.562905 -0.24011,-1.654765 -0.961218,-4.19925 -0.974868,-5.363136 -0.01365,-1.163889 -0.284439,-4.49838 0.09633,-6.178886 0.380769,-1.680506 1.315727,-2.152277 1.214847,-3.749582 -0.10088,-1.597306 -1.086928,-2.346754 -1.118908,-3.412882 -0.03198,-1.066128 0.42276,-5.098328 0.696019,-7.064834 0.273259,-1.966506 0.433029,-5.240288 0.302249,-6.356595 -0.13078,-1.116308 0.1469,-6.063056 0.1469,-6.063056"
|
||||
id="path1009-9" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke:#782121;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="rect1033-73"
|
||||
width="4.3838935"
|
||||
height="1.2419821"
|
||||
x="16.737171"
|
||||
y="32.861389"
|
||||
ry="0.50272685" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke:#782121;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="rect1035-6"
|
||||
width="4.7028742"
|
||||
height="2.5966182"
|
||||
x="16.849268"
|
||||
y="8.5792112"
|
||||
ry="0.50272685" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke:#782121;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="rect1039-1"
|
||||
width="4.9635744"
|
||||
height="2.8769681"
|
||||
x="16.217836"
|
||||
y="56.684788"
|
||||
ry="0.71399415"
|
||||
rx="0.77573645" />
|
||||
</g>
|
||||
</g>
|
||||
<g
|
||||
inkscape:groupmode="layer"
|
||||
id="layer6"
|
||||
inkscape:label="Sand"
|
||||
sodipodi:insensitive="true"
|
||||
style="display:inline">
|
||||
<path
|
||||
style="fill:#b3b3b3;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 40.114373,20.101278 c 0,0 -5.719072,-0.294191 -8.885732,-0.801738 -3.16666,-0.507547 -4.415758,-1.350205 -4.950333,-1.622998 -0.534576,-0.272794 -1.590964,-1.257355 -2.869341,-1.527646 -1.278377,-0.270291 -2.087499,-0.295068 -2.087499,-0.295068 l 1.001829,3.164409 2.418105,3.064301 6.645909,1.099685 z"
|
||||
id="path1370"
|
||||
sodipodi:nodetypes="csssccccc" />
|
||||
<path
|
||||
style="fill:#000000;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 48.414654,17.809936 -8.023143,2.01517 c 0,0 -6.240222,1.701333 -10.335885,1.916064 -4.095661,0.214732 -6.701719,-1.560681 -6.701719,-1.560681 0,0 3.787832,5.141789 4.474071,6.1734 0.686239,1.031613 5.742572,6.665507 5.742572,6.665507 h 3.335343 l 4.953449,-5.863394 5.202606,-6.882663 z"
|
||||
id="path1368" />
|
||||
<path
|
||||
style="fill:#000000;stroke:none;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 21.580019,55.561039 c 0,0 1.081771,0.348662 1.983056,0.24647 0.901284,-0.102196 2.247844,-0.254056 4.967297,-1.384634 2.719457,-1.130577 6.28681,-3.16888 6.28681,-3.16888 0,0 3.859334,1.99336 5.451218,2.445364 1.591882,0.452001 5.066057,1.357437 6.913428,1.559105 1.847368,0.201671 1.991497,0.151717 1.991497,0.151717 l -1.709391,2.268313 -1.55467,0.614595 -11.465208,-0.0729 -10.551498,-0.06135 -1.574564,-1.060301 z"
|
||||
id="path1372" />
|
||||
<circle
|
||||
style="fill:#000000;stroke:none;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1374"
|
||||
cx="34.811371"
|
||||
cy="35.812702"
|
||||
r="0.62584984" />
|
||||
<circle
|
||||
style="fill:#000000;stroke:none;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1374-6"
|
||||
cx="35.770565"
|
||||
cy="36.900738"
|
||||
r="0.62584984" />
|
||||
<circle
|
||||
style="fill:#000000;stroke:none;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1374-6-9"
|
||||
cx="33.862427"
|
||||
cy="38.085899"
|
||||
r="0.62584984" />
|
||||
<circle
|
||||
style="fill:#000000;stroke:none;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1374-6-3"
|
||||
cx="36.723083"
|
||||
cy="39.3293"
|
||||
r="0.62584984" />
|
||||
<circle
|
||||
style="fill:#000000;stroke:none;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1374-6-7"
|
||||
cx="32.700371"
|
||||
cy="40.930286"
|
||||
r="0.62584984" />
|
||||
<circle
|
||||
style="fill:#000000;stroke:none;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1374-6-4"
|
||||
cx="34.650963"
|
||||
cy="39.835091"
|
||||
r="0.62584984" />
|
||||
<circle
|
||||
style="fill:#000000;stroke:none;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1374-6-5"
|
||||
cx="36.483288"
|
||||
cy="42.46571"
|
||||
r="0.62584984" />
|
||||
<circle
|
||||
style="fill:#000000;stroke:none;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1374-6-2"
|
||||
cx="34.704163"
|
||||
cy="43.149635"
|
||||
r="0.62584984" />
|
||||
<circle
|
||||
style="fill:#000000;stroke:none;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1374-6-54"
|
||||
cx="36.52282"
|
||||
cy="45.745441"
|
||||
r="0.62584984" />
|
||||
<circle
|
||||
style="fill:#000000;stroke:none;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1374-6-74"
|
||||
cx="33.423016"
|
||||
cy="45.196705"
|
||||
r="0.62584984" />
|
||||
<ellipse
|
||||
style="fill:#000000;stroke:none;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1374-6-43"
|
||||
cx="38.790001"
|
||||
cy="49.07626"
|
||||
rx="0.62584984"
|
||||
ry="0.53653568" />
|
||||
<circle
|
||||
style="fill:#000000;stroke:none;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1374-6-78"
|
||||
cx="35.108597"
|
||||
cy="48.091038"
|
||||
r="0.62584984" />
|
||||
<circle
|
||||
style="fill:#000000;stroke:none;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1374-6-6"
|
||||
cx="32.226791"
|
||||
cy="50.244827"
|
||||
r="0.62584984" />
|
||||
<circle
|
||||
style="fill:#000000;stroke:none;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1374-6-8"
|
||||
cx="38.158619"
|
||||
cy="51.249733"
|
||||
r="0.62584984" />
|
||||
</g>
|
||||
<g
|
||||
inkscape:groupmode="layer"
|
||||
id="layer4"
|
||||
inkscape:label="Glass"
|
||||
style="display:inline"
|
||||
sodipodi:insensitive="true">
|
||||
<g
|
||||
id="g1363"
|
||||
transform="matrix(1,0,0,1.0444578,0,-1.5061383)"
|
||||
style="stroke:#333333">
|
||||
<g
|
||||
id="g938"
|
||||
transform="matrix(1,0,0,1.0370351,0,-0.39070518)"
|
||||
style="stroke:#333333">
|
||||
<path
|
||||
style="fill:none;stroke:#333333;stroke-width:1.05833;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 33.57055,32.252184 c 0,0 -12.275601,-12.311609 -12.359607,-16.402248 -0.08401,-4.09064 1.764661,-5.26832 3.527407,-5.290483 1.762745,-0.02216 11.294037,0 11.294037,0"
|
||||
id="path919" />
|
||||
<path
|
||||
style="fill:none;stroke:#333333;stroke-width:1.05833;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 36.905892,32.252184 c 0,0 12.275601,-12.311609 12.359607,-16.402248 0.08401,-4.09064 -1.764661,-5.26832 -3.527407,-5.290483 -1.762745,-0.02216 -11.294037,0 -11.294037,0"
|
||||
id="path919-5" />
|
||||
</g>
|
||||
<g
|
||||
id="g938-6"
|
||||
transform="matrix(1,0,0,-1.0593953,0,68.370685)"
|
||||
style="stroke:#333333">
|
||||
<path
|
||||
style="fill:none;stroke:#333333;stroke-width:1.05833;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 33.57055,32.252184 c 0,0 -12.275601,-12.311609 -12.359607,-16.402248 -0.08401,-4.09064 1.764661,-5.26832 3.527407,-5.290483 1.762745,-0.02216 11.294037,0 11.294037,0"
|
||||
id="path919-2" />
|
||||
<path
|
||||
style="fill:none;stroke:#333333;stroke-width:1.05833;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 36.905892,32.252184 c 0,0 12.275601,-12.311609 12.359607,-16.402248 0.08401,-4.09064 -1.764661,-5.26832 -3.527407,-5.290483 -1.762745,-0.02216 -11.294037,0 -11.294037,0"
|
||||
id="path919-5-9" />
|
||||
</g>
|
||||
<ellipse
|
||||
style="fill:none;stroke:#333333;stroke-width:0.79375;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1005"
|
||||
cx="35.212273"
|
||||
cy="33.619923"
|
||||
rx="2.1828346"
|
||||
ry="0.82368582" />
|
||||
</g>
|
||||
</g>
|
||||
<g
|
||||
inkscape:label="Pillars Front"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
style="display:inline"
|
||||
sodipodi:insensitive="true">
|
||||
<g
|
||||
id="g1520">
|
||||
<path
|
||||
style="display:inline;fill:#ffffff;stroke:none;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 20.204467,34.118554 0.201594,1.235795 0.572603,1.589357 0.320998,1.885804 -0.0358,3.769413 -0.394657,3.505145 -0.301228,2.660777 -0.344517,2.779537 -0.05725,5.11479 -2.189874,-0.0066 0.196424,-5.606626 -0.998149,-6.775743 -0.366485,-6.405507 1.342795,-3.78828 z"
|
||||
id="path1495-4" />
|
||||
<path
|
||||
style="display:inline;fill:#ffffff;stroke:none;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 18.138151,11.201764 2.637361,-0.154107 -0.506089,3.156009 -0.250028,2.107844 0.168995,2.793397 0.617285,5.258168 0.301882,4.68224 -0.291513,2.259584 -0.593812,1.535065 -2.186401,-0.03057 -0.928531,-2.770431 0.428178,-5.07305 0.5648,-6.693683 v -5.64297 z"
|
||||
id="path1478-8" />
|
||||
<g
|
||||
id="g1091"
|
||||
style="stroke:#782121">
|
||||
<path
|
||||
style="fill:none;stroke:#782121;stroke-width:0.661458;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 20.775512,11.047657 c 0,0 -0.449722,2.39498 -0.540157,3.290627 -0.09044,0.895648 -0.06275,0.914293 -0.06275,0.914293 0,0 -0.272088,1.156905 -0.115668,2.614035 0.15642,1.45713 0.598996,6.086427 0.598996,6.086427 0,0 0.271195,1.640122 0.341422,3.273546 0.07023,1.633424 0.117678,3.510463 -0.22821,4.237297 -0.345888,0.726834 -0.579346,1.374174 -0.582584,1.924381 -0.0032,0.550206 0.0901,1.498663 0.343767,2.22918 0.253666,0.73052 0.474843,1.092039 0.631598,2.358583 0.156755,1.266545 0.162561,4.692156 0.04946,5.481291 -0.1131,0.789133 -0.84775,6.16313 -0.926685,7.325842 -0.07894,1.162711 -0.06431,6.608161 -0.06431,6.608161"
|
||||
id="path1041" />
|
||||
<path
|
||||
style="fill:none;stroke:#782121;stroke-width:1.32292;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 16.750851,59.206934 c 0,0 -0.193699,-1.936867 0.15288,-2.270057 0.346579,-0.333187 1.130607,-0.151709 1.236687,-1.658405 0.10608,-1.506696 -0.02041,-4.908137 -0.260519,-6.562905 -0.24011,-1.654765 -0.961218,-4.19925 -0.974868,-5.363136 -0.01365,-1.163889 -0.284439,-4.49838 0.09633,-6.178886 0.380769,-1.680506 1.315727,-2.152277 1.214847,-3.749582 -0.10088,-1.597306 -1.086928,-2.346754 -1.118908,-3.412882 -0.03198,-1.066128 0.42276,-5.098328 0.696019,-7.064834 0.273259,-1.966506 0.433029,-5.240288 0.302249,-6.356595 -0.13078,-1.116308 0.1469,-6.063056 0.1469,-6.063056"
|
||||
id="path1009" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke:#782121;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="rect1033"
|
||||
width="4.3838935"
|
||||
height="1.2419821"
|
||||
x="16.737171"
|
||||
y="32.861389"
|
||||
ry="0.50272685" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke:#782121;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="rect1035"
|
||||
width="4.7028742"
|
||||
height="2.5966182"
|
||||
x="16.849268"
|
||||
y="8.5792112"
|
||||
ry="0.50272685" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke:#782121;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="rect1039"
|
||||
width="4.9635744"
|
||||
height="2.8769681"
|
||||
x="16.217836"
|
||||
y="56.684788"
|
||||
ry="0.71399415"
|
||||
rx="0.77573645" />
|
||||
</g>
|
||||
</g>
|
||||
<g
|
||||
id="g1530">
|
||||
<path
|
||||
style="fill:#ffffff;stroke:none;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 43.70937,34.118555 0.201594,1.235795 0.572603,1.589357 0.320998,1.885804 -0.0358,3.769413 -0.394657,3.505145 -0.301228,2.660777 -0.344517,2.779537 -0.05725,5.11479 -2.189874,-0.0066 0.196424,-5.606626 -0.998149,-6.775744 -0.366485,-6.405507 1.342795,-3.78828 z"
|
||||
id="path1495" />
|
||||
<path
|
||||
style="fill:#ffffff;stroke:none;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 41.649467,11.201764 2.637361,-0.154107 -0.506089,3.156009 -0.250029,2.107844 0.168995,2.793397 0.617286,5.258168 0.301881,4.68224 -0.291512,2.259584 -0.593813,1.535065 -2.1864,-0.03057 -0.928531,-2.770431 0.428178,-5.07305 0.5648,-6.693683 v -5.64297 z"
|
||||
id="path1478" />
|
||||
<g
|
||||
id="g1091-0"
|
||||
transform="translate(23.511316)"
|
||||
style="stroke:#782121">
|
||||
<path
|
||||
style="fill:none;stroke:#782121;stroke-width:0.661458;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 20.775512,11.047657 c 0,0 -0.449722,2.39498 -0.540157,3.290627 -0.09044,0.895648 -0.06275,0.914293 -0.06275,0.914293 0,0 -0.272088,1.156905 -0.115668,2.614035 0.15642,1.45713 0.598996,6.086427 0.598996,6.086427 0,0 0.271195,1.640122 0.341422,3.273546 0.07023,1.633424 0.117678,3.510463 -0.22821,4.237297 -0.345888,0.726834 -0.579346,1.374174 -0.582584,1.924381 -0.0032,0.550206 0.0901,1.498663 0.343767,2.22918 0.253666,0.73052 0.474843,1.092039 0.631598,2.358583 0.156755,1.266545 0.162561,4.692156 0.04946,5.481291 -0.1131,0.789133 -0.84775,6.16313 -0.926685,7.325842 -0.07894,1.162711 -0.06431,6.608161 -0.06431,6.608161"
|
||||
id="path1041-2" />
|
||||
<path
|
||||
style="fill:none;stroke:#782121;stroke-width:1.32292;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 16.750851,59.206934 c 0,0 -0.193699,-1.936867 0.15288,-2.270057 0.346579,-0.333187 1.130607,-0.151709 1.236687,-1.658405 0.10608,-1.506696 -0.02041,-4.908137 -0.260519,-6.562905 -0.24011,-1.654765 -0.961218,-4.19925 -0.974868,-5.363136 -0.01365,-1.163889 -0.284439,-4.49838 0.09633,-6.178886 0.380769,-1.680506 1.315727,-2.152277 1.214847,-3.749582 -0.10088,-1.597306 -1.086928,-2.346754 -1.118908,-3.412882 -0.03198,-1.066128 0.42276,-5.098328 0.696019,-7.064834 0.273259,-1.966506 0.433029,-5.240288 0.302249,-6.356595 -0.13078,-1.116308 0.1469,-6.063056 0.1469,-6.063056"
|
||||
id="path1009-3" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke:#782121;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="rect1033-7"
|
||||
width="4.3838935"
|
||||
height="1.2419821"
|
||||
x="16.737171"
|
||||
y="32.861389"
|
||||
ry="0.50272685" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke:#782121;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="rect1035-5"
|
||||
width="4.7028742"
|
||||
height="2.5966182"
|
||||
x="16.849268"
|
||||
y="8.5792112"
|
||||
ry="0.50272685" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke:#782121;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="rect1039-9"
|
||||
width="4.9635744"
|
||||
height="2.8769681"
|
||||
x="16.217836"
|
||||
y="56.684788"
|
||||
ry="0.71399415"
|
||||
rx="0.77573645" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g
|
||||
inkscape:groupmode="layer"
|
||||
id="layer5"
|
||||
inkscape:label="Foot & Head"
|
||||
style="display:inline"
|
||||
sodipodi:insensitive="true">
|
||||
<g
|
||||
id="g1300"
|
||||
style="stroke:#501616">
|
||||
<g
|
||||
id="g1285"
|
||||
transform="translate(-0.23619903)"
|
||||
style="stroke:#501616">
|
||||
<ellipse
|
||||
style="fill:#ffffff;stroke:#501616;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1178"
|
||||
cx="15.071122"
|
||||
cy="5.5063143"
|
||||
rx="2.1022627"
|
||||
ry="1.8671744" />
|
||||
<ellipse
|
||||
style="display:inline;fill:#ffffff;stroke:#501616;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1178-3"
|
||||
cx="19.260841"
|
||||
cy="5.5062537"
|
||||
rx="2.1022627"
|
||||
ry="1.8671745" />
|
||||
<ellipse
|
||||
style="display:inline;fill:#ffffff;stroke:#501616;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1178-1"
|
||||
cx="23.511995"
|
||||
cy="5.5231605"
|
||||
rx="2.1022627"
|
||||
ry="1.8671745" />
|
||||
<ellipse
|
||||
style="display:inline;fill:#ffffff;stroke:#501616;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1178-3-9"
|
||||
cx="27.701714"
|
||||
cy="5.5230999"
|
||||
rx="2.1022627"
|
||||
ry="1.8671745" />
|
||||
<ellipse
|
||||
style="display:inline;fill:#ffffff;stroke:#501616;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1178-4"
|
||||
cx="32.075489"
|
||||
cy="5.5063143"
|
||||
rx="2.1022627"
|
||||
ry="1.8671745" />
|
||||
<ellipse
|
||||
style="display:inline;fill:#ffffff;stroke:#501616;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1178-3-7"
|
||||
cx="36.265209"
|
||||
cy="5.5062537"
|
||||
rx="2.1022627"
|
||||
ry="1.8671745" />
|
||||
<ellipse
|
||||
style="display:inline;fill:#ffffff;stroke:#501616;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1178-1-8"
|
||||
cx="40.516361"
|
||||
cy="5.5231605"
|
||||
rx="2.1022627"
|
||||
ry="1.8671745" />
|
||||
<ellipse
|
||||
style="display:inline;fill:#ffffff;stroke:#501616;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1178-3-9-4"
|
||||
cx="44.706081"
|
||||
cy="5.5230999"
|
||||
rx="2.1022627"
|
||||
ry="1.8671745" />
|
||||
<ellipse
|
||||
style="display:inline;fill:#ffffff;stroke:#501616;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1178-5"
|
||||
cx="48.944889"
|
||||
cy="5.5063143"
|
||||
rx="2.1022627"
|
||||
ry="1.8671745" />
|
||||
<ellipse
|
||||
style="display:inline;fill:#ffffff;stroke:#501616;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1178-3-0"
|
||||
cx="53.134609"
|
||||
cy="5.5062537"
|
||||
rx="2.1022627"
|
||||
ry="1.8671745" />
|
||||
</g>
|
||||
<rect
|
||||
style="fill:#ffffff;stroke:#501616;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="rect1157"
|
||||
width="44.455322"
|
||||
height="1.5251299"
|
||||
x="11.639006"
|
||||
y="3.2402201"
|
||||
rx="0.77573717"
|
||||
ry="0.71399397" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke:#501616;stroke-width:0.658327;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="rect1176"
|
||||
width="39.050079"
|
||||
height="1.1755723"
|
||||
x="14.341627"
|
||||
y="7.4668312"
|
||||
rx="0.7684105"
|
||||
ry="0.34436843" />
|
||||
</g>
|
||||
<g
|
||||
id="g1300-1"
|
||||
style="display:inline;stroke:#501616"
|
||||
transform="matrix(1,0,0,-1,0,67.733144)">
|
||||
<g
|
||||
id="g1285-0"
|
||||
transform="translate(-0.23619903)"
|
||||
style="stroke:#501616">
|
||||
<ellipse
|
||||
style="fill:#ffffff;stroke:#501616;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1178-6"
|
||||
cx="15.071122"
|
||||
cy="5.5063143"
|
||||
rx="2.1022627"
|
||||
ry="1.8671744" />
|
||||
<ellipse
|
||||
style="display:inline;fill:#ffffff;stroke:#501616;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1178-3-3"
|
||||
cx="19.260841"
|
||||
cy="5.5062537"
|
||||
rx="2.1022627"
|
||||
ry="1.8671745" />
|
||||
<ellipse
|
||||
style="display:inline;fill:#ffffff;stroke:#501616;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1178-1-2"
|
||||
cx="23.511995"
|
||||
cy="5.5231605"
|
||||
rx="2.1022627"
|
||||
ry="1.8671745" />
|
||||
<ellipse
|
||||
style="display:inline;fill:#ffffff;stroke:#501616;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1178-3-9-0"
|
||||
cx="27.701714"
|
||||
cy="5.5230999"
|
||||
rx="2.1022627"
|
||||
ry="1.8671745" />
|
||||
<ellipse
|
||||
style="display:inline;fill:#ffffff;stroke:#501616;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1178-4-6"
|
||||
cx="32.075489"
|
||||
cy="5.5063143"
|
||||
rx="2.1022627"
|
||||
ry="1.8671745" />
|
||||
<ellipse
|
||||
style="display:inline;fill:#ffffff;stroke:#501616;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1178-3-7-1"
|
||||
cx="36.265209"
|
||||
cy="5.5062537"
|
||||
rx="2.1022627"
|
||||
ry="1.8671745" />
|
||||
<ellipse
|
||||
style="display:inline;fill:#ffffff;stroke:#501616;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1178-1-8-5"
|
||||
cx="40.516361"
|
||||
cy="5.5231605"
|
||||
rx="2.1022627"
|
||||
ry="1.8671745" />
|
||||
<ellipse
|
||||
style="display:inline;fill:#ffffff;stroke:#501616;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1178-3-9-4-5"
|
||||
cx="44.706081"
|
||||
cy="5.5230999"
|
||||
rx="2.1022627"
|
||||
ry="1.8671745" />
|
||||
<ellipse
|
||||
style="display:inline;fill:#ffffff;stroke:#501616;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1178-5-4"
|
||||
cx="48.944889"
|
||||
cy="5.5063143"
|
||||
rx="2.1022627"
|
||||
ry="1.8671745" />
|
||||
<ellipse
|
||||
style="display:inline;fill:#ffffff;stroke:#501616;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="path1178-3-0-7"
|
||||
cx="53.134609"
|
||||
cy="5.5062537"
|
||||
rx="2.1022627"
|
||||
ry="1.8671745" />
|
||||
</g>
|
||||
<rect
|
||||
style="fill:#ffffff;stroke:#501616;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="rect1157-6"
|
||||
width="44.455322"
|
||||
height="1.5251299"
|
||||
x="11.639006"
|
||||
y="3.2402201"
|
||||
rx="0.77573717"
|
||||
ry="0.71399397" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke:#501616;stroke-width:0.658327;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
id="rect1176-5"
|
||||
width="39.050079"
|
||||
height="1.1755723"
|
||||
x="14.341627"
|
||||
y="7.4668312"
|
||||
rx="0.7684105"
|
||||
ry="0.34436843" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 25 KiB |
120
server/static/styles/charts.css
Normal file
120
server/static/styles/charts.css
Normal file
@ -0,0 +1,120 @@
|
||||
/* CSS rules for charts/plots */
|
||||
.chart_col_tooltip {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border-bottom: 1px dotted black;
|
||||
}
|
||||
|
||||
.chart_col_tooltip .chart_col_tooltiptext {
|
||||
visibility: hidden;
|
||||
width: 120px;
|
||||
background-color: #000;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 5px 0;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
bottom: 110%;
|
||||
left: 50%;
|
||||
margin-left: -60px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.chart_col_tooltip .chart_col_tooltiptext::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
margin-left: -5px;
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: #000 transparent transparent transparent;
|
||||
}
|
||||
|
||||
.chart_col_tooltip:hover .chart_col_tooltiptext {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chart_histogram {
|
||||
height: 250px; /* TODO: possibly remove this */
|
||||
padding: 0.75em;
|
||||
padding-top: 1.5em;
|
||||
margin: auto;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
/*** lined-paper background ***/
|
||||
position: relative;
|
||||
|
||||
background-size: 100% 40px;
|
||||
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chart_histogram_legend {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.chart_histogram_col {
|
||||
border-top: dotted 1px;
|
||||
margin-top: 0.5em;
|
||||
|
||||
max-width: 2em;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.chart_histogram_col_line {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
|
||||
background-color: #785ddc;
|
||||
|
||||
border-color: #e2e8f0;
|
||||
border-style: solid;
|
||||
border-width: 0.1em;
|
||||
|
||||
transition: all 0.2s linear;
|
||||
}
|
||||
|
||||
.chart_histogram_col_line:hover {
|
||||
background-color: #5538ba;
|
||||
|
||||
transition: all 0.2s linear;
|
||||
}
|
||||
|
||||
.chart_histogram_col_label {
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
height: 4em;
|
||||
font-size: 0.5em;
|
||||
text-align: center;
|
||||
margin: auto;
|
||||
overflow: hidden;
|
||||
padding-left: 0.2em;
|
||||
padding-right: 0.2em;
|
||||
}
|
||||
|
||||
.paper {
|
||||
/* font: normal 12px/1.5 "Lucida Grande", arial, sans-serif; */
|
||||
}
|
||||
|
||||
/*
|
||||
.paper::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
top: 0;
|
||||
left: 30px;
|
||||
bottom: 0;
|
||||
border: 1px solid;
|
||||
border-color: transparent #efe4e4;
|
||||
}
|
||||
*/
|
||||
133
server/static/styles/common.css
Normal file
133
server/static/styles/common.css
Normal file
@ -0,0 +1,133 @@
|
||||
body {
|
||||
font-family: Ubuntu;
|
||||
background-color: #302f3b;
|
||||
color: #e0c1c1;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:link {
|
||||
color: #fff;
|
||||
border-bottom: 1px solid #ff0000;
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: #fff;
|
||||
border-bottom: 1px solid #b3b3b3;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #7bd09f;
|
||||
border-bottom: 1px solid #000099;
|
||||
}
|
||||
|
||||
ul.striped_list {
|
||||
max-width: 40em;
|
||||
list-style-type: none;
|
||||
margin: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ul.striped_list > li:nth-of-type(odd) {
|
||||
background-color: #3f4a53;
|
||||
}
|
||||
|
||||
.category_entry {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.category_name {
|
||||
font-size: 3em;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
.category_button {
|
||||
border-radius: 1em;
|
||||
color: white;
|
||||
font-size: 2em;
|
||||
height: 2em;
|
||||
width: 2em;
|
||||
margin: 0.1em;
|
||||
font-family: 'Source Sans Pro', sans-serif;
|
||||
}
|
||||
*/
|
||||
|
||||
.category_icon {
|
||||
border-radius: 2em;
|
||||
height: 4em;
|
||||
width: 4em;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
.category_button_container {
|
||||
margin: 0.1em;
|
||||
background-color: #a4829c;
|
||||
border-radius: 3.5em;
|
||||
width: 7em;
|
||||
height: 7em;
|
||||
}
|
||||
|
||||
.category_button {
|
||||
box-sizing: border-box;
|
||||
height: 60px;
|
||||
|
||||
margin: 30px;
|
||||
margin-top: 25px;
|
||||
|
||||
background-color: transparent;
|
||||
border-color: transparent transparent transparent #302f3b;
|
||||
transition: 100ms all ease;
|
||||
will-change: border-width;
|
||||
cursor: pointer;
|
||||
|
||||
/* play state */
|
||||
border-style: solid;
|
||||
border-width: 30px 0 30px 50px;
|
||||
}
|
||||
|
||||
.category_button_toggled {
|
||||
/* pause state */
|
||||
border-style: double;
|
||||
border-width: 0px 0 0px 50px;
|
||||
}
|
||||
|
||||
.history_entry {
|
||||
padding: 0.2em;
|
||||
}
|
||||
|
||||
.history_entry_category {
|
||||
|
||||
}
|
||||
|
||||
.history_entry_duration {
|
||||
color: #e4c9ff;
|
||||
}
|
||||
|
||||
.history_entry_started {
|
||||
color: #fdab70;
|
||||
}
|
||||
|
||||
.history_entry_ended {
|
||||
color: #ffa9a9;
|
||||
}
|
||||
|
||||
.history_entry_delete_button {
|
||||
color: #aa0000;
|
||||
}
|
||||
|
||||
.hline {
|
||||
width: 99%;
|
||||
height: 3px;
|
||||
background-color: #aaaaaa;
|
||||
margin: auto;
|
||||
}
|
||||
28
server/templates/edit_session.hbs
Normal file
28
server/templates/edit_session.hbs
Normal file
@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{{> head}}
|
||||
<body>
|
||||
<h1 class="title">stl</h1>
|
||||
|
||||
<div>
|
||||
<a href="/history">tillbaka</a>
|
||||
<br>
|
||||
<br>
|
||||
<form action="/api/session/{{session_id}}/edit" method="post">
|
||||
<input type="hidden" id="category" name="category" value="{{session.category}}">
|
||||
<input type="hidden" id="deleted" name="deleted" value="{{session.deleted}}">
|
||||
<span>Started:</span>
|
||||
<input type="text" id="started" name="started" value="{{pretty_datetime session.started}}"></input>
|
||||
<br>
|
||||
<span>Ended:</span>
|
||||
<input type="text" id="ended" name="ended" value="{{pretty_datetime session.ended}}"></input>
|
||||
<br>
|
||||
<button type="submit">spara</button>
|
||||
</form>
|
||||
<br>
|
||||
<form action="/api/session/{{this.session_id}}/delete" method="post">
|
||||
<button type="submit">ta bort</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
12
server/templates/head.hbs
Normal file
12
server/templates/head.hbs
Normal file
@ -0,0 +1,12 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="description" content="">
|
||||
|
||||
<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="https://fonts.googleapis.com/css?family=Ubuntu|Ubuntu+Mono&display=swap">
|
||||
|
||||
<title>stl</title>
|
||||
</head>
|
||||
7
server/templates/header.hbs
Normal file
7
server/templates/header.hbs
Normal file
@ -0,0 +1,7 @@
|
||||
<h1 class="title">
|
||||
<a href="/stats">🗠</a>
|
||||
-
|
||||
<a href="/">stl</a>
|
||||
-
|
||||
<a href="/history">🕮</a>
|
||||
</h1>
|
||||
23
server/templates/history.hbs
Normal file
23
server/templates/history.hbs
Normal file
@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{{> head}}
|
||||
<body>
|
||||
{{> header}}
|
||||
|
||||
<ul class="striped_list">
|
||||
{{#each entries}}
|
||||
<li class="history_entry">
|
||||
<span class="history_entry_category">{{this.category.name}}</span>
|
||||
<span>under</span>
|
||||
<span class="history_entry_duration">{{pretty_seconds this.duration.secs}}</span>
|
||||
<span>från</span>
|
||||
<span class="history_entry_started">{{pretty_datetime this.session.started}}</span>
|
||||
<span>tills</span>
|
||||
<span class="history_entry_ended">{{pretty_datetime this.session.ended}}</span>
|
||||
<span>---</span>
|
||||
<a href="/session/{{this.session_id}}/edit" class="history_entry_edit_button">ändra</a>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
62
server/templates/index.hbs
Normal file
62
server/templates/index.hbs
Normal file
@ -0,0 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{{> head}}
|
||||
<body>
|
||||
<script>
|
||||
function toggle_category(id) {
|
||||
// Find out whether the button is in active (play) or inactive (paused) state
|
||||
let toggled_class = "category_button_toggled";
|
||||
let cl = document.getElementById("toggle-button-" + id).classList;
|
||||
let active = cl.contains(toggled_class);
|
||||
|
||||
// Get the corresponding route to activate/inactivate the category
|
||||
let url;
|
||||
if(active) {
|
||||
url = "/api/category/" + id + "/end_session";
|
||||
cl.remove(toggled_class);
|
||||
} else {
|
||||
url = "/api/category/" + id + "/start_session";
|
||||
cl.add(toggled_class);
|
||||
}
|
||||
|
||||
//var params = "lorem=ipsum&name=alpha";
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", url, true);
|
||||
|
||||
//Send the proper header information along with the request
|
||||
xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
|
||||
|
||||
xhr.send();
|
||||
}
|
||||
</script>
|
||||
|
||||
{{> header}}
|
||||
|
||||
<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>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
11
server/templates/login.hbs
Normal file
11
server/templates/login.hbs
Normal file
@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{{>head}}
|
||||
<body>
|
||||
<h1 class="title">stl</h1>
|
||||
<h2>Logga in</h2>
|
||||
<form action="/api/login" method="post">
|
||||
<input type="password" id="password" name="password"></input>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
13
server/templates/stats_all.hbs
Normal file
13
server/templates/stats_all.hbs
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{{> head}}
|
||||
<body>
|
||||
{{> header}}
|
||||
|
||||
{{#each categories_stats}}
|
||||
{{#if this.last_session_start}}
|
||||
{{> stats_chart this}}
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
</body>
|
||||
</html>
|
||||
33
server/templates/stats_chart.hbs
Normal file
33
server/templates/stats_chart.hbs
Normal file
@ -0,0 +1,33 @@
|
||||
<div>
|
||||
<div class="hline"></div>
|
||||
<h2>
|
||||
<span>Kategori:</span>
|
||||
<a href="/stats/{{category_id}}">{{category.name}}</a>
|
||||
<span style="color: {{category.color}};">●</span>
|
||||
</h2>
|
||||
<h2>
|
||||
<span>Senaste session:</span>
|
||||
<span class="history_entry_started">{{pretty_datetime last_session_start}}</span>
|
||||
<span>i</span>
|
||||
<span class="history_entry_duration">{{pretty_seconds secs_last_session}}</span>
|
||||
</h2>
|
||||
<h2>
|
||||
<span>Senaste veckan:</span>
|
||||
<span class="history_entry_duration">{{pretty_seconds secs_last_week}}</span>
|
||||
<span>Senaste månaden:</span>
|
||||
<span class="history_entry_duration">{{pretty_seconds secs_last_month}}</span>
|
||||
</h2>
|
||||
<h2>Andel per timme:</h2>
|
||||
<div class="chart_histogram">
|
||||
<div class="chart_histogram_legend">{{bars_max}}%</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}}%</span>
|
||||
</div>
|
||||
<div class="chart_histogram_col_label">{{this.0}}</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
8
server/templates/stats_single.hbs
Normal file
8
server/templates/stats_single.hbs
Normal file
@ -0,0 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{{> head}}
|
||||
<body>
|
||||
{{> header}}
|
||||
{{> stats_chart this}}
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user