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}