29_timesheet-management #64
1 changed files with 247 additions and 17 deletions
|
|
@ -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<Vec<LocalizedShard>, StreamdError> {
|
||||
let settings = Settings::load()?;
|
||||
fn load_all_shards(base_folder: &Path) -> Result<Vec<LocalizedShard>, 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<Vec<LocalizedShard>, 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<String> = 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(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue