From 7abf056609c94d7fc0a13f987e6bb717b3e354f6 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Sun, 29 Mar 2026 22:04:29 +0200 Subject: [PATCH] feat(timesheet): add report data structures Add DayReport, DayType, DayWarning, MonthReport, and TimesheetReport structs for generating timesheet reports. These support: - Day types (Regular, SickLeave, Vacation, Holiday, FlexDay, Weekend, Missing, OutsidePeriod) - Warnings for missing days, overlapping timecards, and outside-period work - Monthly and cumulative calculations --- src/timesheet/mod.rs | 2 + src/timesheet/report.rs | 322 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 src/timesheet/report.rs diff --git a/src/timesheet/mod.rs b/src/timesheet/mod.rs index 6d1c1d7..4f0a9ad 100644 --- a/src/timesheet/mod.rs +++ b/src/timesheet/mod.rs @@ -2,8 +2,10 @@ mod config; mod configuration; mod extract; mod point_types; +mod report; pub use config::{Period, RepositoryConfig, TimesheetConfig}; pub use configuration::{BasicTimesheetConfiguration, TIMESHEET_DIMENSION_NAME, TIMESHEET_TAG}; pub use extract::extract_timesheets; pub use point_types::TimesheetPointType; +pub use report::{DayReport, DayType, DayWarning, MonthReport, ReportWarning, TimesheetReport}; diff --git a/src/timesheet/report.rs b/src/timesheet/report.rs new file mode 100644 index 0000000..f96370d --- /dev/null +++ b/src/timesheet/report.rs @@ -0,0 +1,322 @@ +use chrono::{NaiveDate, NaiveTime}; +use std::fmt; + +/// Type of day for timesheet calculations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DayType { + /// Regular working day (Mon-Fri). + Regular, + /// Day with sick leave marker. + SickLeave, + /// Day with vacation marker. + Vacation, + /// Day with holiday marker. + Holiday, + /// Day with flex/undertime marker. + FlexDay, + /// Weekend day (Sat/Sun). + Weekend, + /// Missing: weekday with no entries and no explanation. + Missing, + /// Day outside any configured period. + OutsidePeriod, +} + +impl fmt::Display for DayType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DayType::Regular => write!(f, ""), + DayType::SickLeave => write!(f, "Sick Leave"), + DayType::Vacation => write!(f, "Vacation"), + DayType::Holiday => write!(f, "Holiday"), + DayType::FlexDay => write!(f, "Flex Day"), + DayType::Weekend => write!(f, "Weekend"), + DayType::Missing => write!(f, "\u{26a0} Missing"), + DayType::OutsidePeriod => write!(f, "Outside Period"), + } + } +} + +/// Warning associated with a specific day. +#[derive(Debug, Clone, PartialEq)] +pub enum DayWarning { + /// Weekday has no entries and no leave/holiday marker. + MissingWithoutExplanation, + /// Two timecards overlap. + OverlappingTimecards { + first: (NaiveTime, NaiveTime), + second: (NaiveTime, NaiveTime), + }, + /// Work logged outside any configured period. + OutsidePeriod { hours_worked: f64 }, +} + +impl fmt::Display for DayWarning { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DayWarning::MissingWithoutExplanation => { + write!(f, "No entries and no leave/holiday marker") + } + DayWarning::OverlappingTimecards { first, second } => { + write!( + f, + "{}-{} overlaps with {}-{}", + first.0.format("%H:%M"), + first.1.format("%H:%M"), + second.0.format("%H:%M"), + second.1.format("%H:%M") + ) + } + DayWarning::OutsidePeriod { hours_worked } => { + write!(f, "{:.1}h worked (no period configured)", hours_worked) + } + } + } +} + +/// Report for a single day. +#[derive(Debug, Clone)] +pub struct DayReport { + pub date: NaiveDate, + pub expected_hours: f64, + pub actual_hours: f64, + pub day_type: DayType, + pub warnings: Vec, +} + +impl DayReport { + pub fn new(date: NaiveDate, expected_hours: f64, actual_hours: f64, day_type: DayType) -> Self { + Self { + date, + expected_hours, + actual_hours, + day_type, + warnings: Vec::new(), + } + } + + pub fn with_warning(mut self, warning: DayWarning) -> Self { + self.warnings.push(warning); + self + } + + pub fn with_warnings(mut self, warnings: Vec) -> Self { + self.warnings = warnings; + self + } + + /// Calculate the difference between actual and expected hours. + pub fn diff(&self) -> f64 { + self.actual_hours - self.expected_hours + } + + /// Check if this day has any warnings. + pub fn has_warnings(&self) -> bool { + !self.warnings.is_empty() + } +} + +/// Report for a single month. +#[derive(Debug, Clone)] +pub struct MonthReport { + pub year: i32, + pub month: u32, + pub days: Vec, +} + +impl MonthReport { + pub fn new(year: i32, month: u32) -> Self { + Self { + year, + month, + days: Vec::new(), + } + } + + pub fn with_days(mut self, days: Vec) -> Self { + self.days = days; + self + } + + /// Calculate total expected hours for the month. + pub fn total_expected(&self) -> f64 { + self.days.iter().map(|d| d.expected_hours).sum() + } + + /// Calculate total actual hours for the month. + pub fn total_actual(&self) -> f64 { + self.days.iter().map(|d| d.actual_hours).sum() + } + + /// Calculate the difference for the month. + pub fn diff(&self) -> f64 { + self.total_actual() - self.total_expected() + } + + /// Get the month name. + pub fn month_name(&self) -> &'static str { + match self.month { + 1 => "January", + 2 => "February", + 3 => "March", + 4 => "April", + 5 => "May", + 6 => "June", + 7 => "July", + 8 => "August", + 9 => "September", + 10 => "October", + 11 => "November", + 12 => "December", + _ => "Unknown", + } + } +} + +/// A warning to be displayed in the report summary. +#[derive(Debug, Clone)] +pub struct ReportWarning { + pub date: NaiveDate, + pub warning: DayWarning, +} + +impl ReportWarning { + pub fn new(date: NaiveDate, warning: DayWarning) -> Self { + Self { date, warning } + } +} + +/// Complete timesheet report. +#[derive(Debug, Clone)] +pub struct TimesheetReport { + pub months: Vec, + pub cumulative_balance: f64, + pub warnings: Vec, +} + +impl TimesheetReport { + pub fn new() -> Self { + Self { + months: Vec::new(), + cumulative_balance: 0.0, + warnings: Vec::new(), + } + } + + pub fn with_months(mut self, months: Vec) -> Self { + self.months = months; + self + } + + pub fn with_cumulative_balance(mut self, balance: f64) -> Self { + self.cumulative_balance = balance; + self + } + + pub fn with_warnings(mut self, warnings: Vec) -> Self { + self.warnings = warnings; + self + } + + /// Check if there are any warnings. + pub fn has_warnings(&self) -> bool { + !self.warnings.is_empty() + } +} + +impl Default for TimesheetReport { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + 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() + } + + #[test] + fn test_day_report_diff() { + let report = DayReport::new(date(2026, 3, 2), 7.6, 8.2, DayType::Regular); + assert!((report.diff() - 0.6).abs() < 0.0001); + } + + #[test] + fn test_day_report_negative_diff() { + let report = DayReport::new(date(2026, 3, 2), 7.6, 6.0, DayType::Regular); + assert!((report.diff() - (-1.6)).abs() < 0.0001); + } + + #[test] + fn test_month_report_totals() { + let month = MonthReport::new(2026, 3).with_days(vec![ + DayReport::new(date(2026, 3, 2), 7.6, 8.2, DayType::Regular), + DayReport::new(date(2026, 3, 3), 7.6, 7.6, DayType::Regular), + DayReport::new(date(2026, 3, 4), 7.6, 6.0, DayType::Regular), + ]); + + assert!((month.total_expected() - 22.8).abs() < 0.0001); + assert!((month.total_actual() - 21.8).abs() < 0.0001); + assert!((month.diff() - (-1.0)).abs() < 0.0001); + } + + #[test] + fn test_month_name() { + let month = MonthReport::new(2026, 3); + assert_eq!(month.month_name(), "March"); + } + + #[test] + fn test_day_warning_overlap_display() { + let warning = DayWarning::OverlappingTimecards { + first: (time(9, 0), time(12, 30)), + second: (time(12, 0), time(13, 0)), + }; + assert_eq!(warning.to_string(), "09:00-12:30 overlaps with 12:00-13:00"); + } + + #[test] + fn test_day_warning_missing_display() { + let warning = DayWarning::MissingWithoutExplanation; + assert_eq!( + warning.to_string(), + "No entries and no leave/holiday marker" + ); + } + + #[test] + fn test_day_warning_outside_period_display() { + let warning = DayWarning::OutsidePeriod { hours_worked: 3.5 }; + assert_eq!(warning.to_string(), "3.5h worked (no period configured)"); + } + + #[test] + fn test_day_report_with_warnings() { + let report = DayReport::new(date(2026, 3, 2), 7.6, 8.2, DayType::Regular).with_warning( + DayWarning::OverlappingTimecards { + first: (time(9, 0), time(12, 30)), + second: (time(12, 0), time(13, 0)), + }, + ); + + assert!(report.has_warnings()); + assert_eq!(report.warnings.len(), 1); + } + + #[test] + fn test_timesheeet_report_has_warnings() { + let report = TimesheetReport::new().with_warnings(vec![ReportWarning::new( + date(2026, 3, 4), + DayWarning::MissingWithoutExplanation, + )]); + + assert!(report.has_warnings()); + } +}