Skip to main content

kasl/libs/
monitor.rs

1//! The activity monitor: input events in, workdays and pauses out.
2//!
3//! A background thread listens to raw input via `rdev` and stamps
4//! `last_activity`; the async loop polls that timestamp and drives a
5//! two-state machine (Active / InPause) that writes workday and pause
6//! records.
7//!
8//! ```rust,no_run
9//! # async fn f() -> anyhow::Result<()> {
10//! use kasl::libs::config::MonitorConfig;
11//! use kasl::libs::monitor::Monitor;
12//!
13//! let config = MonitorConfig {
14//!     pause_threshold: 120,
15//!     activity_threshold: 60,
16//!     poll_interval: 1000,
17//!     min_pause_duration: 30,
18//!     min_work_interval: 15,
19//!     ..Default::default()
20//! };
21//!
22//! let mut monitor = Monitor::new(config)?;
23//! monitor.run().await?;
24//! # Ok(())
25//! # }
26//! ```
27
28use crate::db::pauses::Pauses;
29use crate::db::workdays::Workdays;
30use crate::libs::config::MonitorConfig;
31use crate::libs::messages::Message;
32use crate::{msg_debug, msg_error, msg_info};
33use anyhow::Result;
34use chrono::{Local, NaiveDate};
35use rdev::{EventType, listen};
36use std::sync::{Arc, Mutex};
37use tokio::time::{self, Duration, Instant};
38use tracing::{Level, debug, instrument, span};
39
40/// The two states the loop moves between.
41#[derive(Debug, Clone, Copy, PartialEq)]
42enum State {
43    /// Input seen recently; workday end keeps advancing.
44    Active,
45
46    /// Inactivity crossed the threshold; waiting for input to close the pause.
47    InPause,
48}
49
50/// The monitor's moving parts: config, database handles, and the shared
51/// timestamps the input thread writes.
52pub struct Monitor {
53    /// Thresholds and intervals; changes require a restart to apply.
54    pub config: MonitorConfig,
55
56    /// Pause table handle.
57    pub pauses: Pauses,
58
59    /// Workday table handle.
60    pub workdays: Workdays,
61
62    /// When input was last seen; written by the listener thread.
63    pub last_activity: Arc<Mutex<Instant>>,
64
65    /// Start of the current sustained-activity streak, or `None`.
66    ///
67    /// Set on the first input after quiet, cleared when a pause begins or a
68    /// workday is created. Requiring the streak to outlast
69    /// `activity_threshold` keeps a stray mouse nudge from starting a
70    /// workday.
71    pub activity_start: Arc<Mutex<Option<Instant>>>,
72
73    /// Current loop state.
74    state: State,
75}
76
77impl Monitor {
78    /// Opens the database handles and spawns the input listener thread.
79    ///
80    /// The listener updates `last_activity` on every keyboard/mouse event
81    /// and sets `activity_start` when a streak begins. Listener errors are
82    /// logged, not fatal - the loop keeps running without input data rather
83    /// than dying silently in the background.
84    ///
85    /// ```rust,no_run
86    /// # async fn f() -> anyhow::Result<()> {
87    /// use kasl::libs::config::MonitorConfig;
88    /// use kasl::libs::monitor::Monitor;
89    ///
90    /// let config = MonitorConfig::default();
91    /// let mut monitor = Monitor::new(config)?;
92    /// monitor.run().await?;
93    /// # Ok(())
94    /// # }
95    /// ```
96    #[instrument(skip(config))]
97    pub fn new(config: MonitorConfig) -> Result<Self> {
98        let span = span!(Level::INFO, "monitor_init");
99        let _enter = span.enter();
100
101        debug!("Initializing monitor with config: {:?}", config);
102
103        let pauses = Pauses::new()?;
104        let workdays = Workdays::new()?;
105
106        let last_activity = Arc::new(Mutex::new(Instant::now()));
107        let activity_start = Arc::new(Mutex::new(None));
108
109        let last_activity_clone = Arc::clone(&last_activity);
110        let activity_start_clone = Arc::clone(&activity_start);
111
112        // The listener blocks its thread, so it gets its own.
113        std::thread::spawn(move || {
114            if let Err(e) = listen(move |event| match event.event_type {
115                EventType::KeyPress(_)
116                | EventType::KeyRelease(_)
117                | EventType::ButtonPress(_)
118                | EventType::ButtonRelease(_)
119                | EventType::MouseMove { .. }
120                | EventType::Wheel { .. } => {
121                    {
122                        let mut last_activity = last_activity_clone.lock().unwrap();
123                        *last_activity = Instant::now();
124                    }
125
126                    {
127                        let mut activity_start = activity_start_clone.lock().unwrap();
128                        // First input after quiet starts the sustained-activity streak.
129                        if activity_start.is_none() {
130                            *activity_start = Some(Instant::now());
131                        }
132                    }
133                }
134            }) {
135                msg_error!(Message::ErrorInRdevListener(format!("{:?}", e)));
136            }
137        });
138
139        Ok(Monitor {
140            config,
141            pauses,
142            workdays,
143            last_activity,
144            activity_start,
145            state: State::Active,
146        })
147    }
148
149    /// Runs the polling loop until the process is stopped.
150    ///
151    /// Each tick checks for recent input and dispatches on (state, activity):
152    /// Active+quiet may open a pause, InPause+input closes it, Active+input
153    /// keeps the workday going. Database errors inside a tick are logged and
154    /// the loop continues - a transient lock must not kill the daemon.
155    ///
156    /// `pause_threshold == 0` disables the loop entirely (returns at once).
157    ///
158    /// ```rust,no_run
159    /// # async fn f() -> anyhow::Result<()> {
160    /// use kasl::libs::config::MonitorConfig;
161    /// use kasl::libs::monitor::Monitor;
162    ///
163    /// let config = MonitorConfig {
164    ///     poll_interval: 1000,      // Check every second
165    ///     pause_threshold: 120,     // Pause after 2 minutes
166    ///     activity_threshold: 30,   // Workday starts after 30s
167    ///     ..Default::default()
168    /// };
169    ///
170    /// let mut monitor = Monitor::new(config)?;
171    /// monitor.run().await?; // Runs indefinitely
172    /// # Ok(())
173    /// # }
174    /// ```
175    #[instrument(skip(self))]
176    pub async fn run(&mut self) -> Result<()> {
177        msg_info!(Message::MonitorStarted {
178            pause_threshold: self.config.pause_threshold,
179            poll_interval: self.config.poll_interval,
180            activity_threshold: self.config.activity_threshold,
181        });
182
183        // pause_threshold 0 means "no pause tracking".
184        if self.config.pause_threshold == 0 {
185            return Ok(());
186        }
187
188        loop {
189            let activity_detected = self.detect_activity();
190            let today = Local::now().date_naive();
191
192            match self.state {
193                State::Active if !activity_detected => {
194                    if let Err(e) = self.handle_inactivity() {
195                        msg_error!(Message::DatabaseOperationFailed {
196                            operation: "handle_inactivity".to_string(),
197                            error: e.to_string()
198                        });
199                    }
200                }
201                State::InPause if activity_detected => {
202                    if let Err(e) = self.handle_return_from_pause() {
203                        msg_error!(Message::DatabaseOperationFailed {
204                            operation: "handle_return_from_pause".to_string(),
205                            error: e.to_string()
206                        });
207                    }
208                }
209                State::Active if activity_detected => {
210                    if let Err(e) = self.ensure_workday_started(today) {
211                        msg_error!(Message::DatabaseOperationFailed {
212                            operation: "ensure_workday_started".to_string(),
213                            error: e.to_string()
214                        });
215                    }
216                }
217                // InPause with no activity: nothing to do.
218                _ => {}
219            }
220
221            time::sleep(Duration::from_millis(self.config.poll_interval)).await;
222        }
223    }
224
225    /// True when input was seen within the last poll interval.
226    ///
227    /// ```rust,no_run
228    /// use kasl::libs::monitor::Monitor;
229    /// use kasl::libs::config::MonitorConfig;
230    ///
231    /// let monitor = Monitor::new(MonitorConfig::default())?;
232    ///
233    /// if monitor.detect_activity() {
234    ///     println!("User is active");
235    /// } else {
236    ///     println!("User appears inactive");
237    /// }
238    /// # Ok::<(), anyhow::Error>(())
239    /// ```
240    pub fn detect_activity(&self) -> bool {
241        let elapsed = self.last_activity.lock().unwrap().elapsed();
242        let is_active = elapsed < Duration::from_millis(self.config.poll_interval);
243
244        msg_debug!(format!(
245            "Activity check: elapsed={:?}, active={}, threshold={:?}",
246            elapsed,
247            is_active,
248            Duration::from_millis(self.config.poll_interval)
249        ));
250
251        is_active
252    }
253
254    /// Opens a pause once inactivity exceeds `pause_threshold`.
255    ///
256    /// The pause start is backdated by the threshold: the user stopped
257    /// working when the input stopped, not when the detector noticed.
258    /// Nothing is recorded before the workday exists - pre-work idle must
259    /// not become a pause ending seconds before the day starts.
260    ///
261    /// ```rust,no_run
262    /// use kasl::libs::config::MonitorConfig;
263    ///
264    /// // Called automatically by the monitoring loop; sensitivity comes
265    /// // from the config:
266    /// let config = MonitorConfig {
267    ///     pause_threshold: 30,  // Detect pauses after 30 seconds
268    ///     ..Default::default()
269    /// };
270    /// ```
271    fn handle_inactivity(&mut self) -> Result<()> {
272        let idle_time = self.last_activity.lock().unwrap().elapsed();
273
274        if idle_time >= Duration::from_secs(self.config.pause_threshold) {
275            let today = Local::now().date_naive();
276            // Do not record pauses before the workday has started — otherwise
277            // pre-work idle creates a pause that ends seconds before workdays.start.
278            if self.workdays.fetch(today)?.is_none() {
279                return Ok(());
280            }
281
282            msg_info!(Message::PauseStarted);
283
284            // Backdate the start: the pause began when input stopped.
285            let pause_start_time = Local::now().naive_local() - chrono::Duration::seconds(self.config.pause_threshold as i64);
286            self.pauses.insert_start_with_time(pause_start_time)?;
287
288            self.state = State::InPause;
289
290            // Reset the streak so a post-pause workday start requires
291            // sustained activity again.
292            *self.activity_start.lock().unwrap() = None;
293        }
294
295        Ok(())
296    }
297
298    /// Closes the open pause and returns to Active.
299    ///
300    /// ```rust,no_run
301    /// // Called automatically by the monitoring loop when input resumes;
302    /// // completes the record opened by handle_inactivity.
303    /// ```
304    fn handle_return_from_pause(&mut self) -> Result<()> {
305        msg_info!(Message::PauseEnded);
306        self.pauses.insert_end()?;
307        self.state = State::Active;
308        Ok(())
309    }
310
311    /// Creates today's workday once sustained activity outlasts
312    /// `activity_threshold`; the streak tracker is cleared afterwards so
313    /// only one workday per date is created.
314    ///
315    /// ```rust,no_run
316    /// // Called automatically during the monitoring loop. With
317    /// // activity_threshold = 30: first input sets the streak start,
318    /// // and 30s of continued input creates the workday.
319    /// ```
320    pub fn ensure_workday_started(&mut self, today: NaiveDate) -> Result<()> {
321        let activity_start_time = {
322            let activity_start_guard = self.activity_start.lock().unwrap();
323            *activity_start_guard
324        };
325
326        if let Some(start_time) = activity_start_time {
327            let activity_duration = start_time.elapsed();
328
329            if activity_duration >= Duration::from_secs(self.config.activity_threshold) && self.workdays.fetch(today)?.is_none() {
330                match self.workdays.insert_start(today) {
331                    Ok(()) => {
332                        msg_info!(Message::WorkdayStarting(today.to_string()));
333                        *self.activity_start.lock().unwrap() = None;
334                    }
335                    Err(e) => {
336                        // Log and keep monitoring; one failed insert must
337                        // not stop the daemon.
338                        msg_error!(Message::WorkdayCreateFailed);
339                        debug!("Workday creation error: {:?}", e);
340                    }
341                }
342            }
343        }
344
345        Ok(())
346    }
347}