use crate::libs::pause::Pause;
use crate::{db::workdays::Workday, libs::productivity::Productivity};
use anyhow::Result;
use chrono::{Duration, NaiveDateTime};
#[derive(Debug, Clone)]
pub struct WorkInterval {
pub start: NaiveDateTime,
pub end: NaiveDateTime,
pub duration: Duration,
pub pause_after: Option<usize>,
}
impl WorkInterval {
pub fn is_short(&self, min_minutes: u64) -> bool {
self.duration < Duration::minutes(min_minutes as i64)
}
}
#[derive(Debug)]
pub struct ShortIntervalsInfo {
pub count: usize,
pub total_duration: Duration,
pub intervals: Vec<(usize, WorkInterval)>,
pub pauses_to_remove: Vec<usize>,
}
pub fn workday_end_time(workday: &Workday, pauses: &[Pause]) -> chrono::NaiveDateTime {
if let Some(end) = workday.end {
return end;
}
let now = chrono::Local::now().naive_local();
if workday.date == now.date() && now > workday.start {
return now;
}
pauses
.iter()
.filter_map(|pause| pause.end)
.max()
.filter(|last| *last > workday.start)
.unwrap_or(workday.start)
}
pub fn calculate_work_intervals(workday: &Workday, pauses: &[Pause]) -> Vec<WorkInterval> {
let end_time = workday_end_time(workday, pauses);
let mut intervals = vec![];
let mut current_time = workday.start;
let mut complete_pauses: Vec<(usize, &Pause)> = pauses.iter().enumerate().filter(|(_, pause)| pause.end.is_some()).collect();
complete_pauses.sort_by_key(|(_, pause)| pause.start);
for (original_idx, pause) in complete_pauses {
if current_time < pause.start {
intervals.push(WorkInterval {
start: current_time,
end: pause.start,
duration: pause.start - current_time,
pause_after: Some(original_idx),
});
}
if let Some(pause_end) = pause.end {
current_time = pause_end;
}
}
if current_time < end_time {
intervals.push(WorkInterval {
start: current_time,
end: end_time,
duration: end_time - current_time,
pause_after: None, });
}
intervals
}
pub fn analyze_short_intervals(intervals: &[WorkInterval], min_minutes: u64) -> Option<ShortIntervalsInfo> {
let mut short_intervals = Vec::new();
let mut total_duration = Duration::zero();
let mut pauses_to_remove = Vec::new();
for (idx, interval) in intervals.iter().enumerate() {
if interval.is_short(min_minutes) {
short_intervals.push((idx, interval.clone()));
total_duration += interval.duration;
if idx > 0 {
if let Some(pause_idx) = intervals[idx - 1].pause_after {
pauses_to_remove.push(pause_idx);
}
}
}
}
if short_intervals.is_empty() {
None
} else {
Some(ShortIntervalsInfo {
count: short_intervals.len(),
total_duration,
intervals: short_intervals,
pauses_to_remove,
})
}
}
pub fn filter_short_intervals(intervals: &[WorkInterval], min_minutes: u64) -> (Vec<WorkInterval>, Option<ShortIntervalsInfo>) {
let mut filtered_intervals = Vec::new();
let mut short_intervals = Vec::new();
let mut total_duration = Duration::zero();
for (idx, interval) in intervals.iter().enumerate() {
if interval.is_short(min_minutes) {
short_intervals.push((idx, interval.clone()));
total_duration += interval.duration;
} else {
filtered_intervals.push(interval.clone());
}
}
let filtered_info = if short_intervals.is_empty() {
None
} else {
Some(ShortIntervalsInfo {
count: short_intervals.len(),
total_duration,
intervals: short_intervals,
pauses_to_remove: Vec::new(), })
};
(filtered_intervals, filtered_info)
}
pub fn report_with_intervals(workday: &Workday, intervals: &[WorkInterval]) -> Result<(Duration, f64)> {
let filtered_duration = intervals.iter().fold(Duration::zero(), |acc, interval| acc + interval.duration);
let productivity = Productivity::new(workday)?.calculate_productivity();
Ok((filtered_duration, productivity))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, NaiveDate, NaiveDateTime};
fn at(date: NaiveDate, h: u32, m: u32) -> NaiveDateTime {
date.and_hms_opt(h, m, 0).unwrap()
}
fn workday(date: NaiveDate, start_h: u32, end: Option<NaiveDateTime>) -> Workday {
Workday {
id: 1,
date,
start: at(date, start_h, 0),
end,
}
}
fn pause(date: NaiveDate, from: (u32, u32), to: (u32, u32)) -> Pause {
let start = at(date, from.0, from.1);
let end = at(date, to.0, to.1);
Pause::detected(1, start, Some(end), Some(end - start))
}
#[test]
fn recorded_end_is_used_as_is() {
let date = NaiveDate::from_ymd_opt(2025, 8, 22).unwrap();
let end = at(date, 18, 0);
let wd = workday(date, 9, Some(end));
assert_eq!(workday_end_time(&wd, &[]), end);
}
#[test]
fn unclosed_past_day_ends_at_last_pause_not_now() {
let date = NaiveDate::from_ymd_opt(2025, 8, 22).unwrap();
let wd = workday(date, 9, None);
let pauses = [pause(date, (12, 0), (12, 30)), pause(date, (16, 0), (16, 43))];
let end = workday_end_time(&wd, &pauses);
assert_eq!(end, at(date, 16, 43));
assert!(end - wd.start < Duration::hours(24));
}
#[test]
fn unclosed_past_day_without_pauses_collapses_to_start() {
let date = NaiveDate::from_ymd_opt(2025, 8, 22).unwrap();
let wd = workday(date, 9, None);
assert_eq!(workday_end_time(&wd, &[]), wd.start);
}
#[test]
fn unclosed_today_never_ends_before_it_starts() {
let now = chrono::Local::now().naive_local();
let wd = workday(now.date(), 0, None);
let wd = Workday {
start: now + Duration::hours(2),
..wd
};
let end = workday_end_time(&wd, &[]);
assert!(end >= wd.start, "end {end} precedes start {}", wd.start);
}
#[test]
fn unclosed_today_still_runs_to_now() {
let today = chrono::Local::now().naive_local();
let wd = workday(today.date(), 0, None);
let end = workday_end_time(&wd, &[]);
assert!((end - today).num_seconds().abs() < 5);
}
}