Compare commits
2 commits
a9acd34801
...
d614d678af
| Author | SHA1 | Date | |
|---|---|---|---|
| d614d678af | |||
| a79111c650 |
4 changed files with 162 additions and 135 deletions
|
|
@ -103,6 +103,12 @@
|
|||
package = toolchain;
|
||||
};
|
||||
commitizen.enable = true;
|
||||
cargo-audit = {
|
||||
enable = true;
|
||||
entry = "${pkgs.cargo-audit}/bin/cargo-audit audit";
|
||||
files = "^Cargo\\.(toml|lock)$";
|
||||
pass_filenames = false;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -40,30 +40,28 @@ fn load_all_shards(base_folder: &Path) -> Result<Vec<LocalizedShard>, StreamdErr
|
|||
Ok(shards)
|
||||
}
|
||||
|
||||
/// Format hours with sign for display.
|
||||
fn format_diff(hours: f64, use_minutes: bool) -> String {
|
||||
/// Format minutes with sign for display.
|
||||
fn format_diff(minutes: i64, use_minutes: bool) -> String {
|
||||
let sign = if minutes >= 0 { "+" } else { "-" };
|
||||
if use_minutes {
|
||||
let total_minutes = (hours * 60.0).round() as i32;
|
||||
let h = total_minutes.abs() / 60;
|
||||
let m = total_minutes.abs() % 60;
|
||||
let sign = if hours >= 0.0 { "+" } else { "-" };
|
||||
let h = minutes.unsigned_abs() / 60;
|
||||
let m = minutes.unsigned_abs() % 60;
|
||||
format!("{}{}:{:02}", sign, h, m)
|
||||
} else if hours >= 0.0 {
|
||||
format!("+{:.1}h", hours.abs())
|
||||
} else {
|
||||
format!("{:.1}h", hours)
|
||||
let hours = minutes.unsigned_abs() as f64 / 60.0;
|
||||
format!("{}{:.1}h", sign, hours)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format hours for display without sign.
|
||||
fn format_hours(hours: f64, use_minutes: bool) -> String {
|
||||
/// Format minutes for display without sign.
|
||||
fn format_hours(minutes: i64, use_minutes: bool) -> String {
|
||||
if use_minutes {
|
||||
let total_minutes = (hours * 60.0).round() as i32;
|
||||
let h = total_minutes.abs() / 60;
|
||||
let m = total_minutes.abs() % 60;
|
||||
let h = minutes.unsigned_abs() / 60;
|
||||
let m = minutes.unsigned_abs() % 60;
|
||||
format!("{}:{:02}", h, m)
|
||||
} else {
|
||||
format!("{:.1}h", hours.abs())
|
||||
let hours = minutes.unsigned_abs() as f64 / 60.0;
|
||||
format!("{:.1}h", hours)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -112,8 +110,8 @@ fn print_month(month: &MonthReport, use_minutes: bool) {
|
|||
for day in &month.days {
|
||||
let date_str = day.date.format("%Y-%m-%d").to_string();
|
||||
let weekday = weekday_abbrev(day.date);
|
||||
let expected = format_hours(day.expected_hours, use_minutes);
|
||||
let actual = format_hours(day.actual_hours, use_minutes);
|
||||
let expected = format_hours(day.expected_minutes, use_minutes);
|
||||
let actual = format_hours(day.actual_minutes, use_minutes);
|
||||
let diff = format_diff(day.diff(), use_minutes);
|
||||
|
||||
let type_str = match day.day_type {
|
||||
|
|
@ -155,7 +153,7 @@ fn print_month(month: &MonthReport, use_minutes: bool) {
|
|||
}
|
||||
|
||||
/// Print the cumulative balance.
|
||||
fn print_cumulative_balance(balance: f64, use_minutes: bool) {
|
||||
fn print_cumulative_balance(balance: i64, use_minutes: bool) {
|
||||
let light_line = "\u{2500}".repeat(SEPARATOR_WIDTH);
|
||||
println!("{}", light_line);
|
||||
println!(
|
||||
|
|
@ -227,11 +225,11 @@ fn print_warnings(report: &TimesheetReport, use_minutes: bool) {
|
|||
if !outside_period_warnings.is_empty() {
|
||||
println!(" Work logged outside configured periods:");
|
||||
for w in &outside_period_warnings {
|
||||
if let DayWarning::OutsidePeriod { hours_worked } = &w.warning {
|
||||
if let DayWarning::OutsidePeriod { minutes_worked } = &w.warning {
|
||||
println!(
|
||||
" - {}: {} worked (no period configured)",
|
||||
w.date.format("%Y-%m-%d"),
|
||||
format_hours(*hours_worked, use_minutes)
|
||||
format_hours(*minutes_worked, use_minutes)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -294,31 +292,33 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_format_hours_decimal() {
|
||||
assert_eq!(format_hours(8.0, false), "8.0h");
|
||||
assert_eq!(format_hours(8.5, false), "8.5h");
|
||||
assert_eq!(format_hours(0.0, false), "0.0h");
|
||||
assert_eq!(format_hours(480, false), "8.0h");
|
||||
assert_eq!(format_hours(510, false), "8.5h");
|
||||
assert_eq!(format_hours(0, false), "0.0h");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_hours_minutes() {
|
||||
assert_eq!(format_hours(8.0, true), "8:00");
|
||||
assert_eq!(format_hours(8.5, true), "8:30");
|
||||
assert_eq!(format_hours(0.0, true), "0:00");
|
||||
assert_eq!(format_hours(1.25, true), "1:15");
|
||||
assert_eq!(format_hours(480, true), "8:00");
|
||||
assert_eq!(format_hours(510, true), "8:30");
|
||||
assert_eq!(format_hours(0, true), "0:00");
|
||||
assert_eq!(format_hours(75, true), "1:15");
|
||||
assert_eq!(format_hours(77, true), "1:17");
|
||||
assert_eq!(format_hours(200, true), "3:20");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_diff_decimal() {
|
||||
assert_eq!(format_diff(0.5, false), "+0.5h");
|
||||
assert_eq!(format_diff(-1.5, false), "-1.5h");
|
||||
assert_eq!(format_diff(0.0, false), "+0.0h");
|
||||
assert_eq!(format_diff(30, false), "+0.5h");
|
||||
assert_eq!(format_diff(-90, false), "-1.5h");
|
||||
assert_eq!(format_diff(0, false), "+0.0h");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_diff_minutes() {
|
||||
assert_eq!(format_diff(0.5, true), "+0:30");
|
||||
assert_eq!(format_diff(-1.5, true), "-1:30");
|
||||
assert_eq!(format_diff(0.0, true), "+0:00");
|
||||
assert_eq!(format_diff(1.25, true), "+1:15");
|
||||
assert_eq!(format_diff(30, true), "+0:30");
|
||||
assert_eq!(format_diff(-90, true), "-1:30");
|
||||
assert_eq!(format_diff(0, true), "+0:00");
|
||||
assert_eq!(format_diff(75, true), "+1:15");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,14 +30,14 @@ pub fn load_repository_config(base_folder: &Path) -> Result<RepositoryConfig, St
|
|||
Ok(config)
|
||||
}
|
||||
|
||||
/// Calculate total hours worked from timecards.
|
||||
fn calculate_timecard_hours(timesheet: &Timesheet) -> f64 {
|
||||
/// Calculate total minutes worked from timecards.
|
||||
fn calculate_timecard_minutes(timesheet: &Timesheet) -> i64 {
|
||||
timesheet
|
||||
.timecards
|
||||
.iter()
|
||||
.map(|tc| {
|
||||
let duration = tc.to_time - tc.from_time;
|
||||
duration.num_minutes() as f64 / 60.0
|
||||
duration.num_minutes()
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
|
@ -79,31 +79,35 @@ fn determine_day_type(date: NaiveDate, timesheet: Option<&Timesheet>, has_period
|
|||
DayType::Missing
|
||||
}
|
||||
|
||||
/// Calculate expected hours for a day based on period config and day type.
|
||||
fn calculate_expected_hours(day_type: DayType, hours_per_day: f64, _date: NaiveDate) -> f64 {
|
||||
/// Calculate expected minutes for a day based on period config and day type.
|
||||
fn calculate_expected_minutes(day_type: DayType, minutes_per_day: i64) -> i64 {
|
||||
match day_type {
|
||||
DayType::Regular => hours_per_day,
|
||||
DayType::SickLeave => hours_per_day,
|
||||
DayType::Vacation => hours_per_day,
|
||||
DayType::Holiday => 0.0,
|
||||
DayType::FlexDay => hours_per_day,
|
||||
DayType::Weekend => 0.0,
|
||||
DayType::Missing => hours_per_day,
|
||||
DayType::OutsidePeriod => 0.0,
|
||||
DayType::Regular => minutes_per_day,
|
||||
DayType::SickLeave => minutes_per_day,
|
||||
DayType::Vacation => minutes_per_day,
|
||||
DayType::Holiday => 0,
|
||||
DayType::FlexDay => minutes_per_day,
|
||||
DayType::Weekend => 0,
|
||||
DayType::Missing => minutes_per_day,
|
||||
DayType::OutsidePeriod => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate actual hours for a day based on day type rules.
|
||||
fn calculate_actual_hours(day_type: DayType, timecard_hours: f64, expected_hours: f64) -> f64 {
|
||||
/// Calculate actual minutes for a day based on day type rules.
|
||||
fn calculate_actual_minutes(
|
||||
day_type: DayType,
|
||||
timecard_minutes: i64,
|
||||
expected_minutes: i64,
|
||||
) -> i64 {
|
||||
match day_type {
|
||||
DayType::Regular => timecard_hours,
|
||||
DayType::SickLeave => expected_hours.max(timecard_hours),
|
||||
DayType::Vacation => expected_hours + timecard_hours,
|
||||
DayType::Holiday => timecard_hours,
|
||||
DayType::FlexDay => 0.0,
|
||||
DayType::Weekend => timecard_hours,
|
||||
DayType::Missing => 0.0,
|
||||
DayType::OutsidePeriod => timecard_hours,
|
||||
DayType::Regular => timecard_minutes,
|
||||
DayType::SickLeave => expected_minutes.max(timecard_minutes),
|
||||
DayType::Vacation => expected_minutes + timecard_minutes,
|
||||
DayType::Holiday => timecard_minutes,
|
||||
DayType::FlexDay => 0,
|
||||
DayType::Weekend => timecard_minutes,
|
||||
DayType::Missing => 0,
|
||||
DayType::OutsidePeriod => timecard_minutes,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -131,7 +135,7 @@ pub fn generate_report(
|
|||
// Group by month and generate reports
|
||||
let mut month_reports: Vec<MonthReport> = Vec::new();
|
||||
let mut all_warnings: Vec<ReportWarning> = Vec::new();
|
||||
let mut cumulative_balance: f64 = 0.0;
|
||||
let mut cumulative_balance: i64 = 0;
|
||||
|
||||
// Iterate through all dates in the range
|
||||
let mut current_date = earliest_period_start;
|
||||
|
|
@ -160,29 +164,32 @@ pub fn generate_report(
|
|||
// Find if this date falls within a period
|
||||
let period = config.find_period(current_date);
|
||||
let has_period = period.is_some();
|
||||
let hours_per_day = period.map(|p| p.hours_per_day()).unwrap_or(0.0);
|
||||
let minutes_per_day = period
|
||||
.map(|p| (p.hours_per_day() * 60.0).round() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
// Get timesheet for this date
|
||||
let timesheet = timesheets_by_date.get(¤t_date).copied();
|
||||
let timecard_hours = timesheet.map(calculate_timecard_hours).unwrap_or(0.0);
|
||||
let timecard_minutes = timesheet.map(calculate_timecard_minutes).unwrap_or(0);
|
||||
|
||||
// Determine day type
|
||||
let day_type = determine_day_type(current_date, timesheet, has_period);
|
||||
|
||||
// Skip weekends with no work and days outside periods with no work
|
||||
let should_include = match day_type {
|
||||
DayType::Weekend => timecard_hours > 0.0,
|
||||
DayType::OutsidePeriod => timecard_hours > 0.0,
|
||||
DayType::Weekend => timecard_minutes > 0,
|
||||
DayType::OutsidePeriod => timecard_minutes > 0,
|
||||
_ => has_period, // Only include days within periods
|
||||
};
|
||||
|
||||
if should_include {
|
||||
// Calculate expected and actual hours
|
||||
let expected_hours = calculate_expected_hours(day_type, hours_per_day, current_date);
|
||||
let actual_hours = calculate_actual_hours(day_type, timecard_hours, expected_hours);
|
||||
// Calculate expected and actual minutes
|
||||
let expected_minutes = calculate_expected_minutes(day_type, minutes_per_day);
|
||||
let actual_minutes =
|
||||
calculate_actual_minutes(day_type, timecard_minutes, expected_minutes);
|
||||
|
||||
let mut day_report =
|
||||
DayReport::new(current_date, expected_hours, actual_hours, day_type);
|
||||
DayReport::new(current_date, expected_minutes, actual_minutes, day_type);
|
||||
|
||||
// Collect warnings
|
||||
let mut day_warnings: Vec<DayWarning> = Vec::new();
|
||||
|
|
@ -207,9 +214,9 @@ pub fn generate_report(
|
|||
}
|
||||
|
||||
// Warning: Work outside period
|
||||
if day_type == DayType::OutsidePeriod && timecard_hours > 0.0 {
|
||||
if day_type == DayType::OutsidePeriod && timecard_minutes > 0 {
|
||||
let warning = DayWarning::OutsidePeriod {
|
||||
hours_worked: timecard_hours,
|
||||
minutes_worked: timecard_minutes,
|
||||
};
|
||||
day_warnings.push(warning.clone());
|
||||
all_warnings.push(ReportWarning::new(current_date, warning));
|
||||
|
|
@ -286,17 +293,17 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_timecard_hours() {
|
||||
fn test_calculate_timecard_minutes() {
|
||||
let ts = make_timesheet(date(2026, 3, 2), vec![(9, 0, 12, 0), (13, 0, 17, 0)]);
|
||||
let hours = calculate_timecard_hours(&ts);
|
||||
assert!((hours - 7.0).abs() < 0.0001);
|
||||
let minutes = calculate_timecard_minutes(&ts);
|
||||
assert_eq!(minutes, 420); // 3h + 4h = 7h = 420 min
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_timecard_hours_with_minutes() {
|
||||
fn test_calculate_timecard_minutes_with_minutes() {
|
||||
let ts = make_timesheet(date(2026, 3, 2), vec![(9, 0, 12, 30), (13, 0, 17, 15)]);
|
||||
let hours = calculate_timecard_hours(&ts);
|
||||
assert!((hours - 7.75).abs() < 0.0001);
|
||||
let minutes = calculate_timecard_minutes(&ts);
|
||||
assert_eq!(minutes, 465); // 3.5h + 4.25h = 7.75h = 465 min
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -376,55 +383,55 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn test_expected_hours_regular() {
|
||||
let hours = calculate_expected_hours(DayType::Regular, 7.6, date(2026, 3, 2));
|
||||
assert!((hours - 7.6).abs() < 0.0001);
|
||||
fn test_expected_minutes_regular() {
|
||||
let minutes = calculate_expected_minutes(DayType::Regular, 456); // 7.6h = 456 min
|
||||
assert_eq!(minutes, 456);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expected_hours_holiday() {
|
||||
let hours = calculate_expected_hours(DayType::Holiday, 7.6, date(2026, 3, 2));
|
||||
assert!((hours - 0.0).abs() < 0.0001);
|
||||
fn test_expected_minutes_holiday() {
|
||||
let minutes = calculate_expected_minutes(DayType::Holiday, 456);
|
||||
assert_eq!(minutes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expected_hours_weekend() {
|
||||
let hours = calculate_expected_hours(DayType::Weekend, 7.6, date(2026, 3, 7));
|
||||
assert!((hours - 0.0).abs() < 0.0001);
|
||||
fn test_expected_minutes_weekend() {
|
||||
let minutes = calculate_expected_minutes(DayType::Weekend, 456);
|
||||
assert_eq!(minutes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_actual_hours_regular() {
|
||||
let hours = calculate_actual_hours(DayType::Regular, 8.0, 7.6);
|
||||
assert!((hours - 8.0).abs() < 0.0001);
|
||||
fn test_actual_minutes_regular() {
|
||||
let minutes = calculate_actual_minutes(DayType::Regular, 480, 456); // 8h, expected 7.6h
|
||||
assert_eq!(minutes, 480);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_actual_hours_sick_leave_max() {
|
||||
fn test_actual_minutes_sick_leave_max() {
|
||||
// Sick leave: max(expected, worked)
|
||||
let hours = calculate_actual_hours(DayType::SickLeave, 3.0, 7.6);
|
||||
assert!((hours - 7.6).abs() < 0.0001);
|
||||
let minutes = calculate_actual_minutes(DayType::SickLeave, 180, 456); // 3h worked, 7.6h expected
|
||||
assert_eq!(minutes, 456);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_actual_hours_sick_leave_worked_more() {
|
||||
fn test_actual_minutes_sick_leave_worked_more() {
|
||||
// Sick leave where worked > expected
|
||||
let hours = calculate_actual_hours(DayType::SickLeave, 9.0, 7.6);
|
||||
assert!((hours - 9.0).abs() < 0.0001);
|
||||
let minutes = calculate_actual_minutes(DayType::SickLeave, 540, 456); // 9h worked, 7.6h expected
|
||||
assert_eq!(minutes, 540);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_actual_hours_vacation() {
|
||||
fn test_actual_minutes_vacation() {
|
||||
// Vacation: expected + worked
|
||||
let hours = calculate_actual_hours(DayType::Vacation, 2.0, 7.6);
|
||||
assert!((hours - 9.6).abs() < 0.0001);
|
||||
let minutes = calculate_actual_minutes(DayType::Vacation, 120, 456); // 2h worked, 7.6h expected
|
||||
assert_eq!(minutes, 576); // 2h + 7.6h = 9.6h = 576 min
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_actual_hours_flex_day() {
|
||||
fn test_actual_minutes_flex_day() {
|
||||
// Flex day: always 0
|
||||
let hours = calculate_actual_hours(DayType::FlexDay, 5.0, 7.6);
|
||||
assert!((hours - 0.0).abs() < 0.0001);
|
||||
let minutes = calculate_actual_minutes(DayType::FlexDay, 300, 456);
|
||||
assert_eq!(minutes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -447,8 +454,8 @@ mod tests {
|
|||
|
||||
let day = &report.months[0].days[0];
|
||||
assert_eq!(day.date, date(2026, 3, 2));
|
||||
assert!((day.expected_hours - 8.0).abs() < 0.0001);
|
||||
assert!((day.actual_hours - 8.0).abs() < 0.0001);
|
||||
assert_eq!(day.expected_minutes, 480); // 8h = 480 min
|
||||
assert_eq!(day.actual_minutes, 480);
|
||||
assert_eq!(day.day_type, DayType::Regular);
|
||||
}
|
||||
|
||||
|
|
@ -536,7 +543,7 @@ mod tests {
|
|||
|
||||
let report = generate_report(×heets, &config).unwrap();
|
||||
|
||||
// Balance should be +2h (18h actual - 16h expected)
|
||||
assert!((report.cumulative_balance - 2.0).abs() < 0.0001);
|
||||
// Balance should be +120 min (+2h: 18h actual - 16h expected)
|
||||
assert_eq!(report.cumulative_balance, 120);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ pub enum DayWarning {
|
|||
second: (NaiveTime, NaiveTime),
|
||||
},
|
||||
/// Work logged outside any configured period.
|
||||
OutsidePeriod { hours_worked: f64 },
|
||||
OutsidePeriod { minutes_worked: i64 },
|
||||
}
|
||||
|
||||
impl fmt::Display for DayWarning {
|
||||
|
|
@ -67,8 +67,12 @@ impl fmt::Display for DayWarning {
|
|||
second.1.format("%H:%M")
|
||||
)
|
||||
}
|
||||
DayWarning::OutsidePeriod { hours_worked } => {
|
||||
write!(f, "{:.1}h worked (no period configured)", hours_worked)
|
||||
DayWarning::OutsidePeriod { minutes_worked } => {
|
||||
write!(
|
||||
f,
|
||||
"{:.1}h worked (no period configured)",
|
||||
*minutes_worked as f64 / 60.0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -78,18 +82,23 @@ impl fmt::Display for DayWarning {
|
|||
#[derive(Debug, Clone)]
|
||||
pub struct DayReport {
|
||||
pub date: NaiveDate,
|
||||
pub expected_hours: f64,
|
||||
pub actual_hours: f64,
|
||||
pub expected_minutes: i64,
|
||||
pub actual_minutes: i64,
|
||||
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 {
|
||||
pub fn new(
|
||||
date: NaiveDate,
|
||||
expected_minutes: i64,
|
||||
actual_minutes: i64,
|
||||
day_type: DayType,
|
||||
) -> Self {
|
||||
Self {
|
||||
date,
|
||||
expected_hours,
|
||||
actual_hours,
|
||||
expected_minutes,
|
||||
actual_minutes,
|
||||
day_type,
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
|
|
@ -105,9 +114,9 @@ impl DayReport {
|
|||
self
|
||||
}
|
||||
|
||||
/// Calculate the difference between actual and expected hours.
|
||||
pub fn diff(&self) -> f64 {
|
||||
self.actual_hours - self.expected_hours
|
||||
/// 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.
|
||||
|
|
@ -138,18 +147,18 @@ impl MonthReport {
|
|||
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 expected minutes for the month.
|
||||
pub fn total_expected(&self) -> i64 {
|
||||
self.days.iter().map(|d| d.expected_minutes).sum()
|
||||
}
|
||||
|
||||
/// Calculate total actual hours for the month.
|
||||
pub fn total_actual(&self) -> f64 {
|
||||
self.days.iter().map(|d| d.actual_hours).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) -> f64 {
|
||||
pub fn diff(&self) -> i64 {
|
||||
self.total_actual() - self.total_expected()
|
||||
}
|
||||
|
||||
|
|
@ -178,7 +187,7 @@ impl ReportWarning {
|
|||
#[derive(Debug, Clone)]
|
||||
pub struct TimesheetReport {
|
||||
pub months: Vec<MonthReport>,
|
||||
pub cumulative_balance: f64,
|
||||
pub cumulative_balance: i64,
|
||||
pub warnings: Vec<ReportWarning>,
|
||||
}
|
||||
|
||||
|
|
@ -186,7 +195,7 @@ impl TimesheetReport {
|
|||
pub fn new() -> Self {
|
||||
Self {
|
||||
months: Vec::new(),
|
||||
cumulative_balance: 0.0,
|
||||
cumulative_balance: 0,
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -196,7 +205,7 @@ impl TimesheetReport {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn with_cumulative_balance(mut self, balance: f64) -> Self {
|
||||
pub fn with_cumulative_balance(mut self, balance: i64) -> Self {
|
||||
self.cumulative_balance = balance;
|
||||
self
|
||||
}
|
||||
|
|
@ -232,27 +241,30 @@ mod tests {
|
|||
|
||||
#[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);
|
||||
// 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() {
|
||||
let report = DayReport::new(date(2026, 3, 2), 7.6, 6.0, DayType::Regular);
|
||||
assert!((report.diff() - (-1.6)).abs() < 0.0001);
|
||||
// 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), 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),
|
||||
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!((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);
|
||||
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]
|
||||
|
|
@ -281,13 +293,15 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_day_warning_outside_period_display() {
|
||||
let warning = DayWarning::OutsidePeriod { hours_worked: 3.5 };
|
||||
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), 7.6, 8.2, DayType::Regular).with_warning(
|
||||
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)),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue