1use 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
9pub 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
16pub 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
39fn 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
67fn 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
98fn 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
122fn table_row(out: &mut String, cells: &[String]) -> fmt::Result {
124 writeln!(out, "| {} |", cells.join(" | "))
125}
126
127fn cell(d: Duration) -> String {
129 if d.is_zero() { String::new() } else { fmt_dur(d) }
130}
131
132fn escape(s: &str) -> String {
134 s.replace('|', "\\|")
135}
136
137#[cfg(test)]
138#[path = "../tests/export.rs"]
139mod tests;