437 lines
14 KiB
Rust
437 lines
14 KiB
Rust
use crate::auth::Authorized;
|
|
use crate::database::latest::trees::{category, session};
|
|
use crate::status_json::StatusJson;
|
|
use crate::util::OrdL;
|
|
use chrono::{Date, DateTime, Datelike, Duration, Local, Timelike};
|
|
use itertools::Itertools;
|
|
use rocket::{get, http::Status, response::content::Html, serde::uuid::Uuid, State};
|
|
use rocket_dyn_templates::Template;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct CategoryStatsCtx {
|
|
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)>,
|
|
|
|
calendar: CalendarCtx,
|
|
}
|
|
|
|
type ChildMap = HashMap<category::K, HashSet<category::K>>;
|
|
|
|
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()
|
|
}
|
|
|
|
/// Generate a flat child-map
|
|
///
|
|
/// The result is a map from category to *every* category below it in the tree
|
|
fn calculate_child_map(categories: &HashMap<category::K, category::V>) -> ChildMap {
|
|
let mut child_map: ChildMap = HashMap::new();
|
|
|
|
const RECURSION_LIMIT: usize = 255;
|
|
|
|
let mut last_len = 0;
|
|
let mut new_child_buffer = vec![];
|
|
|
|
// make sure we don't recurse forever
|
|
// if RECURSION_LIMIT is less than the tree depth, the resulting map will be incomplete
|
|
for _ in 0..RECURSION_LIMIT {
|
|
for (&id, category) in categories.iter() {
|
|
// find parent category and add *my* children to the list of *its* children
|
|
if let Some(parent_id) = category.parent {
|
|
child_map.entry(parent_id).or_default().insert(id);
|
|
|
|
for &child_id in child_map.entry(id).or_default().iter() {
|
|
new_child_buffer.push(child_id);
|
|
}
|
|
|
|
for child_id in new_child_buffer.drain(..) {
|
|
child_map.entry(parent_id).or_default().insert(child_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// if the list didn't change, we're done
|
|
let new_len = child_map.values().map(|children| children.len()).sum();
|
|
if new_len == last_len {
|
|
return child_map;
|
|
}
|
|
last_len = new_len;
|
|
}
|
|
|
|
panic!(
|
|
"Recursion-limit ({}) reached traversing category graph. Likely stuck in infinite loop.",
|
|
RECURSION_LIMIT
|
|
);
|
|
}
|
|
|
|
/// Create a new CategoryStatsCtx
|
|
fn category_stats_ctx(
|
|
now: DateTime<Local>,
|
|
category_id: category::K,
|
|
category: category::V,
|
|
sessions: &HashMap<session::K, session::V>,
|
|
child_map: &ChildMap,
|
|
) -> CategoryStatsCtx {
|
|
let my_sessions = sessions.values().filter(|session| {
|
|
session.category == category_id
|
|
|| child_map
|
|
.get(&category_id)
|
|
.map(|children| children.contains(&session.category))
|
|
.unwrap_or(false)
|
|
});
|
|
|
|
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.clone());
|
|
let biggest_hour = *stats_per_hour
|
|
.values()
|
|
.max_by_key(|&f| OrdL(f))
|
|
.unwrap_or(&1.0);
|
|
|
|
CategoryStatsCtx {
|
|
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(),
|
|
|
|
calendar: compute_calendar_stats(my_sessions),
|
|
}
|
|
}
|
|
|
|
#[get("/stats/<category_id>")]
|
|
pub fn single_stats(
|
|
_auth: Authorized,
|
|
category_id: Uuid,
|
|
db: &State<sled::Db>,
|
|
) -> Result<Html<Template>, StatusJson> {
|
|
let categories_tree = db.open_tree(category::NAME)?;
|
|
let sessions_tree = db.open_tree(session::NAME)?;
|
|
|
|
let mut categories = category::get_all(&categories_tree)?;
|
|
let child_map = calculate_child_map(&categories);
|
|
let category = categories.remove(&category_id).ok_or(Status::NotFound)?;
|
|
|
|
let sessions: HashMap<session::K, session::V> = session::get_all(&sessions_tree)?;
|
|
|
|
let now = Local::now();
|
|
let ctx = category_stats_ctx(now, category_id, category, &sessions, &child_map);
|
|
Ok(Html(Template::render("stats_single", dbg!(&ctx))))
|
|
}
|
|
|
|
#[get("/stats")]
|
|
pub fn all_stats(_auth: Authorized, db: &State<sled::Db>) -> Result<Html<Template>, StatusJson> {
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct StatsContext {
|
|
categories_stats: Vec<CategoryStatsCtx>,
|
|
}
|
|
|
|
let now = Local::now();
|
|
|
|
let categories_tree = db.open_tree(category::NAME)?;
|
|
let sessions_tree = db.open_tree(session::NAME)?;
|
|
|
|
let categories = category::get_all(&categories_tree)?;
|
|
let sessions = session::get_all(&sessions_tree)?;
|
|
|
|
let child_map = calculate_child_map(&categories);
|
|
|
|
let mut categories_stats: Vec<_> = categories
|
|
.into_iter()
|
|
.filter(|(_, category)| !category.deleted)
|
|
.map(|(category_id, category)| {
|
|
category_stats_ctx(now, category_id, category, &sessions, &child_map)
|
|
})
|
|
.collect();
|
|
|
|
categories_stats.sort_by(|a, b| a.category.name.cmp(&b.category.name));
|
|
|
|
let context = StatsContext { categories_stats };
|
|
|
|
Ok(Html(Template::render("stats_all", &context)))
|
|
}
|
|
|
|
/// Compute the duration of `day` that is covered by the span `start..end`
|
|
fn span_duration_of_day(
|
|
start: DateTime<Local>,
|
|
end: DateTime<Local>,
|
|
day: Date<Local>,
|
|
) -> Duration {
|
|
if end < start {
|
|
panic!("start must come before end");
|
|
}
|
|
|
|
// if the span is 0
|
|
// or if the day is not in the span
|
|
// the duration is zero
|
|
if end == start || start.date() > day || end.date() < day {
|
|
return Duration::zero();
|
|
}
|
|
|
|
if start.date() < day {
|
|
if end.date() > day {
|
|
Duration::days(1)
|
|
} else {
|
|
debug_assert_eq!(end.date(), day);
|
|
|
|
end - day.and_hms(0, 0, 0)
|
|
}
|
|
} else if end.date() > day {
|
|
debug_assert_eq!(start.date(), day);
|
|
|
|
day.and_hms(0, 0, 0) + Duration::days(1) - start
|
|
} else {
|
|
debug_assert!(start < end);
|
|
debug_assert_eq!(start.date(), day);
|
|
debug_assert_eq!(end.date(), day);
|
|
|
|
end - start
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct CalendarDayCtx {
|
|
border_left: bool,
|
|
border_right: bool,
|
|
border_top: bool,
|
|
border_bottom: bool,
|
|
|
|
/// The visual weight of the day (between 0 and 1)
|
|
weight: f32,
|
|
|
|
/// Duration in seconds
|
|
duration: Option<u64>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct CalendarCtx {
|
|
weeks: Vec<Vec<Option<CalendarDayCtx>>>,
|
|
}
|
|
|
|
fn compute_calendar_stats<'a, I>(sessions: I) -> CalendarCtx
|
|
where
|
|
I: Iterator<Item = &'a session::V>,
|
|
{
|
|
const NUM_WEEKS: usize = 12;
|
|
|
|
let today = Local::today();
|
|
let last_day = today
|
|
// take at least NUM_WEEKS * 7 days
|
|
- Duration::weeks(NUM_WEEKS as i64)
|
|
// round up to nearest monday
|
|
- Duration::days(today.weekday().num_days_from_monday() as i64);
|
|
|
|
let mut days: BTreeMap<Date<Local>, Duration> = Default::default();
|
|
|
|
// calculate the time spent logging this category for every day of the last NUM_WEEKS
|
|
for session in sessions {
|
|
let mut current_day = today;
|
|
while current_day >= last_day {
|
|
let day_stats = days.entry(current_day).or_insert_with(Duration::zero);
|
|
|
|
*day_stats =
|
|
*day_stats + span_duration_of_day(session.started, session.ended, current_day);
|
|
|
|
current_day = current_day - Duration::days(1);
|
|
}
|
|
}
|
|
|
|
let biggest_day_duration = days
|
|
.values()
|
|
.copied()
|
|
.max()
|
|
.unwrap_or_else(|| Duration::seconds(1))
|
|
.num_seconds() as f32;
|
|
|
|
let weeks = days.iter().group_by(|(day, _)| day.iso_week());
|
|
let mut weeks: Vec<_> = weeks
|
|
.into_iter()
|
|
.map(|(week, weekdays)| {
|
|
weekdays
|
|
.map(|(&day, duration)| {
|
|
let duration = if duration.is_zero() {
|
|
None
|
|
} else {
|
|
Some(duration.num_seconds() as u64)
|
|
};
|
|
|
|
let month = day.month();
|
|
|
|
let month_border = |other_day: Date<_>| other_day.month() != month;
|
|
let month_or_week_border = |other_day: Date<_>| {
|
|
other_day.iso_week() != week || month_border(other_day)
|
|
};
|
|
|
|
const MIN_WEIGHT: f32 = 0.5;
|
|
|
|
let ctx = CalendarDayCtx {
|
|
border_left: month_border(day - Duration::weeks(1)),
|
|
border_right: month_border(day + Duration::weeks(1)),
|
|
border_top: month_or_week_border(day - Duration::days(1)),
|
|
border_bottom: month_or_week_border(day + Duration::days(1)),
|
|
|
|
weight: duration
|
|
.map(|d| d as f32 / biggest_day_duration)
|
|
.map(|w| (MIN_WEIGHT + w * (1.0 - MIN_WEIGHT)).clamp(0.0, 1.0))
|
|
.unwrap_or(1.0),
|
|
|
|
duration,
|
|
};
|
|
|
|
Some(ctx)
|
|
})
|
|
.collect()
|
|
})
|
|
.collect();
|
|
|
|
// calendar is shown as flex-direction: row-reverse
|
|
// because it should be scrolled from the right
|
|
weeks.reverse();
|
|
|
|
CalendarCtx { weeks }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use super::*;
|
|
use crate::database::latest::trees::session;
|
|
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()));
|
|
}
|
|
}
|
|
}
|