Add cli & lib crates

This commit is contained in:
2021-04-22 15:13:28 +02:00
parent e39cffa3f6
commit 3a9ecc398a
53 changed files with 5065 additions and 99 deletions

2281
server/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

41
server/Cargo.toml Normal file
View 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
View File

@ -0,0 +1,4 @@
segment_size: 524288
use_compression: false
version: 0.34
vQ<>

BIN
server/database/db Normal file

Binary file not shown.

Binary file not shown.

16
server/example.env Normal file
View 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
View 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))
}

View 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)
}
}

View 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(())
}

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

View File

@ -0,0 +1,6 @@
pub mod global {
pub mod schema_version {
pub const K: &str = "SCHEMA_VERSION";
pub type V = u32;
}
}

View 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<_, _>, _>>()??)
}

View File

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

50
server/src/database/v1.rs Normal file
View 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
View 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,
}
}
}

View 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
View 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
View 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
View File

@ -0,0 +1,2 @@
pub mod api;
pub mod pages;

113
server/src/routes/pages.rs Normal file
View 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))
}

View 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
View 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
View 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
View 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 &amp; 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

View 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;
}
*/

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

View 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
View 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>

View File

@ -0,0 +1,7 @@
<h1 class="title">
<a href="/stats">🗠</a>
-
<a href="/">stl</a>
-
<a href="/history">🕮</a>
</h1>

View 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>

View 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>

View 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>

View 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>

View 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}}&percnt;</div>
{{#each bars}}
<div class="chart_histogram_col">
<div style="flex-grow: {{this.2}};"></div>
<div class="chart_histogram_col_line chart_col_tooltip" style="flex-grow: {{this.1}};">
<span class="chart_col_tooltiptext">{{this.1}}&percnt;</span>
</div>
<div class="chart_histogram_col_label">{{this.0}}</div>
</div>
{{/each}}
</div>
</div>

View File

@ -0,0 +1,8 @@
<!DOCTYPE html>
<html lang="en">
{{> head}}
<body>
{{> header}}
{{> stats_chart this}}
</body>
</html>