streamd/src/timesheet/report.rs
Konstantin Fickel a8c41ec833
All checks were successful
Continuous Integration / Lint, Check & Test (push) Successful in 1m20s
Continuous Integration / Build Package (push) Successful in 1m40s
refactor: use chrono for month names
2026-04-02 18:18:48 +02:00

310 lines
8.4 KiB
Rust

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) -> String {
NaiveDate::from_ymd_opt(self.year, self.month, 1)
.map(|d| d.format("%B").to_string())
.unwrap_or_else(|| "Unknown".to_string())
}
}
/// 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());
}
}