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

This commit is contained in:
Konstantin Fickel 2026-04-07 08:28:50 +02:00
parent a79111c650
commit d614d678af
Signed by: kfickel
GPG key ID: A793722F9933C1A5
3 changed files with 156 additions and 135 deletions

View file

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

View file

@ -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)),