276 lines
8.1 KiB
Rust
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(×heets, ×heet_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(())
|
|
}
|