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
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