324 lines
8.8 KiB
Rust
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());
|
|
}
|
|
}
|