29_timesheet-management #64

Merged
kfickel merged 9 commits from 29_timesheet-management into main 2026-04-02 18:17:37 +02:00
4 changed files with 352 additions and 0 deletions
Showing only changes of commit 92c8e9712a - Show all commits

91
Cargo.lock generated
View file

@ -163,6 +163,28 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "chrono-tz"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
dependencies = [
"chrono",
"chrono-tz-build",
"phf",
]
[[package]]
name = "chrono-tz-build"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
dependencies = [
"parse-zoneinfo",
"phf",
"phf_codegen",
]
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.6.0" version = "4.6.0"
@ -550,6 +572,53 @@ version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
[[package]]
name = "parse-zoneinfo"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24"
dependencies = [
"regex",
]
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_shared",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]] [[package]]
name = "pretty_assertions" name = "pretty_assertions"
version = "1.4.1" version = "1.4.1"
@ -613,6 +682,21 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.5.2" version = "0.5.2"
@ -751,11 +835,18 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "siphasher"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
[[package]] [[package]]
name = "streamd" name = "streamd"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"chrono-tz",
"clap", "clap",
"clap_complete", "clap_complete",
"directories", "directories",

View file

@ -18,6 +18,7 @@ pulldown-cmark = "0.13"
regex = "1" regex = "1"
once_cell = "1" once_cell = "1"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
chrono-tz = "0.9"
walkdir = "2" walkdir = "2"
indexmap = { version = "2", features = ["serde"] } indexmap = { version = "2", features = ["serde"] }
itertools = "0.14" itertools = "0.14"

258
src/timesheet/config.rs Normal file
View file

