use crate::{
db::{pauses::Pauses, tasks::Tasks, workdays::Workdays},
libs::{
config::Config,
formatter::format_duration,
locale::{Language, Locale},
messages::Message,
pause::Pause,
report::{self, WorkInterval},
report_template::{FontSpec, ReportTemplate},
task::{Task, TaskFilter},
},
msg_error_anyhow, msg_info, msg_success,
};
use anyhow::Result;
use chrono::{Datelike, Duration, Local, NaiveDate, NaiveDateTime, Timelike};
use rust_xlsxwriter::{Format, FormatAlign, FormatBorder, Workbook};
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum ExportFormat {
Csv,
Json,
Excel,
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum ExportData {
Report,
Tasks,
Summary,
All,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ExportReport {
pub date: String,
pub start_time: String,
pub end_time: String,
pub total_hours: String,
pub productivity: f64,
pub intervals: Vec<ExportInterval>,
pub tasks: Vec<ExportTask>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ExportInterval {
pub index: usize,
pub start: String,
pub end: String,
pub duration: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ExportTask {
pub id: i32,
pub name: String,
pub comment: String,
pub completeness: i32,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ExportSummary {
pub month: String,
pub days: Vec<ExportDaySum>,
pub total_hours: String,
pub average_hours: String,
pub total_days: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ExportDaySum {
pub date: String,
pub hours: String,
pub is_workday: bool,
}
pub struct Exporter {
format: ExportFormat,
output_path: PathBuf,
hourly: bool,
}
impl Exporter {
pub fn new(format: ExportFormat, output_path: Option<PathBuf>) -> Self {
let default_name = format!("kasl_export_{}", Local::now().format("%Y%m%d_%H%M%S"));
let extension = match format {
ExportFormat::Csv => "csv",
ExportFormat::Json => "json",
ExportFormat::Excel => "xlsx",
};
let output_path = output_path.unwrap_or_else(|| PathBuf::from(format!("{}.{}", default_name, extension)));
Self {
format,
output_path,
hourly: false,
}
}
pub fn hourly(mut self, hourly: bool) -> Self {
self.hourly = hourly;
self
}
pub async fn export(&self, data_type: ExportData, date: NaiveDate) -> Result<()> {
match data_type {
ExportData::Report => self.export_report(date).await,
ExportData::Tasks => self.export_tasks(date).await,
ExportData::Summary => self.export_summary(date).await,
ExportData::All => self.export_all(date).await,
}
}
async fn export_report(&self, date: NaiveDate) -> Result<()> {
if self.hourly
&& let ExportFormat::Excel = self.format
{
self.export_report_excel_hourly(date)?;
msg_success!(Message::ExportCompleted(self.output_path.display().to_string()));
return Ok(());
}
let report_data = self.gather_report_data(date)?;
match self.format {
ExportFormat::Csv => self.export_report_csv(&report_data)?,
ExportFormat::Json => self.export_report_json(&report_data)?,
ExportFormat::Excel => self.export_report_excel(&report_data)?,
}
msg_success!(Message::ExportCompleted(self.output_path.display().to_string()));
Ok(())
}
async fn export_tasks(&self, date: NaiveDate) -> Result<()> {
let tasks = Tasks::new()?.fetch(TaskFilter::Date(date))?;
let export_tasks: Vec<ExportTask> = tasks
.into_iter()
.map(|t| ExportTask {
id: t.id.unwrap_or(0),
name: t.name,
comment: t.comment,
completeness: t.completeness.unwrap_or(100),
})
.collect();
match self.format {
ExportFormat::Csv => self.export_tasks_csv(&export_tasks)?,
ExportFormat::Json => {
let json = serde_json::to_string_pretty(&export_tasks)?;
File::create(&self.output_path)?.write_all(json.as_bytes())?;
}
ExportFormat::Excel => self.export_tasks_excel(&export_tasks)?,
}
msg_success!(Message::ExportCompleted(self.output_path.display().to_string()));
Ok(())
}
async fn export_summary(&self, date: NaiveDate) -> Result<()> {
let summary_data = self.gather_summary_data(date)?;
match self.format {
ExportFormat::Csv => self.export_summary_csv(&summary_data)?,
ExportFormat::Json => {
let json = serde_json::to_string_pretty(&summary_data)?;
File::create(&self.output_path)?.write_all(json.as_bytes())?;
}
ExportFormat::Excel => self.export_summary_excel(&summary_data)?,
}
msg_success!(Message::ExportCompleted(self.output_path.display().to_string()));
Ok(())
}
async fn export_all(&self, date: NaiveDate) -> Result<()> {
msg_info!(Message::ExportingAllData);
if let ExportFormat::Json = self.format {
let report = self.gather_report_data(date).ok();
let tasks = Tasks::new()?
.fetch(TaskFilter::Date(date))?
.into_iter()
.map(|t| ExportTask {
id: t.id.unwrap_or(0),
name: t.name,
comment: t.comment,
completeness: t.completeness.unwrap_or(100),
})
.collect::<Vec<_>>();
let summary = self.gather_summary_data(date).ok();
let all_data = serde_json::json!({
"export_date": Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
"daily_report": report,
"tasks": tasks,
"monthly_summary": summary,
});
let json = serde_json::to_string_pretty(&all_data)?;
File::create(&self.output_path)?.write_all(json.as_bytes())?;
} else {
let base = self.output_path.file_stem().unwrap().to_string_lossy();
let ext = self.output_path.extension().unwrap().to_string_lossy();
let report_path = self.output_path.with_file_name(format!("{}_report.{}", base, ext));
let tasks_path = self.output_path.with_file_name(format!("{}_tasks.{}", base, ext));
let summary_path = self.output_path.with_file_name(format!("{}_summary.{}", base, ext));
let report_exporter = Exporter::new(self.format, Some(report_path));
let tasks_exporter = Exporter::new(self.format, Some(tasks_path));
let summary_exporter = Exporter::new(self.format, Some(summary_path));
report_exporter.export_report(date).await?;
tasks_exporter.export_tasks(date).await?;
summary_exporter.export_summary(date).await?;
return Ok(());
}
msg_success!(Message::ExportCompleted(self.output_path.display().to_string()));
Ok(())
}
fn gather_report_data(&self, date: NaiveDate) -> Result<ExportReport> {
let workday = Workdays::new()?
.fetch(date)?
.ok_or_else(|| msg_error_anyhow!(Message::WorkdayNotFoundForDate(date.to_string())))?;
let tasks = Tasks::new()?.fetch(TaskFilter::Date(date))?;
let pauses = Pauses::new()?.get_workday_pauses(&workday)?;
let end_time = report::workday_end_time(&workday, &pauses);
let intervals = report::calculate_work_intervals(&workday, &pauses);
let total_pause_duration = pauses.iter().filter_map(|p| p.duration).fold(Duration::zero(), |acc, d| acc + d);
let gross_duration = end_time - workday.start;
let net_duration = gross_duration - total_pause_duration;
let productivity = if gross_duration.num_seconds() > 0 {
(net_duration.num_seconds() as f64 / gross_duration.num_seconds() as f64) * 100.0
} else {
0.0
};
Ok(ExportReport {
date: date.format("%Y-%m-%d").to_string(),
start_time: workday.start.format("%H:%M").to_string(),
end_time: end_time.format("%H:%M").to_string(),
total_hours: format_duration(&net_duration),
productivity: (productivity * 10.0).round() / 10.0, intervals: intervals
.iter()
.enumerate()
.map(|(i, interval)| ExportInterval {
index: i + 1, start: interval.start.format("%H:%M").to_string(),
end: interval.end.format("%H:%M").to_string(),
duration: format_duration(&interval.duration),
})
.collect(),
tasks: tasks
.into_iter()
.map(|t| ExportTask {
id: t.id.unwrap_or(0),
name: t.name,
comment: t.comment,
completeness: t.completeness.unwrap_or(100),
})
.collect(),
})
}
fn gather_summary_data(&self, date: NaiveDate) -> Result<ExportSummary> {
let workdays = Workdays::new()?.fetch_month(date)?;
let mut days = Vec::new();
let mut total_duration = Duration::zero();
for workday in &workdays {
let day_pauses = Pauses::new()?.get_workday_pauses(workday)?;
let end_time = report::workday_end_time(workday, &day_pauses);
let duration = end_time - workday.start;
total_duration += duration;
days.push(ExportDaySum {
date: workday.date.format("%Y-%m-%d").to_string(),
hours: format_duration(&duration),
is_workday: true, });
}
let avg_duration = if !workdays.is_empty() {
Duration::seconds(total_duration.num_seconds() / workdays.len() as i64)
} else {
Duration::zero()
};
Ok(ExportSummary {
month: date.format("%B %Y").to_string(), days,
total_hours: format_duration(&total_duration),
average_hours: format_duration(&avg_duration),
total_days: workdays.len(),
})
}
fn export_report_csv(&self, report: &ExportReport) -> Result<()> {
let mut wtr = csv::Writer::from_path(&self.output_path)?;
wtr.write_record(["WORK INTERVALS", "", "", ""])?;
wtr.write_record(["Index", "Start", "End", "Duration"])?;
for interval in &report.intervals {
wtr.write_record(&[
interval.index.to_string(),
interval.start.clone(),
interval.end.clone(),
interval.duration.clone(),
])?;
}
wtr.write_record(["", "", "", ""])?;
wtr.write_record(["SUMMARY", "", "", ""])?;
wtr.write_record(["Date", &report.date, "", ""])?;
wtr.write_record(["Total Hours", &report.total_hours, "", ""])?;
wtr.write_record(["Productivity", &format!("{:.1}%", report.productivity), "", ""])?;
wtr.write_record(["", "", "", ""])?;
wtr.write_record(["TASKS", "", "", ""])?;
wtr.write_record(["ID", "Name", "Comment", "Completeness"])?;
for task in &report.tasks {
wtr.write_record(&[task.id.to_string(), task.name.clone(), task.comment.clone(), format!("{}%", task.completeness)])?;
}
wtr.flush()?;
Ok(())
}
fn export_tasks_csv(&self, tasks: &[ExportTask]) -> Result<()> {
let mut wtr = csv::Writer::from_path(&self.output_path)?;
wtr.write_record(["ID", "Name", "Comment", "Completeness"])?;
for task in tasks {
wtr.write_record(&[task.id.to_string(), task.name.clone(), task.comment.clone(), format!("{}%", task.completeness)])?;
}
wtr.flush()?;
Ok(())
}
fn export_summary_csv(&self, summary: &ExportSummary) -> Result<()> {
let mut wtr = csv::Writer::from_path(&self.output_path)?;
wtr.write_record(&[format!("Monthly Summary - {}", summary.month), "".to_owned(), "".to_owned()])?;
wtr.write_record(["Date", "Hours", "Type"])?;
for day in &summary.days {
wtr.write_record(&[
day.date.clone(),
day.hours.clone(),
if day.is_workday { "Work".to_owned() } else { "Rest".to_owned() },
])?;
}
wtr.write_record(["", "", ""])?;
wtr.write_record(["Total Hours", &summary.total_hours, ""])?;
wtr.write_record(["Average Hours", &summary.average_hours, ""])?;
wtr.write_record(["Total Days", &summary.total_days.to_string(), ""])?;
wtr.flush()?;
Ok(())
}
fn export_report_json(&self, report: &ExportReport) -> Result<()> {
let json = serde_json::to_string_pretty(report)?;
File::create(&self.output_path)?.write_all(json.as_bytes())?;
Ok(())
}
fn export_report_excel(&self, report: &ExportReport) -> Result<()> {
let mut workbook = Workbook::new();
let worksheet = workbook.add_worksheet();
let header_format = Format::new().set_bold().set_background_color(rust_xlsxwriter::Color::Gray);
worksheet.write_string_with_format(0, 0, "WORK INTERVALS", &header_format)?;
worksheet.write_string_with_format(1, 0, "Index", &header_format)?;
worksheet.write_string_with_format(1, 1, "Start", &header_format)?;
worksheet.write_string_with_format(1, 2, "End", &header_format)?;
worksheet.write_string_with_format(1, 3, "Duration", &header_format)?;
let mut row = 2;
for interval in &report.intervals {
worksheet.write_number(row, 0, interval.index as f64)?;
worksheet.write_string(row, 1, &interval.start)?;
worksheet.write_string(row, 2, &interval.end)?;
worksheet.write_string(row, 3, &interval.duration)?;
row += 1;
}
row += 2;
worksheet.write_string_with_format(row, 0, "SUMMARY", &header_format)?;
row += 1;
worksheet.write_string(row, 0, "Date")?;
worksheet.write_string(row, 1, &report.date)?;
row += 1;
worksheet.write_string(row, 0, "Total Hours")?;
worksheet.write_string(row, 1, &report.total_hours)?;
row += 1;
worksheet.write_string(row, 0, "Productivity")?;
worksheet.write_string(row, 1, format!("{:.1}%", report.productivity))?;
row += 2;
worksheet.write_string_with_format(row, 0, "TASKS", &header_format)?;
row += 1;
worksheet.write_string_with_format(row, 0, "ID", &header_format)?;
worksheet.write_string_with_format(row, 1, "Name", &header_format)?;
worksheet.write_string_with_format(row, 2, "Comment", &header_format)?;
worksheet.write_string_with_format(row, 3, "Completeness", &header_format)?;
row += 1;
for task in &report.tasks {
worksheet.write_number(row, 0, task.id as f64)?;
worksheet.write_string(row, 1, &task.name)?;
worksheet.write_string(row, 2, &task.comment)?;
worksheet.write_string(row, 3, format!("{}%", task.completeness))?;
row += 1;
}
worksheet.autofit();
workbook.save(&self.output_path)?;
Ok(())
}
fn export_tasks_excel(&self, tasks: &[ExportTask]) -> Result<()> {
let mut workbook = Workbook::new();
let worksheet = workbook.add_worksheet();
let header_format = Format::new().set_bold().set_background_color(rust_xlsxwriter::Color::Gray);
worksheet.write_string_with_format(0, 0, "ID", &header_format)?;
worksheet.write_string_with_format(0, 1, "Name", &header_format)?;
worksheet.write_string_with_format(0, 2, "Comment", &header_format)?;
worksheet.write_string_with_format(0, 3, "Completeness", &header_format)?;
for (i, task) in tasks.iter().enumerate() {
let row = i as u32 + 1;
worksheet.write_number(row, 0, task.id as f64)?;
worksheet.write_string(row, 1, &task.name)?;
worksheet.write_string(row, 2, &task.comment)?;
worksheet.write_string(row, 3, format!("{}%", task.completeness))?;
}
worksheet.autofit();
workbook.save(&self.output_path)?;
Ok(())
}
fn export_summary_excel(&self, summary: &ExportSummary) -> Result<()> {
let mut workbook = Workbook::new();
let worksheet = workbook.add_worksheet();
let header_format = Format::new().set_bold().set_background_color(rust_xlsxwriter::Color::Gray);
let title_format = Format::new().set_bold().set_font_size(14.0);
worksheet.write_string_with_format(0, 0, format!("Monthly Summary - {}", summary.month), &title_format)?;
worksheet.write_string_with_format(2, 0, "Date", &header_format)?;
worksheet.write_string_with_format(2, 1, "Hours", &header_format)?;
worksheet.write_string_with_format(2, 2, "Type", &header_format)?;
let mut row = 3;
for day in &summary.days {
worksheet.write_string(row, 0, &day.date)?;
worksheet.write_string(row, 1, &day.hours)?;
worksheet.write_string(row, 2, if day.is_workday { "Work" } else { "Rest" })?;
row += 1;
}
row += 1;
worksheet.write_string(row, 0, "Total Hours")?;
worksheet.write_string(row, 1, &summary.total_hours)?;
row += 1;
worksheet.write_string(row, 0, "Average Hours")?;
worksheet.write_string(row, 1, &summary.average_hours)?;
row += 1;
worksheet.write_string(row, 0, "Total Days")?;
worksheet.write_number(row, 1, summary.total_days as f64)?;
worksheet.autofit();
workbook.save(&self.output_path)?;
Ok(())
}
fn gather_hourly_data(&self, date: NaiveDate, locale: &Locale) -> Result<HourlyReport> {
let workday = Workdays::new()?
.fetch(date)?
.ok_or_else(|| msg_error_anyhow!(Message::WorkdayNotFoundForDate(date.to_string())))?;
let config = Config::read()?;
let monitor_config = config.monitor.as_ref().cloned().unwrap_or_default();
let pauses = Pauses::new()?
.set_min_duration(monitor_config.min_pause_duration)
.get_workday_pauses(&workday)?;
let intervals = report::calculate_work_intervals(&workday, &pauses);
let tasks = Tasks::new()?.fetch(TaskFilter::Date(date))?;
let end_time = report::workday_end_time(&workday, &pauses);
let slots = classify_hour_slots(workday.start, end_time, &intervals, &pauses);
let task_texts = assign_tasks_to_hour_slots(&tasks, &slots, locale);
let rows = build_hourly_rows(&slots, &task_texts, locale.break_label);
let worked = intervals.iter().fold(Duration::zero(), |acc, i| acc + i.duration);
let weekday_idx = date.weekday().num_days_from_monday() as usize;
let month_idx = (date.month().saturating_sub(1)) as usize;
Ok(HourlyReport {
date,
weekday: locale.weekdays[weekday_idx].to_string(),
month: locale.months[month_idx].to_string(),
day_hours: worked.num_hours().max(0),
worked: format_duration(&worked),
rows,
})
}
fn export_report_excel_hourly(&self, date: NaiveDate) -> Result<()> {
let config = Config::read()?;
let report_config = config.report.clone().unwrap_or_default();
let language = Language::from_code(report_config.language.as_deref().unwrap_or("en"));
let locale = Locale::for_language(language);
let template = ReportTemplate::load(report_config.template.as_deref().unwrap_or("siserver"));
let data = self.gather_hourly_data(date, locale)?;
let mut workbook = Workbook::new();
let worksheet = workbook.add_worksheet();
let border_color = template.border();
let header_fill = template.fill();
let font_base = |spec: &FontSpec| -> Format {
let mut fmt = Format::new().set_font_name(spec.name.as_str()).set_font_size(spec.size);
if spec.bold {
fmt = fmt.set_bold();
}
fmt
};
let fmt_title = font_base(&template.fonts.title)
.set_border(FormatBorder::Thin)
.set_border_color(border_color)
.set_align(FormatAlign::Center)
.set_align(FormatAlign::VerticalCenter);
let fmt_month = font_base(&template.fonts.month).set_align(FormatAlign::Center);
let fmt_date = font_base(&template.fonts.date)
.set_border(FormatBorder::Thin)
.set_border_color(border_color)
.set_align(FormatAlign::Center);
let fmt_center = Format::new().set_align(FormatAlign::Center);
let fmt_right = Format::new().set_align(FormatAlign::Right);
let fmt_header = font_base(&template.fonts.header)
.set_background_color(header_fill)
.set_border(FormatBorder::Thin)
.set_border_color(border_color)
.set_align(FormatAlign::Center)
.set_align(FormatAlign::VerticalCenter);
let fmt_time = font_base(&template.fonts.time)
.set_border(FormatBorder::Thin)
.set_border_color(border_color)
.set_align(FormatAlign::Center)
.set_align(FormatAlign::VerticalCenter);
let fmt_desc = Format::new()
.set_border(FormatBorder::Thin)
.set_border_color(border_color)
.set_align(FormatAlign::Center)
.set_align(FormatAlign::VerticalCenter)
.set_text_wrap();
let fmt_empty = Format::new()
.set_border(FormatBorder::Thin)
.set_border_color(border_color)
.set_align(FormatAlign::Center)
.set_align(FormatAlign::VerticalCenter)
.set_text_wrap();
let fmt_total_label = Format::new()
.set_background_color(header_fill)
.set_border(FormatBorder::Thin)
.set_border_color(border_color)
.set_align(FormatAlign::Right);
let fmt_comment_label = font_base(&template.fonts.header);
let fmt_comment_box = Format::new()
.set_border(FormatBorder::Thin)
.set_border_color(border_color)
.set_align(FormatAlign::VerticalCenter);
worksheet.set_column_width(1, template.col_widths[0])?;
worksheet.set_column_width(2, template.col_widths[1])?;
worksheet.set_column_width(3, template.col_widths[2])?;
if template.show_hours_column {
worksheet.set_column_width(4, template.col_widths[3])?;
}
if template.show_result_column {
worksheet.set_column_width(5, template.col_widths[4])?;
}
worksheet.set_row_height(1, template.title_row_height)?;
worksheet.merge_range(1, 1, 1, 2, locale.report_title, &fmt_title)?;
worksheet.write_string_with_format(1, 3, &data.month, &fmt_month)?;
worksheet.merge_range(2, 1, 2, 2, &data.date.format(locale.date_format).to_string(), &fmt_date)?;
worksheet.write_string_with_format(4, 1, &data.weekday, &fmt_center)?;
worksheet.write_string_with_format(4, 2, locale.day_type_working, &fmt_center)?;
worksheet.write_string_with_format(4, 3, locale.workday_length, &fmt_right)?;
worksheet.write_number(4, 4, data.day_hours as f64)?;
worksheet.merge_range(6, 1, 6, 2, locale.header_day, &fmt_header)?;
worksheet.write_string_with_format(7, 1, locale.header_start, &fmt_header)?;
worksheet.write_string_with_format(7, 2, locale.header_end, &fmt_header)?;
worksheet.merge_range(6, 3, 7, 3, "", &fmt_header)?;
if template.show_hours_column {
worksheet.merge_range(6, 4, 7, 4, locale.header_hours, &fmt_header)?;
}
if template.show_result_column {
worksheet.merge_range(6, 5, 7, 5, locale.header_result, &fmt_header)?;
}
let mut row: u32 = 8;
for item in &data.rows {
worksheet.set_row_height(row, template.data_row_height)?;
worksheet.write_string_with_format(row, 1, &item.start, &fmt_time)?;
worksheet.write_string_with_format(row, 2, &item.end, &fmt_time)?;
worksheet.write_string_with_format(row, 3, &item.description, &fmt_desc)?;
if template.show_hours_column {
worksheet.write_string_with_format(row, 4, "", &fmt_empty)?;
}
if template.show_result_column {
worksheet.write_string_with_format(row, 5, "", &fmt_empty)?;
}
row += 1;
}
let last_data_row = row.saturating_sub(1);
let total_row = last_data_row + 3;
worksheet.merge_range(total_row, 1, total_row, 3, locale.total_worked, &fmt_total_label)?;
worksheet.write_string_with_format(total_row, 4, &data.worked, &fmt_time)?;
if template.show_comment {
let comment_row = total_row + 2;
worksheet.write_string_with_format(comment_row, 1, locale.comment, &fmt_comment_label)?;
let box_top = comment_row + 1;
let box_bottom = box_top + template.comment_rows.saturating_sub(1);
worksheet.merge_range(box_top, 1, box_bottom, 5, "", &fmt_comment_box)?;
}
workbook.save(&self.output_path)?;
Ok(())
}
}
struct HourlyRow {
start: String,
end: String,
description: String,
}
struct HourlyReport {
date: NaiveDate,
weekday: String,
month: String,
day_hours: i64,
worked: String,
rows: Vec<HourlyRow>,
}
#[derive(Debug, Clone)]
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
}
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
}
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
}
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 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");
}
}