#[cfg(test)]
use crate::libs::locale::Language;
use crate::libs::locale::Locale;
use crate::libs::pause::Pause;
use crate::libs::report::WorkInterval;
use crate::libs::task::Task;
use chrono::{Duration, NaiveDate, NaiveDateTime, Timelike};
pub(super) struct HourlyRow {
pub(super) start: String,
pub(super) end: String,
pub(super) description: String,
}
pub(super) struct HourlyReport {
pub(super) date: NaiveDate,
pub(super) weekday: String,
pub(super) month: String,
pub(super) day_hours: i64,
pub(super) worked: String,
pub(super) rows: Vec<HourlyRow>,
}
#[derive(Debug, Clone)]
pub(super) struct HourSlot {
start: NaiveDateTime,
end: NaiveDateTime,
has_work: bool,
has_break: bool,
}
fn floor_to_hour(dt: NaiveDateTime) -> NaiveDateTime {
dt.with_minute(0)
.and_then(|d| d.with_second(0))
.and_then(|d| d.with_nanosecond(0))
.unwrap_or(dt)
}
fn ranges_overlap(a_start: NaiveDateTime, a_end: NaiveDateTime, b_start: NaiveDateTime, b_end: NaiveDateTime) -> bool {
a_start < b_end && b_start < a_end
}
pub(super) fn classify_hour_slots(work_start: NaiveDateTime, work_end: NaiveDateTime, intervals: &[WorkInterval], interruptions: &[Pause]) -> Vec<HourSlot> {
let mut slots = Vec::new();
if work_end <= work_start {
return slots;
}
let mut slot_start = floor_to_hour(work_start);
while slot_start < work_end {
let slot_grid_end = slot_start + Duration::hours(1);
let slot_end = slot_grid_end.min(work_end);
let window_start = slot_start.max(work_start);
let has_work = intervals
.iter()
.any(|interval| ranges_overlap(window_start, slot_end, interval.start, interval.end));
let has_break = interruptions.iter().any(|pause| {
let Some(pause_end) = pause.end else {
return false;
};
let start = pause.start.max(work_start);
let end = pause_end.min(work_end);
start < end && ranges_overlap(window_start, slot_end, start, end)
});
slots.push(HourSlot {
start: slot_start,
end: slot_end,
has_work,
has_break,
});
slot_start = slot_grid_end;
}
slots
}
pub(super) fn assign_tasks_to_hour_slots(tasks: &[Task], slots: &[HourSlot], locale: &Locale) -> Vec<Option<String>> {
let mut texts: Vec<Option<String>> = vec![None; slots.len()];
let work_indices: Vec<usize> = slots.iter().enumerate().filter(|(_, s)| s.has_work).map(|(i, _)| i).collect();
if work_indices.is_empty() {
return texts;
}
if tasks.is_empty() {
for &idx in &work_indices {
texts[idx] = Some(locale.work_generic.to_string());
}
return texts;
}
let num_work = work_indices.len();
let num_tasks = tasks.len();
if num_tasks <= num_work {
let base = num_work / num_tasks;
let mut extra = num_work % num_tasks;
let mut cursor = 0usize;
for task in tasks {
let count = base + if extra > 0 { 1 } else { 0 };
extra = extra.saturating_sub(1);
let text = locale.work_text(&task.name);
for _ in 0..count {
if cursor < num_work {
texts[work_indices[cursor]] = Some(text.clone());
cursor += 1;
}
}
}
} else {
let mut parts: Vec<Vec<String>> = work_indices.iter().enumerate().map(|(i, _)| vec![locale.work_text(&tasks[i].name)]).collect();
let surplus: Vec<String> = tasks[num_work..].iter().map(|t| locale.work_text(&t.name)).collect();
let mut no_break_local: Vec<usize> = work_indices
.iter()
.enumerate()
.filter(|&(_, &slot_idx)| !slots[slot_idx].has_break)
.map(|(local_i, _)| local_i)
.collect();
if no_break_local.is_empty() {
no_break_local = (0..num_work).collect();
}
let base = surplus.len() / no_break_local.len();
let mut rem = surplus.len() % no_break_local.len();
let mut iter = surplus.into_iter();
for &local_i in &no_break_local {
let count = base + if rem > 0 { 1 } else { 0 };
rem = rem.saturating_sub(1);
for _ in 0..count {
if let Some(text) = iter.next() {
parts[local_i].push(text);
}
}
}
for (local_i, &slot_idx) in work_indices.iter().enumerate() {
texts[slot_idx] = Some(parts[local_i].join(". "));
}
}
texts
}
pub(super) fn build_hourly_rows(slots: &[HourSlot], task_texts: &[Option<String>], break_label: &str) -> Vec<HourlyRow> {
slots
.iter()
.enumerate()
.map(|(i, slot)| {
let description = if !slot.has_work {
break_label.to_string()
} else {
let work = task_texts.get(i).and_then(|t| t.as_ref()).map(String::as_str).unwrap_or("");
if slot.has_break {
if work.is_empty() {
break_label.to_string()
} else {
format!("{work}. {break_label}")
}
} else if work.is_empty() {
break_label.to_string()
} else {
work.to_string()
}
};
HourlyRow {
start: slot.start.format("%H:%M").to_string(),
end: slot.end.format("%H:%M").to_string(),
description,
}
})
.collect()
}
#[cfg(test)]
mod hourly_tests {
use super::*;
use crate::libs::task::Task;
use chrono::NaiveDate;
fn dt(hour: u32, min: u32) -> NaiveDateTime {
NaiveDate::from_ymd_opt(2025, 1, 15).unwrap().and_hms_opt(hour, min, 0).unwrap()
}
fn interval(start_h: u32, start_m: u32, end_h: u32, end_m: u32) -> WorkInterval {
let start = dt(start_h, start_m);
let end = dt(end_h, end_m);
WorkInterval {
start,
end,
duration: end - start,
pause_after: None,
}
}
fn pause(start_h: u32, start_m: u32, end_h: u32, end_m: u32) -> Pause {
let start = dt(start_h, start_m);
let end = dt(end_h, end_m);
Pause::detected(1, start, Some(end), Some(end - start))
}
fn task(name: &str) -> Task {
Task::new(name, "", Some(0))
}
#[test]
fn fewer_tasks_fill_contiguous_blocks() {
let locale = Locale::for_language(Language::En);
let intervals = vec![interval(9, 0, 14, 0)];
let slots = classify_hour_slots(dt(9, 0), dt(14, 0), &intervals, &[]);
assert_eq!(slots.len(), 5);
assert!(slots.iter().all(|s| s.has_work && !s.has_break));
let tasks = vec![task("A"), task("B"), task("C")];
let texts = assign_tasks_to_hour_slots(&tasks, &slots, locale);
let names: Vec<&str> = texts.iter().map(|t| t.as_deref().unwrap()).collect();
assert_eq!(
names,
vec![
"Work on task [A]",
"Work on task [A]",
"Work on task [B]",
"Work on task [B]",
"Work on task [C]",
]
);
}
#[test]
fn surplus_tasks_go_to_no_break_hours() {
let locale = Locale::for_language(Language::En);
let intervals = vec![interval(9, 0, 12, 0), interval(12, 30, 14, 0)];
let interruptions = vec![pause(12, 0, 12, 30)];
let slots = classify_hour_slots(dt(9, 0), dt(14, 0), &intervals, &interruptions);
assert_eq!(slots.len(), 5);
assert!(slots[0].has_work && !slots[0].has_break); assert!(slots[1].has_work && !slots[1].has_break); assert!(slots[2].has_work && !slots[2].has_break); assert!(slots[3].has_work && slots[3].has_break); assert!(slots[4].has_work && !slots[4].has_break);
let tasks = vec![task("A"), task("B"), task("C"), task("D"), task("E")];
let texts = assign_tasks_to_hour_slots(&tasks, &slots, locale);
assert_eq!(texts[3].as_deref(), Some("Work on task [D]"));
let tasks = vec![task("A"), task("B"), task("C"), task("D"), task("E"), task("F")];
let texts = assign_tasks_to_hour_slots(&tasks, &slots, locale);
assert_eq!(texts[3].as_deref(), Some("Work on task [D]"));
assert!(texts.iter().enumerate().any(|(i, t)| i != 3 && t.as_ref().is_some_and(|s| s.contains("[F]"))));
assert!(!texts[3].as_ref().unwrap().contains("[F]"));
}
#[test]
fn surplus_distributed_across_no_break_hours() {
let locale = Locale::for_language(Language::En);
let intervals = vec![interval(9, 0, 12, 0)];
let slots = classify_hour_slots(dt(9, 0), dt(12, 0), &intervals, &[]);
let tasks = vec![task("A"), task("B"), task("C"), task("D"), task("E")];
let texts = assign_tasks_to_hour_slots(&tasks, &slots, locale);
assert_eq!(texts[0].as_deref(), Some("Work on task [A]. Work on task [D]"));
assert_eq!(texts[1].as_deref(), Some("Work on task [B]. Work on task [E]"));
assert_eq!(texts[2].as_deref(), Some("Work on task [C]"));
}
#[test]
fn pure_break_hour_has_only_break_label() {
let locale = Locale::for_language(Language::En);
let intervals = vec![interval(9, 0, 12, 0), interval(13, 0, 14, 0)];
let interruptions = vec![pause(12, 0, 13, 0)];
let slots = classify_hour_slots(dt(9, 0), dt(14, 0), &intervals, &interruptions);
let tasks = vec![task("A"), task("B"), task("C")];
let texts = assign_tasks_to_hour_slots(&tasks, &slots, locale);
let rows = build_hourly_rows(&slots, &texts, locale.break_label);
assert!(!slots[3].has_work && slots[3].has_break);
assert_eq!(rows[3].description, "Break");
assert!(texts[3].is_none());
}
#[test]
fn mixed_hour_shows_task_and_break() {
let locale = Locale::for_language(Language::En);
let intervals = vec![interval(9, 0, 12, 0), interval(12, 30, 13, 0)];
let interruptions = vec![pause(12, 0, 12, 30)];
let slots = classify_hour_slots(dt(9, 0), dt(13, 0), &intervals, &interruptions);
let tasks = vec![task("A"), task("B"), task("C"), task("D")];
let texts = assign_tasks_to_hour_slots(&tasks, &slots, locale);
let rows = build_hourly_rows(&slots, &texts, locale.break_label);
assert!(slots[3].has_work && slots[3].has_break);
assert_eq!(rows[3].description, "Work on task [D]. Break");
}
}