Fix per-hour stats view
The computation now accounts for partial hours
This commit is contained in:
@ -59,7 +59,7 @@ async 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::pages::stats::view,
|
||||||
routes::api::edit_session,
|
routes::api::edit_session,
|
||||||
routes::api::create_category,
|
routes::api::create_category,
|
||||||
routes::api::start_session,
|
routes::api::start_session,
|
||||||
|
|||||||
@ -1,15 +1,16 @@
|
|||||||
|
pub mod stats;
|
||||||
|
|
||||||
use crate::auth::Authorized;
|
use crate::auth::Authorized;
|
||||||
use crate::database::latest::trees::{categories, sessions};
|
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::{BTreeMap, HashMap};
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[get("/")]
|
#[get("/")]
|
||||||
@ -111,121 +112,3 @@ pub fn history(_auth: Authorized, db: State<'_, sled::Db>) -> Result<Template, S
|
|||||||
|
|
||||||
Ok(Template::render("history", &context))
|
Ok(Template::render("history", &context))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/stats")]
|
|
||||||
pub fn stats(_auth: Authorized, 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, 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))
|
|
||||||
}
|
|
||||||
|
|||||||
223
src/routes/pages/stats.rs
Normal file
223
src/routes/pages/stats.rs
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
use crate::auth::Authorized;
|
||||||
|
use crate::database::latest::trees::{categories, sessions};
|
||||||
|
use crate::status_json::StatusJson;
|
||||||
|
use bincode::deserialize;
|
||||||
|
use chrono::{DateTime, Local, Timelike};
|
||||||
|
use rocket::{get, State};
|
||||||
|
use rocket_contrib::templates::Template;
|
||||||
|
use serde_derive::{Deserialize, Serialize};
|
||||||
|
use std::cmp::Ordering;
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
|
||||||
|
#[get("/stats")]
|
||||||
|
pub fn view(_auth: Authorized, 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, f64, f64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = 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: (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", &context))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_percentage_per_hour<'a, I>(sessions: I) -> BTreeMap<u32, f64>
|
||||||
|
where
|
||||||
|
I: Iterator<Item = &'a sessions::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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dbg!(&stats_per_hour);
|
||||||
|
let sum_weight: f64 = dbg!(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()
|
||||||
|
};
|
||||||
|
|
||||||
|
sessions::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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -40,8 +40,8 @@
|
|||||||
<div class="chart_histogram">
|
<div class="chart_histogram">
|
||||||
{{#each this.bars}}
|
{{#each this.bars}}
|
||||||
<div class="chart_histogram_col">
|
<div class="chart_histogram_col">
|
||||||
<div style="flex-basis: {{this.2}}%;"></div>
|
<div style="flex-grow: {{this.2}};"></div>
|
||||||
<div class="chart_histogram_col_line chart_col_tooltip" style="flex-basis: {{this.1}}%;">
|
<div class="chart_histogram_col_line chart_col_tooltip" style="flex-grow: {{this.1}};">
|
||||||
<span class="chart_col_tooltiptext">{{this.1}}%</span>
|
<span class="chart_col_tooltiptext">{{this.1}}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart_histogram_col_label">{{this.0}}</div>
|
<div class="chart_histogram_col_label">{{this.0}}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user