29_timesheet-management #64
4 changed files with 352 additions and 0 deletions
91
Cargo.lock
generated
91
Cargo.lock
generated
|
|
@ -163,6 +163,28 @@ dependencies = [
|
|||
"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]]
|
||||
name = "clap"
|
||||
version = "4.6.0"
|
||||
|
|
@ -550,6 +572,53 @@ version = "4.3.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "pretty_assertions"
|
||||
version = "1.4.1"
|
||||
|
|
@ -613,6 +682,21 @@ version = "6.0.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
|
|
@ -751,11 +835,18 @@ version = "1.3.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
||||
|
||||
[[package]]
|
||||
name = "streamd"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"directories",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ pulldown-cmark = "0.13"
|
|||
regex = "1"
|
||||
once_cell = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono-tz = "0.9"
|
||||
walkdir = "2"
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
itertools = "0.14"
|
||||
|
|
|
|||
258
src/timesheet/config.rs
Normal file
258
src/timesheet/config.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
mod config;
|
||||
mod configuration;
|
||||
mod extract;
|
||||
mod point_types;
|
||||
|
||||
pub use config::{Period, RepositoryConfig, TimesheetConfig};
|
||||
pub use configuration::{BasicTimesheetConfiguration, TIMESHEET_DIMENSION_NAME, TIMESHEET_TAG};
|
||||
pub use extract::extract_timesheets;
|
||||
pub use point_types::TimesheetPointType;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue