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