Skip to main content

kasl/db/
pauses.rs

1//! Database operations for tracking pause periods.
2//!
3//! Manages the storage and retrieval of pause records during work sessions:
4//! absences detected automatically by the activity monitor, and ones the user
5//! recorded by hand because the monitor could not see them.
6//!
7//! ## Features
8//!
9//! - **Automatic Detection**: Records pauses when user activity stops
10//! - **Manual Entry**: Records a complete pause with user-stated bounds
11//! - **Protection**: Manual pauses can bypass threshold filtering and merging
12//! - **Duration Calculation**: Automatic computation of pause lengths
13//! - **Daily Filtering**: Retrieve pauses for specific dates with duration thresholds
14//! - **Batch Operations**: Delete multiple pause records efficiently
15//!
16//! ## Usage
17//!
18//! ```rust,no_run
19//! # fn main() -> anyhow::Result<()> {
20//! use kasl::db::pauses::Pauses;
21//!
22//! let pauses = Pauses::new()?;
23//! pauses.insert_start()?;
24//! pauses.insert_end()?; // completes the most recent open pause
25//! # Ok(())
26//! # }
27//! ```
28
29use crate::db::db::Db;
30use crate::db::workdays::Workday;
31use crate::libs::config::Config;
32use crate::libs::pause::Pause;
33use anyhow::Result;
34use chrono::{Local, NaiveDate, NaiveDateTime, TimeDelta};
35use parking_lot::Mutex;
36use rusqlite::{Connection, params};
37use std::sync::Arc;
38
39/// SQL schema for the pauses table.
40///
41/// Defines the structure for storing pause/break records with temporal data.
42/// The schema supports both ongoing pauses (end IS NULL) and completed pauses
43/// with calculated durations for reporting and analysis.
44const SCHEMA_PAUSES: &str = "CREATE TABLE IF NOT EXISTS pauses (
45    id INTEGER NOT NULL PRIMARY KEY,
46    start TIMESTAMP NOT NULL,
47    end TIMESTAMP,
48    duration INTEGER,
49    protected INTEGER NOT NULL DEFAULT 0,
50    reason TEXT
51)";
52
53/// Insert a new pause start record with the current timestamp.
54///
55/// This query creates a new pause record with only the start time set,
56/// leaving end and duration as NULL until the pause is completed.
57const INSERT_PAUSE: &str = "INSERT INTO pauses (start) VALUES (datetime(CURRENT_TIMESTAMP, 'localtime'))";
58
59/// Insert a new pause start record with a specific timestamp.
60///
61/// Used for manually adding pauses or when importing historical data
62/// where the exact start time is known.
63const INSERT_PAUSE_WITH_TIME: &str = "INSERT INTO pauses (start) VALUES (?1)";
64
65/// Update the most recent open pause with end time and calculated duration.
66///
67/// Completes a pause record by setting the end timestamp and storing
68/// the calculated duration in seconds for later analysis.
69const UPDATE_PAUSE: &str = "UPDATE pauses SET end = (datetime(CURRENT_TIMESTAMP, 'localtime')), duration = ?1 WHERE id = ?2";
70
71/// Select the most recent uncompleted pause record.
72///
73/// Finds the last pause that has a start time but no end time,
74/// indicating an ongoing pause that needs to be completed.
75const SELECT_LAST_PAUSE: &str = "SELECT id, start FROM pauses WHERE end IS NULL ORDER BY id DESC LIMIT 1";
76
77/// Select all completed pauses for a specific date.
78///
79/// Retrieves every completed pause (end IS NOT NULL) for the given date
80/// ordered chronologically. Duration filtering is performed in Rust after
81/// merging consecutive pauses, so no threshold is applied here.
82const SELECT_DAILY_PAUSES: &str =
83    "SELECT id, start, end, duration, protected FROM pauses WHERE date(start) = date(?1) AND end IS NOT NULL ORDER BY start ASC, id ASC";
84
85/// Insert a complete manual pause with explicit start, end and duration.
86///
87/// Used by `kasl pauses add`, where the user states when they were away
88/// instead of the monitor detecting it. `protected` decides whether the
89/// record is exempt from threshold filtering and merging.
90const INSERT_MANUAL_PAUSE: &str = "INSERT INTO pauses (start, end, duration, protected, reason) VALUES (?1, ?2, ?3, ?4, ?5)";
91
92/// Delete a single pause record by ID.
93///
94/// Removes a pause record from the database, typically used for
95/// correcting incorrectly recorded pauses or data cleanup.
96const DELETE_PAUSE: &str = "DELETE FROM pauses WHERE id = ?";
97
98/// Database manager for pause/break tracking operations.
99///
100/// The `Pauses` struct provides a high-level interface for managing work break
101/// records in the database. It uses thread-safe connection handling to support
102/// concurrent access from the activity monitor and user commands.
103///
104/// ## Thread Safety
105///
106/// The connection is wrapped in an `Arc<Mutex<>>` to allow safe concurrent access
107/// from multiple threads, particularly important when the activity monitor
108/// is running in the background while users interact with the CLI.
109///
110/// ## Connection Management
111///
112/// Each `Pauses` instance maintains its own database connection and ensures
113/// the pauses table schema is properly initialized on creation.
114pub struct Pauses {
115    /// Thread-safe database connection wrapper.
116    ///
117    /// The connection is protected by a mutex to prevent race conditions
118    /// when multiple threads attempt to record or query pause data
119    /// simultaneously.
120    pub conn: Arc<Mutex<Connection>>,
121    pub min_duration: Option<String>,
122    pub max_duration: Option<String>,
123}
124
125impl Pauses {
126    /// Creates a new `Pauses` instance and initializes the database schema.
127    ///
128    /// This constructor establishes a database connection, ensures the pauses
129    /// table exists with the proper schema, and wraps the connection for
130    /// thread-safe access. The schema creation is idempotent and safe to
131    /// call multiple times.
132    ///
133    /// # Returns
134    ///
135    /// Returns a new `Pauses` instance ready for pause tracking operations,
136    /// or an error if database initialization fails.
137    ///
138    /// # Example
139    ///
140    /// ```rust,no_run
141    /// # fn main() -> anyhow::Result<()> {
142    /// use kasl::db::pauses::Pauses;
143    ///
144    /// let pauses = Pauses::new()?;
145    /// // Ready to track pauses
146    /// # Ok(())
147    /// # }
148    /// ```
149    ///
150    /// # Errors
151    ///
152    /// Returns an error if:
153    /// - Database connection cannot be established
154    /// - Schema creation fails due to permissions or corruption
155    /// - Table initialization encounters SQL errors
156    pub fn new() -> Result<Pauses> {
157        // Establish database connection through the central Db manager
158        let db_conn = Db::new()?.conn;
159
160        // Initialize the pauses table schema if it doesn't exist
161        db_conn.execute(SCHEMA_PAUSES, [])?;
162
163        // Wrap connection for thread-safe access
164        Ok(Pauses {
165            conn: Arc::new(Mutex::new(db_conn)),
166            min_duration: None,
167            max_duration: None,
168        })
169    }
170
171    pub fn set_min_duration(&self, min_duration: u64) -> Self {
172        let min_duration_secs = (min_duration * 60) as i64; // Convert minutes to seconds
173        Self {
174            conn: self.conn.clone(),
175            min_duration: Some(min_duration_secs.to_string()),
176            max_duration: None,
177        }
178    }
179
180    pub fn set_max_duration(&self, max_duration: u64) -> Self {
181        let max_duration_secs = (max_duration * 60) as i64; // Convert minutes to second
182        Self {
183            conn: self.conn.clone(),
184            min_duration: None,
185            max_duration: Some(max_duration_secs.to_string()),
186        }
187    }
188
189    /// Merges consecutive pauses separated only by an insignificant work gap.
190    ///
191    /// The activity monitor can split a single, effectively continuous break into
192    /// several adjacent pause records separated by brief bursts of activity (a
193    /// stray mouse move, a few seconds of typing). Because a duration threshold is
194    /// applied per-record, some of these segments may individually fall below the
195    /// threshold and be discarded, even though together they form one long pause.
196    /// Merging such chains before filtering ensures they are treated as a single
197    /// pause.
198    ///
199    /// Two pauses are merged when the gap between the end of one and the start of
200    /// the next does not exceed `max_gap_secs` (a work interval shorter than this
201    /// is not considered meaningful work). The merged pause keeps the earliest
202    /// start, the latest end, and its duration is recomputed as `end - start`.
203    ///
204    /// The input is expected to be sorted chronologically by start time.
205    /// Protected pauses never participate in merging: they were stated by the
206    /// user with explicit bounds, so absorbing them into a neighbour (or
207    /// absorbing a neighbour into them) would falsify what the user recorded.
208    fn merge_consecutive_pauses(pauses: Vec<Pause>, max_gap_secs: i64) -> Vec<Pause> {
209        let mut merged: Vec<Pause> = Vec::with_capacity(pauses.len());
210
211        for pause in pauses {
212            if pause.protected {
213                merged.push(pause);
214                continue;
215            }
216
217            if let Some(last) = merged.last_mut()
218                && !last.protected
219                && let Some(last_end) = last.end
220            {
221                // Merge when the work gap between the pauses is small enough
222                // (contiguous, overlapping, or a negligible burst of activity).
223                let gap = (pause.start - last_end).num_seconds();
224                if gap <= max_gap_secs {
225                    let new_end = match pause.end {
226                        Some(end) => Some(end.max(last_end)),
227                        None => Some(last_end),
228                    };
229                    last.end = new_end;
230                    if let Some(end) = last.end {
231                        last.duration = Some(TimeDelta::seconds((end - last.start).num_seconds()));
232                    }
233                    continue;
234                }
235            }
236            merged.push(pause);
237        }
238
239        merged
240    }
241
242    /// Returns the active duration threshold in seconds, if any is configured.
243    ///
244    /// Positive value with `min` semantics is expressed through `min_duration`,
245    /// `max` semantics through `max_duration`. Only one of them is ever set.
246    fn duration_threshold_seconds(&self) -> Option<i64> {
247        self.min_duration.as_ref().or(self.max_duration.as_ref()).and_then(|d| d.parse::<i64>().ok())
248    }
249
250    /// Records the start of a new pause with the current timestamp.
251    ///
252    /// This method creates a new pause record using the current system time
253    /// as the start timestamp. The pause remains "open" (end IS NULL) until
254    /// it's completed with `insert_end()`. Multiple open pauses are allowed
255    /// to handle edge cases in activity detection.
256    ///
257    /// # Returns
258    ///
259    /// Returns `Ok(())` if the pause start is recorded successfully,
260    /// or an error if the database operation fails.
261    ///
262    /// # Example
263    ///
264    /// ```rust,no_run
265    /// # use kasl::db::pauses::Pauses;
266    /// # fn main() -> anyhow::Result<()> {
267    /// let pauses = Pauses::new()?;
268    /// pauses.insert_start()?; // Pause started at current time
269    /// # Ok(())
270    /// # }
271    /// ```
272    ///
273    /// # Thread Safety
274    ///
275    /// This method is thread-safe and can be called concurrently from
276    /// multiple threads, such as the activity monitor daemon.
277    pub fn insert_start(&self) -> rusqlite::Result<()> {
278        let conn_guard = self.conn.lock();
279        conn_guard.execute(INSERT_PAUSE, [])?;
280        Ok(())
281    }
282
283    /// Records the start of a new pause with a specific timestamp.
284    ///
285    /// This method allows manual insertion of pause records with exact
286    /// timestamps, useful for importing historical data or correcting
287    /// activity tracking records. The specified time should be in the
288    /// local timezone for consistency with other records.
289    ///
290    /// # Arguments
291    ///
292    /// * `start_time` - The exact timestamp when the pause began
293    ///
294    /// # Returns
295    ///
296    /// Returns `Ok(())` if the pause is recorded successfully,
297    /// or an error if the database operation fails.
298    ///
299    /// # Example
300    ///
301    /// ```rust,no_run
302    /// # use kasl::db::pauses::Pauses;
303    /// use chrono::NaiveDateTime;
304    ///
305    /// # fn main() -> anyhow::Result<()> {
306    /// let pauses = Pauses::new()?;
307    /// let start_time = NaiveDateTime::parse_from_str(
308    ///     "2025-01-15 14:30:00",
309    ///     "%Y-%m-%d %H:%M:%S"
310    /// )?;
311    /// pauses.insert_start_with_time(start_time)?;
312    /// # Ok(())
313    /// # }
314    /// ```
315    ///
316    /// # Data Integrity
317    ///
318    /// The caller is responsible for ensuring the timestamp is reasonable
319    /// and doesn't conflict with existing work session boundaries.
320    pub fn insert_start_with_time(&self, start_time: NaiveDateTime) -> Result<()> {
321        let conn_guard = self.conn.lock();
322        let start_str = start_time.format("%Y-%m-%d %H:%M:%S").to_string();
323        conn_guard.execute(INSERT_PAUSE_WITH_TIME, [&start_str])?;
324        Ok(())
325    }
326
327    /// Completes the most recent open pause with duration calculation.
328    ///
329    /// This method finds the last pause record that has a start time but no
330    /// end time, then updates it with the current timestamp and the provided
331    /// duration. The duration is typically calculated by the activity monitor
332    /// based on the actual inactive period.
333    ///
334    /// ## Duration Calculation
335    ///
336    /// While the end timestamp is set to the current time, the duration
337    /// parameter contains the actual pause length in seconds. This allows
338    /// for accurate tracking even when there's a delay between activity
339    /// resumption and pause recording.
340    ///
341    /// # Returns
342    ///
343    /// Returns `Ok(())` if the pause is completed successfully, or an error
344    /// if no open pause exists or the database operation fails.
345    ///
346    /// # Example
347    ///
348    /// ```rust,no_run
349    /// # use kasl::db::pauses::Pauses;
350    /// # fn main() -> anyhow::Result<()> {
351    /// let pauses = Pauses::new()?;
352    /// pauses.insert_start()?;
353    /// // ... user is inactive for 5 minutes ...
354    /// pauses.insert_end()?;
355    /// # Ok(())
356    /// # }
357    /// ```
358    ///
359    /// # Behavior Notes
360    ///
361    /// - Only affects the most recent open pause record
362    /// - If no open pause exists, the operation may fail silently
363    /// - Duration should be a positive number of seconds
364    pub fn insert_end(&self) -> Result<()> {
365        let end = Local::now().naive_local();
366        let conn_guard = self.conn.lock();
367
368        let mut stmt = conn_guard.prepare(SELECT_LAST_PAUSE)?;
369        let pause_row = stmt.query_row([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)));
370        if let Ok((id, start_str)) = pause_row {
371            let start = NaiveDateTime::parse_from_str(&start_str, "%Y-%m-%d %H:%M:%S")?;
372            let duration = (end - start).num_seconds();
373            conn_guard.execute(UPDATE_PAUSE, [&duration.to_string(), &id.to_string()])?;
374        }
375
376        Ok(())
377    }
378
379    /// Records a complete pause stated by the user, with explicit bounds.
380    ///
381    /// Unlike monitor-detected pauses, which are opened by `insert_start` and
382    /// closed later by `insert_end`, a manual pause is written in one shot: the
383    /// user knows when they left and how long they were gone. No placement is
384    /// inferred and no time is invented.
385    ///
386    /// # Arguments
387    ///
388    /// * `start` - When the absence began
389    /// * `duration` - How long it lasted
390    /// * `protected` - Exempt the record from threshold filtering and merging
391    /// * `reason` - Optional note describing the absence
392    ///
393    /// # Returns
394    ///
395    /// Returns the id of the inserted pause record.
396    pub fn insert_manual(&self, start: NaiveDateTime, duration: TimeDelta, protected: bool, reason: Option<&str>) -> Result<i64> {
397        let end = start + duration;
398        let conn_guard = self.conn.lock();
399        conn_guard.execute(
400            INSERT_MANUAL_PAUSE,
401            params![
402                start.format("%Y-%m-%d %H:%M:%S").to_string(),
403                end.format("%Y-%m-%d %H:%M:%S").to_string(),
404                duration.num_seconds(),
405                protected as i64,
406                reason,
407            ],
408        )?;
409        Ok(conn_guard.last_insert_rowid())
410    }
411
412    /// Returns the pause overlapping the given time range, if any exists.
413    ///
414    /// Used to reject a manual pause that would collide with an already
415    /// recorded one, so the day never contains contradictory absences.
416    pub fn find_overlapping(&self, start: NaiveDateTime, end: NaiveDateTime) -> Result<Option<Pause>> {
417        let pauses = self.get_daily_pauses(start.date())?;
418        Ok(pauses.into_iter().find(|p| p.end.map(|p_end| p.start < end && start < p_end).unwrap_or(false)))
419    }
420
421    /// Retrieves all pause records for a specific date with duration filtering.
422    ///
423    /// This method fetches all completed pause records for the given date that
424    /// meet or exceed the specified minimum duration threshold. It's commonly
425    /// used for daily reporting and work time calculations where very short
426    /// pauses (e.g., under 5 minutes) may be ignored.
427    ///
428    /// ## Filtering Logic
429    ///
430    /// - Only includes pauses that started on the specified date
431    /// - Filters out pauses shorter than the minimum duration
432    /// - Includes ongoing pauses (duration IS NULL) regardless of threshold
433    /// - Results are ordered by start time for chronological display
434    ///
435    /// # Arguments
436    ///
437    /// * `date` - The target date to query (uses local timezone)
438    /// * `min_duration` - Minimum pause length to include (in minutes)
439    ///
440    /// # Returns
441    ///
442    /// Returns a vector of `Pause` objects representing the filtered pause
443    /// records, or an error if the database query fails.
444    ///
445    /// # Example
446    ///
447    /// ```rust,no_run
448    /// # use kasl::db::pauses::Pauses;
449    /// use chrono::Local;
450    ///
451    /// # fn main() -> anyhow::Result<()> {
452    /// // Get pauses of 10 minutes or longer
453    /// let pauses = Pauses::new()?.set_min_duration(10);
454    /// let today = Local::now().date_naive();
455    ///
456    /// let significant_pauses = pauses.get_daily_pauses(today)?;
457    /// for pause in significant_pauses {
458    ///     println!("Pause: {:?} - {:?}", pause.start, pause.end);
459    /// }
460    /// # Ok(())
461    /// # }
462    /// ```
463    ///
464    /// # Performance Notes
465    ///
466    /// This query uses date functions and may be slower on large datasets.
467    /// Consider adding indices on the start column for better performance.
468    pub fn get_daily_pauses(&self, date: NaiveDate) -> Result<Vec<Pause>> {
469        let date_str = date.format("%Y-%m-%d").to_string();
470        let conn_guard = self.conn.lock();
471        // Fetch all completed pauses for the date, ordered chronologically.
472        let mut stmt = conn_guard.prepare(SELECT_DAILY_PAUSES)?;
473        let pause_iter = stmt.query_map([&date_str], |row| {
474            // Parse timestamps from database strings
475            let start_str: String = row.get(1)?;
476            let end_str: Option<String> = row.get(2)?;
477            let duration: i64 = row.get(3).unwrap_or(0);
478
479            // Create Pause object with parsed data
480            Ok(Pause {
481                id: row.get(0)?,
482                start: NaiveDateTime::parse_from_str(&start_str, "%Y-%m-%d %H:%M:%S").unwrap(),
483                end: end_str.map(|s| NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S").unwrap()),
484                duration: Some(TimeDelta::seconds(duration)),
485                protected: row.get::<_, i64>(4).unwrap_or(0) != 0,
486            })
487        })?;
488
489        // Collect results, handling any parsing errors
490        let mut pauses = Vec::new();
491        for pause_result in pause_iter {
492            pauses.push(pause_result?);
493        }
494
495        // Merge consecutive pauses separated only by an insignificant gap so that
496        // a chain of adjacent pauses (split by a stray input) is treated as a
497        // single continuous pause before filtering. The tolerance is the small,
498        // dedicated `pause_merge_gap` setting (in seconds): genuine work periods
499        // between pauses are longer than this and remain separate.
500        let max_gap_secs = Config::read().ok().and_then(|c| c.monitor).map(|m| m.pause_merge_gap as i64).unwrap_or(0);
501        let pauses = Self::merge_consecutive_pauses(pauses, max_gap_secs);
502
503        // Apply the configured duration threshold to the merged pauses.
504        //
505        // Protected pauses bypass the `min_duration` threshold entirely: the user
506        // stated them deliberately, so a short manual entry must not be filtered
507        // away. The `max_duration` filter is used to isolate *short* pauses for
508        // productivity accounting, so protected records are excluded from it —
509        // they are accounted for as real, long-form absences.
510        let pauses = match (&self.min_duration, &self.max_duration) {
511            (Some(_), _) => {
512                let min = self.duration_threshold_seconds().unwrap_or(0);
513                pauses
514                    .into_iter()
515                    .filter(|p| p.protected || p.duration.map(|d| d.num_seconds()).unwrap_or(0) >= min)
516                    .collect()
517            }
518            (_, Some(_)) => {
519                let max = self.duration_threshold_seconds().unwrap_or(0);
520                pauses
521                    .into_iter()
522                    .filter(|p| !p.protected && p.duration.map(|d| d.num_seconds()).unwrap_or(0) < max)
523                    .collect()
524            }
525            _ => pauses,
526        };
527
528        Ok(pauses)
529    }
530
531    /// Fetches daily pauses and keeps only the portions inside the workday bounds.
532    ///
533    /// Drops pauses entirely before `workday.start` or after `workday.end`, and
534    /// clips straddling pauses to `[workday.start, workday.end]`.
535    pub fn get_workday_pauses(&self, workday: &Workday) -> Result<Vec<Pause>> {
536        let pauses = self.get_daily_pauses(workday.date)?;
537        Ok(filter_pauses_to_workday(pauses, workday))
538    }
539
540    /// Deletes a single pause record by its unique identifier.
541    ///
542    /// This method removes a specific pause record from the database,
543    /// typically used for correcting erroneous pause recordings or
544    /// user-requested deletions. The operation is permanent and cannot
545    /// be undone without database backups.
546    ///
547    /// # Arguments
548    ///
549    /// * `id` - The unique identifier of the pause record to delete
550    ///
551    /// # Returns
552    ///
553    /// Returns `Ok(())` if the deletion succeeds, or an error if the
554    /// database operation fails. Note that deleting a non-existent
555    /// record is not considered an error.
556    ///
557    /// # Example
558    ///
559    /// ```rust,no_run
560    /// # use kasl::db::pauses::Pauses;
561    /// # fn main() -> anyhow::Result<()> {
562    /// let pauses = Pauses::new()?;
563    /// pauses.delete(123)?; // Delete pause with ID 123
564    /// # Ok(())
565    /// # }
566    /// ```
567    ///
568    /// # Safety Considerations
569    ///
570    /// - Deletion is immediate and permanent
571    /// - No confirmation prompts are provided at this level
572    /// - Callers should implement appropriate confirmation flows
573    pub fn delete(&self, id: i32) -> Result<()> {
574        let conn_guard = self.conn.lock();
575        conn_guard.execute(DELETE_PAUSE, params![id])?;
576        Ok(())
577    }
578
579    /// Deletes multiple pause records efficiently in a batch operation.
580    ///
581    /// This method removes multiple pause records in a single transaction,
582    /// providing better performance than individual deletions and ensuring
583    /// atomicity. If any deletion fails, all changes are rolled back.
584    ///
585    /// ## Transaction Handling
586    ///
587    /// All deletions are performed within a single database transaction
588    /// to ensure consistency. Either all specified records are deleted
589    /// or none are deleted if any error occurs.
590    ///
591    /// # Arguments
592    ///
593    /// * `ids` - Slice of pause record IDs to delete
594    ///
595    /// # Returns
596    ///
597    /// Returns the number of records actually deleted, or an error if
598    /// the batch operation fails. The count may be less than the input
599    /// length if some IDs don't exist in the database.
600    ///
601    /// # Example
602    ///
603    /// ```rust,no_run
604    /// # use kasl::db::pauses::Pauses;
605    /// # fn main() -> anyhow::Result<()> {
606    /// let pauses = Pauses::new()?;
607    /// let ids_to_delete = vec![101, 102, 103];
608    /// let deleted_count = pauses.delete_many(&ids_to_delete)?;
609    /// println!("Deleted {} pause records", deleted_count);
610    /// # Ok(())
611    /// # }
612    /// ```
613    ///
614    /// # Performance Benefits
615    ///
616    /// - Single transaction reduces database overhead
617    /// - More efficient than individual delete operations
618    /// - Atomic operation ensures data consistency
619    ///
620    /// # Edge Cases
621    ///
622    /// - Empty input slice returns 0 without database interaction
623    /// - Non-existent IDs are silently ignored
624    /// - Partial failures result in complete rollback
625    pub fn delete_many(&self, ids: &[i32]) -> Result<usize> {
626        // Handle empty input early to avoid unnecessary database operations
627        if ids.is_empty() {
628            return Ok(0);
629        }
630
631        let conn_guard = self.conn.lock();
632        let mut deleted = 0;
633
634        // Delete each record individually within the locked connection
635        // This could be optimized with a single IN clause query for large batches
636        for id in ids {
637            deleted += conn_guard.execute(DELETE_PAUSE, params![id])?;
638        }
639
640        Ok(deleted)
641    }
642}
643
644/// Keeps only pause portions that fall inside the workday time bounds.
645///
646/// - Drops pauses that end at or before `workday.start`
647/// - Drops open-ended pauses that start before `workday.start`
648/// - Drops pauses that start at or after `workday.end` (when end is set)
649/// - Clips straddling pauses to `[workday.start, workday.end]` and recomputes duration
650pub fn filter_pauses_to_workday(pauses: Vec<Pause>, workday: &Workday) -> Vec<Pause> {
651    let work_start = workday.start;
652    let work_end = workday.end;
653
654    pauses
655        .into_iter()
656        .filter_map(|mut pause| {
657            if let Some(end) = pause.end {
658                if end <= work_start {
659                    return None;
660                }
661            } else if pause.start < work_start {
662                return None;
663            }
664
665            if let Some(work_end) = work_end
666                && pause.start >= work_end
667            {
668                return None;
669            }
670
671            if pause.start < work_start {
672                pause.start = work_start;
673            }
674            if let (Some(end), Some(work_end)) = (pause.end, work_end)
675                && end > work_end
676            {
677                pause.end = Some(work_end);
678            }
679            if let Some(end) = pause.end {
680                if end <= pause.start {
681                    return None;
682                }
683                pause.duration = Some(end - pause.start);
684            }
685
686            Some(pause)
687        })
688        .collect()
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694    use chrono::NaiveDate;
695
696    fn workday(start: &str, end: Option<&str>) -> Workday {
697        Workday {
698            id: 1,
699            date: NaiveDate::from_ymd_opt(2026, 7, 29).unwrap(),
700            start: NaiveDateTime::parse_from_str(start, "%Y-%m-%d %H:%M:%S").unwrap(),
701            end: end.map(|e| NaiveDateTime::parse_from_str(e, "%Y-%m-%d %H:%M:%S").unwrap()),
702        }
703    }
704
705    fn pause(id: i32, start: &str, end: &str) -> Pause {
706        let start = NaiveDateTime::parse_from_str(start, "%Y-%m-%d %H:%M:%S").unwrap();
707        let end = NaiveDateTime::parse_from_str(end, "%Y-%m-%d %H:%M:%S").unwrap();
708        Pause {
709            id,
710            start,
711            end: Some(end),
712            duration: Some(end - start),
713            protected: false,
714        }
715    }
716
717    #[test]
718    fn drops_pauses_entirely_before_workday_start() {
719        let wd = workday("2026-07-29 10:04:30", None);
720        let pauses = vec![pause(1, "2026-07-29 10:02:23", "2026-07-29 10:04:26")];
721        let filtered = filter_pauses_to_workday(pauses, &wd);
722        assert!(filtered.is_empty());
723    }
724
725    #[test]
726    fn drops_pauses_entirely_after_workday_end() {
727        let wd = workday("2026-07-29 10:00:00", Some("2026-07-29 18:00:00"));
728        let pauses = vec![pause(1, "2026-07-29 18:30:00", "2026-07-29 18:45:00")];
729        let filtered = filter_pauses_to_workday(pauses, &wd);
730        assert!(filtered.is_empty());
731    }
732
733    #[test]
734    fn clips_pause_that_straddles_workday_start() {
735        let wd = workday("2026-07-29 10:00:00", Some("2026-07-29 18:00:00"));
736        let pauses = vec![pause(1, "2026-07-29 09:50:00", "2026-07-29 10:10:00")];
737        let filtered = filter_pauses_to_workday(pauses, &wd);
738        assert_eq!(filtered.len(), 1);
739        assert_eq!(
740            filtered[0].start,
741            NaiveDateTime::parse_from_str("2026-07-29 10:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
742        );
743        assert_eq!(
744            filtered[0].end.unwrap(),
745            NaiveDateTime::parse_from_str("2026-07-29 10:10:00", "%Y-%m-%d %H:%M:%S").unwrap()
746        );
747        assert_eq!(filtered[0].duration.unwrap().num_minutes(), 10);
748    }
749
750    #[test]
751    fn keeps_pause_fully_inside_workday() {
752        let wd = workday("2026-07-29 10:00:00", Some("2026-07-29 18:00:00"));
753        let pauses = vec![pause(1, "2026-07-29 12:00:00", "2026-07-29 12:20:00")];
754        let filtered = filter_pauses_to_workday(pauses, &wd);
755        assert_eq!(filtered.len(), 1);
756        assert_eq!(filtered[0].duration.unwrap().num_minutes(), 20);
757    }
758}