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, NaiveDate, 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/// Everything `kasl report` says about one day, read once for every way of printing it.
345///
346/// The table and `--json` are two renderings of this, not two readings of the
347/// database, so they cannot disagree about a day.
348pub struct DayReport {
349    pub date: NaiveDate,
350    /// `None` for a day nothing was recorded on.
351    pub workday: Option<Workday>,
352    /// The intervals the report counts, short ones already left out.
353    pub intervals: Vec<WorkInterval>,
354    /// The sum of `intervals`.
355    pub work: Duration,
356    pub productivity: Option<f64>,
357    /// The configured minimum, when there is a day to hold to it.
358    pub threshold: Option<f64>,
359    /// Whether the day is below the minimum yet - see [`Productivity::is_below_threshold`].
360    pub below_threshold: bool,
361    /// The short intervals `intervals` leaves out.
362    pub dropped: Option<ShortIntervalsInfo>,
363    pub tasks: Vec<crate::libs::task::Task>,
364    pub inbox: crate::db::jira_inbox::InboxCounts,
365}
366
367impl DayReport {
368    /// Reads the day as `kasl report` shows it.
369    pub fn read(date: NaiveDate) -> Result<DayReport> {
370        use crate::db::{jira_inbox::JiraInbox, pauses::Pauses, tasks::Tasks, workdays::Workdays};
371        use crate::libs::{config::Config, task::TaskFilter};
372
373        let tasks = Tasks::new()?.fetch(TaskFilter::Date(date))?;
374        let inbox = JiraInbox::new()?.counts()?;
375        let Some(workday) = Workdays::new()?.fetch(date)? else {
376            return Ok(DayReport {
377                date,
378                workday: None,
379                intervals: Vec::new(),
380                work: Duration::zero(),
381                productivity: None,
382                threshold: None,
383                below_threshold: false,
384                dropped: None,
385                tasks,
386                inbox,
387            });
388        };
389
390        let monitor_config = Config::read()?.monitor.unwrap_or_default();
391        // Interruptions: detected pauses above the threshold plus any manual
392        // pauses the user recorded (protected records bypass the threshold).
393        let long_pauses = Pauses::new()?
394            .set_min_duration(monitor_config.min_pause_duration)
395            .get_workday_pauses(&workday)?;
396        let all_intervals = calculate_work_intervals(&workday, &long_pauses);
397        let (intervals, dropped) = filter_short_intervals(&all_intervals, monitor_config.min_work_interval);
398        let (work, productivity) = report_with_intervals(&workday, &intervals)?;
399        let gauge = Productivity::new(&workday)?;
400
401        Ok(DayReport {
402            date,
403            intervals,
404            work,
405            productivity: Some(productivity),
406            threshold: Some(gauge.config.min_productivity_threshold),
407            below_threshold: gauge.is_below_threshold(),
408            dropped,
409            tasks,
410            inbox,
411            workday: Some(workday),
412        })
413    }
414
415    /// The day as `--json` prints it.
416    pub fn to_json(&self) -> crate::libs::json::Day {
417        use crate::libs::json;
418        json::Day {
419            date: self.date,
420            workday: self.workday.as_ref().map(|workday| json::Workday {
421                start: workday.start,
422                end: workday.end,
423            }),
424            intervals: self.intervals.iter().map(json::Interval::from).collect(),
425            work_seconds: json::seconds(&self.work),
426            productivity: self.productivity.map(json::percent),
427            below_threshold: self.below_threshold,
428            dropped: self
429                .dropped
430                .as_ref()
431                .map(|dropped| json::Dropped {
432                    count: dropped.count,
433                    seconds: json::seconds(&dropped.total_duration),
434                })
435                .unwrap_or_default(),
436            tasks: self.tasks.iter().map(json::Task::from).collect(),
437            inbox: self.inbox.into(),
438        }
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use chrono::{Duration, NaiveDate, NaiveDateTime};
446
447    fn at(date: NaiveDate, h: u32, m: u32) -> NaiveDateTime {
448        date.and_hms_opt(h, m, 0).unwrap()
449    }
450
451    fn workday(date: NaiveDate, start_h: u32, end: Option<NaiveDateTime>) -> Workday {
452        Workday {
453            id: 1,
454            date,
455            start: at(date, start_h, 0),
456            end,
457        }
458    }
459
460    fn pause(date: NaiveDate, from: (u32, u32), to: (u32, u32)) -> Pause {
461        let start = at(date, from.0, from.1);
462        let end = at(date, to.0, to.1);
463        Pause::detected(1, start, Some(end), Some(end - start))
464    }
465
466    #[test]
467    fn recorded_end_is_used_as_is() {
468        let date = NaiveDate::from_ymd_opt(2025, 8, 22).unwrap();
469        let end = at(date, 18, 0);
470        let wd = workday(date, 9, Some(end));
471        assert_eq!(workday_end_time(&wd, &[]), end);
472    }
473
474    #[test]
475    fn unclosed_past_day_ends_at_last_pause_not_now() {
476        // Regression: "now" as the fallback stretched an unclosed August day
477        // across every hour since, reporting thousands of hours.
478        let date = NaiveDate::from_ymd_opt(2025, 8, 22).unwrap();
479        let wd = workday(date, 9, None);
480        let pauses = [pause(date, (12, 0), (12, 30)), pause(date, (16, 0), (16, 43))];
481
482        let end = workday_end_time(&wd, &pauses);
483
484        assert_eq!(end, at(date, 16, 43));
485        assert!(end - wd.start < Duration::hours(24));
486    }
487
488    #[test]
489    fn unclosed_past_day_without_pauses_collapses_to_start() {
490        let date = NaiveDate::from_ymd_opt(2025, 8, 22).unwrap();
491        let wd = workday(date, 9, None);
492        assert_eq!(workday_end_time(&wd, &[]), wd.start);
493    }
494
495    #[test]
496    fn unclosed_today_never_ends_before_it_starts() {
497        // A start slightly ahead of the clock - DST, a corrected system time -
498        // must not produce a negative-length day, which read as 0% productivity.
499        let now = chrono::Local::now().naive_local();
500        let wd = workday(now.date(), 0, None);
501        let wd = Workday {
502            start: now + Duration::hours(2),
503            ..wd
504        };
505
506        let end = workday_end_time(&wd, &[]);
507
508        assert!(end >= wd.start, "end {end} precedes start {}", wd.start);
509    }
510
511    #[test]
512    fn unclosed_today_still_runs_to_now() {
513        let today = chrono::Local::now().naive_local();
514        let wd = workday(today.date(), 0, None);
515
516        let end = workday_end_time(&wd, &[]);
517
518        // Ongoing day: end tracks the current moment rather than a past pause.
519        assert!((end - today).num_seconds().abs() < 5);
520    }
521}