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}