streamd/src/timesheet/report.rs
Konstantin Fickel d614d678af
Some checks failed
Release / Build and Release (push) Successful in 6s
Continuous Integration / Lint, Check & Test (push) Failing after 56s
Continuous Integration / Build Package (push) Successful in 1m42s
chore: switch from h-float to min-int in timesheet
2026-04-07 08:28:50 +02:00

324 lines
8.8 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 { minutes_worked: i64 },
}
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 { minutes_worked } => {
write!(
f,
"{:.1}h worked (no period configured)",
*minutes_worked as f64 / 60.0
)
}
}
}
}
/// Report for a single day.
#[derive(Debug, Clone)]
pub struct DayReport {
pub date: NaiveDate,
pub expected_minutes: i64,
pub actual_minutes: i64,
pub day_type: DayType,
pub warnings: Vec<DayWarning>,
}
impl DayReport {
pub fn new(
date: NaiveDate,
expected_minutes: i64,
actual_minutes: i64,
day_type: DayType,
) -> Self {
Self {
date,
expected_minutes,
actual_minutes,
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 minutes.
pub fn diff(&self) -> i64 {
self.actual_minutes - self.expected_minutes
}
/// 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 minutes for the month.
pub fn total_expected(&self) -> i64 {
self.days.iter().map(|d| d.expected_minutes).sum()
}
/// Calculate total actual minutes for the month.
pub fn total_actual(&self) -> i64 {
self.days.iter().map(|d| d.actual_minutes).sum()
}
/// Calculate the difference for the month.
pub fn diff(&self) -> i64 {
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: i64,
pub warnings: Vec<ReportWarning>,
}
impl TimesheetReport {
pub fn new() -> Self {
Self {
months: Vec::new(),
cumulative_balance: 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: i64) -> 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() {
// 7.6h = 456 min, 8.2h = 492 min, diff = 36 min
let report = DayReport::new(date(2026, 3, 2), 456, 492, DayType::Regular);
assert_eq!(report.diff(), 36);
}
#[test]
fn test_day_report_negative_diff() {
// 7.6h = 456 min, 6.0h = 360 min, diff = -96 min
let report = DayReport::new(date(2026, 3, 2), 456, 360, DayType::Regular);
assert_eq!(report.diff(), -96);
}
#[test]
fn test_month_report_totals() {
// 7.6h = 456 min, 8.2h = 492 min, 6.0h = 360 min
let month = MonthReport::new(2026, 3).with_days(vec![
DayReport::new(date(2026, 3, 2), 456, 492, DayType::Regular),
DayReport::new(date(2026, 3, 3), 456, 456, DayType::Regular),
DayReport::new(date(2026, 3, 4), 456, 360, DayType::Regular),
]);
assert_eq!(month.total_expected(), 1368); // 456 * 3
assert_eq!(month.total_actual(), 1308); // 492 + 456 + 360
assert_eq!(month.diff(), -60); // -1 hour
}
#[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 {
minutes_worked: 210,
}; // 3.5h
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), 456, 492, 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());
}
}