Compare commits

..

2 commits

Author SHA1 Message Date
d614d678af
chore: switch from h-float to min-int in timesheet
Some checks failed
Release / Build and Release (push) Successful in 6s
Continuous Integration / Lint, Check & Test (push) Failing after 56s
Continuous Integration / Build Package (push) Successful in 1m42s
2026-04-07 08:28:50 +02:00
a79111c650
ci: add cargo audit 2026-04-07 08:16:18 +02:00
4 changed files with 162 additions and 135 deletions

View file

@ -103,6 +103,12 @@
package = toolchain; package = toolchain;
}; };
commitizen.enable = true; commitizen.enable = true;
cargo-audit = {
enable = true;
entry = "${pkgs.cargo-audit}/bin/cargo-audit audit";
files = "^Cargo\\.(toml|lock)$";
pass_filenames = false;
};
}; };
}; };

View file

@ -40,30 +40,28 @@ fn load_all_shards(base_folder: &Path) -> Result<Vec<LocalizedShard>, StreamdErr
Ok(shards) Ok(shards)
} }
/// Format hours with sign for display. /// Format minutes with sign for display.
fn format_diff(hours: f64, use_minutes: bool) -> String { fn format_diff(minutes: i64, use_minutes: bool) -> String {
let sign = if minutes >= 0 { "+" } else { "-" };
if use_minutes { if use_minutes {
let total_minutes = (hours * 60.0).round() as i32; let h = minutes.unsigned_abs() / 60;
let h = total_minutes.abs() / 60; let m = minutes.unsigned_abs() % 60;
let m = total_minutes.abs() % 60;
let sign = if hours >= 0.0 { "+" } else { "-" };
format!("{}{}:{:02}", sign, h, m) format!("{}{}:{:02}", sign, h, m)
} else if hours >= 0.0 {
format!("+{:.1}h", hours.abs())
} else { } 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. /// Format minutes for display without sign.
fn format_hours(hours: f64, use_minutes: bool) -> String { fn format_hours(minutes: i64, use_minutes: bool) -> String {
if use_minutes { if use_minutes {
let total_minutes = (hours * 60.0).round() as i32; let h = minutes.unsigned_abs() / 60;
let h = total_minutes.abs() / 60; let m = minutes.unsigned_abs() % 60;
let m = total_minutes.abs() % 60;
format!("{}:{:02}", h, m) format!("{}:{:02}", h, m)
} else { } 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 { for day in &month.days {
let date_str = day.date.format("%Y-%m-%d").to_string(); let date_str = day.date.format("%Y-%m-%d").to_string();
let weekday = weekday_abbrev(day.date); let weekday = weekday_abbrev(day.date);
let expected = format_hours(day.expected_hours, use_minutes); let expected = format_hours(day.expected_minutes, use_minutes);
let actual = format_hours(day.actual_hours, use_minutes); let actual = format_hours(day.actual_minutes, use_minutes);
let diff = format_diff(day.diff(), use_minutes); let diff = format_diff(day.diff(), use_minutes);
let type_str = match day.day_type { let type_str = match day.day_type {
@ -155,7 +153,7 @@ fn print_month(month: &MonthReport, use_minutes: bool) {
} }
/// Print the cumulative balance. /// 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); let light_line = "\u{2500}".repeat(SEPARATOR_WIDTH);
println!("{}", light_line); println!("{}", light_line);
println!( println!(
@ -227,11 +225,11 @@ fn print_warnings(report: &TimesheetReport, use_minutes: bool) {
if !outside_period_warnings.is_empty() { if !outside_period_warnings.is_empty() {
println!(" Work logged outside configured periods:"); println!(" Work logged outside configured periods:");
for w in &outside_period_warnings { for w in &outside_period_warnings {
if let DayWarning::OutsidePeriod { hours_worked } = &w.warning { if let DayWarning::OutsidePeriod { minutes_worked } = &w.warning {
println!( println!(
" - {}: {} worked (no period configured)", " - {}: {} worked (no period configured)",
w.date.format("%Y-%m-%d"), 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] #[test]
fn test_format_hours_decimal() { fn test_format_hours_decimal() {
assert_eq!(format_hours(8.0, false), "8.0h"); assert_eq!(format_hours(480, false), "8.0h");
assert_eq!(format_hours(8.5, false), "8.5h"); assert_eq!(format_hours(510, false), "8.5h");
assert_eq!(format_hours(0.0, false), "0.0h"); assert_eq!(format_hours(0, false), "0.0h");
} }
#[test] #[test]
fn test_format_hours_minutes() { fn test_format_hours_minutes() {
assert_eq!(format_hours(8.0, true), "8:00"); assert_eq!(format_hours(480, true), "8:00");
assert_eq!(format_hours(8.5, true), "8:30"); assert_eq!(format_hours(510, true), "8:30");
assert_eq!(format_hours(0.0, true), "0:00"); assert_eq!(format_hours(0, true), "0:00");
assert_eq!(format_hours(1.25, true), "1:15"); 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] #[test]
fn test_format_diff_decimal() { fn test_format_diff_decimal() {
assert_eq!(format_diff(0.5, false), "+0.5h"); assert_eq!(format_diff(30, false), "+0.5h");
assert_eq!(format_diff(-1.5, false), "-1.5h"); assert_eq!(format_diff(-90, false), "-1.5h");
assert_eq!(format_diff(0.0, false), "+0.0h"); assert_eq!(format_diff(0, false), "+0.0h");
} }
#[test] #[test]
fn test_format_diff_minutes() { fn test_format_diff_minutes() {
assert_eq!(format_diff(0.5, true), "+0:30"); assert_eq!(format_diff(30, true), "+0:30");
assert_eq!(format_diff(-1.5, true), "-1:30"); assert_eq!(format_diff(-90, true), "-1:30");
assert_eq!(format_diff(0.0, true), "+0:00"); assert_eq!(format_diff(0, true), "+0:00");
assert_eq!(format_diff(1.25, true), "+1:15"); assert_eq!(format_diff(75, true), "+1:15");
} }
} }

View file

@ -30,14 +30,14 @@ pub fn load_repository_config(base_folder: &Path) -> Result<RepositoryConfig, St
Ok(config) Ok(config)
} }
/// Calculate total hours worked from timecards. /// Calculate total minutes worked from timecards.
fn calculate_timecard_hours(timesheet: &Timesheet) -> f64 { fn calculate_timecard_minutes(timesheet: &Timesheet) -> i64 {
timesheet timesheet
.timecards .timecards
.iter() .iter()
.map(|tc| { .map(|tc| {
let duration = tc.to_time - tc.from_time; let duration = tc.to_time - tc.from_time;
duration.num_minutes() as f64 / 60.0 duration.num_minutes()
}) })
.sum() .sum()
} }
@ -79,31 +79,35 @@ fn determine_day_type(date: NaiveDate, timesheet: Option<&Timesheet>, has_period
DayType::Missing DayType::Missing
} }
/// Calculate expected hours for a day based on period config and day type. /// Calculate expected minutes for a day based on period config and day type.
fn calculate_expected_hours(day_type: DayType, hours_per_day: f64, _date: NaiveDate) -> f64 { fn calculate_expected_minutes(day_type: DayType, minutes_per_day: i64) -> i64 {
match day_type { match day_type {
DayType::Regular => hours_per_day, DayType::Regular => minutes_per_day,
DayType::SickLeave => hours_per_day, DayType::SickLeave => minutes_per_day,
DayType::Vacation => hours_per_day, DayType::Vacation => minutes_per_day,
DayType::Holiday => 0.0, DayType::Holiday => 0,
DayType::FlexDay => hours_per_day, DayType::FlexDay => minutes_per_day,
DayType::Weekend => 0.0, DayType::Weekend => 0,
DayType::Missing => hours_per_day, DayType::Missing => minutes_per_day,
DayType::OutsidePeriod => 0.0, DayType::OutsidePeriod => 0,
} }
} }
/// Calculate actual hours for a day based on day type rules. /// Calculate actual minutes for a day based on day type rules.
fn calculate_actual_hours(day_type: DayType, timecard_hours: f64, expected_hours: f64) -> f64 { fn calculate_actual_minutes(
day_type: DayType,
timecard_minutes: i64,
expected_minutes: i64,
) -> i64 {
match day_type { match day_type {
DayType::Regular => timecard_hours, DayType::Regular => timecard_minutes,
DayType::SickLeave => expected_hours.max(timecard_hours), DayType::SickLeave => expected_minutes.max(timecard_minutes),
DayType::Vacation => expected_hours + timecard_hours, DayType::Vacation => expected_minutes + timecard_minutes,
DayType::Holiday => timecard_hours, DayType::Holiday => timecard_minutes,
DayType::FlexDay => 0.0, DayType::FlexDay => 0,
DayType::Weekend => timecard_hours, DayType::Weekend => timecard_minutes,
DayType::Missing => 0.0, DayType::Missing => 0,
DayType::OutsidePeriod => timecard_hours, DayType::OutsidePeriod => timecard_minutes,
} }
} }
@ -131,7 +135,7 @@ pub fn generate_report(
// Group by month and generate reports // Group by month and generate reports
let mut month_reports: Vec<MonthReport> = Vec::new(); let mut month_reports: Vec<MonthReport> = Vec::new();
let mut all_warnings: Vec<ReportWarning> = 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 // Iterate through all dates in the range
let mut current_date = earliest_period_start; let mut current_date = earliest_period_start;
@ -160,29 +164,32 @@ pub fn generate_report(
// Find if this date falls within a period // Find if this date falls within a period
let period = config.find_period(current_date); let period = config.find_period(current_date);
let has_period = period.is_some(); 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 // Get timesheet for this date
let timesheet = timesheets_by_date.get(&current_date).copied(); let timesheet = timesheets_by_date.get(&current_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 // Determine day type
let day_type = determine_day_type(current_date, timesheet, has_period); let day_type = determine_day_type(current_date, timesheet, has_period);
// Skip weekends with no work and days outside periods with no work // Skip weekends with no work and days outside periods with no work
let should_include = match day_type { let should_include = match day_type {
DayType::Weekend => timecard_hours > 0.0, DayType::Weekend => timecard_minutes > 0,
DayType::OutsidePeriod => timecard_hours > 0.0, DayType::OutsidePeriod => timecard_minutes > 0,
_ => has_period, // Only include days within periods _ => has_period, // Only include days within periods
}; };
if should_include { if should_include {
// Calculate expected and actual hours // Calculate expected and actual minutes
let expected_hours = calculate_expected_hours(day_type, hours_per_day, current_date); let expected_minutes = calculate_expected_minutes(day_type, minutes_per_day);
let actual_hours = calculate_actual_hours(day_type, timecard_hours, expected_hours); let actual_minutes =
calculate_actual_minutes(day_type, timecard_minutes, expected_minutes);
let mut day_report = 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 // Collect warnings
let mut day_warnings: Vec<DayWarning> = Vec::new(); let mut day_warnings: Vec<DayWarning> = Vec::new();
@ -207,9 +214,9 @@ pub fn generate_report(
} }
// Warning: Work outside period // 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 { let warning = DayWarning::OutsidePeriod {
hours_worked: timecard_hours, minutes_worked: timecard_minutes,
}; };
day_warnings.push(warning.clone()); day_warnings.push(warning.clone());
all_warnings.push(ReportWarning::new(current_date, warning)); all_warnings.push(ReportWarning::new(current_date, warning));
@ -286,17 +293,17 @@ mod tests {
} }
#[test] #[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 ts = make_timesheet(date(2026, 3, 2), vec![(9, 0, 12, 0), (13, 0, 17, 0)]);
let hours = calculate_timecard_hours(&ts); let minutes = calculate_timecard_minutes(&ts);
assert!((hours - 7.0).abs() < 0.0001); assert_eq!(minutes, 420); // 3h + 4h = 7h = 420 min
} }
#[test] #[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 ts = make_timesheet(date(2026, 3, 2), vec![(9, 0, 12, 30), (13, 0, 17, 15)]);
let hours = calculate_timecard_hours(&ts); let minutes = calculate_timecard_minutes(&ts);
assert!((hours - 7.75).abs() < 0.0001); assert_eq!(minutes, 465); // 3.5h + 4.25h = 7.75h = 465 min
} }
#[test] #[test]
@ -376,55 +383,55 @@ mod tests {
} }
#[test] #[test]
fn test_expected_hours_regular() { fn test_expected_minutes_regular() {
let hours = calculate_expected_hours(DayType::Regular, 7.6, date(2026, 3, 2)); let minutes = calculate_expected_minutes(DayType::Regular, 456); // 7.6h = 456 min
assert!((hours - 7.6).abs() < 0.0001); assert_eq!(minutes, 456);
} }
#[test] #[test]
fn test_expected_hours_holiday() { fn test_expected_minutes_holiday() {
let hours = calculate_expected_hours(DayType::Holiday, 7.6, date(2026, 3, 2)); let minutes = calculate_expected_minutes(DayType::Holiday, 456);
assert!((hours - 0.0).abs() < 0.0001); assert_eq!(minutes, 0);
} }
#[test] #[test]
fn test_expected_hours_weekend() { fn test_expected_minutes_weekend() {
let hours = calculate_expected_hours(DayType::Weekend, 7.6, date(2026, 3, 7)); let minutes = calculate_expected_minutes(DayType::Weekend, 456);
assert!((hours - 0.0).abs() < 0.0001); assert_eq!(minutes, 0);
} }
#[test] #[test]
fn test_actual_hours_regular() { fn test_actual_minutes_regular() {
let hours = calculate_actual_hours(DayType::Regular, 8.0, 7.6); let minutes = calculate_actual_minutes(DayType::Regular, 480, 456); // 8h, expected 7.6h
assert!((hours - 8.0).abs() < 0.0001); assert_eq!(minutes, 480);
} }
#[test] #[test]
fn test_actual_hours_sick_leave_max() { fn test_actual_minutes_sick_leave_max() {
// Sick leave: max(expected, worked) // Sick leave: max(expected, worked)
let hours = calculate_actual_hours(DayType::SickLeave, 3.0, 7.6); let minutes = calculate_actual_minutes(DayType::SickLeave, 180, 456); // 3h worked, 7.6h expected
assert!((hours - 7.6).abs() < 0.0001); assert_eq!(minutes, 456);
} }
#[test] #[test]
fn test_actual_hours_sick_leave_worked_more() { fn test_actual_minutes_sick_leave_worked_more() {
// Sick leave where worked > expected // Sick leave where worked > expected
let hours = calculate_actual_hours(DayType::SickLeave, 9.0, 7.6); let minutes = calculate_actual_minutes(DayType::SickLeave, 540, 456); // 9h worked, 7.6h expected
assert!((hours - 9.0).abs() < 0.0001); assert_eq!(minutes, 540);
} }
#[test] #[test]
fn test_actual_hours_vacation() { fn test_actual_minutes_vacation() {
// Vacation: expected + worked // Vacation: expected + worked
let hours = calculate_actual_hours(DayType::Vacation, 2.0, 7.6); let minutes = calculate_actual_minutes(DayType::Vacation, 120, 456); // 2h worked, 7.6h expected
assert!((hours - 9.6).abs() < 0.0001); assert_eq!(minutes, 576); // 2h + 7.6h = 9.6h = 576 min
} }
#[test] #[test]
fn test_actual_hours_flex_day() { fn test_actual_minutes_flex_day() {
// Flex day: always 0 // Flex day: always 0
let hours = calculate_actual_hours(DayType::FlexDay, 5.0, 7.6); let minutes = calculate_actual_minutes(DayType::FlexDay, 300, 456);
assert!((hours - 0.0).abs() < 0.0001); assert_eq!(minutes, 0);
} }
#[test] #[test]
@ -447,8 +454,8 @@ mod tests {
let day = &report.months[0].days[0]; let day = &report.months[0].days[0];
assert_eq!(day.date, date(2026, 3, 2)); assert_eq!(day.date, date(2026, 3, 2));
assert!((day.expected_hours - 8.0).abs() < 0.0001); assert_eq!(day.expected_minutes, 480); // 8h = 480 min
assert!((day.actual_hours - 8.0).abs() < 0.0001); assert_eq!(day.actual_minutes, 480);
assert_eq!(day.day_type, DayType::Regular); assert_eq!(day.day_type, DayType::Regular);
} }
@ -536,7 +543,7 @@ mod tests {
let report = generate_report(&timesheets, &config).unwrap(); let report = generate_report(&timesheets, &config).unwrap();
// Balance should be +2h (18h actual - 16h expected) // Balance should be +120 min (+2h: 18h actual - 16h expected)
assert!((report.cumulative_balance - 2.0).abs() < 0.0001); assert_eq!(report.cumulative_balance, 120);
} }
} }

View file

@ -48,7 +48,7 @@ pub enum DayWarning {
second: (NaiveTime, NaiveTime), second: (NaiveTime, NaiveTime),
}, },
/// Work logged outside any configured period. /// Work logged outside any configured period.
OutsidePeriod { hours_worked: f64 }, OutsidePeriod { minutes_worked: i64 },
} }
impl fmt::Display for DayWarning { impl fmt::Display for DayWarning {
@ -67,8 +67,12 @@ impl fmt::Display for DayWarning {
second.1.format("%H:%M") second.1.format("%H:%M")
) )
} }
DayWarning::OutsidePeriod { hours_worked } => { DayWarning::OutsidePeriod { minutes_worked } => {
write!(f, "{:.1}h worked (no period configured)", hours_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)] #[derive(Debug, Clone)]
pub struct DayReport { pub struct DayReport {
pub date: NaiveDate, pub date: NaiveDate,
pub expected_hours: f64, pub expected_minutes: i64,
pub actual_hours: f64, pub actual_minutes: i64,
pub day_type: DayType, pub day_type: DayType,
pub warnings: Vec<DayWarning>, pub warnings: Vec<DayWarning>,
} }
impl DayReport { 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 { Self {
date, date,
expected_hours, expected_minutes,
actual_hours, actual_minutes,
day_type, day_type,
warnings: Vec::new(), warnings: Vec::new(),
} }
@ -105,9 +114,9 @@ impl DayReport {
self self
} }
/// Calculate the difference between actual and expected hours. /// Calculate the difference between actual and expected minutes.
pub fn diff(&self) -> f64 { pub fn diff(&self) -> i64 {
self.actual_hours - self.expected_hours self.actual_minutes - self.expected_minutes
} }
/// Check if this day has any warnings. /// Check if this day has any warnings.
@ -138,18 +147,18 @@ impl MonthReport {
self self
} }
/// Calculate total expected hours for the month. /// Calculate total expected minutes for the month.
pub fn total_expected(&self) -> f64 { pub fn total_expected(&self) -> i64 {
self.days.iter().map(|d| d.expected_hours).sum() self.days.iter().map(|d| d.expected_minutes).sum()
} }
/// Calculate total actual hours for the month. /// Calculate total actual minutes for the month.
pub fn total_actual(&self) -> f64 { pub fn total_actual(&self) -> i64 {
self.days.iter().map(|d| d.actual_hours).sum() self.days.iter().map(|d| d.actual_minutes).sum()
} }
/// Calculate the difference for the month. /// Calculate the difference for the month.
pub fn diff(&self) -> f64 { pub fn diff(&self) -> i64 {
self.total_actual() - self.total_expected() self.total_actual() - self.total_expected()
} }
@ -178,7 +187,7 @@ impl ReportWarning {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct TimesheetReport { pub struct TimesheetReport {
pub months: Vec<MonthReport>, pub months: Vec<MonthReport>,
pub cumulative_balance: f64, pub cumulative_balance: i64,
pub warnings: Vec<ReportWarning>, pub warnings: Vec<ReportWarning>,
} }
@ -186,7 +195,7 @@ impl TimesheetReport {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
months: Vec::new(), months: Vec::new(),
cumulative_balance: 0.0, cumulative_balance: 0,
warnings: Vec::new(), warnings: Vec::new(),
} }
} }
@ -196,7 +205,7 @@ impl TimesheetReport {
self 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.cumulative_balance = balance;
self self
} }
@ -232,27 +241,30 @@ mod tests {
#[test] #[test]
fn test_day_report_diff() { fn test_day_report_diff() {
let report = DayReport::new(date(2026, 3, 2), 7.6, 8.2, DayType::Regular); // 7.6h = 456 min, 8.2h = 492 min, diff = 36 min
assert!((report.diff() - 0.6).abs() < 0.0001); let report = DayReport::new(date(2026, 3, 2), 456, 492, DayType::Regular);
assert_eq!(report.diff(), 36);
} }
#[test] #[test]
fn test_day_report_negative_diff() { fn test_day_report_negative_diff() {
let report = DayReport::new(date(2026, 3, 2), 7.6, 6.0, DayType::Regular); // 7.6h = 456 min, 6.0h = 360 min, diff = -96 min
assert!((report.diff() - (-1.6)).abs() < 0.0001); let report = DayReport::new(date(2026, 3, 2), 456, 360, DayType::Regular);
assert_eq!(report.diff(), -96);
} }
#[test] #[test]
fn test_month_report_totals() { 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![ 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, 2), 456, 492, DayType::Regular),
DayReport::new(date(2026, 3, 3), 7.6, 7.6, DayType::Regular), DayReport::new(date(2026, 3, 3), 456, 456, DayType::Regular),
DayReport::new(date(2026, 3, 4), 7.6, 6.0, DayType::Regular), DayReport::new(date(2026, 3, 4), 456, 360, DayType::Regular),
]); ]);
assert!((month.total_expected() - 22.8).abs() < 0.0001); assert_eq!(month.total_expected(), 1368); // 456 * 3
assert!((month.total_actual() - 21.8).abs() < 0.0001); assert_eq!(month.total_actual(), 1308); // 492 + 456 + 360
assert!((month.diff() - (-1.0)).abs() < 0.0001); assert_eq!(month.diff(), -60); // -1 hour
} }
#[test] #[test]
@ -281,13 +293,15 @@ mod tests {
#[test] #[test]
fn test_day_warning_outside_period_display() { 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)"); assert_eq!(warning.to_string(), "3.5h worked (no period configured)");
} }
#[test] #[test]
fn test_day_report_with_warnings() { 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 { DayWarning::OverlappingTimecards {
first: (time(9, 0), time(12, 30)), first: (time(9, 0), time(12, 30)),
second: (time(12, 0), time(13, 0)), second: (time(12, 0), time(13, 0)),