caltemps/
reporter.rs

1use super::cli::{EnumOutputTypes, EnumReportTypes};
2use super::{CalTempsDateRange, CalTempsEntry};
3use std::cmp::min;
4use std::io;
5
6use chrono::{DateTime, Datelike, Days, Local, Months, Timelike};
7use cli_table::{Cell, Style, Table, format::Justify, print_stdout};
8use itertools::Itertools;
9
10fn reset_day(date: DateTime<Local>) -> DateTime<Local> {
11    date.with_hour(0)
12        .unwrap_or(date)
13        .with_minute(0)
14        .unwrap_or(date)
15        .with_second(0)
16        .unwrap_or(date)
17        .with_nanosecond(0)
18        .unwrap_or(date)
19}
20pub fn advance_years(date: DateTime<Local>, count: u32) -> DateTime<Local> {
21    reset_day(date)
22        .with_day(1)
23        .unwrap_or(date)
24        .with_month(1)
25        .unwrap_or(date)
26        .checked_add_months(Months::new(count * 12))
27        .unwrap_or(date)
28}
29pub fn advance_quarters(date: DateTime<Local>, count: u32) -> DateTime<Local> {
30    advance_months(date, 3 * count - (date.month0() % 3))
31}
32pub fn advance_months(date: DateTime<Local>, count: u32) -> DateTime<Local> {
33    reset_day(date)
34        .with_day(1)
35        .unwrap_or(date)
36        .checked_add_months(Months::new(count))
37        .unwrap_or(date)
38}
39pub fn advance_mondays(date: DateTime<Local>, count: u32) -> DateTime<Local> {
40    date.with_hour(0)
41        .unwrap_or(date)
42        .with_minute(0)
43        .unwrap_or(date)
44        .with_second(0)
45        .unwrap_or(date)
46        .with_nanosecond(0)
47        .unwrap_or(date)
48        .checked_add_days(Days::new(
49            (count * 7 - date.weekday().num_days_from_monday()).into(),
50        ))
51        .unwrap_or(date)
52}
53
54pub fn format_datetime(datetime: Option<DateTime<Local>>, report_type: EnumReportTypes) -> String {
55    match datetime {
56        Some(dt) => match report_type {
57            EnumReportTypes::Totals => format!("{}", dt),
58            EnumReportTypes::Yearly => format!("{:0>4}", dt.year()),
59            EnumReportTypes::Quarterly | EnumReportTypes::Monthly => {
60                format!("{:0>4}-{:0>2}", dt.year(), dt.month())
61            }
62            EnumReportTypes::Fortnightly | EnumReportTypes::Weekly => {
63                format!("{:0>4}-W{:0>2}", dt.year(), dt.iso_week().week())
64            }
65        },
66        _ => "".into(),
67    }
68}
69
70pub fn split_date_range(
71    date_range: &CalTempsDateRange,
72    report_type: EnumReportTypes,
73) -> Vec<CalTempsDateRange> {
74    match date_range.end {
75        Some(dre) => match date_range.start {
76            Some(drs) => match report_type {
77                // Only list totals
78                EnumReportTypes::Totals => vec![],
79                EnumReportTypes::Yearly
80                | EnumReportTypes::Quarterly
81                | EnumReportTypes::Monthly
82                | EnumReportTypes::Fortnightly
83                | EnumReportTypes::Weekly => {
84                    let mut o = Vec::<CalTempsDateRange>::new();
85                    let mut prev_start = drs;
86                    let get_next_day = match report_type {
87                        EnumReportTypes::Totals => todo!(), // This shouldn't be hit
88                        EnumReportTypes::Yearly => |d| advance_years(d, 1),
89                        EnumReportTypes::Quarterly => |d| advance_quarters(d, 1),
90                        EnumReportTypes::Monthly => |d| advance_months(d, 1),
91                        EnumReportTypes::Fortnightly => |d| advance_mondays(d, 2),
92                        EnumReportTypes::Weekly => |d| advance_mondays(d, 1),
93                    };
94                    let mut next_day = get_next_day(drs);
95                    while next_day < dre {
96                        // Add piece
97                        o.push(CalTempsDateRange {
98                            start: Some(prev_start),
99                            end: Some(next_day),
100                        });
101                        // Advance period
102                        prev_start = next_day;
103                        next_day = get_next_day(next_day);
104                    }
105                    o.push(CalTempsDateRange {
106                        start: Some(prev_start),
107                        end: Some(dre),
108                    });
109                    o
110                }
111            },
112            _ => vec![],
113        },
114        _ => vec![],
115    }
116}
117
118pub fn report_summary(
119    active_filter: String,
120    date_range: &CalTempsDateRange,
121    report_type: EnumReportTypes,
122    output_type: EnumOutputTypes,
123    entries: Vec<CalTempsEntry>,
124) {
125    let total = entries
126        .clone()
127        .into_iter()
128        .filter_map(|e| e.duration_minutes())
129        .sum::<i64>() as f64
130        / 60.0;
131    let last_date = entries
132        .clone()
133        .into_iter()
134        .filter_map(|e| match date_range.end {
135            Some(_) => min(date_range.end, e.end),
136            _ => e.end,
137        })
138        .max();
139    let covered_range = CalTempsDateRange {
140        start: date_range.start,
141        end: last_date,
142    };
143
144    let date_slots = split_date_range(&covered_range, report_type);
145
146    let data_header = [active_filter]
147        .into_iter()
148        .chain(
149            date_slots
150                .clone()
151                .into_iter()
152                .map(|dsl| format_datetime(dsl.start, report_type)),
153        )
154        .chain(["Hours".into()]);
155    let data_footer = [["TOTAL".to_string()]
156        .into_iter()
157        .chain(date_slots.clone().into_iter().map(|dsl| {
158            format!(
159                "{:.2}",
160                entries
161                    .clone()
162                    .into_iter()
163                    .filter_map(|e| e.date_range_intersection_minutes(&dsl.clone()))
164                    .sum::<i64>() as f64
165                    / 60.0
166            )
167        }))
168        .chain([format!("{:.2}", total)])
169        .collect::<Vec<_>>()];
170    let mut es = entries.clone();
171    es.sort_by_cached_key(|e| e.full_tags().join(" ").to_lowercase());
172    let data_body = es
173        .into_iter()
174        .chunk_by(|e| e.full_tags().join(" "))
175        .into_iter()
176        .map(|c| {
177            let matched_entries = c.1.collect::<Vec<_>>();
178            let matched_total_hours = matched_entries
179                .clone()
180                .into_iter()
181                .filter_map(|e| e.duration_minutes())
182                .sum::<i64>() as f64
183                / 60.0;
184            [c.0]
185                .into_iter()
186                .chain(date_slots.clone().into_iter().map(move |dsl| {
187                    let dsl_mins = matched_entries
188                        .clone()
189                        .into_iter()
190                        .filter_map(|e| e.date_range_intersection_minutes(&dsl.clone()))
191                        .sum::<i64>() as f64
192                        / 60.0;
193                    if dsl_mins > 0.0 {
194                        format!("{:.2}", dsl_mins)
195                    } else {
196                        "".into()
197                    }
198                }))
199                .chain([format!("{:.2}", matched_total_hours)])
200        })
201        .collect::<Vec<_>>();
202    match output_type {
203        EnumOutputTypes::Cli => {
204            // Format header row
205            let mut header_iter = data_header.into_iter();
206            let header_first = header_iter.next().unwrap();
207            let header_last = header_iter.next_back().unwrap();
208            let table_header = [header_first.cell().bold(true)]
209                .into_iter()
210                .chain(header_iter.map(|c| c.cell().bold(true).justify(Justify::Right)))
211                .chain([header_last.cell().bold(true).justify(Justify::Right)]);
212            // Format data rows
213            let table_body = data_body.into_iter().map(|r| {
214                let mut r_iter = r.into_iter();
215                let r_first = r_iter.next().unwrap();
216                [r_first.cell()]
217                    .into_iter()
218                    .chain(r_iter.map(|c| c.cell().justify(Justify::Right)))
219                    .collect::<Vec<_>>()
220            });
221            // Format footer rows
222            let table_footer = data_footer.into_iter().map(|r| {
223                let mut r_iter = r.into_iter();
224                let r_first = r_iter.next().unwrap();
225                [r_first.cell().bold(true)]
226                    .into_iter()
227                    .chain(r_iter.map(|c| c.cell().bold(true).justify(Justify::Right)))
228                    .collect::<Vec<_>>()
229            });
230            let table = table_body
231                .chain(table_footer)
232                .table()
233                .title(table_header)
234                .bold(true);
235            assert!(print_stdout(table).is_ok())
236        }
237        EnumOutputTypes::Csv => {
238            let mut wtr = csv::Writer::from_writer(io::stdout());
239            let _ = wtr.write_record(data_header);
240            let _ = data_body
241                .into_iter()
242                .map(|r| wtr.write_record(r))
243                .collect::<Vec<_>>();
244            let _ = data_footer
245                .into_iter()
246                .map(|r| wtr.write_record(r))
247                .collect::<Vec<_>>();
248            assert!(wtr.flush().is_ok());
249        }
250    }
251}