From 3429f2e65d0e94b1bc12b9bccdb97066cffdd280 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Sun, 29 Mar 2026 22:06:44 +0200 Subject: [PATCH] feat(timesheet): add report generation logic Implement the core report generation that: - Loads .streamd.toml configuration - Calculates expected hours based on periods and day types - Calculates actual hours following day type rules: - Sick leave: max(expected, worked) - Vacation: expected + worked - Flex day: 0 - Holiday: 0 expected - Generates warnings for missing days, overlapping timecards, and work outside configured periods - Calculates monthly and cumulative balances - Sorts months in descending order --- src/timesheet/generator.rs | 542 +++++++++++++++++++++++++++++++++++++ src/timesheet/mod.rs | 2 + 2 files changed, 544 insertions(+) create mode 100644 src/timesheet/generator.rs diff --git a/src/timesheet/generator.rs b/src/timesheet/generator.rs new file mode 100644 index 0000000..6ff191c --- /dev/null +++ b/src/timesheet/generator.rs @@ -0,0 +1,542 @@ +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +use chrono::{Datelike, NaiveDate, Weekday}; + +use crate::error::StreamdError; +use crate::models::{SpecialDayType, Timesheet}; + +use super::config::{RepositoryConfig, TimesheetConfig}; +use super::report::{DayReport, DayType, DayWarning, MonthReport, ReportWarning, TimesheetReport}; +use super::validation::find_overlapping_timecards; + +/// Load repository configuration from .streamd.toml file. +pub fn load_repository_config(base_folder: &Path) -> Result { + let config_path = base_folder.join(".streamd.toml"); + + if !config_path.exists() { + return Ok(RepositoryConfig::default()); + } + + let content = fs::read_to_string(&config_path)?; + let config: RepositoryConfig = toml::from_str(&content)?; + + // Validate timesheet config if present + if let Some(ref timesheet) = config.timesheet { + timesheet.validate()?; + } + + Ok(config) +} + +/// Calculate total hours worked from timecards. +fn calculate_timecard_hours(timesheet: &Timesheet) -> f64 { + timesheet + .timecards + .iter() + .map(|tc| { + let duration = tc.to_time - tc.from_time; + duration.num_minutes() as f64 / 60.0 + }) + .sum() +} + +/// Determine the day type based on timesheet data. +fn determine_day_type(date: NaiveDate, timesheet: Option<&Timesheet>, has_period: bool) -> DayType { + let weekday = date.weekday(); + + // Check weekend first + if weekday == Weekday::Sat || weekday == Weekday::Sun { + return DayType::Weekend; + } + + // Check if outside any period + if !has_period { + return DayType::OutsidePeriod; + } + + // Check special day types from timesheet + if let Some(ts) = timesheet { + if let Some(special) = ts.special_day_type { + match special { + SpecialDayType::Vacation => return DayType::Vacation, + SpecialDayType::Holiday => return DayType::Holiday, + SpecialDayType::Undertime => return DayType::FlexDay, + SpecialDayType::Weekend => return DayType::Weekend, + } + } + + if ts.is_sick_leave { + return DayType::SickLeave; + } + + // Has timecards or special type + return DayType::Regular; + } + + // No timesheet entry for a weekday = Missing + DayType::Missing +} + +/// Calculate expected hours for a day based on period config and day type. +fn calculate_expected_hours(day_type: DayType, hours_per_day: f64, _date: NaiveDate) -> f64 { + match day_type { + DayType::Regular => hours_per_day, + DayType::SickLeave => hours_per_day, + DayType::Vacation => hours_per_day, + DayType::Holiday => 0.0, + DayType::FlexDay => hours_per_day, + DayType::Weekend => 0.0, + DayType::Missing => hours_per_day, + DayType::OutsidePeriod => 0.0, + } +} + +/// Calculate actual hours for a day based on day type rules. +fn calculate_actual_hours(day_type: DayType, timecard_hours: f64, expected_hours: f64) -> f64 { + match day_type { + DayType::Regular => timecard_hours, + DayType::SickLeave => expected_hours.max(timecard_hours), + DayType::Vacation => expected_hours + timecard_hours, + DayType::Holiday => timecard_hours, + DayType::FlexDay => 0.0, + DayType::Weekend => timecard_hours, + DayType::Missing => 0.0, + DayType::OutsidePeriod => timecard_hours, + } +} + +/// Generate a timesheet report from timesheets and configuration. +pub fn generate_report( + timesheets: &[Timesheet], + config: &TimesheetConfig, +) -> Result { + if config.periods.is_empty() { + return Ok(TimesheetReport::new()); + } + + // Index timesheets by date for quick lookup + let timesheets_by_date: HashMap = + timesheets.iter().map(|ts| (ts.date, ts)).collect(); + + // Find the date range to report on + let earliest_period_start = config.periods.iter().map(|p| p.start).min().unwrap(); + let latest_period_end = config.periods.iter().map(|p| p.end).max().unwrap(); + + // Limit to today + let today = chrono::Local::now().date_naive(); + let end_date = latest_period_end.min(today); + + // Group by month and generate reports + let mut month_reports: Vec = Vec::new(); + let mut all_warnings: Vec = Vec::new(); + let mut cumulative_balance: f64 = 0.0; + + // Iterate through all dates in the range + let mut current_date = earliest_period_start; + let mut current_month: Option<(i32, u32)> = None; + let mut current_month_days: Vec = Vec::new(); + + while current_date <= end_date { + let year = current_date.year(); + let month = current_date.month(); + + // Check if we've moved to a new month + if current_month != Some((year, month)) { + // Save the previous month if it had days + if let Some((prev_year, prev_month)) = current_month { + if !current_month_days.is_empty() { + let month_report = + MonthReport::new(prev_year, prev_month).with_days(current_month_days); + cumulative_balance += month_report.diff(); + month_reports.push(month_report); + } + } + current_month = Some((year, month)); + current_month_days = Vec::new(); + } + + // Find if this date falls within a period + let period = config.find_period(current_date); + let has_period = period.is_some(); + let hours_per_day = period.map(|p| p.hours_per_day()).unwrap_or(0.0); + + // Get timesheet for this date + let timesheet = timesheets_by_date.get(¤t_date).copied(); + let timecard_hours = timesheet.map(calculate_timecard_hours).unwrap_or(0.0); + + // Determine day type + let day_type = determine_day_type(current_date, timesheet, has_period); + + // Skip weekends with no work and days outside periods with no work + let should_include = match day_type { + DayType::Weekend => timecard_hours > 0.0, + DayType::OutsidePeriod => timecard_hours > 0.0, + _ => has_period, // Only include days within periods + }; + + if should_include { + // Calculate expected and actual hours + let expected_hours = calculate_expected_hours(day_type, hours_per_day, current_date); + let actual_hours = calculate_actual_hours(day_type, timecard_hours, expected_hours); + + let mut day_report = + DayReport::new(current_date, expected_hours, actual_hours, day_type); + + // Collect warnings + let mut day_warnings: Vec = Vec::new(); + + // Warning: Missing without explanation + if day_type == DayType::Missing { + day_warnings.push(DayWarning::MissingWithoutExplanation); + all_warnings.push(ReportWarning::new( + current_date, + DayWarning::MissingWithoutExplanation, + )); + } + + // Warning: Overlapping timecards + if let Some(ts) = timesheet { + let overlaps = find_overlapping_timecards(&ts.timecards); + for (first, second) in overlaps { + let warning = DayWarning::OverlappingTimecards { first, second }; + day_warnings.push(warning.clone()); + all_warnings.push(ReportWarning::new(current_date, warning)); + } + } + + // Warning: Work outside period + if day_type == DayType::OutsidePeriod && timecard_hours > 0.0 { + let warning = DayWarning::OutsidePeriod { + hours_worked: timecard_hours, + }; + day_warnings.push(warning.clone()); + all_warnings.push(ReportWarning::new(current_date, warning)); + } + + day_report = day_report.with_warnings(day_warnings); + current_month_days.push(day_report); + } + + // Move to next day + current_date = current_date.succ_opt().unwrap_or(current_date); + if current_date == end_date && current_date <= latest_period_end { + // We've hit end_date, process this day then stop + } + } + + // Don't forget the last month + if let Some((year, month)) = current_month { + if !current_month_days.is_empty() { + let month_report = MonthReport::new(year, month).with_days(current_month_days); + cumulative_balance += month_report.diff(); + month_reports.push(month_report); + } + } + + // Sort months in descending order (most recent first) + month_reports.sort_by(|a, b| { + let a_date = (a.year, a.month); + let b_date = (b.year, b.month); + b_date.cmp(&a_date) + }); + + Ok(TimesheetReport::new() + .with_months(month_reports) + .with_cumulative_balance(cumulative_balance) + .with_warnings(all_warnings)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::Timecard; + use crate::timesheet::Period; + use chrono::NaiveTime; + + fn date(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).unwrap() + } + + fn time(hour: u32, min: u32) -> NaiveTime { + NaiveTime::from_hms_opt(hour, min, 0).unwrap() + } + + fn make_timesheet(date: NaiveDate, cards: Vec<(u32, u32, u32, u32)>) -> Timesheet { + Timesheet { + date, + is_sick_leave: false, + special_day_type: None, + timecards: cards + .into_iter() + .map(|(fh, fm, th, tm)| Timecard::new(time(fh, fm), time(th, tm))) + .collect(), + } + } + + fn make_config(start: NaiveDate, end: NaiveDate, hours_per_week: f64) -> TimesheetConfig { + TimesheetConfig { + periods: vec![Period { + start, + end, + hours_per_week, + }], + } + } + + #[test] + fn test_calculate_timecard_hours() { + let ts = make_timesheet(date(2026, 3, 2), vec![(9, 0, 12, 0), (13, 0, 17, 0)]); + let hours = calculate_timecard_hours(&ts); + assert!((hours - 7.0).abs() < 0.0001); + } + + #[test] + fn test_calculate_timecard_hours_with_minutes() { + let ts = make_timesheet(date(2026, 3, 2), vec![(9, 0, 12, 30), (13, 0, 17, 15)]); + let hours = calculate_timecard_hours(&ts); + assert!((hours - 7.75).abs() < 0.0001); + } + + #[test] + fn test_day_type_regular() { + // Monday with timesheet + let ts = make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)]); + let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true); + assert_eq!(day_type, DayType::Regular); + } + + #[test] + fn test_day_type_weekend() { + // Saturday + let day_type = determine_day_type(date(2026, 3, 7), None, true); + assert_eq!(day_type, DayType::Weekend); + } + + #[test] + fn test_day_type_missing() { + // Monday with no timesheet + let day_type = determine_day_type(date(2026, 3, 2), None, true); + assert_eq!(day_type, DayType::Missing); + } + + #[test] + fn test_day_type_vacation() { + let ts = Timesheet { + date: date(2026, 3, 2), + is_sick_leave: false, + special_day_type: Some(SpecialDayType::Vacation), + timecards: vec![], + }; + let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true); + assert_eq!(day_type, DayType::Vacation); + } + + #[test] + fn test_day_type_sick_leave() { + let ts = Timesheet { + date: date(2026, 3, 2), + is_sick_leave: true, + special_day_type: None, + timecards: vec![], + }; + let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true); + assert_eq!(day_type, DayType::SickLeave); + } + + #[test] + fn test_day_type_holiday() { + let ts = Timesheet { + date: date(2026, 3, 2), + is_sick_leave: false, + special_day_type: Some(SpecialDayType::Holiday), + timecards: vec![], + }; + let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true); + assert_eq!(day_type, DayType::Holiday); + } + + #[test] + fn test_day_type_flex_day() { + let ts = Timesheet { + date: date(2026, 3, 2), + is_sick_leave: false, + special_day_type: Some(SpecialDayType::Undertime), + timecards: vec![], + }; + let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true); + assert_eq!(day_type, DayType::FlexDay); + } + + #[test] + fn test_day_type_outside_period() { + let day_type = determine_day_type(date(2026, 3, 2), None, false); + assert_eq!(day_type, DayType::OutsidePeriod); + } + + #[test] + fn test_expected_hours_regular() { + let hours = calculate_expected_hours(DayType::Regular, 7.6, date(2026, 3, 2)); + assert!((hours - 7.6).abs() < 0.0001); + } + + #[test] + fn test_expected_hours_holiday() { + let hours = calculate_expected_hours(DayType::Holiday, 7.6, date(2026, 3, 2)); + assert!((hours - 0.0).abs() < 0.0001); + } + + #[test] + fn test_expected_hours_weekend() { + let hours = calculate_expected_hours(DayType::Weekend, 7.6, date(2026, 3, 7)); + assert!((hours - 0.0).abs() < 0.0001); + } + + #[test] + fn test_actual_hours_regular() { + let hours = calculate_actual_hours(DayType::Regular, 8.0, 7.6); + assert!((hours - 8.0).abs() < 0.0001); + } + + #[test] + fn test_actual_hours_sick_leave_max() { + // Sick leave: max(expected, worked) + let hours = calculate_actual_hours(DayType::SickLeave, 3.0, 7.6); + assert!((hours - 7.6).abs() < 0.0001); + } + + #[test] + fn test_actual_hours_sick_leave_worked_more() { + // Sick leave where worked > expected + let hours = calculate_actual_hours(DayType::SickLeave, 9.0, 7.6); + assert!((hours - 9.0).abs() < 0.0001); + } + + #[test] + fn test_actual_hours_vacation() { + // Vacation: expected + worked + let hours = calculate_actual_hours(DayType::Vacation, 2.0, 7.6); + assert!((hours - 9.6).abs() < 0.0001); + } + + #[test] + fn test_actual_hours_flex_day() { + // Flex day: always 0 + let hours = calculate_actual_hours(DayType::FlexDay, 5.0, 7.6); + assert!((hours - 0.0).abs() < 0.0001); + } + + #[test] + fn test_generate_report_empty_config() { + let config = TimesheetConfig { periods: vec![] }; + let report = generate_report(&[], &config).unwrap(); + assert!(report.months.is_empty()); + } + + #[test] + fn test_generate_report_single_day() { + // Monday 2026-03-02 + let timesheets = vec![make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)])]; + let config = make_config(date(2026, 3, 2), date(2026, 3, 2), 40.0); + + let report = generate_report(×heets, &config).unwrap(); + + assert_eq!(report.months.len(), 1); + assert_eq!(report.months[0].days.len(), 1); + + let day = &report.months[0].days[0]; + assert_eq!(day.date, date(2026, 3, 2)); + assert!((day.expected_hours - 8.0).abs() < 0.0001); + assert!((day.actual_hours - 8.0).abs() < 0.0001); + assert_eq!(day.day_type, DayType::Regular); + } + + #[test] + fn test_generate_report_detects_missing_day() { + // Period covers Mon-Tue, but only Mon has timesheet + let timesheets = vec![make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)])]; + // March 2 is Monday, March 3 is Tuesday + let config = make_config(date(2026, 3, 2), date(2026, 3, 3), 40.0); + + let report = generate_report(×heets, &config).unwrap(); + + assert_eq!(report.months[0].days.len(), 2); + + // First day (Mon) should be regular + assert_eq!(report.months[0].days[0].day_type, DayType::Regular); + + // Second day (Tue) should be missing + assert_eq!(report.months[0].days[1].day_type, DayType::Missing); + assert!(report.months[0].days[1].has_warnings()); + } + + #[test] + fn test_generate_report_weekend_excluded_if_no_work() { + // Period covers Mon-Sun, but only Mon has timesheet + let timesheets = vec![make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)])]; + let config = make_config(date(2026, 3, 2), date(2026, 3, 8), 40.0); + + let report = generate_report(×heets, &config).unwrap(); + + // Should only include Mon-Fri (5 days), not Sat-Sun + let days = &report.months[0].days; + for day in days { + let weekday = day.date.weekday(); + assert!(weekday != Weekday::Sat && weekday != Weekday::Sun); + } + } + + #[test] + fn test_generate_report_weekend_included_if_work() { + // Work on Saturday + let timesheets = vec![ + make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)]), + make_timesheet(date(2026, 3, 7), vec![(10, 0, 14, 0)]), // Saturday + ]; + let config = make_config(date(2026, 3, 2), date(2026, 3, 8), 40.0); + + let report = generate_report(×heets, &config).unwrap(); + + // Should include Saturday + let has_saturday = report.months[0] + .days + .iter() + .any(|d| d.date == date(2026, 3, 7)); + assert!(has_saturday); + } + + #[test] + fn test_generate_report_overlapping_timecards_warning() { + let ts = Timesheet { + date: date(2026, 3, 2), + is_sick_leave: false, + special_day_type: None, + timecards: vec![ + Timecard::new(time(9, 0), time(12, 30)), + Timecard::new(time(12, 0), time(13, 0)), + ], + }; + let config = make_config(date(2026, 3, 2), date(2026, 3, 2), 40.0); + + let report = generate_report(&[ts], &config).unwrap(); + + assert!(report.has_warnings()); + assert!(report.months[0].days[0].has_warnings()); + } + + #[test] + fn test_generate_report_cumulative_balance() { + // Two days: one with 8h (expected 8h), one with 10h (expected 8h) + let timesheets = vec![ + make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)]), // 8h + make_timesheet(date(2026, 3, 3), vec![(8, 0, 18, 0)]), // 10h + ]; + let config = make_config(date(2026, 3, 2), date(2026, 3, 3), 40.0); + + let report = generate_report(×heets, &config).unwrap(); + + // Balance should be +2h (18h actual - 16h expected) + assert!((report.cumulative_balance - 2.0).abs() < 0.0001); + } +} diff --git a/src/timesheet/mod.rs b/src/timesheet/mod.rs index 0f76d12..9e4f000 100644 --- a/src/timesheet/mod.rs +++ b/src/timesheet/mod.rs @@ -1,6 +1,7 @@ mod config; mod configuration; mod extract; +mod generator; mod point_types; mod report; mod validation; @@ -8,6 +9,7 @@ mod validation; pub use config::{Period, RepositoryConfig, TimesheetConfig}; pub use configuration::{BasicTimesheetConfiguration, TIMESHEET_DIMENSION_NAME, TIMESHEET_TAG}; pub use extract::extract_timesheets; +pub use generator::{generate_report, load_repository_config}; pub use point_types::TimesheetPointType; pub use report::{DayReport, DayType, DayWarning, MonthReport, ReportWarning, TimesheetReport}; pub use validation::find_overlapping_timecards;