kasl/libs/summary.rs
1//! Monthly summary math: fold in rest days, total and average, format.
2//!
3//! ```rust,no_run
4//! use kasl::libs::summary::{DailySummary, SummaryCalculator, SummaryFormatter};
5//! use chrono::{Duration, NaiveDate};
6//! use std::collections::HashSet;
7//!
8//! let summaries = vec![
9//! DailySummary {
10//! date: NaiveDate::from_ymd_opt(2025, 8, 11).unwrap(),
11//! duration: Duration::hours(8),
12//! productivity: 85.5,
13//! rest_day: false,
14//! },
15//! ];
16//!
17//! let rest_dates = HashSet::new();
18//! let (processed, total, average) = summaries
19//! .add_rest_dates(rest_dates, Duration::hours(8))
20//! .calculate_totals();
21//! ```
22
23use crate::libs::formatter::format_duration;
24use chrono::{Duration, NaiveDate};
25use std::collections::{HashMap, HashSet};
26
27/// One day's line in the monthly summary.
28#[derive(Debug, Clone)]
29pub struct DailySummary {
30 /// Local calendar date.
31 pub date: NaiveDate,
32
33 /// Net productive time: presence minus pauses.
34 pub duration: Duration,
35
36 /// Productivity percentage for the day, 0-100.
37 pub productivity: f64,
38
39 /// A company rest day counted as a full day, rather than a recorded one.
40 pub rest_day: bool,
41}
42
43/// Builds the monthly figures by chaining transformations:
44///
45/// ```rust,no_run
46/// use kasl::libs::summary::{DailySummary, SummaryCalculator};
47/// use chrono::Duration;
48/// use std::collections::HashSet;
49///
50/// let summaries: Vec<DailySummary> = vec![];
51/// let company_holidays = HashSet::new();
52/// let default_hours = Duration::hours(8);
53///
54/// let result = summaries
55/// .add_rest_dates(company_holidays, default_hours)
56/// .calculate_totals();
57/// ```
58pub trait SummaryCalculator {
59 /// Adds an entry for each rest date the collection does not already
60 /// cover, with the given default duration and zero productivity.
61 ///
62 /// Rest days carry default hours on purpose: the monthly report is what
63 /// goes to payroll, and paid holidays count toward the monthly total
64 /// while contributing no productivity.
65 ///
66 /// ```rust,no_run
67 /// use kasl::libs::summary::{DailySummary, SummaryCalculator};
68 /// use std::collections::HashSet;
69 /// use chrono::{Duration, NaiveDate};
70 ///
71 /// let work_summaries: Vec<DailySummary> = vec![];
72 /// let mut rest_dates = HashSet::new();
73 /// rest_dates.insert(NaiveDate::from_ymd_opt(2025, 8, 15).unwrap()); // Company holiday
74 ///
75 /// let enhanced_summaries = work_summaries
76 /// .add_rest_dates(rest_dates, Duration::hours(8));
77 /// ```
78 fn add_rest_dates(self, rest_dates: HashSet<NaiveDate>, duration: Duration) -> Self;
79
80 /// Sorts by date and returns `(sorted, total, average-per-day)`;
81 /// an empty collection yields zero for both figures.
82 ///
83 /// ```rust,no_run
84 /// use kasl::libs::summary::{DailySummary, SummaryCalculator};
85 /// use kasl::libs::formatter::format_duration;
86 /// use std::collections::HashSet;
87 /// use chrono::Duration;
88 ///
89 /// let summaries: Vec<DailySummary> = vec![];
90 /// let rest_dates = HashSet::new();
91 ///
92 /// let (processed_summaries, total_hours, average_daily) = summaries
93 /// .add_rest_dates(rest_dates, Duration::hours(8))
94 /// .calculate_totals();
95 ///
96 /// println!("Total monthly hours: {}", format_duration(&total_hours));
97 /// println!("Average daily hours: {}", format_duration(&average_daily));
98 /// # let _ = processed_summaries;
99 /// ```
100 fn calculate_totals(self) -> (Self, Duration, Duration)
101 where
102 Self: Sized;
103}
104
105impl SummaryCalculator for Vec<DailySummary> {
106 fn add_rest_dates(mut self, rest_dates: HashSet<NaiveDate>, duration: Duration) -> Self {
107 for rest_date in rest_dates {
108 let date_exists = self.iter().any(|summary| summary.date == rest_date);
109
110 if !date_exists {
111 self.push(DailySummary {
112 date: rest_date,
113 duration,
114 productivity: 0.0, // Rest days have zero productivity
115 rest_day: true,
116 });
117 }
118 }
119
120 self
121 }
122
123 fn calculate_totals(mut self) -> (Self, Duration, Duration) {
124 self.sort_by_key(|summary| summary.date);
125
126 let total_duration = self.iter().fold(Duration::zero(), |accumulator, summary| accumulator + summary.duration);
127
128 let day_count = self.len() as i64;
129 let average_duration = if day_count > 0 {
130 Duration::seconds(total_duration.num_seconds() / day_count)
131 } else {
132 Duration::zero()
133 };
134
135 (self, total_duration, average_duration)
136 }
137}
138
139/// Renders calculated summaries for display.
140pub trait SummaryFormatter {
141 /// Returns `(per-date map of (duration, productivity), total, average)`,
142 /// durations as `HH:MM` and productivity as `XX.X%`.
143 ///
144 /// ```rust,no_run
145 /// use kasl::libs::summary::{DailySummary, SummaryFormatter};
146 /// use chrono::Duration;
147 ///
148 /// let calculated_summaries: (Vec<DailySummary>, Duration, Duration) =
149 /// (vec![], Duration::zero(), Duration::zero());
150 ///
151 /// let (daily_map, total_str, avg_str) = calculated_summaries.format_summary();
152 ///
153 /// for (date, (duration, productivity)) in daily_map {
154 /// println!("{}: {} hours ({})", date, duration, productivity);
155 /// }
156 ///
157 /// println!("Total: {}, Average: {}", total_str, avg_str);
158 /// ```
159 fn format_summary(&self) -> (HashMap<NaiveDate, (String, String)>, String, String);
160}
161
162impl SummaryFormatter for (Vec<DailySummary>, Duration, Duration) {
163 fn format_summary(&self) -> (HashMap<NaiveDate, (String, String)>, String, String) {
164 let (daily_summaries, total_duration, average_duration) = self;
165
166 let daily_durations = daily_summaries
167 .iter()
168 .map(|summary| {
169 let formatted_duration = format_duration(&summary.duration);
170 let formatted_productivity = format!("{:.1}%", summary.productivity);
171 (summary.date, (formatted_duration, formatted_productivity))
172 })
173 .collect();
174
175 let total_duration_str = format_duration(total_duration);
176 let average_duration_str = format_duration(average_duration);
177
178 (daily_durations, total_duration_str, average_duration_str)
179 }
180}