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
This commit is contained in:
parent
92c8e9712a
commit
92ca364e55
2 changed files with 324 additions and 0 deletions
|
|
@ -2,8 +2,10 @@ mod config;
|
||||||
mod configuration;
|
mod configuration;
|
||||||
mod extract;
|
mod extract;
|
||||||
mod point_types;
|
mod point_types;
|
||||||
|
mod report;
|
||||||
|
|
||||||
pub use config::{Period, RepositoryConfig, TimesheetConfig};
|
pub use config::{Period, RepositoryConfig, TimesheetConfig};
|
||||||
pub use configuration::{BasicTimesheetConfiguration, TIMESHEET_DIMENSION_NAME, TIMESHEET_TAG};
|
pub use configuration::{BasicTimesheetConfiguration, TIMESHEET_DIMENSION_NAME, TIMESHEET_TAG};
|
||||||
pub use extract::extract_timesheets;
|
pub use extract::extract_timesheets;
|
||||||
pub use point_types::TimesheetPointType;
|
pub use point_types::TimesheetPointType;
|
||||||
|
pub use report::{DayReport, DayType, DayWarning, MonthReport, ReportWarning, TimesheetReport};
|
||||||
|
|
|
||||||
322
src/timesheet/report.rs
Normal file
322
src/timesheet/report.rs
Normal file
|
|
@ -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<DayWarning>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<DayWarning>) -> 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<DayReport>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MonthReport {
|
||||||
|
pub fn new(year: i32, month: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
days: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_days(mut self, days: Vec<DayReport>) -> 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<MonthReport>,
|
||||||
|
pub cumulative_balance: f64,
|
||||||
|
pub warnings: Vec<ReportWarning>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<MonthReport>) -> 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<ReportWarning>) -> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue