Add stats page
This commit is contained in:
@ -50,6 +50,7 @@ fn main() -> io::Result<()> {
|
|||||||
routes::pages::index,
|
routes::pages::index,
|
||||||
routes::pages::history,
|
routes::pages::history,
|
||||||
routes::pages::session_edit,
|
routes::pages::session_edit,
|
||||||
|
routes::pages::stats,
|
||||||
routes::api::edit_session,
|
routes::api::edit_session,
|
||||||
routes::api::create_category,
|
routes::api::create_category,
|
||||||
routes::api::start_session,
|
routes::api::start_session,
|
||||||
|
|||||||
@ -2,12 +2,13 @@ use crate::database::latest::trees::{categories, sessions};
|
|||||||
use crate::status_json::StatusJson;
|
use crate::status_json::StatusJson;
|
||||||
use bincode::deserialize;
|
use bincode::deserialize;
|
||||||
use bincode::serialize;
|
use bincode::serialize;
|
||||||
|
use chrono::{DateTime, Local, Timelike};
|
||||||
use rocket::http::Status;
|
use rocket::http::Status;
|
||||||
use rocket::{get, State};
|
use rocket::{get, State};
|
||||||
use rocket_contrib::templates::Template;
|
use rocket_contrib::templates::Template;
|
||||||
use rocket_contrib::uuid::Uuid;
|
use rocket_contrib::uuid::Uuid;
|
||||||
use serde_derive::{Deserialize, Serialize};
|
use serde_derive::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[get("/")]
|
#[get("/")]
|
||||||
@ -105,3 +106,121 @@ pub fn history(db: State<'_, sled::Db>) -> Result<Template, StatusJson> {
|
|||||||
|
|
||||||
Ok(Template::render("history", &context))
|
Ok(Template::render("history", &context))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[get("/stats")]
|
||||||
|
pub fn stats(db: State<'_, sled::Db>) -> Result<Template, StatusJson> {
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct StatsContext {
|
||||||
|
categories_stats: Vec<CategoryStatsContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct CategoryStatsContext {
|
||||||
|
category_id: categories::K,
|
||||||
|
category: categories::V,
|
||||||
|
|
||||||
|
last_session_start: Option<DateTime<Local>>,
|
||||||
|
secs_last_session: u64,
|
||||||
|
secs_last_week: u64,
|
||||||
|
secs_last_month: u64,
|
||||||
|
|
||||||
|
bars: Vec<(u32, u32, u32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Local::now();
|
||||||
|
|
||||||
|
let categories_tree = db.open_tree(categories::NAME)?;
|
||||||
|
let sessions_tree = db.open_tree(sessions::NAME)?;
|
||||||
|
|
||||||
|
let categories: HashMap<categories::K, categories::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<sessions::K, sessions::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)| {
|
||||||
|
fn sum_sessions<'a>(iter: impl IntoIterator<Item = &'a sessions::V>) -> u64 {
|
||||||
|
iter.into_iter()
|
||||||
|
.map(|session| session.ended - session.started)
|
||||||
|
.map(|duration| duration.num_seconds() as u64)
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = BTreeMap::new();
|
||||||
|
for session in my_sessions {
|
||||||
|
let step_size = chrono::Duration::minutes(60);
|
||||||
|
let started_hour = session
|
||||||
|
.started
|
||||||
|
.with_minute(0)
|
||||||
|
.unwrap()
|
||||||
|
.with_second(0)
|
||||||
|
.unwrap()
|
||||||
|
.with_nanosecond(0)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut time = started_hour;
|
||||||
|
while time < session.ended {
|
||||||
|
*stats_per_hour.entry(time.hour()).or_default() += 1;
|
||||||
|
time = time.checked_add_signed(step_size).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let max_weight = *stats_per_hour.values().max().unwrap_or(&0);
|
||||||
|
for weight in stats_per_hour.values_mut() {
|
||||||
|
*weight = *weight * 100 / max_weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
CategoryStatsContext {
|
||||||
|
category_id,
|
||||||
|
category,
|
||||||
|
|
||||||
|
last_session_start: last_session.map(|session| session.started),
|
||||||
|
secs_last_session,
|
||||||
|
secs_last_week,
|
||||||
|
secs_last_month,
|
||||||
|
|
||||||
|
bars: (0..24)
|
||||||
|
.map(|hour| {
|
||||||
|
let percentage = *stats_per_hour.entry(hour).or_default();
|
||||||
|
(hour + 1, percentage, 100 - 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", &context))
|
||||||
|
}
|
||||||
|
|||||||
124
static/charts.css
Normal file
124
static/charts.css
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
/* 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: #fff;
|
||||||
|
background: -webkit-gradient(linear, 0 0, 0 100%, from(#d1d1d1), color-stop(4%, #fff)) 0 0;
|
||||||
|
background: -webkit-linear-gradient(top, #d1d1d1 0%, #fff 4%) 0 0;
|
||||||
|
background: -moz-linear-gradient(top, #d1d1d1 0%, #fff 4%) 0 0;
|
||||||
|
background: -ms-linear-gradient(top, #d1d1d1 0%, #fff 4%) 0 0;
|
||||||
|
background: -o-linear-gradient(top, #d1d1d1 0%, #fff 4%) 0 0;
|
||||||
|
background: linear-gradient(top, #d1d1d1 0%, #fff 4%) 0 0;
|
||||||
|
|
||||||
|
background-size: 100% 40px;
|
||||||
|
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart_histogram_col {
|
||||||
|
max-width: 2em;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart_histogram_col_line {
|
||||||
|
display: flex;
|
||||||
|
width: 1em;
|
||||||
|
margin: auto;
|
||||||
|
|
||||||
|
background-color: #785ddc;
|
||||||
|
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 0.1em;
|
||||||
|
border-radius: 1em;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
*/
|
||||||
@ -103,3 +103,10 @@ ul.striped_list > li:nth-of-type(odd) {
|
|||||||
.history_entry_delete_button {
|
.history_entry_delete_button {
|
||||||
color: #aa0000;
|
color: #aa0000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hline {
|
||||||
|
width: 99%;
|
||||||
|
height: 3px;
|
||||||
|
background-color: #aaaaaa;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|||||||
51
templates/stats.hbs
Normal file
51
templates/stats.hbs
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<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/png" href="/static/icon.png">
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
<link rel="stylesheet" href="/static/charts.css">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Ubuntu|Ubuntu+Mono&display=swap">
|
||||||
|
|
||||||
|
<title>stl</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h1 class="title">stl</h1>
|
||||||
|
|
||||||
|
{{#each categories_stats}}
|
||||||
|
{{#if this.last_session_start}}
|
||||||
|
<div class="hline"></div>
|
||||||
|
<h2>
|
||||||
|
<span>Senaste session:</span>
|
||||||
|
<span class="history_entry_started">{{pretty_datetime this.last_session_start}}</span>
|
||||||
|
<span>i</span>
|
||||||
|
<span class="history_entry_duration">{{pretty_seconds this.secs_last_session}}</span>
|
||||||
|
</h2>
|
||||||
|
<h2>
|
||||||
|
<span>Denna veckan:</span>
|
||||||
|
<span class="history_entry_duration">{{pretty_seconds this.secs_last_week}}</span>
|
||||||
|
</h2>
|
||||||
|
<h2>
|
||||||
|
<span>Denna månaden:</span>
|
||||||
|
<span class="history_entry_duration">{{pretty_seconds this.secs_last_month}}</span>
|
||||||
|
</h2>
|
||||||
|
<h2>Andel per timme:</h2>
|
||||||
|
<div class="chart_histogram">
|
||||||
|
{{#each this.bars}}
|
||||||
|
<div class="chart_histogram_col">
|
||||||
|
<div style="flex-basis: {{this.2}}%;"></div>
|
||||||
|
<div class="chart_histogram_col_line chart_col_tooltip" style="flex-basis: {{this.1}}%;">
|
||||||
|
<span class="chart_col_tooltiptext">{{this.1}}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="chart_histogram_col_label">{{this.0}}</div>
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
{{/each}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user