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