use super::Brain;
use crate::enums::named_enum;
use crate::model::{Session, Task, covered, 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>,
pub project: Option<String>,
}
impl Slot {
pub fn label(&self) -> String {
match &self.project {
Some(project) => format!("{project} #{} {}", self.id, self.title),
None => format!("#{} {}", self.id, self.title),
}
}
}
#[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)>, view: &ReportView) -> Report {
let timeline = parts
.into_iter()
.flat_map(|(project, report)| {
report.timeline.into_iter().map(move |slot| Slot { project: project.clone(), ..slot })
})
.collect();
Report::from_timeline(timeline, view, now())
}
fn from_timeline(mut timeline: Vec<Slot>, view: &ReportView, now: DateTime<Local>) -> Report {
let (from, to) = view.period.bounds();
let (columns, 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();
timeline.sort_by_key(|s| s.start);
let time = |slots: &[&Slot], (a, b): (DateTime<Local>, DateTime<Local>)| {
covered(slots.iter().map(|s| Session { start: s.start, end: s.end }.span(a, b, now)))
};
let all: Vec<&Slot> = timeline.iter().collect();
let mut groups: BTreeMap<String, (Vec<&Slot>, Vec<String>)> = BTreeMap::new();
for slot in &timeline {
for label in row_labels(slot, view.group) {
let group = groups.entry(label).or_default();
group.0.push(slot);
if view.group == Group::Task {
group.1.clone_from(&slot.tags);
}
}
}
let mut rows: Vec<ReportRow> = groups
.into_iter()
.map(|(label, (slots, tags))| ReportRow {
label,
tags,
total: time(&slots, (from, to)),
cells: ranges.iter().map(|&range| time(&slots, range)).collect(),
})
.collect();
rows.sort_by_key(|r| std::cmp::Reverse(r.total));
Report {
total: time(&all, (from, to)),
column_totals: ranges.iter().map(|&range| time(&all, range)).collect(),
rows,
columns,
timeline,
}
}
}
impl Brain {
pub fn report(&self, view: &ReportView) -> Report {
let now = now();
let (from, to) = view.period.bounds();
let timeline = self
.tasks
.iter()
.filter(|t| view.tag.as_ref().is_none_or(|tag| t.tags.contains(tag)) && t.matches(&view.query))
.flat_map(|task| slots(task, from, to, now))
.collect();
Report::from_timeline(timeline, view, now)
}
}
fn row_labels(slot: &Slot, group: Group) -> Vec<String> {
match group {
Group::Task => vec![slot.label()],
Group::Tag if slot.tags.is_empty() => vec![UNTAGGED.to_string()],
Group::Tag => slot.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(),
project: None,
})
}