Skip to main content

kasl/libs/
report.rs

1//! Work interval calculation and productivity analysis for daily reports.
2//!
3//! Provides core logic for analyzing work patterns and generating detailed reports
4//! about productivity, work intervals, and break patterns.
5//!
6//! ## Features
7//!
8//! - **Work Interval Analysis**: Convert workday and pause data into continuous work periods
9//! - **Productivity Metrics**: Calculate efficiency ratios and work pattern analysis
10//! - **Short Interval Detection**: Identify and analyze brief work periods that may indicate interruptions
11//! - **Interval Filtering**: Filter out short intervals for cleaner reporting (display-level, no database changes)
12//! - **Report Generation**: Comprehensive breakdown of workdays with productivity analysis
13//!
14//! ## Usage
15//!
16//! ```rust
17//! use kasl::libs::report::{calculate_work_intervals, filter_short_intervals, WorkInterval};
18//! use kasl::db::workdays::Workday;
19//! use kasl::libs::pause::Pause;
20//! use chrono::Local;
21//!
22//! let workday = Workday {
23//!     id: 1,
24//!     date: Local::now().date_naive(),
25//!     start: Local::now().naive_local(),
26//!     end: Some(Local::now().naive_local()),
27//! };
28//!
29//! let pauses: Vec<Pause> = vec![/* pause data */];
30//! let intervals = calculate_work_intervals(&workday, &pauses);
31//!
32//! // Filter short intervals for cleaner reporting
33//! let (filtered_intervals, filter_info) = filter_short_intervals(&intervals, 30);
34//! ```
35
36use crate::libs::pause::Pause;
37use crate::{db::workdays::Workday, libs::productivity::Productivity};
38use anyhow::Result;
39use chrono::{Duration, NaiveDateTime};
40
41/// Represents a single continuous work interval between breaks.
42///
43/// This structure captures a period of uninterrupted work time, providing
44/// the foundation for productivity analysis and reporting. Each interval
45/// represents a focused work session bounded by either the workday start/end
46/// or pause periods.
47///
48/// ## Interval Boundaries
49///
50/// Work intervals are defined by:
51/// - **Start Time**: When focused work began (workday start or end of previous pause)
52/// - **End Time**: When focused work ended (start of next pause or workday end)
53/// - **Duration**: Total time spent in focused work during this period
54///
55/// ## Pause Association
56///
57/// Each interval can be associated with the pause that follows it:
58/// - **Some(index)**: Index of the pause that ended this work interval
59/// - **None**: This interval extends to the end of the workday
60///
61/// This association enables:
62/// - Analysis of work-break patterns
63/// - Identification of interruption causes
64/// - Optimization recommendations for pause timing
65///
66/// ## Usage Context
67///
68/// Work intervals are used for:
69/// - Productivity calculation and analysis
70/// - Generating detailed work reports
71/// - Identifying optimization opportunities
72/// - Visualizing work patterns in charts and graphs
73#[derive(Debug, Clone)]
74pub struct WorkInterval {
75    /// The timestamp when this work interval began.
76    ///
77    /// This is either the workday start time (for the first interval)
78    /// or the end time of the previous pause (for subsequent intervals).
79    /// Represents the moment when focused work activity resumed.
80    pub start: NaiveDateTime,
81
82    /// The timestamp when this work interval ended.
83    ///
84    /// This is either the start time of the next pause (for most intervals)
85    /// or the workday end time (for the final interval). Represents the
86    /// moment when focused work was interrupted or completed.
87    pub end: NaiveDateTime,
88
89    /// The total duration of focused work during this interval.
90    ///
91    /// Calculated as `end - start`, this represents the net productive
92    /// time during this period. Used for productivity calculations,
93    /// efficiency analysis, and time accounting in reports.
94    pub duration: Duration,
95
96    /// Optional reference to the pause that follows this interval.
97    ///
98    /// Contains the index of the pause in the original pause collection
99    /// that ended this work interval. `None` indicates this interval
100    /// extends to the end of the workday without interruption.
101    ///
102    /// ## Usage Notes
103    /// - Used for analyzing work-break patterns
104    /// - Enables identification of frequent interruption points
105    /// - Supports optimization recommendations for pause timing
106    /// - Links intervals to specific causes of work interruption
107    pub pause_after: Option<usize>, // Index of pause after this interval
108}
109
110impl WorkInterval {
111    /// Determines if this interval is shorter than the specified minimum duration.
112    ///
113    /// This method is used to identify "short intervals" that may indicate
114    /// excessive interruptions or poor work habits. Short intervals often
115    /// represent brief periods of work between frequent breaks, which can
116    /// significantly impact overall productivity.
117    ///
118    /// ## Usage in Analysis
119    ///
120    /// Short intervals are identified for:
121    /// - **Productivity Analysis**: Understanding interruption patterns
122    /// - **Optimization Recommendations**: Suggesting pause consolidation
123    /// - **Work Habit Assessment**: Identifying areas for improvement
124    /// - **Focus Period Analysis**: Measuring sustained work capability
125    ///
126    /// ## Threshold Considerations
127    ///
128    /// Common minimum duration thresholds:
129    /// - **15 minutes**: Very strict, identifies micro-interruptions
130    /// - **30 minutes**: Moderate, focuses on meaningful work blocks
131    /// - **60 minutes**: Lenient, identifies only major fragmentation
132    ///
133    /// # Arguments
134    ///
135    /// * `min_minutes` - Minimum duration threshold in minutes
136    ///
137    /// # Returns
138    ///
139    /// Returns `true` if the interval duration is less than the threshold.
140    ///
141    /// # Examples
142    ///
143    /// ```rust
144    /// use kasl::libs::report::WorkInterval;
145    /// use chrono::{Duration, Local};
146    ///
147    /// let start_time = Local::now().naive_local();
148    /// let interval = WorkInterval {
149    ///     start: start_time,
150    ///     end: start_time + Duration::minutes(20),
151    ///     duration: Duration::minutes(20),
152    ///     pause_after: Some(1),
153    /// };
154    ///
155    /// assert_eq!(interval.is_short(30), true);  // 20 < 30
156    /// assert_eq!(interval.is_short(15), false); // 20 >= 15
157    /// ```
158    pub fn is_short(&self, min_minutes: u64) -> bool {
159        self.duration < Duration::minutes(min_minutes as i64)
160    }
161}
162
163/// Information about short intervals detected in a workday.
164///
165/// This structure provides comprehensive analysis of work intervals that
166/// fall below the minimum duration threshold. It includes both statistical
167/// information about the impact of short intervals and actionable
168/// recommendations for optimization.
169///
170/// ## Analysis Components
171///
172/// ### Statistical Information
173/// - **Count**: Number of short intervals detected
174/// - **Total Duration**: Cumulative time spent in short work periods
175/// - **Individual Intervals**: Specific intervals with their details
176///
177/// ### Optimization Recommendations
178/// - **Pauses to Remove**: Specific pauses that could be eliminated
179/// - **Merge Opportunities**: Intervals that could be combined
180/// - **Productivity Impact**: Potential time savings from optimization
181///
182/// ## Usage Context
183///
184/// This analysis is used for:
185/// - Generating optimization recommendations in reports
186/// - Identifying patterns of work fragmentation
187/// - Calculating potential productivity improvements
188/// - Providing actionable feedback to users
189#[derive(Debug)]
190pub struct ShortIntervalsInfo {
191    /// The number of intervals that fall below the minimum duration threshold.
192    ///
193    /// This count provides a quick assessment of work fragmentation:
194    /// - **0**: No fragmentation issues detected
195    /// - **1-3**: Minor fragmentation, limited impact
196    /// - **4+**: Significant fragmentation, optimization recommended
197    pub count: usize,
198
199    /// The cumulative duration of all short intervals combined.
200    ///
201    /// Represents the total amount of time spent in fragmented work
202    /// periods. This metric helps quantify the impact of work
203    /// interruptions and provides context for optimization efforts.
204    ///
205    /// ## Impact Assessment
206    /// - **< 30 minutes**: Minor impact on overall productivity
207    /// - **30-60 minutes**: Moderate impact, optimization beneficial
208    /// - **> 60 minutes**: Significant impact, optimization essential
209    pub total_duration: Duration,
210
211    /// Detailed information about each short interval detected.
212    ///
213    /// Each tuple contains:
214    /// - **Index**: Position of the interval in the original collection
215    /// - **WorkInterval**: Complete interval data with timing information
216    ///
217    /// This detailed information enables:
218    /// - Specific analysis of each fragmented period
219    /// - Identification of patterns in interruption timing
220    /// - Targeted recommendations for specific intervals
221    pub intervals: Vec<(usize, WorkInterval)>, // (index, interval)
222
223    /// Indices of pauses that could be removed to merge short intervals.
224    ///
225    /// These pause indices represent optimization opportunities where
226    /// removing or consolidating breaks could create longer, more
227    /// productive work intervals. The indices correspond to positions
228    /// in the original pause collection.
229    ///
230    /// ## Optimization Strategy
231    /// - **Pause Removal**: Eliminate unnecessary short breaks
232    /// - **Pause Consolidation**: Combine multiple short breaks into fewer, longer ones
233    /// - **Timing Adjustment**: Shift break timing to create better work blocks
234    ///
235    /// ## Implementation Notes
236    /// To remove a short interval, remove the pause that created it by
237    /// separating it from the previous interval. This effectively merges
238    /// the short interval with its predecessor.
239    pub pauses_to_remove: Vec<usize>, // Indices of pauses that create short intervals
240}
241
242/// Calculates work intervals for a given workday based on pause records.
243///
244/// This function performs the core algorithm for converting raw workday and
245/// pause data into a structured collection of work intervals. It handles the
246/// complexity of time calculations, pause filtering, and interval boundary
247/// determination to produce accurate work period analysis.
248///
249/// ## Algorithm Overview
250///
251/// 1. **Initialization**: Start with workday boundaries and empty interval list
252/// 2. **Pause Filtering**: Remove incomplete pauses and sort chronologically
253/// 3. **Interval Generation**: Create work periods between consecutive pauses
254/// 4. **Boundary Handling**: Handle workday start/end as interval boundaries
255/// 5. **Duration Calculation**: Compute accurate durations for each interval
256///
257/// ## Pause Processing
258///
259/// The function handles various pause scenarios:
260/// - **Complete Pauses**: Have both start and end times
261/// - **Incomplete Pauses**: Missing end times (filtered out)
262/// - **Overlapping Pauses**: Handled through chronological sorting
263/// - **Out-of-bounds Pauses**: Pauses outside workday boundaries
264///
265/// ## Edge Cases Handled
266///
267/// - **No Pauses**: Single interval covering entire workday
268/// - **Workday Boundaries**: Pauses at start/end of workday
269/// - **Consecutive Pauses**: Multiple pauses with no work time between
270/// - **Invalid Times**: Pauses with end time before start time
271///
272/// # Arguments
273///
274/// * `workday` - The workday record containing start and end times
275/// * `pauses` - Collection of pause records for the workday
276///
277/// # Returns
278///
279/// A vector of `WorkInterval` objects representing continuous work periods.
280///
281/// # Examples
282///
283/// ```rust
284/// use kasl::libs::report::calculate_work_intervals;
285/// use kasl::db::workdays::Workday;
286/// use kasl::libs::pause::Pause;
287/// use chrono::{Local, Duration};
288///
289/// let start_time = Local::now().naive_local();
290/// let end_time = start_time + Duration::hours(8);
291/// let lunch_start = start_time + Duration::hours(4);
292/// let lunch_end = lunch_start + Duration::minutes(30);
293/// let lunch_duration = Duration::minutes(30);
294///
295/// let workday = Workday {
296///     id: 1,
297///     date: start_time.date(),
298///     start: start_time,
299///     end: Some(end_time),
300///     // ... other fields
301/// };
302///
303/// let pauses = vec![
304///     Pause {
305///         id: 1,
306///         start: lunch_start,
307///         end: Some(lunch_end),
308///         duration: Some(lunch_duration),
309///         protected: false,
310///     },
311///     // ... more pauses
312/// ];
313///
314/// let intervals = calculate_work_intervals(&workday, &pauses);
315/// println!("Generated {} work intervals", intervals.len());
316/// ```
317///
318/// # Performance Considerations
319///
320/// - **Time Complexity**: O(n log n) due to pause sorting
321/// - **Space Complexity**: O(n) for interval storage
322/// - **Memory Usage**: Minimal allocation during processing
323///
324/// Resolves the effective end of a workday that has no recorded end time.
325///
326/// A workday stays open when the daemon was killed or the machine slept before
327/// `kasl end` ran. "Now" is only a sensible stand-in while the day is still
328/// today; for any earlier date it would stretch the day across every hour since,
329/// which is how an unclosed August day reported 8425 hours. For a past date the
330/// day is closed at the last evidence of activity - the end of its final pause,
331/// or its start when nothing else is known.
332pub fn workday_end_time(workday: &Workday, pauses: &[Pause]) -> chrono::NaiveDateTime {
333    if let Some(end) = workday.end {
334        return end;
335    }
336
337    let now = chrono::Local::now().naive_local();
338    // Only while the day is still today, and only if the clock has actually
339    // passed the start: a workday timestamped slightly ahead of the clock - a
340    // DST shift, a corrected system time - would otherwise yield an end before
341    // the start, and every duration computed from it would go negative.
342    if workday.date == now.date() && now > workday.start {
343        return now;
344    }
345
346    // A past day that was never closed: fall back to the last thing we observed.
347    pauses
348        .iter()
349        .filter_map(|pause| pause.end)
350        .max()
351        .filter(|last| *last > workday.start)
352        .unwrap_or(workday.start)
353}
354
355pub fn calculate_work_intervals(workday: &Workday, pauses: &[Pause]) -> Vec<WorkInterval> {
356    // Determine workday end time (current time if still ongoing)
357    let end_time = workday_end_time(workday, pauses);
358
359    // Initialize interval collection and current time tracker
360    let mut intervals = vec![];
361    let mut current_time = workday.start;
362
363    // Filter out incomplete pauses and sort chronologically
364    // Only pauses with both start and end times can create work intervals
365    let mut complete_pauses: Vec<(usize, &Pause)> = pauses.iter().enumerate().filter(|(_, pause)| pause.end.is_some()).collect();
366
367    // Sort pauses by start time to ensure chronological processing
368    complete_pauses.sort_by_key(|(_, pause)| pause.start);
369
370    // Process each pause to create work intervals
371    for (original_idx, pause) in complete_pauses {
372        // Create work interval before this pause (if there's time)
373        if current_time < pause.start {
374            intervals.push(WorkInterval {
375                start: current_time,
376                end: pause.start,
377                duration: pause.start - current_time,
378                pause_after: Some(original_idx),
379            });
380        }
381
382        // Move current time to the end of the pause
383        if let Some(pause_end) = pause.end {
384            current_time = pause_end;
385        }
386    }
387
388    // Add the final work interval after the last pause (if there's time)
389    if current_time < end_time {
390        intervals.push(WorkInterval {
391            start: current_time,
392            end: end_time,
393            duration: end_time - current_time,
394            pause_after: None, // No pause after the final interval
395        });
396    }
397
398    intervals
399}
400
401/// Analyzes work intervals to identify short periods that may indicate poor productivity.
402///
403/// This function performs comprehensive analysis of work intervals to identify
404/// periods that fall below the minimum duration threshold. It provides both
405/// statistical analysis and actionable optimization recommendations to help
406/// users improve their work patterns and productivity.
407///
408/// ## Analysis Process
409///
410/// 1. **Threshold Filtering**: Identify intervals shorter than minimum duration
411/// 2. **Statistical Calculation**: Compute total count and cumulative duration
412/// 3. **Optimization Analysis**: Identify pauses that could be removed
413/// 4. **Recommendation Generation**: Provide specific improvement suggestions
414///
415/// ## Optimization Logic
416///
417/// Short intervals are typically created by pauses that interrupt focused work:
418/// - **Pause Identification**: Find pauses that create short intervals
419/// - **Merge Opportunities**: Identify intervals that could be combined
420/// - **Impact Assessment**: Calculate potential productivity improvements
421///
422/// ## Return Value Analysis
423///
424/// - **Some(info)**: Short intervals detected, optimization possible
425/// - **None**: No short intervals found, work patterns are optimal
426///
427/// # Arguments
428///
429/// * `intervals` - Collection of work intervals to analyze
430/// * `min_minutes` - Minimum acceptable interval duration in minutes
431///
432/// # Returns
433///
434/// `Some(ShortIntervalsInfo)` if short intervals are found, `None` otherwise.
435///
436/// # Examples
437///
438/// ```rust
439/// use kasl::libs::report::{analyze_short_intervals, WorkInterval};
440///
441/// let intervals = vec![/* work intervals */];
442/// let min_duration = 30; // 30-minute minimum
443///
444/// match analyze_short_intervals(&intervals, min_duration) {
445///     Some(analysis) => {
446///         println!("Found {} short intervals", analysis.count);
447///         println!("Total fragmented time: {:?}", analysis.total_duration);
448///         println!("Optimization: remove pauses {:?}", analysis.pauses_to_remove);
449///     },
450///     None => {
451///         println!("No short intervals detected - work patterns are optimal");
452///     }
453/// }
454/// ```
455///
456/// # Optimization Recommendations
457///
458/// The function provides specific recommendations:
459/// - **Pause Removal**: Eliminate unnecessary short breaks
460/// - **Break Consolidation**: Combine multiple short breaks
461/// - **Timing Adjustment**: Reschedule breaks to preserve focus periods
462pub fn analyze_short_intervals(intervals: &[WorkInterval], min_minutes: u64) -> Option<ShortIntervalsInfo> {
463    // Collect all intervals that fall below the minimum duration threshold
464    let mut short_intervals = Vec::new();
465    let mut total_duration = Duration::zero();
466    let mut pauses_to_remove = Vec::new();
467
468    // Analyze each interval for duration and optimization opportunities
469    for (idx, interval) in intervals.iter().enumerate() {
470        if interval.is_short(min_minutes) {
471            // Record this short interval for analysis
472            short_intervals.push((idx, interval.clone()));
473            total_duration += interval.duration;
474
475            // Identify optimization opportunity: remove the pause that created this interval
476            // To remove a short interval, we need to remove the pause before it
477            // (which connects it to the previous interval)
478            if idx > 0 {
479                // Get the pause that created this interval by ending the previous one
480                if let Some(pause_idx) = intervals[idx - 1].pause_after {
481                    pauses_to_remove.push(pause_idx);
482                }
483            }
484        }
485    }
486
487    // Return analysis results only if short intervals were found
488    if short_intervals.is_empty() {
489        None
490    } else {
491        Some(ShortIntervalsInfo {
492            count: short_intervals.len(),
493            total_duration,
494            intervals: short_intervals,
495            pauses_to_remove,
496        })
497    }
498}
499
500/// Filters out short work intervals from the provided interval list.
501///
502/// This function removes work intervals that are shorter than the specified
503/// minimum duration, providing cleaner reporting by eliminating brief
504/// interruptions that don't represent meaningful work periods. This is the
505/// new approach for handling short intervals - filtering at display time
506/// instead of modifying the database.
507///
508/// ## Filtering Logic
509///
510/// - Intervals shorter than `min_minutes` are excluded from the result
511/// - Remaining intervals maintain their original timing and properties
512/// - No database changes are made - this is purely a display/API filter
513/// - Used by both `kasl report` (display) and `kasl report --send` (API submission)
514///
515/// ## Return Value
516///
517/// Returns a tuple containing:
518/// - **Filtered intervals**: Only intervals meeting the minimum duration
519/// - **Filtered intervals info**: Analysis of what was filtered out (if any)
520///
521/// # Arguments
522///
523/// * `intervals` - Original work intervals to filter
524/// * `min_minutes` - Minimum duration in minutes for intervals to keep
525///
526/// # Returns
527///
528/// Returns `(filtered_intervals, filtered_info)` where:
529/// - `filtered_intervals` contains only intervals >= min_minutes
530/// - `filtered_info` contains details about filtered intervals (None if nothing was filtered)
531///
532/// # Examples
533///
534/// ```rust
535/// use kasl::libs::report::{calculate_work_intervals, filter_short_intervals};
536/// use kasl::db::workdays::Workday;
537/// use kasl::libs::pause::Pause;
538/// use chrono::Local;
539///
540/// let workday = Workday {
541///     id: 1,
542///     date: Local::now().date_naive(),
543///     start: Local::now().naive_local(),
544///     end: Some(Local::now().naive_local()),
545/// };
546/// let pauses: Vec<Pause> = vec![];
547///
548/// let intervals = calculate_work_intervals(&workday, &pauses);
549/// let (filtered, info) = filter_short_intervals(&intervals, 30);
550///
551/// if let Some(info) = info {
552///     println!("Filtered {} short intervals", info.count);
553/// }
554/// ```
555pub fn filter_short_intervals(intervals: &[WorkInterval], min_minutes: u64) -> (Vec<WorkInterval>, Option<ShortIntervalsInfo>) {
556    let mut filtered_intervals = Vec::new();
557    let mut short_intervals = Vec::new();
558    let mut total_duration = Duration::zero();
559
560    for (idx, interval) in intervals.iter().enumerate() {
561        if interval.is_short(min_minutes) {
562            // This is a short interval - add to filtered list
563            short_intervals.push((idx, interval.clone()));
564            total_duration += interval.duration;
565        } else {
566            // This interval meets minimum duration - keep it
567            filtered_intervals.push(interval.clone());
568        }
569    }
570
571    let filtered_info = if short_intervals.is_empty() {
572        None
573    } else {
574        Some(ShortIntervalsInfo {
575            count: short_intervals.len(),
576            total_duration,
577            intervals: short_intervals,
578            pauses_to_remove: Vec::new(), // Not needed for display filtering
579        })
580    };
581
582    (filtered_intervals, filtered_info)
583}
584
585/// Process daily work report data using pre-calculated intervals.
586///
587/// This function handles the data processing for daily work reports, calculating
588/// productivity metrics and work durations. It leverages the centralized `Productivity`
589/// module for consistent calculations across the application.
590///
591/// ## Calculation Method
592///
593/// The function uses two different approaches for different metrics:
594/// - **Filtered Duration**: Summed directly from provided intervals (for display purposes)
595/// - **Productivity**: Calculated using the comprehensive `Productivity::calculate_productivity()`
596///   method which properly handles all pause types, breaks, and overlaps
597///
598/// This separation allows for interval-based filtering (for clean reports) while maintaining
599/// accurate productivity calculations that account for all time categories.
600///
601/// ## Data Consistency
602///
603/// By using `Productivity::new()`, this function automatically:
604/// - Loads the same data used throughout the application
605/// - Applies consistent calculation logic
606/// - Handles all edge cases and data integrity issues
607///
608/// # Arguments
609///
610/// * `workday` - The workday record containing start/end times
611/// * `intervals` - Pre-calculated and optionally filtered work intervals for duration calculation
612///
613/// # Returns
614///
615/// Returns a tuple containing:
616/// - **Filtered Duration**: Sum of provided work intervals (may exclude short intervals)
617/// - **Productivity**: Comprehensive productivity percentage using centralized calculation
618///
619/// # Examples
620///
621/// ```rust,no_run
622/// # fn f() -> anyhow::Result<()> {
623/// use kasl::libs::report::{report_with_intervals, WorkInterval};
624/// use kasl::libs::formatter::format_duration;
625/// use kasl::db::workdays::Workday;
626/// use chrono::Local;
627///
628/// let workday = Workday {
629///     id: 1,
630///     date: Local::now().date_naive(),
631///     start: Local::now().naive_local(),
632///     end: Some(Local::now().naive_local()),
633/// };
634/// let filtered_intervals: Vec<WorkInterval> = vec![];
635///
636/// let (duration, productivity) = report_with_intervals(&workday, &filtered_intervals)?;
637/// println!("Work time: {}, Productivity: {:.1}%", format_duration(&duration), productivity);
638/// # Ok(())
639/// # }
640/// ```
641pub fn report_with_intervals(workday: &Workday, intervals: &[WorkInterval]) -> Result<(Duration, f64)> {
642    // Calculate filtered duration based on provided intervals (for display purposes)
643    let filtered_duration = intervals.iter().fold(Duration::zero(), |acc, interval| acc + interval.duration);
644
645    // Use centralized productivity module for consistent, comprehensive calculation
646    let productivity = Productivity::new(workday)?.calculate_productivity();
647
648    Ok((filtered_duration, productivity))
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654    use chrono::{Duration, NaiveDate, NaiveDateTime};
655
656    fn at(date: NaiveDate, h: u32, m: u32) -> NaiveDateTime {
657        date.and_hms_opt(h, m, 0).unwrap()
658    }
659
660    fn workday(date: NaiveDate, start_h: u32, end: Option<NaiveDateTime>) -> Workday {
661        Workday {
662            id: 1,
663            date,
664            start: at(date, start_h, 0),
665            end,
666        }
667    }
668
669    fn pause(date: NaiveDate, from: (u32, u32), to: (u32, u32)) -> Pause {
670        let start = at(date, from.0, from.1);
671        let end = at(date, to.0, to.1);
672        Pause::detected(1, start, Some(end), Some(end - start))
673    }
674
675    #[test]
676    fn recorded_end_is_used_as_is() {
677        let date = NaiveDate::from_ymd_opt(2025, 8, 22).unwrap();
678        let end = at(date, 18, 0);
679        let wd = workday(date, 9, Some(end));
680        assert_eq!(workday_end_time(&wd, &[]), end);
681    }
682
683    #[test]
684    fn unclosed_past_day_ends_at_last_pause_not_now() {
685        // Regression: "now" as the fallback stretched an unclosed August day
686        // across every hour since, reporting thousands of hours.
687        let date = NaiveDate::from_ymd_opt(2025, 8, 22).unwrap();
688        let wd = workday(date, 9, None);
689        let pauses = [pause(date, (12, 0), (12, 30)), pause(date, (16, 0), (16, 43))];
690
691        let end = workday_end_time(&wd, &pauses);
692
693        assert_eq!(end, at(date, 16, 43));
694        assert!(end - wd.start < Duration::hours(24));
695    }
696
697    #[test]
698    fn unclosed_past_day_without_pauses_collapses_to_start() {
699        let date = NaiveDate::from_ymd_opt(2025, 8, 22).unwrap();
700        let wd = workday(date, 9, None);
701        assert_eq!(workday_end_time(&wd, &[]), wd.start);
702    }
703
704    #[test]
705    fn unclosed_today_never_ends_before_it_starts() {
706        // A start slightly ahead of the clock - DST, a corrected system time -
707        // must not produce a negative-length day, which read as 0% productivity.
708        let now = chrono::Local::now().naive_local();
709        let wd = workday(now.date(), 0, None);
710        let wd = Workday {
711            start: now + Duration::hours(2),
712            ..wd
713        };
714
715        let end = workday_end_time(&wd, &[]);
716
717        assert!(end >= wd.start, "end {end} precedes start {}", wd.start);
718    }
719
720    #[test]
721    fn unclosed_today_still_runs_to_now() {
722        let today = chrono::Local::now().naive_local();
723        let wd = workday(today.date(), 0, None);
724
725        let end = workday_end_time(&wd, &[]);
726
727        // Ongoing day: end tracks the current moment rather than a past pause.
728        assert!((end - today).num_seconds().abs() < 5);
729    }
730}