Skip to main content

kasl/libs/
report.rs

1//! Work-interval math for the daily report: the day sliced at its
2//! pauses, short-interval filtering, and the end-time fallbacks.
3//!
4//! ```rust
5//! use kasl::libs::report::{calculate_work_intervals, filter_short_intervals, WorkInterval};
6//! use kasl::db::workdays::Workday;
7//! use kasl::libs::pause::Pause;
8//! use chrono::Local;
9//!
10//! let workday = Workday {
11//!     id: 1,
12//!     date: Local::now().date_naive(),
13//!     start: Local::now().naive_local(),
14//!     end: Some(Local::now().naive_local()),
15//! };
16//!
17//! let pauses: Vec<Pause> = vec![/* pause data */];
18//! let intervals = calculate_work_intervals(&workday, &pauses);
19//!
20//! // Filter short intervals for cleaner reporting
21//! let (filtered_intervals, filter_info) = filter_short_intervals(&intervals, 30);
22//! ```
23
24use crate::libs::pause::Pause;
25use crate::{db::workdays::Workday, libs::productivity::Productivity};
26use anyhow::Result;
27use chrono::{Duration, NaiveDateTime};
28
29/// One uninterrupted stretch of work between pauses (or day bounds).
30#[derive(Debug, Clone)]
31pub struct WorkInterval {
32    /// Workday start, or the end of the previous pause.
33    pub start: NaiveDateTime,
34
35    /// Start of the next pause, or the workday end.
36    pub end: NaiveDateTime,
37
38    /// `end - start`.
39    pub duration: Duration,
40
41    /// Index (into the original pause list) of the pause that ended this
42    /// interval; `None` for the day's final interval.
43    pub pause_after: Option<usize>,
44}
45
46impl WorkInterval {
47    /// True when the interval is under `min_minutes`.
48    ///
49    /// ```rust
50    /// use kasl::libs::report::WorkInterval;
51    /// use chrono::{Duration, Local};
52    ///
53    /// let start_time = Local::now().naive_local();
54    /// let interval = WorkInterval {
55    ///     start: start_time,
56    ///     end: start_time + Duration::minutes(20),
57    ///     duration: Duration::minutes(20),
58    ///     pause_after: Some(1),
59    /// };
60    ///
61    /// assert_eq!(interval.is_short(30), true);  // 20 < 30
62    /// assert_eq!(interval.is_short(15), false); // 20 >= 15
63    /// ```
64    pub fn is_short(&self, min_minutes: u64) -> bool {
65        self.duration < Duration::minutes(min_minutes as i64)
66    }
67}
68
69/// What fell under the short-interval threshold, and which pauses would
70/// have to go to merge the fragments back together.
71#[derive(Debug)]
72pub struct ShortIntervalsInfo {
73    pub count: usize,
74
75    /// Combined length of all short intervals.
76    pub total_duration: Duration,
77
78    /// `(index in the original interval list, interval)` pairs.
79    pub intervals: Vec<(usize, WorkInterval)>,
80
81    /// Pause indices whose removal would merge each short interval into
82    /// its predecessor; empty when only display filtering was requested.
83    pub pauses_to_remove: Vec<usize>,
84}
85
86/// The moment the day effectively ends: the recorded end; else "now"
87/// for today (guarded so a start ahead of the clock cannot make the day
88/// negative); else, for an unclosed past day, the last observed pause
89/// end - falling back to the start rather than stretching to "now".
90pub fn workday_end_time(workday: &Workday, pauses: &[Pause]) -> chrono::NaiveDateTime {
91    if let Some(end) = workday.end {
92        return end;
93    }
94
95    let now = chrono::Local::now().naive_local();
96    // Only while the day is still today, and only if the clock has actually
97    // passed the start: a workday timestamped slightly ahead of the clock - a
98    // DST shift, a corrected system time - would otherwise yield an end before
99    // the start, and every duration computed from it would go negative.
100    if workday.date == now.date() && now > workday.start {
101        return now;
102    }
103
104    // A past day that was never closed: fall back to the last thing we observed.
105    pauses
106        .iter()
107        .filter_map(|pause| pause.end)
108        .max()
109        .filter(|last| *last > workday.start)
110        .unwrap_or(workday.start)
111}
112
113/// Slices the workday at its completed pauses into work intervals.
114///
115/// Pauses without an end are skipped; the rest are processed in
116/// chronological order, and the tail interval runs to [`workday_end_time`].
117///
118/// ```rust
119/// use kasl::libs::report::calculate_work_intervals;
120/// use kasl::db::workdays::Workday;
121/// use kasl::libs::pause::Pause;
122/// use chrono::{Local, Duration};
123///
124/// let start_time = Local::now().naive_local();
125/// let end_time = start_time + Duration::hours(8);
126/// let lunch_start = start_time + Duration::hours(4);
127/// let lunch_end = lunch_start + Duration::minutes(30);
128/// let lunch_duration = Duration::minutes(30);
129///
130/// let workday = Workday {
131///     id: 1,
132///     date: start_time.date(),
133///     start: start_time,
134///     end: Some(end_time),
135/// };
136///
137/// let pauses = vec![
138///     Pause {
139///         id: 1,
140///         start: lunch_start,
141///         end: Some(lunch_end),
142///         duration: Some(lunch_duration),
143///         protected: false,
144///     },
145/// ];
146///
147/// let intervals = calculate_work_intervals(&workday, &pauses);
148/// println!("Generated {} work intervals", intervals.len());
149/// ```
150pub fn calculate_work_intervals(workday: &Workday, pauses: &[Pause]) -> Vec<WorkInterval> {
151    // Determine workday end time (current time if still ongoing)
152    let end_time = workday_end_time(workday, pauses);
153
154    // Initialize interval collection and current time tracker
155    let mut intervals = vec![];
156    let mut current_time = workday.start;
157
158    // Filter out incomplete pauses and sort chronologically
159    // Only pauses with both start and end times can create work intervals
160    let mut complete_pauses: Vec<(usize, &Pause)> = pauses.iter().enumerate().filter(|(_, pause)| pause.end.is_some()).collect();
161
162    // Sort pauses by start time to ensure chronological processing
163    complete_pauses.sort_by_key(|(_, pause)| pause.start);
164
165    // Process each pause to create work intervals
166    for (original_idx, pause) in complete_pauses {
167        // Create work interval before this pause (if there's time)
168        if current_time < pause.start {
169            intervals.push(WorkInterval {
170                start: current_time,
171                end: pause.start,
172                duration: pause.start - current_time,
173                pause_after: Some(original_idx),
174            });
175        }
176
177        // Move current time to the end of the pause
178        if let Some(pause_end) = pause.end {
179            current_time = pause_end;
180        }
181    }
182
183    // Add the final work interval after the last pause (if there's time)
184    if current_time < end_time {
185        intervals.push(WorkInterval {
186            start: current_time,
187            end: end_time,
188            duration: end_time - current_time,
189            pause_after: None, // No pause after the final interval
190        });
191    }
192
193    intervals
194}
195
196/// Finds intervals under the threshold and the pauses whose removal
197/// would merge them away; `None` when there are none.
198///
199/// ```rust
200/// use kasl::libs::report::{analyze_short_intervals, WorkInterval};
201///
202/// let intervals = vec![/* work intervals */];
203/// let min_duration = 30; // 30-minute minimum
204///
205/// match analyze_short_intervals(&intervals, min_duration) {
206///     Some(analysis) => {
207///         println!("Found {} short intervals", analysis.count);
208///         println!("Total fragmented time: {:?}", analysis.total_duration);
209///         println!("Optimization: remove pauses {:?}", analysis.pauses_to_remove);
210///     },
211///     None => {
212///         println!("No short intervals detected - work patterns are optimal");
213///     }
214/// }
215/// ```
216///
217pub fn analyze_short_intervals(intervals: &[WorkInterval], min_minutes: u64) -> Option<ShortIntervalsInfo> {
218    // Collect all intervals that fall below the minimum duration threshold
219    let mut short_intervals = Vec::new();
220    let mut total_duration = Duration::zero();
221    let mut pauses_to_remove = Vec::new();
222
223    // Analyze each interval for duration and optimization opportunities
224    for (idx, interval) in intervals.iter().enumerate() {
225        if interval.is_short(min_minutes) {
226            // Record this short interval for analysis
227            short_intervals.push((idx, interval.clone()));
228            total_duration += interval.duration;
229
230            // Identify optimization opportunity: remove the pause that created this interval
231            // To remove a short interval, we need to remove the pause before it
232            // (which connects it to the previous interval)
233            if idx > 0 {
234                // Get the pause that created this interval by ending the previous one
235                if let Some(pause_idx) = intervals[idx - 1].pause_after {
236                    pauses_to_remove.push(pause_idx);
237                }
238            }
239        }
240    }
241
242    // Return analysis results only if short intervals were found
243    if short_intervals.is_empty() {
244        None
245    } else {
246        Some(ShortIntervalsInfo {
247            count: short_intervals.len(),
248            total_duration,
249            intervals: short_intervals,
250            pauses_to_remove,
251        })
252    }
253}
254
255/// Splits intervals into (kept, dropped-as-short) at display time - the
256/// database is never modified by this filter.
257///
258/// ```rust
259/// use kasl::libs::report::{calculate_work_intervals, filter_short_intervals};
260/// use kasl::db::workdays::Workday;
261/// use kasl::libs::pause::Pause;
262/// use chrono::Local;
263///
264/// let workday = Workday {
265///     id: 1,
266///     date: Local::now().date_naive(),
267///     start: Local::now().naive_local(),
268///     end: Some(Local::now().naive_local()),
269/// };
270/// let pauses: Vec<Pause> = vec![];
271///
272/// let intervals = calculate_work_intervals(&workday, &pauses);
273/// let (filtered, info) = filter_short_intervals(&intervals, 30);
274///
275/// if let Some(info) = info {
276///     println!("Filtered {} short intervals", info.count);
277/// }
278/// ```
279pub fn filter_short_intervals(intervals: &[WorkInterval], min_minutes: u64) -> (Vec<WorkInterval>, Option<ShortIntervalsInfo>) {
280    let mut filtered_intervals = Vec::new();
281    let mut short_intervals = Vec::new();
282    let mut total_duration = Duration::zero();
283
284    for (idx, interval) in intervals.iter().enumerate() {
285        if interval.is_short(min_minutes) {
286            // This is a short interval - add to filtered list
287            short_intervals.push((idx, interval.clone()));
288            total_duration += interval.duration;
289        } else {
290            // This interval meets minimum duration - keep it
291            filtered_intervals.push(interval.clone());
292        }
293    }
294
295    let filtered_info = if short_intervals.is_empty() {
296        None
297    } else {
298        Some(ShortIntervalsInfo {
299            count: short_intervals.len(),
300            total_duration,
301            intervals: short_intervals,
302            pauses_to_remove: Vec::new(), // Not needed for display filtering
303        })
304    };
305
306    (filtered_intervals, filtered_info)
307}
308
309/// Returns `(displayed duration, productivity)` for the report: the
310/// duration sums the (already filtered) intervals, while productivity
311/// comes from the central [`Productivity`] calculation so every command
312/// shows the same figure.
313///
314/// ```rust,no_run
315/// # fn f() -> anyhow::Result<()> {
316/// use kasl::libs::report::{report_with_intervals, WorkInterval};
317/// use kasl::libs::formatter::format_duration;
318/// use kasl::db::workdays::Workday;
319/// use chrono::Local;
320///
321/// let workday = Workday {
322///     id: 1,
323///     date: Local::now().date_naive(),
324///     start: Local::now().naive_local(),
325///     end: Some(Local::now().naive_local()),
326/// };
327/// let filtered_intervals: Vec<WorkInterval> = vec![];
328///
329/// let (duration, productivity) = report_with_intervals(&workday, &filtered_intervals)?;
330/// println!("Work time: {}, Productivity: {:.1}%", format_duration(&duration), productivity);
331/// # Ok(())
332/// # }
333/// ```
334pub fn report_with_intervals(workday: &Workday, intervals: &[WorkInterval]) -> Result<(Duration, f64)> {
335    // Calculate filtered duration based on provided intervals (for display purposes)
336    let filtered_duration = intervals.iter().fold(Duration::zero(), |acc, interval| acc + interval.duration);
337
338    // Use centralized productivity module for consistent, comprehensive calculation
339    let productivity = Productivity::new(workday)?.calculate_productivity();
340
341    Ok((filtered_duration, productivity))
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use chrono::{Duration, NaiveDate, NaiveDateTime};
348
349    fn at(date: NaiveDate, h: u32, m: u32) -> NaiveDateTime {
350        date.and_hms_opt(h, m, 0).unwrap()
351    }
352
353    fn workday(date: NaiveDate, start_h: u32, end: Option<NaiveDateTime>) -> Workday {
354        Workday {
355            id: 1,
356            date,
357            start: at(date, start_h, 0),
358            end,
359        }
360    }
361
362    fn pause(date: NaiveDate, from: (u32, u32), to: (u32, u32)) -> Pause {
363        let start = at(date, from.0, from.1);
364        let end = at(date, to.0, to.1);
365        Pause::detected(1, start, Some(end), Some(end - start))
366    }
367
368    #[test]
369    fn recorded_end_is_used_as_is() {
370        let date = NaiveDate::from_ymd_opt(2025, 8, 22).unwrap();
371        let end = at(date, 18, 0);
372        let wd = workday(date, 9, Some(end));
373        assert_eq!(workday_end_time(&wd, &[]), end);
374    }
375
376    #[test]
377    fn unclosed_past_day_ends_at_last_pause_not_now() {
378        // Regression: "now" as the fallback stretched an unclosed August day
379        // across every hour since, reporting thousands of hours.
380        let date = NaiveDate::from_ymd_opt(2025, 8, 22).unwrap();
381        let wd = workday(date, 9, None);
382        let pauses = [pause(date, (12, 0), (12, 30)), pause(date, (16, 0), (16, 43))];
383
384        let end = workday_end_time(&wd, &pauses);
385
386        assert_eq!(end, at(date, 16, 43));
387        assert!(end - wd.start < Duration::hours(24));
388    }
389
390    #[test]
391    fn unclosed_past_day_without_pauses_collapses_to_start() {
392        let date = NaiveDate::from_ymd_opt(2025, 8, 22).unwrap();
393        let wd = workday(date, 9, None);
394        assert_eq!(workday_end_time(&wd, &[]), wd.start);
395    }
396
397    #[test]
398    fn unclosed_today_never_ends_before_it_starts() {
399        // A start slightly ahead of the clock - DST, a corrected system time -
400        // must not produce a negative-length day, which read as 0% productivity.
401        let now = chrono::Local::now().naive_local();
402        let wd = workday(now.date(), 0, None);
403        let wd = Workday {
404            start: now + Duration::hours(2),
405            ..wd
406        };
407
408        let end = workday_end_time(&wd, &[]);
409
410        assert!(end >= wd.start, "end {end} precedes start {}", wd.start);
411    }
412
413    #[test]
414    fn unclosed_today_still_runs_to_now() {
415        let today = chrono::Local::now().naive_local();
416        let wd = workday(today.date(), 0, None);
417
418        let end = workday_end_time(&wd, &[]);
419
420        // Ongoing day: end tracks the current moment rather than a past pause.
421        assert!((end - today).num_seconds().abs() < 5);
422    }
423}