Add stats page

This commit is contained in:
2020-11-11 22:54:17 +01:00
parent 2e2d0c9332
commit 243c5e6e5a
5 changed files with 303 additions and 1 deletions

View File

@ -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,

View File

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

View File

@ -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
View 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}}&percnt;</span>
</div>
<div class="chart_histogram_col_label">{{this.0}}</div>
</div>
{{/each}}
</div>
{{/if}}
{{/each}}
</body>
</html>