Display category children & Add calendar stats
This commit is contained in:
@ -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]
|
||||
|
||||
Reference in New Issue
Block a user