streamd/src/cli/commands/timesheet.rs
Konstantin Fickel d11a35c157
All checks were successful
Continuous Integration / Build Package (push) Successful in 25s
Continuous Integration / Lint, Check & Test (push) Successful in 41s
refactor(timesheet): use repeat() for separator lines and sort points before grouping
2026-04-02 17:07:36 +02:00

276 lines
8.1 KiB
Rust

use std::fs;
use std::path::Path;
use chrono::Datelike;
use walkdir::WalkDir;
use crate::config::Settings;
const SEPARATOR_WIDTH: usize = 71;
const COLUMN_SEPARATOR_WIDTH: usize = 65;
use crate::error::StreamdError;
use crate::extract::parse_markdown_file;
use crate::localize::localize_stream_file;
use crate::models::LocalizedShard;
use crate::timesheet::{
extract_timesheets, generate_report, load_repository_config, BasicTimesheetConfiguration,
DayType, DayWarning, MonthReport, TimesheetReport,
};
fn load_all_shards(base_folder: &Path) -> Result<Vec<LocalizedShard>, StreamdError> {
let mut shards = Vec::new();
for entry in WalkDir::new(base_folder)
.max_depth(1)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.extension().map(|e| e == "md").unwrap_or(false) {
let file_name = path.to_string_lossy().to_string();
let content = fs::read_to_string(path)?;
let stream_file = parse_markdown_file(&file_name, &content);
if let Ok(shard) = localize_stream_file(&stream_file, &BasicTimesheetConfiguration) {
shards.push(shard);
}
}
}
Ok(shards)
}
/// Format hours with sign for display.
fn format_diff(hours: f64) -> String {
if hours >= 0.0 {
format!("+{:.1}h", hours.abs())
} else {
format!("{:.1}h", hours)
}
}
/// Format hours for display without sign.
fn format_hours(hours: f64) -> String {
format!("{:.1}h", hours.abs())
}
/// Get the weekday abbreviation.
fn weekday_abbrev(date: chrono::NaiveDate) -> &'static str {
match date.weekday() {
chrono::Weekday::Mon => "Mon",
chrono::Weekday::Tue => "Tue",
chrono::Weekday::Wed => "Wed",
chrono::Weekday::Thu => "Thu",
chrono::Weekday::Fri => "Fri",
chrono::Weekday::Sat => "Sat",
chrono::Weekday::Sun => "Sun",
}
}
/// Print the timesheet report header.
fn print_header() {
let double_line = "\u{2550}".repeat(SEPARATOR_WIDTH);
println!("{}", double_line);
println!(" TIMESHEET REPORT");
println!("{}", double_line);
println!();
}
/// Print a month report.
fn print_month(month: &MonthReport) {
let diff_str = format_diff(month.diff());
let month_title = format!("{} {}", month.month_name(), month.year);
// Month header with diff
print!("\u{2550}\u{2550}\u{2550} {} ", month_title);
let padding = 52 - month_title.len() - diff_str.len();
for _ in 0..padding {
print!("\u{2550}");
}
println!(" Diff: {} \u{2550}\u{2550}\u{2550}", diff_str);
println!();
// Column headers
let light_line = "\u{2500}".repeat(COLUMN_SEPARATOR_WIDTH);
println!(" Date Day Expected Actual Diff Type");
println!(" {}", light_line);
// Day rows
for day in &month.days {
let date_str = day.date.format("%Y-%m-%d").to_string();
let weekday = weekday_abbrev(day.date);
let expected = format_hours(day.expected_hours);
let actual = format_hours(day.actual_hours);
let diff = format_diff(day.diff());
let type_str = match day.day_type {
DayType::Regular => String::new(),
DayType::Missing if day.has_warnings() => "\u{26a0} Missing".to_string(),
_ => day.day_type.to_string(),
};
// Add overlap warning indicator
let type_str = if day
.warnings
.iter()
.any(|w| matches!(w, DayWarning::OverlappingTimecards { .. }))
{
if type_str.is_empty() {
"\u{26a0} Overlap".to_string()
} else {
format!("{} \u{26a0}", type_str)
}
} else {
type_str
};
println!(
" {} {} {:>7} {:>7} {:>6} {}",
date_str, weekday, expected, actual, diff, type_str
);
}
// Monthly totals
println!(" {}", light_line);
println!(
" Monthly: {:>7} {:>7} {:>6}",
format_hours(month.total_expected()),
format_hours(month.total_actual()),
format_diff(month.diff())
);
println!();
}
/// Print the cumulative balance.
fn print_cumulative_balance(balance: f64) {
let light_line = "\u{2500}".repeat(SEPARATOR_WIDTH);
println!("{}", light_line);
println!(
" CUMULATIVE BALANCE: {}",
format_diff(balance)
);
println!("{}", light_line);
}
/// Print warnings section.
fn print_warnings(report: &TimesheetReport) {
if !report.has_warnings() {
return;
}
println!();
println!("\u{26a0} Warnings:");
println!();
// Group warnings by type
let missing_warnings: Vec<_> = report
.warnings
.iter()
.filter(|w| matches!(w.warning, DayWarning::MissingWithoutExplanation))
.collect();
let overlap_warnings: Vec<_> = report
.warnings
.iter()
.filter(|w| matches!(w.warning, DayWarning::OverlappingTimecards { .. }))
.collect();
let outside_period_warnings: Vec<_> = report
.warnings
.iter()
.filter(|w| matches!(w.warning, DayWarning::OutsidePeriod { .. }))
.collect();
if !missing_warnings.is_empty() {
println!(" Missing days without explanation:");
for w in &missing_warnings {
let weekday = weekday_abbrev(w.date);
println!(
" - {} ({}): No entries and no leave/holiday marker",
w.date.format("%Y-%m-%d"),
weekday
);
}
println!();
}
if !overlap_warnings.is_empty() {
println!(" Overlapping timecards:");
for w in &overlap_warnings {
if let DayWarning::OverlappingTimecards { first, second } = &w.warning {
println!(
" - {}: {}-{} overlaps with {}-{}",
w.date.format("%Y-%m-%d"),
first.0.format("%H:%M"),
first.1.format("%H:%M"),
second.0.format("%H:%M"),
second.1.format("%H:%M")
);
}
}
println!();
}
if !outside_period_warnings.is_empty() {
println!(" Work logged outside configured periods:");
for w in &outside_period_warnings {
if let DayWarning::OutsidePeriod { hours_worked } = &w.warning {
println!(
" - {}: {:.1}h worked (no period configured)",
w.date.format("%Y-%m-%d"),
hours_worked
);
}
}
println!();
}
}
pub fn run() -> Result<(), StreamdError> {
let settings = Settings::load()?;
let base_folder = Path::new(&settings.base_folder);
// Load repository configuration
let repo_config = load_repository_config(base_folder)?;
// Check if timesheet is configured
let timesheet_config = match repo_config.timesheet {
Some(config) => config,
None => {
println!("No timesheet configuration found in .streamd.toml");
println!();
println!("Add a [timesheet] section with periods to enable timesheet reporting:");
println!();
println!(" [timesheet]");
println!(" [[timesheet.periods]]");
println!(" start = \"2026-01-01\"");
println!(" end = \"2026-12-31\"");
println!(" hours_per_week = 40.0");
return Ok(());
}
};
// Load all markdown files and extract timesheets
let all_shards = load_all_shards(base_folder)?;
let timesheets = extract_timesheets(&all_shards)?;
// Generate the report
let report = generate_report(&timesheets, &timesheet_config)?;
if report.months.is_empty() {
println!("No timesheet data found for the configured periods.");
return Ok(());
}
// Print the report
print_header();
for month in &report.months {
print_month(month);
}
print_cumulative_balance(report.cumulative_balance);
print_warnings(&report);
Ok(())
}