Skip to main content

kanbrain_core/
export.rs

1//! Reports as markdown: what the desktop app's report shows, as tables.
2
3use crate::brain::{Group, Report, ReportView};
4use crate::model::{fmt_dur, slugify};
5use crate::period::Unit;
6use chrono::{DateTime, Datelike, Duration, Local};
7use std::fmt::{self, Write};
8
9/// Markdown version of a report.
10pub fn to_markdown(report: &Report, view: &ReportView, now: DateTime<Local>) -> String {
11    let mut out = String::new();
12    write_report(&mut out, report, view, now).expect("writing to a String cannot fail");
13    out
14}
15
16/// e.g. `report-2026-W39.md`, `report-2026-09-24-by-tag.md`, `report-2026-09-prod.md`.
17pub fn file_name(view: &ReportView) -> String {
18    let start = view.period.start;
19    let mut name = match view.period.unit {
20        Unit::Hour => start.format("report-%Y-%m-%d-%Hh").to_string(),
21        Unit::Day => start.format("report-%Y-%m-%d").to_string(),
22        Unit::Week => {
23            let week = start.iso_week();
24            format!("report-{}-W{:02}", week.year(), week.week())
25        }
26        Unit::Month => start.format("report-%Y-%m").to_string(),
27    };
28    if view.group == Group::Tag {
29        name.push_str("-by-tag");
30    }
31    if let Some(tag) = &view.tag {
32        name.push('-');
33        name.push_str(&slugify(tag));
34    }
35    name.push_str(".md");
36    name
37}
38
39/// Writes the whole report: summary, table and (for hours and days) the timeline.
40fn write_report(out: &mut String, report: &Report, view: &ReportView, now: DateTime<Local>) -> fmt::Result {
41    let period = view.period;
42    writeln!(out, "# Time report ยท {}\n", period.label())?;
43    writeln!(out, "- period: {}", period.unit.as_str())?;
44    writeln!(out, "- grouped by: {}", view.group.as_str())?;
45    if let Some(tag) = &view.tag {
46        writeln!(out, "- only tag: #{tag}")?;
47    }
48    if !view.query.trim().is_empty() {
49        writeln!(out, "- only tasks matching: {}", view.query.trim())?;
50    }
51    writeln!(out, "- tracked: **{}**", fmt_dur(report.total))?;
52    writeln!(out, "- generated: {}\n", now.format("%Y-%m-%d %H:%M"))?;
53
54    if report.rows.is_empty() {
55        return writeln!(out, "Nothing tracked in this period.");
56    }
57    write_table(out, report, view.group)?;
58    if view.group == Group::Tag && report.rows.len() > 1 {
59        writeln!(out, "\n_Tasks with several tags count under each, so tag rows can add up to more than the total._")?;
60    }
61    if matches!(period.unit, Unit::Hour | Unit::Day) {
62        write_timeline(out, report, now)?;
63    }
64    Ok(())
65}
66
67/// One row per task (with a tags column) or per tag, one column per day/week, and a totals row.
68fn write_table(out: &mut String, report: &Report, group: Group) -> fmt::Result {
69    let with_tags = group == Group::Task;
70    let tags_column = |value: String| if with_tags { vec![value] } else { Vec::new() };
71
72    let mut header = vec![group.as_str().to_string()];
73    header.extend(tags_column("tags".into()));
74    header.extend(report.columns.iter().cloned());
75    header.push("total".into());
76    table_row(out, &header)?;
77
78    let mut align = vec!["---".to_string()];
79    align.extend(tags_column("---".into()));
80    align.extend(std::iter::repeat_n("---:".to_string(), report.columns.len() + 1));
81    table_row(out, &align)?;
82
83    for row in &report.rows {
84        let mut cells = vec![escape(&row.label)];
85        cells.extend(tags_column(row.tags.join(", ")));
86        cells.extend(row.cells.iter().map(|&d| cell(d)));
87        cells.push(fmt_dur(row.total));
88        table_row(out, &cells)?;
89    }
90
91    let mut totals = vec!["**total**".to_string()];
92    totals.extend(tags_column(String::new()));
93    totals.extend(report.column_totals.iter().map(|&d| cell(d)));
94    totals.push(format!("**{}**", fmt_dur(report.total)));
95    table_row(out, &totals)
96}
97
98/// Every session in order, with untracked gaps between them.
99fn write_timeline(out: &mut String, report: &Report, now: DateTime<Local>) -> fmt::Result {
100    writeln!(out, "\n## Timeline\n")?;
101    table_row(out, &["start", "end", "duration", "task", "tags"].map(String::from))?;
102    table_row(out, &["---", "---", "---:", "---", "---"].map(String::from))?;
103    for (gap, slot) in report.timeline_with_gaps(now) {
104        if let Some(gap) = gap {
105            table_row(out, &[String::new(), String::new(), fmt_dur(gap), "_untracked_".into(), String::new()])?;
106        }
107        let end = slot.end.unwrap_or(now);
108        table_row(
109            out,
110            &[
111                slot.start.format("%H:%M").to_string(),
112                slot.end.map_or_else(|| "running".to_string(), |e| e.format("%H:%M").to_string()),
113                fmt_dur(end - slot.start),
114                escape(&slot.label()),
115                slot.tags.join(", "),
116            ],
117        )?;
118    }
119    Ok(())
120}
121
122/// Writes one markdown table row: `| a | b | c |`.
123fn table_row(out: &mut String, cells: &[String]) -> fmt::Result {
124    writeln!(out, "| {} |", cells.join(" | "))
125}
126
127/// Empty cells stay blank so the table is easy to scan.
128fn cell(d: Duration) -> String {
129    if d.is_zero() { String::new() } else { fmt_dur(d) }
130}
131
132/// Keeps task titles from breaking the table.
133fn escape(s: &str) -> String {
134    s.replace('|', "\\|")
135}
136
137#[cfg(test)]
138#[path = "../tests/export.rs"]
139mod tests;