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}