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}