Display category children & Add calendar stats

This commit is contained in:
2021-04-30 14:48:42 +02:00
parent e8e8f535c2
commit c3870bcded
22 changed files with 759 additions and 338 deletions

View File

@ -1,19 +1,18 @@
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 crate::util::OrdL;
use chrono::{Date, DateTime, Datelike, Duration, Local, Timelike};
use itertools::Itertools;
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};
use std::collections::{BTreeMap, HashMap, HashSet};
#[derive(Debug, Serialize, Deserialize)]
struct CategoryStatsContext {
struct CategoryStatsCtx {
category_id: category::K,
category: category::V,
@ -24,8 +23,12 @@ struct CategoryStatsContext {
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)
@ -33,35 +36,69 @@ fn sum_sessions<'a>(iter: impl IntoIterator<Item = &'a session::V>) -> 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)?;
/// 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();
let category: category::V =
get_category(&categories_tree, &category_uuid)?.ok_or(Status::NotFound)?;
const RECURSION_LIMIT: usize = 255;
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 last_len = 0;
let mut new_child_buffer = vec![];
let my_sessions = sessions
.values()
.filter(|session| session.category == *category_uuid);
// 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 now = Local::now();
let secs_last_week = sum_sessions(
my_sessions
.clone()
@ -74,18 +111,14 @@ pub fn single_stats(
.filter(|session| (now - session.started) <= chrono::Duration::days(30)),
);
let mut stats_per_hour = compute_percentage_per_hour(my_sessions);
let mut stats_per_hour = compute_percentage_per_hour(my_sessions.clone());
let biggest_hour = *stats_per_hour
.values()
.max_by(|f1, f2| match () {
_ if f1 == f2 => Ordering::Equal,
_ if f1 > f2 => Ordering::Greater,
_ => Ordering::Less,
})
.max_by_key(|&f| OrdL(f))
.unwrap_or(&1.0);
let context = CategoryStatsContext {
category_id: *category_uuid,
CategoryStatsCtx {
category_id,
category,
last_session_start: last_session.map(|session| session.started),
@ -100,16 +133,36 @@ pub fn single_stats(
(hour, percentage, biggest_hour - percentage)
})
.collect(),
};
Ok(Template::render("stats_single", &context))
calendar: compute_calendar_stats(my_sessions),
}
}
#[get("/stats/<category_id>")]
pub fn single_stats(
_auth: Authorized,
category_id: 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 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(Template::render("stats_single", dbg!(&ctx)))
}
#[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>,
categories_stats: Vec<CategoryStatsCtx>,
}
let now = Local::now();
@ -117,70 +170,16 @@ pub fn all_stats(_auth: Authorized, db: State<'_, sled::Db>) -> Result<Template,
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 categories = category::get_all(&categories_tree)?;
let sessions = session::get_all(&sessions_tree)?;
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 child_map = calculate_child_map(&categories);
let mut categories_stats: Vec<_> = categories
.into_iter()
.filter(|(_, category)| !category.deleted)
.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(),
}
category_stats_ctx(now, category_id, category, &sessions, &child_map)
})
.collect();
@ -191,6 +190,44 @@ pub fn all_stats(_auth: Authorized, db: State<'_, sled::Db>) -> Result<Template,
Ok(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>,
@ -247,10 +284,115 @@ where
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 {
days: 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());
CalendarCtx {
days: 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| match days.get(&other_day) {
Some(_) => other_day.month() != month,
None => true,
};
let month_or_week_border = |other_day| match days.get(&other_day) {
Some(_) => other_day.iso_week() != week || month_border(other_day),
None => true,
};
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,
};
//(day.weekday(), Some(ctx))
Some(ctx)
})
.collect()
})
.collect(),
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::database::latest::trees::sessions;
use crate::database::latest::trees::session;
use chrono::{DateTime, Local};
#[test]