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}