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) -> 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, 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()); } }