Skip to main content

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}