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//!     },
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}