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}