diff --git a/src/cli/commands/timesheet.rs b/src/cli/commands/timesheet.rs index f727edc..9fe413f 100644 --- a/src/cli/commands/timesheet.rs +++ b/src/cli/commands/timesheet.rs @@ -1,5 +1,7 @@ use std::fs; +use std::path::Path; +use chrono::Datelike; use walkdir::WalkDir; use crate::config::Settings; @@ -7,13 +9,15 @@ 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, BasicTimesheetConfiguration}; +use crate::timesheet::{ + extract_timesheets, generate_report, load_repository_config, BasicTimesheetConfiguration, + DayType, DayWarning, MonthReport, TimesheetReport, +}; -fn all_files() -> Result, StreamdError> { - let settings = Settings::load()?; +fn load_all_shards(base_folder: &Path) -> Result, StreamdError> { let mut shards = Vec::new(); - for entry in WalkDir::new(&settings.base_folder) + for entry in WalkDir::new(base_folder) .max_depth(1) .into_iter() .filter_map(|e| e.ok()) @@ -33,20 +37,246 @@ fn all_files() -> Result, StreamdError> { Ok(shards) } -pub fn run() -> Result<(), StreamdError> { - let all_shards = all_files()?; - let mut sheets = extract_timesheets(&all_shards)?; - sheets.sort_by_key(|s| s.date); - - for sheet in sheets { - println!("{}", sheet.date); - let times: Vec = sheet - .timecards - .iter() - .map(|card| format!("{},{}", card.from_time, card.to_time)) - .collect(); - println!("{}", times.join(",")); +/// Format hours with sign for display. +fn format_diff(hours: f64) -> String { + if hours >= 0.0 { + format!("+{:.1}h", hours) + } else { + format!("{:.1}h", hours) } +} + +/// 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(×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(()) }