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    if workday.date == now.date() {
339        return now;
340    }
341
342    // A past day that was never closed: fall back to the last thing we observed.
343    pauses
344        .iter()
345        .filter_map(|pause| pause.end)
346        .max()
347        .filter(|last| *last > workday.start)
348        .unwrap_or(workday.start)
349}
350
351pub fn calculate_work_intervals(workday: &Workday, pauses: &[Pause]) -> Vec<WorkInterval> {
352    // Determine workday end time (current time if still ongoing)
353    let end_time = workday_end_time(workday, pauses);
354
355    // Initialize interval collection and current time tracker
356    let mut intervals = vec![];
357    let mut current_time = workday.start;
358
359    // Filter out incomplete pauses and sort chronologically
360    // Only pauses with both start and end times can create work intervals
361    let mut complete_pauses: Vec<(usize, &Pause)> = pauses.iter().enumerate().filter(|(_, pause)| pause.end.is_some()).collect();
362
363    // Sort pauses by start time to ensure chronological processing
364    complete_pauses.sort_by_key(|(_, pause)| pause.start);
365
366    // Process each pause to create work intervals
367    for (original_idx, pause) in complete_pauses {
368        // Create work interval before this pause (if there's time)
369        if current_time < pause.start {
370            intervals.push(WorkInterval {
371                start: current_time,
372                end: pause.start,
373                duration: pause.start - current_time,
374                pause_after: Some(original_idx),
375            });
376        }
377
378        // Move current time to the end of the pause
379        if let Some(pause_end) = pause.end {
380            current_time = pause_end;
381        }
382    }
383
384    // Add the final work interval after the last pause (if there's time)
385    if current_time < end_time {
386        intervals.push(WorkInterval {
387            start: current_time,
388            end: end_time,
389            duration: end_time - current_time,
390            pause_after: None, // No pause after the final interval
391        });
392    }
393
394    intervals
395}
396
397/// Analyzes work intervals to identify short periods that may indicate poor productivity.
398///
399/// This function performs comprehensive analysis of work intervals to identify
400/// periods that fall below the minimum duration threshold. It provides both
401/// statistical analysis and actionable optimization recommendations to help
402/// users improve their work patterns and productivity.
403///
404/// ## Analysis Process
405///
406/// 1. **Threshold Filtering**: Identify intervals shorter than minimum duration
407/// 2. **Statistical Calculation**: Compute total count and cumulative duration
408/// 3. **Optimization Analysis**: Identify pauses that could be removed
409/// 4. **Recommendation Generation**: Provide specific improvement suggestions
410///
411/// ## Optimization Logic
412///
413/// Short intervals are typically created by pauses that interrupt focused work:
414/// - **Pause Identification**: Find pauses that create short intervals
415/// - **Merge Opportunities**: Identify intervals that could be combined
416/// - **Impact Assessment**: Calculate potential productivity improvements
417///
418/// ## Return Value Analysis
419///
420/// - **Some(info)**: Short intervals detected, optimization possible
421/// - **None**: No short intervals found, work patterns are optimal
422///
423/// # Arguments
424///
425/// * `intervals` - Collection of work intervals to analyze
426/// * `min_minutes` - Minimum acceptable interval duration in minutes
427///
428/// # Returns
429///
430/// `Some(ShortIntervalsInfo)` if short intervals are found, `None` otherwise.
431///
432/// # Examples
433///
434/// ```rust
435/// use kasl::libs::report::{analyze_short_intervals, WorkInterval};
436///
437/// let intervals = vec![/* work intervals */];
438/// let min_duration = 30; // 30-minute minimum
439///
440/// match analyze_short_intervals(&intervals, min_duration) {
441///     Some(analysis) => {
442///         println!("Found {} short intervals", analysis.count);
443///         println!("Total fragmented time: {:?}", analysis.total_duration);
444///         println!("Optimization: remove pauses {:?}", analysis.pauses_to_remove);
445///     },
446///     None => {
447///         println!("No short intervals detected - work patterns are optimal");
448///     }
449/// }
450/// ```
451///
452/// # Optimization Recommendations
453///
454/// The function provides specific recommendations:
455/// - **Pause Removal**: Eliminate unnecessary short breaks
456/// - **Break Consolidation**: Combine multiple short breaks
457/// - **Timing Adjustment**: Reschedule breaks to preserve focus periods
458pub fn analyze_short_intervals(intervals: &[WorkInterval], min_minutes: u64) -> Option<ShortIntervalsInfo> {
459    // Collect all intervals that fall below the minimum duration threshold
460    let mut short_intervals = Vec::new();
461    let mut total_duration = Duration::zero();
462    let mut pauses_to_remove = Vec::new();
463
464    // Analyze each interval for duration and optimization opportunities
465    for (idx, interval) in intervals.iter().enumerate() {
466        if interval.is_short(min_minutes) {
467            // Record this short interval for analysis
468            short_intervals.push((idx, interval.clone()));
469            total_duration += interval.duration;
470
471            // Identify optimization opportunity: remove the pause that created this interval
472            // To remove a short interval, we need to remove the pause before it
473            // (which connects it to the previous interval)
474            if idx > 0 {
475                // Get the pause that created this interval by ending the previous one
476                if let Some(pause_idx) = intervals[idx - 1].pause_after {
477                    pauses_to_remove.push(pause_idx);
478                }
479            }
480        }
481    }
482
483    // Return analysis results only if short intervals were found
484    if short_intervals.is_empty() {
485        None
486    } else {
487        Some(ShortIntervalsInfo {
488            count: short_intervals.len(),
489            total_duration,
490            intervals: short_intervals,
491            pauses_to_remove,
492        })
493    }
494}
495
496/// Filters out short work intervals from the provided interval list.
497///
498/// This function removes work intervals that are shorter than the specified
499/// minimum duration, providing cleaner reporting by eliminating brief
500/// interruptions that don't represent meaningful work periods. This is the
501/// new approach for handling short intervals - filtering at display time
502/// instead of modifying the database.
503///
504/// ## Filtering Logic
505///
506/// - Intervals shorter than `min_minutes` are excluded from the result
507/// - Remaining intervals maintain their original timing and properties
508/// - No database changes are made - this is purely a display/API filter
509/// - Used by both `kasl report` (display) and `kasl report --send` (API submission)
510///
511/// ## Return Value
512///
513/// Returns a tuple containing:
514/// - **Filtered intervals**: Only intervals meeting the minimum duration
515/// - **Filtered intervals info**: Analysis of what was filtered out (if any)
516///
517/// # Arguments
518///
519/// * `intervals` - Original work intervals to filter
520/// * `min_minutes` - Minimum duration in minutes for intervals to keep
521///
522/// # Returns
523///
524/// Returns `(filtered_intervals, filtered_info)` where:
525/// - `filtered_intervals` contains only intervals >= min_minutes
526/// - `filtered_info` contains details about filtered intervals (None if nothing was filtered)
527///
528/// # Examples
529///
530/// ```rust
531/// use kasl::libs::report::{calculate_work_intervals, filter_short_intervals};
532/// use kasl::db::workdays::Workday;
533/// use kasl::libs::pause::Pause;
534/// use chrono::Local;
535///
536/// let workday = Workday {
537///     id: 1,
538///     date: Local::now().date_naive(),
539///     start: Local::now().naive_local(),
540///     end: Some(Local::now().naive_local()),
541/// };
542/// let pauses: Vec<Pause> = vec![];
543///
544/// let intervals = calculate_work_intervals(&workday, &pauses);
545/// let (filtered, info) = filter_short_intervals(&intervals, 30);
546///
547/// if let Some(info) = info {
548///     println!("Filtered {} short intervals", info.count);
549/// }
550/// ```
551pub fn filter_short_intervals(intervals: &[WorkInterval], min_minutes: u64) -> (Vec<WorkInterval>, Option<ShortIntervalsInfo>) {
552    let mut filtered_intervals = Vec::new();
553    let mut short_intervals = Vec::new();
554    let mut total_duration = Duration::zero();
555
556    for (idx, interval) in intervals.iter().enumerate() {
557        if interval.is_short(min_minutes) {
558            // This is a short interval - add to filtered list
559            short_intervals.push((idx, interval.clone()));
560            total_duration += interval.duration;
561        } else {
562            // This interval meets minimum duration - keep it
563            filtered_intervals.push(interval.clone());
564        }
565    }
566
567    let filtered_info = if short_intervals.is_empty() {
568        None
569    } else {
570        Some(ShortIntervalsInfo {
571            count: short_intervals.len(),
572            total_duration,
573            intervals: short_intervals,
574            pauses_to_remove: Vec::new(), // Not needed for display filtering
575        })
576    };
577
578    (filtered_intervals, filtered_info)
579}
580
581/// Process daily work report data using pre-calculated intervals.
582///
583/// This function handles the data processing for daily work reports, calculating
584/// productivity metrics and work durations. It leverages the centralized `Productivity`
585/// module for consistent calculations across the application.
586///
587/// ## Calculation Method
588///
589/// The function uses two different approaches for different metrics:
590/// - **Filtered Duration**: Summed directly from provided intervals (for display purposes)
591/// - **Productivity**: Calculated using the comprehensive `Productivity::calculate_productivity()`
592///   method which properly handles all pause types, breaks, and overlaps
593///
594/// This separation allows for interval-based filtering (for clean reports) while maintaining
595/// accurate productivity calculations that account for all time categories.
596///
597/// ## Data Consistency
598///
599/// By using `Productivity::new()`, this function automatically:
600/// - Loads the same data used throughout the application
601/// - Applies consistent calculation logic
602/// - Handles all edge cases and data integrity issues
603///
604/// # Arguments
605///
606/// * `workday` - The workday record containing start/end times
607/// * `intervals` - Pre-calculated and optionally filtered work intervals for duration calculation
608///
609/// # Returns
610///
611/// Returns a tuple containing:
612/// - **Filtered Duration**: Sum of provided work intervals (may exclude short intervals)
613/// - **Productivity**: Comprehensive productivity percentage using centralized calculation
614///
615/// # Examples
616///
617/// ```rust,no_run
618/// # fn f() -> anyhow::Result<()> {
619/// use kasl::libs::report::{report_with_intervals, WorkInterval};
620/// use kasl::libs::formatter::format_duration;
621/// use kasl::db::workdays::Workday;
622/// use chrono::Local;
623///
624/// let workday = Workday {
625///     id: 1,
626///     date: Local::now().date_naive(),
627///     start: Local::now().naive_local(),
628///     end: Some(Local::now().naive_local()),
629/// };
630/// let filtered_intervals: Vec<WorkInterval> = vec![];
631///
632/// let (duration, productivity) = report_with_intervals(&workday, &filtered_intervals)?;
633/// println!("Work time: {}, Productivity: {:.1}%", format_duration(&duration), productivity);
634/// # Ok(())
635/// # }
636/// ```
637pub fn report_with_intervals(workday: &Workday, intervals: &[WorkInterval]) -> Result<(Duration, f64)> {
638    // Calculate filtered duration based on provided intervals (for display purposes)
639    let filtered_duration = intervals.iter().fold(Duration::zero(), |acc, interval| acc + interval.duration);
640
641    // Use centralized productivity module for consistent, comprehensive calculation
642    let productivity = Productivity::new(workday)?.calculate_productivity();
643
644    Ok((filtered_duration, productivity))
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650    use chrono::{Duration, NaiveDate, NaiveDateTime};
651
652    fn at(date: NaiveDate, h: u32, m: u32) -> NaiveDateTime {
653        date.and_hms_opt(h, m, 0).unwrap()
654    }
655
656    fn workday(date: NaiveDate, start_h: u32, end: Option<NaiveDateTime>) -> Workday {
657        Workday {
658            id: 1,
659            date,
660            start: at(date, start_h, 0),
661            end,
662        }
663    }
664
665    fn pause(date: NaiveDate, from: (u32, u32), to: (u32, u32)) -> Pause {
666        let start = at(date, from.0, from.1);
667        let end = at(date, to.0, to.1);
668        Pause::detected(1, start, Some(end), Some(end - start))
669    }
670
671    #[test]
672    fn recorded_end_is_used_as_is() {
673        let date = NaiveDate::from_ymd_opt(2025, 8, 22).unwrap();
674        let end = at(date, 18, 0);
675        let wd = workday(date, 9, Some(end));
676        assert_eq!(workday_end_time(&wd, &[]), end);
677    }
678
679    #[test]
680    fn unclosed_past_day_ends_at_last_pause_not_now() {
681        // Regression: "now" as the fallback stretched an unclosed August day
682        // across every hour since, reporting thousands of hours.
683        let date = NaiveDate::from_ymd_opt(2025, 8, 22).unwrap();
684        let wd = workday(date, 9, None);
685        let pauses = [pause(date, (12, 0), (12, 30)), pause(date, (16, 0), (16, 43))];
686
687        let end = workday_end_time(&wd, &pauses);
688
689        assert_eq!(end, at(date, 16, 43));
690        assert!(end - wd.start < Duration::hours(24));
691    }
692
693    #[test]
694    fn unclosed_past_day_without_pauses_collapses_to_start() {
695        let date = NaiveDate::from_ymd_opt(2025, 8, 22).unwrap();
696        let wd = workday(date, 9, None);
697        assert_eq!(workday_end_time(&wd, &[]), wd.start);
698    }
699
700    #[test]
701    fn unclosed_today_still_runs_to_now() {
702        let today = chrono::Local::now().naive_local();
703        let wd = workday(today.date(), 0, None);
704
705        let end = workday_end_time(&wd, &[]);
706
707        // Ongoing day: end tracks the current moment rather than a past pause.
708        assert!((end - today).num_seconds().abs() < 5);
709    }
710}