@ -0,0 +1,258 @@
use chrono::NaiveDate;
use serde::Deserialize;
use crate::error::StreamdError;
/// Configuration for timesheet periods and timezone.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct TimesheetConfig {
#[serde(default)]
pub periods: Vec<Period>,
}
/// A period of time with expected working hours per week.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Period {
pub start: NaiveDate,
pub end: NaiveDate,
pub hours_per_week: f64,
}
impl Period {
/// Calculate the expected hours per day (Mon-Fri distribution).
pub fn hours_per_day(&self) -> f64 {
self.hours_per_week / 5.0
}
/// Check if a date falls within this period.
pub fn contains(&self, date: NaiveDate) -> bool {
date >= self.start && date <= self.end
}
}
impl TimesheetConfig {
/// Validate the timesheet configuration.
/// - Ensures no periods overlap
/// - Ensures start <= end for each period
pub fn validate(&self) -> Result<(), StreamdError> {
// Check each period has valid date range
for period in &self.periods {
if period.start > period.end {
return Err(StreamdError::ConfigError(format!(
"Period start date {} is after end date {}",
period.start, period.end
)));
}
}
// Check for overlapping periods
for i in 0..self.periods.len() {
for j in (i + 1)..self.periods.len() {
if periods_overlap(&self.periods[i], &self.periods[j]) {
return Err(StreamdError::ConfigError(format!(
"Periods overlap: {}-{} and {}-{}",
self.periods[i].start,
self.periods[i].end,
self.periods[j].start,
self.periods[j].end
)));
}
}
}
Ok(())
}
/// Find the period that contains a given date.
pub fn find_period(&self, date: NaiveDate) -> Option<&Period> {
self.periods.iter().find(|p| p.contains(date))
}
}
/// Check if two periods overlap.
fn periods_overlap(a: &Period, b: &Period) -> bool {
a.start <= b.end && b.start <= a.end
}
/// Repository-level configuration loaded from .streamd.toml.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct RepositoryConfig {
#[serde(default)]
pub timezone: Option<String>,
#[serde(default)]
pub timesheet: Option<TimesheetConfig>,
}
#[cfg(test)]
mod tests {
use super::*;
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).unwrap()
}
#[test]
fn test_period_contains_date() {
let period = Period {
start: date(2026, 1, 1),
end: date(2026, 6, 30),
hours_per_week: 38.0,
};
assert!(period.contains(date(2026, 1, 1)));
assert!(period.contains(date(2026, 3, 15)));
assert!(period.contains(date(2026, 6, 30)));
assert!(!period.contains(date(2025, 12, 31)));
assert!(!period.contains(date(2026, 7, 1)));
}
#[test]
fn test_period_hours_per_day() {
let period = Period {
start: date(2026, 1, 1),
end: date(2026, 6, 30),
hours_per_week: 38.0,
};
assert!((period.hours_per_day() - 7.6).abs() < 0.0001);
}
#[test]
fn test_validate_valid_config() {
let config = TimesheetConfig {
periods: vec![
Period {
start: date(2026, 1, 1),
end: date(2026, 6, 30),
hours_per_week: 38.0,
},
Period {
start: date(2026, 7, 1),
end: date(2026, 12, 31),
hours_per_week: 40.0,
},
],
};
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_overlapping_periods() {
let config = TimesheetConfig {
periods: vec![
Period {
start: date(2026, 1, 1),
end: date(2026, 6, 30),
hours_per_week: 38.0,
},
Period {
start: date(2026, 6, 15),
end: date(2026, 12, 31),
hours_per_week: 40.0,
},
],
};
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("overlap"));
}
#[test]
fn test_validate_start_after_end() {
let config = TimesheetConfig {
periods: vec![Period {
start: date(2026, 6, 30),
end: date(2026, 1, 1),
hours_per_week: 38.0,
}],
};
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("after"));
}
#[test]
fn test_find_period() {
let config = TimesheetConfig {
periods: vec![
Period {
start: date(2026, 1, 1),
end: date(2026, 6, 30),
hours_per_week: 38.0,
},
Period {
start: date(2026, 7, 1),
end: date(2026, 12, 31),
hours_per_week: 40.0,
},
],
};
let period = config.find_period(date(2026, 3, 15));
assert!(period.is_some());
assert!((period.unwrap().hours_per_week - 38.0).abs() < 0.0001);
let period = config.find_period(date(2026, 9, 15));
assert!(period.is_some());
assert!((period.unwrap().hours_per_week - 40.0).abs() < 0.0001);
let period = config.find_period(date(2025, 12, 15));
assert!(period.is_none());
}
#[test]
fn test_empty_config_is_valid() {
let config = TimesheetConfig { periods: vec![] };
assert!(config.validate().is_ok());
}
#[test]
fn test_adjacent_periods_not_overlapping() {
let config = TimesheetConfig {
periods: vec![
Period {
start: date(2026, 1, 1),
end: date(2026, 6, 30),
hours_per_week: 38.0,
},
Period {
start: date(2026, 7, 1),
end: date(2026, 12, 31),
hours_per_week: 40.0,
},
],
};
assert!(config.validate().is_ok());
}
#[test]
fn test_deserialize_repository_config() {
let toml_str = r#"
timezone = "Europe/Berlin"
[timesheet]
[[timesheet.periods]]
start = "2026-01-01"
end = "2026-06-30"
hours_per_week = 38.0
[[timesheet.periods]]
start = "2026-07-01"
end = "2026-12-31"
hours_per_week = 40.0
"#;
let config: RepositoryConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.timezone, Some("Europe/Berlin".to_string()));
assert!(config.timesheet.is_some());
let timesheet = config.timesheet.unwrap();
assert_eq!(timesheet.periods.len(), 2);
assert!((timesheet.periods[0].hours_per_week - 38.0).abs() < 0.0001);
assert!((timesheet.periods[1].hours_per_week - 40.0).abs() < 0.0001);
}
}

View file

@ -1,7 +1,9 @@
mod config;
mod configuration; mod configuration;
mod extract; mod extract;
mod point_types; mod point_types;
pub use config::{Period, RepositoryConfig, TimesheetConfig};
pub use configuration::{BasicTimesheetConfiguration, TIMESHEET_DIMENSION_NAME, TIMESHEET_TAG}; pub use configuration::{BasicTimesheetConfiguration, TIMESHEET_DIMENSION_NAME, TIMESHEET_TAG};
pub use extract::extract_timesheets; pub use extract::extract_timesheets;
pub use point_types::TimesheetPointType; pub use point_types::TimesheetPointType;