Skip to main content

kasl/libs/
productivity.rs

1//! Productivity calculation utilities for work time analysis.
2//!
3//! ## Productivity Formula
4//!
5//! ```text
6//! Productivity = (Net Work Time / Available Work Time) * 100
7//!
8//! Where:
9//! - Available Work Time = Total Time - Long Pauses
10//! - Net Work Time = Available Work Time - Short Pauses
11//! ```
12
13use crate::db::pauses::Pauses;
14use crate::db::workdays::Workday;
15use crate::libs::config::{Config, ProductivityConfig};
16use crate::libs::pause::Pause;
17use anyhow::Result;
18use chrono::Duration;
19
20/// The central productivity calculation, with the data it runs on.
21///
22/// The split into short and long pauses drives the whole formula: long
23/// pauses shrink the available time, short ones count against it.
24pub struct Productivity {
25    /// The workday record containing start/end times
26    pub workday: Workday,
27    /// Short automatic pauses (< min_pause_duration, not in database)
28    pub short_pauses: Vec<Pause>,
29    /// Long pauses (>= min_pause_duration, plus any manual protected pauses)
30    pub long_pauses: Vec<Pause>,
31    /// Productivity configuration settings and thresholds
32    pub config: ProductivityConfig,
33}
34
35impl Productivity {
36    /// Loads the workday's pauses, split at `min_pause_duration` from the
37    /// monitor config.
38    ///
39    /// ```rust,no_run
40    /// # fn f() -> anyhow::Result<()> {
41    /// use kasl::libs::productivity::Productivity;
42    /// use kasl::db::workdays::Workdays;
43    /// use chrono::Local;
44    ///
45    /// let mut workdays = Workdays::new()?;
46    /// let workday = workdays.fetch(Local::now().date_naive())?.unwrap();
47    /// let productivity = Productivity::new(&workday)?;
48    /// let current_productivity = productivity.calculate_productivity();
49    /// println!("Current productivity: {:.1}%", current_productivity);
50    /// # Ok(())
51    /// # }
52    /// ```
53    pub fn new(workday: &Workday) -> Result<Self> {
54        let config = Config::read()?;
55        let monitor_config = config.monitor.unwrap_or_default();
56        let productivity_config = config.productivity.unwrap_or_default();
57
58        Ok(Self {
59            workday: workday.clone(),
60            short_pauses: Pauses::new()?.set_max_duration(monitor_config.min_pause_duration).get_workday_pauses(workday)?,
61            long_pauses: Pauses::new()?.set_min_duration(monitor_config.min_pause_duration).get_workday_pauses(workday)?,
62            config: productivity_config,
63        })
64    }
65
66    /// Builds a calculator from explicit data, bypassing config and database -
67    /// for tests.
68    ///
69    /// ```rust,no_run
70    /// # fn f() {
71    /// use kasl::libs::productivity::Productivity;
72    /// use kasl::db::workdays::Workday;
73    /// use chrono::Local;
74    ///
75    /// let workday = Workday {
76    ///     id: 1,
77    ///     date: Local::now().date_naive(),
78    ///     start: Local::now().naive_local(),
79    ///     end: None,
80    /// };
81    /// let productivity = Productivity::with_test_data(
82    ///     &workday,
83    ///     vec![],
84    ///     vec![]
85    /// );
86    /// let result = productivity.calculate_productivity();
87    /// # let _ = result;
88    /// # }
89    /// ```
90    pub fn with_test_data(workday: &Workday, short_pauses: Vec<Pause>, long_pauses: Vec<Pause>) -> Self {
91        Self {
92            workday: workday.clone(),
93            short_pauses,
94            long_pauses,
95            config: ProductivityConfig::default(),
96        }
97    }
98
99    /// Reports whether productivity has fallen below the configured threshold.
100    ///
101    /// The check is suppressed early in the day: a short elapsed period makes the
102    /// ratio swing wildly on a single pause, so warning then would be noise. Once
103    /// `min_workday_fraction_before_suggest` of the expected workday has passed,
104    /// the figure is stable enough to act on.
105    pub fn is_below_threshold(&self) -> bool {
106        let now = chrono::Local::now().naive_local();
107        let elapsed = now - self.workday.start;
108        let expected_duration = Duration::seconds((self.config.workday_hours * 3600.0) as i64);
109        let min_elapsed = Duration::seconds((expected_duration.num_seconds() as f64 * self.config.min_workday_fraction_before_suggest) as i64);
110
111        if elapsed < min_elapsed {
112            return false;
113        }
114
115        self.calculate_productivity() < self.config.min_productivity_threshold
116    }
117
118    /// Returns the day's productivity percentage, clamped to 0-100.
119    ///
120    /// `(work - short pauses) / work`, where `work` is the gross day minus
121    /// long pauses. An ongoing day is measured up to now (via
122    /// [`crate::libs::report::workday_end_time`]); a day with no work time
123    /// yet reads 0.
124    ///
125    /// ```rust
126    /// use kasl::libs::productivity::Productivity;
127    /// use kasl::db::workdays::Workday;
128    /// use chrono::Local;
129    ///
130    /// let workday = Workday {
131    ///     id: 1,
132    ///     date: Local::now().date_naive(),
133    ///     start: Local::now().naive_local(),
134    ///     end: None,
135    /// };
136    /// let productivity_instance = Productivity::with_test_data(&workday, vec![], vec![]);
137    /// let productivity = productivity_instance.calculate_productivity();
138    ///
139    /// if productivity >= 75.0 {
140    ///     println!("Good productivity: {:.1}%", productivity);
141    /// } else {
142    ///     println!("Consider taking a break to improve focus");
143    /// }
144    /// ```
145    pub fn calculate_productivity(&self) -> f64 {
146        let end_time = crate::libs::report::workday_end_time(&self.workday, &self.long_pauses);
147        let gross_duration = end_time - self.workday.start;
148
149        // Long pauses: detected absences above the threshold, plus any manual
150        // pauses the user recorded (protected records bypass the threshold).
151        let long_pause_duration: Duration = self.long_pauses.iter().filter_map(|p| p.duration).sum();
152
153        // Short pauses: brief interruptions below the threshold.
154        let short_pause_duration: Duration = self.short_pauses.iter().filter_map(|p| p.duration).sum();
155
156        // Available work time excludes time the user was away entirely
157        let work_time = gross_duration - long_pause_duration;
158
159        // Net work time further excludes short pauses
160        let net_work_time = work_time - short_pause_duration;
161
162        // Calculate productivity percentage
163        if work_time.num_seconds() > 0 {
164            let productivity = (net_work_time.num_seconds() as f64 / work_time.num_seconds() as f64) * 100.0;
165            productivity.clamp(0.0, 100.0)
166        } else {
167            0.0
168        }
169    }
170}