feat(timesheet): add formatted report output to CLI

Replace CSV output with formatted table display showing:
- Monthly breakdown with expected/actual hours per day
- Day types (Regular, Sick Leave, Vacation, Holiday, Flex Day, etc.)
- Warning indicators for missing days and overlapping timecards
- Monthly summaries with total expected/actual hours
- Cumulative balance across all months
- Detailed warnings section at the end

Shows helpful message when no .streamd.toml configuration is found.
This commit is contained in:
Konstantin Fickel 2026-03-29 22:07:58 +02:00
parent 1a716f6d0e
commit ca43106486
Signed by: kfickel
GPG key ID: A793722F9933C1A5

View file

@ -1,5 +1,7 @@
use std::fs; use std::fs;
use std::path::Path;
use chrono::Datelike;
use walkdir::WalkDir; use walkdir::WalkDir;
use crate::config::Settings; use crate::config::Settings;
@ -7,13 +9,15 @@ use crate::error::StreamdError;
use crate::extract::parse_markdown_file; use crate::extract::parse_markdown_file;
use crate::localize::localize_stream_file; use crate::localize::localize_stream_file;
use crate::models::LocalizedShard; use crate::models::LocalizedShard;
use crate::timesheet::{extract_timesheets, BasicTimesheetConfiguration}; use crate::timesheet::{
extract_timesheets, generate_report, load_repository_config, BasicTimesheetConfiguration,
DayType, DayWarning, MonthReport, TimesheetReport,
};
fn all_files() -> Result<Vec<LocalizedShard>, StreamdError> { fn load_all_shards(base_folder: &Path) -> Result<Vec<LocalizedShard>, StreamdError> {
let settings = Settings::load()?;
let mut shards = Vec::new(); let mut shards = Vec::new();
for entry in WalkDir::new(&settings.base_folder) for entry in WalkDir::new(base_folder)
.max_depth(1) .max_depth(1)
.into_iter() .into_iter()
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
@ -33,20 +37,246 @@ fn all_files() -> Result<Vec<LocalizedShard>, StreamdError> {
Ok(shards) Ok(shards)
} }
pub fn run() -> Result<(), StreamdError> { /// Format hours with sign for display.
let all_shards = all_files()?; fn format_diff(hours: f64) -> String {
let mut sheets = extract_timesheets(&all_shards)?; if hours >= 0.0 {
sheets.sort_by_key(|s| s.date); format!("+{:.1}h", hours)
} else {
for sheet in sheets { format!("{:.1}h", hours)
println!("{}", sheet.date);
let times: Vec<String> = sheet
.timecards
.iter()
.map(|card| format!("{},{}", card.from_time, card.to_time))
.collect();
println!("{}", times.join(","));
} }
}
/// Format hours for display without sign.
fn format_hours(hours: f64) -> String {
format!("{:.1}h", hours)
}
/// 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() {
println!(
"\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}"
);
println!(" TIMESHEET REPORT");
println!(
"\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}"
);
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
println!(" Date Day Expected Actual Diff Type");
println!(
" \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
);
// 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!(
" \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
);
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) {
println!(
"\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
);
println!(
" CUMULATIVE BALANCE: {}",
format_diff(balance)
);
println!(
"\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
);
}
/// 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(()) Ok(())
} }