use super::Brain;
use crate::enums::named_enum;
use crate::model::{Task, now};
use crate::period::Period;
use chrono::{DateTime, Duration, Local};
use std::collections::BTreeMap;
named_enum! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Group {
#[default]
Task => "task",
Tag => "tag",
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReportView {
pub period: Period,
pub group: Group,
pub tag: Option<String>,
pub query: String,
}
impl ReportView {
pub fn new(period: Period) -> Self {
Self { period, group: Group::default(), tag: None, query: String::new() }
}
}
const UNTAGGED: &str = "(untagged)";
const MIN_GAP_MINUTES: i64 = 5;
#[derive(Debug)]
pub struct Slot {
pub start: DateTime<Local>,
pub end: Option<DateTime<Local>>,
pub id: u32,
pub title: String,
pub tags: Vec<String>,
}
#[derive(Debug)]
pub struct ReportRow {
pub label: String,
pub tags: Vec<String>,
pub total: Duration,
pub cells: Vec<Duration>,
}
#[derive(Debug)]
pub struct Report {
pub total: Duration,
pub rows: Vec<ReportRow>,
pub columns: Vec<String>,
pub column_totals: Vec<Duration>,
pub timeline: Vec<Slot>,
}
impl Report {
pub fn timeline_with_gaps(&self, now: DateTime<Local>) -> Vec<(Option<Duration>, &Slot)> {
let mut prev_end: Option<DateTime<Local>> = None;
self.timeline
.iter()
.map(|slot| {
let gap = prev_end.map(|p| slot.start - p).filter(|g| *g >= Duration::minutes(MIN_GAP_MINUTES));
let end = slot.end.unwrap_or(now);
prev_end = Some(prev_end.map_or(end, |p| p.max(end)));
(gap, slot)
})
.collect()
}
}
impl Report {
pub fn merge(parts: Vec<(Option<String>, Report)>, group: Group) -> Report {
let columns = parts.first().map(|(_, r)| r.columns.clone()).unwrap_or_default();
let mut total = Duration::zero();
let mut column_totals = vec![Duration::zero(); columns.len()];
let mut rows: BTreeMap<String, ReportRow> = BTreeMap::new();
let mut timeline = Vec::new();
for (project, report) in parts {
let named = |label: String| project.as_ref().map_or_else(|| label.clone(), |p| format!("{p} · {label}"));
total += report.total;
add_cells(&mut column_totals, &report.column_totals);
for row in report.rows {
let label = if group == Group::Task { named(row.label) } else { row.label };
match rows.get_mut(&label) {
Some(sum) => {
sum.total += row.total;
add_cells(&mut sum.cells, &row.cells);
}
None => _ = rows.insert(label.clone(), ReportRow { label, ..row }),
}
}
timeline.extend(report.timeline.into_iter().map(|slot| Slot { title: named(slot.title.clone()), ..slot }));
}
let mut rows: Vec<ReportRow> = rows.into_values().collect();
rows.sort_by_key(|r| std::cmp::Reverse(r.total));
timeline.sort_by_key(|s| s.start);
Report { total, rows, columns, column_totals, timeline }
}
}
struct RowSums {
total: Duration,
cells: Vec<Duration>,
tags: Vec<String>,
}
impl RowSums {
fn new(columns: usize) -> Self {
Self { total: Duration::zero(), cells: vec![Duration::zero(); columns], tags: Vec::new() }
}
fn add(&mut self, total: Duration, cells: &[Duration]) {
self.total += total;
add_cells(&mut self.cells, cells);
}
}
fn add_cells(sums: &mut [Duration], cells: &[Duration]) {
for (sum, cell) in sums.iter_mut().zip(cells) {
*sum += *cell;
}
}
impl Brain {
pub fn report(&self, view: &ReportView) -> Report {
let now = now();
let (from, to) = view.period.bounds();
let (labels, ranges): (Vec<String>, Vec<_>) = view
.period
.columns()
.into_iter()
.map(|(label, p)| {
let (a, b) = p.bounds();
(label, (a.max(from), b.min(to)))
})
.unzip();
let mut total = Duration::zero();
let mut column_totals = vec![Duration::zero(); labels.len()];
let mut rows: BTreeMap<String, RowSums> = BTreeMap::new();
let mut timeline = Vec::new();
let tasks = self
.tasks
.iter()
.filter(|t| view.tag.as_ref().is_none_or(|tag| t.tags.contains(tag)) && t.matches(&view.query));
for task in tasks {
let task_total = task.time_between(from, to, now);
if task_total.is_zero() {
continue;
}
let cells: Vec<Duration> = ranges.iter().map(|&(a, b)| task.time_between(a, b, now)).collect();
total += task_total;
add_cells(&mut column_totals, &cells);
for label in row_labels(task, view.group) {
let row = rows.entry(label).or_insert_with(|| RowSums::new(ranges.len()));
if view.group == Group::Task {
row.tags.clone_from(&task.tags);
}
row.add(task_total, &cells);
}
timeline.extend(slots(task, from, to, now));
}
let mut rows: Vec<ReportRow> = rows
.into_iter()
.map(|(label, sums)| ReportRow { label, tags: sums.tags, total: sums.total, cells: sums.cells })
.collect();
rows.sort_by_key(|r| std::cmp::Reverse(r.total));
timeline.sort_by_key(|s| s.start);
Report { total, rows, columns: labels, column_totals, timeline }
}
}
fn row_labels(task: &Task, group: Group) -> Vec<String> {
match group {
Group::Task => vec![format!("#{} {}", task.id, task.title)],
Group::Tag if task.tags.is_empty() => vec![UNTAGGED.to_string()],
Group::Tag => task.tags.iter().map(|tag| format!("#{tag}")).collect(),
}
}
fn slots(
task: &Task,
from: DateTime<Local>,
to: DateTime<Local>,
now: DateTime<Local>,
) -> impl Iterator<Item = Slot> + '_ {
task.sessions.iter().filter(move |s| !s.overlap(from, to, now).is_zero()).map(move |s| Slot {
start: s.start.max(from),
end: s.end.map(|e| e.min(to)),
id: task.id,
title: task.title.clone(),
tags: task.tags.clone(),
})
}