feat(timesheet): add configuration model with period validation

Add TimesheetConfig and Period structs for configuring timesheet
periods with expected working hours per week. Include RepositoryConfig
for loading .streamd.toml with timezone and timesheet configuration.

Validation ensures:
- No overlapping periods
- Valid date ranges (start <= end)

Also adds chrono-tz dependency for timezone support.
This commit is contained in:
Konstantin Fickel 2026-03-29 22:03:24 +02:00
parent 38597e9fbb
commit 92c8e9712a
Signed by: kfickel
GPG key ID: A793722F9933C1A5
4 changed files with 352 additions and 0 deletions

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 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;