From 86433ca3dcd1647e91738cae30c2715095d95e5f Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Sun, 29 Mar 2026 22:03:24 +0200 Subject: [PATCH 01/21] 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. --- Cargo.lock | 91 ++++++++++++++ Cargo.toml | 1 + src/timesheet/config.rs | 258 ++++++++++++++++++++++++++++++++++++++++ src/timesheet/mod.rs | 2 + 4 files changed, 352 insertions(+) create mode 100644 src/timesheet/config.rs diff --git a/Cargo.lock b/Cargo.lock index ba51b84..e0a0cfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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.4.6" @@ -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", diff --git a/Cargo.toml b/Cargo.toml index d933964..8b8d502 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ pulldown-cmark = "0.12" regex = "1" once_cell = "1" chrono = { version = "0.4", features = ["serde"] } +chrono-tz = "0.9" walkdir = "2" indexmap = { version = "2", features = ["serde"] } itertools = "0.13" diff --git a/src/timesheet/config.rs b/src/timesheet/config.rs new file mode 100644 index 0000000..e743cc2 --- /dev/null +++ b/src/timesheet/config.rs @@ -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, +} + +/// 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, + #[serde(default)] + pub timesheet: Option, +} + +#[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); + } +} diff --git a/src/timesheet/mod.rs b/src/timesheet/mod.rs index 85bbab2..6d1c1d7 100644 --- a/src/timesheet/mod.rs +++ b/src/timesheet/mod.rs @@ -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; From 7abf056609c94d7fc0a13f987e6bb717b3e354f6 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Sun, 29 Mar 2026 22:04:29 +0200 Subject: [PATCH 02/21] feat(timesheet): add report data structures Add DayReport, DayType, DayWarning, MonthReport, and TimesheetReport structs for generating timesheet reports. These support: - Day types (Regular, SickLeave, Vacation, Holiday, FlexDay, Weekend, Missing, OutsidePeriod) - Warnings for missing days, overlapping timecards, and outside-period work - Monthly and cumulative calculations --- src/timesheet/mod.rs | 2 + src/timesheet/report.rs | 322 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 src/timesheet/report.rs diff --git a/src/timesheet/mod.rs b/src/timesheet/mod.rs index 6d1c1d7..4f0a9ad 100644 --- a/src/timesheet/mod.rs +++ b/src/timesheet/mod.rs @@ -2,8 +2,10 @@ mod config; mod configuration; mod extract; mod point_types; +mod report; 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; +pub use report::{DayReport, DayType, DayWarning, MonthReport, ReportWarning, TimesheetReport}; diff --git a/src/timesheet/report.rs b/src/timesheet/report.rs new file mode 100644 index 0000000..f96370d --- /dev/null +++ b/src/timesheet/report.rs @@ -0,0 +1,322 @@ +use chrono::{NaiveDate, NaiveTime}; +use std::fmt; + +/// Type of day for timesheet calculations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DayType { + /// Regular working day (Mon-Fri). + Regular, + /// Day with sick leave marker. + SickLeave, + /// Day with vacation marker. + Vacation, + /// Day with holiday marker. + Holiday, + /// Day with flex/undertime marker. + FlexDay, + /// Weekend day (Sat/Sun). + Weekend, + /// Missing: weekday with no entries and no explanation. + Missing, + /// Day outside any configured period. + OutsidePeriod, +} + +impl fmt::Display for DayType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DayType::Regular => write!(f, ""), + DayType::SickLeave => write!(f, "Sick Leave"), + DayType::Vacation => write!(f, "Vacation"), + DayType::Holiday => write!(f, "Holiday"), + DayType::FlexDay => write!(f, "Flex Day"), + DayType::Weekend => write!(f, "Weekend"), + DayType::Missing => write!(f, "\u{26a0} Missing"), + DayType::OutsidePeriod => write!(f, "Outside Period"), + } + } +} + +/// Warning associated with a specific day. +#[derive(Debug, Clone, PartialEq)] +pub enum DayWarning { + /// Weekday has no entries and no leave/holiday marker. + MissingWithoutExplanation, + /// Two timecards overlap. + OverlappingTimecards { + first: (NaiveTime, NaiveTime), + second: (NaiveTime, NaiveTime), + }, + /// Work logged outside any configured period. + OutsidePeriod { hours_worked: f64 }, +} + +impl fmt::Display for DayWarning { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DayWarning::MissingWithoutExplanation => { + write!(f, "No entries and no leave/holiday marker") + } + DayWarning::OverlappingTimecards { first, second } => { + write!( + f, + "{}-{} overlaps with {}-{}", + first.0.format("%H:%M"), + first.1.format("%H:%M"), + second.0.format("%H:%M"), + second.1.format("%H:%M") + ) + } + DayWarning::OutsidePeriod { hours_worked } => { + write!(f, "{:.1}h worked (no period configured)", hours_worked) + } + } + } +} + +/// Report for a single day. +#[derive(Debug, Clone)] +pub struct DayReport { + pub date: NaiveDate, + pub expected_hours: f64, + pub actual_hours: f64, + pub day_type: DayType, + pub warnings: Vec, +} + +impl DayReport { + pub fn new(date: NaiveDate, expected_hours: f64, actual_hours: f64, day_type: DayType) -> Self { + Self { + date, + expected_hours, + actual_hours, + day_type, + warnings: Vec::new(), + } + } + + pub fn with_warning(mut self, warning: DayWarning) -> Self { + self.warnings.push(warning); + self + } + + pub fn with_warnings(mut self, warnings: Vec) -> Self { + self.warnings = warnings; + self + } + + /// Calculate the difference between actual and expected hours. + pub fn diff(&self) -> f64 { + self.actual_hours - self.expected_hours + } + + /// Check if this day has any warnings. + pub fn has_warnings(&self) -> bool { + !self.warnings.is_empty() + } +} + +/// Report for a single month. +#[derive(Debug, Clone)] +pub struct MonthReport { + pub year: i32, + pub month: u32, + pub days: Vec, +} + +impl MonthReport { + pub fn new(year: i32, month: u32) -> Self { + Self { + year, + month, + days: Vec::new(), + } + } + + pub fn with_days(mut self, days: Vec) -> Self { + self.days = days; + 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 actual hours for the month. + pub fn total_actual(&self) -> f64 { + self.days.iter().map(|d| d.actual_hours).sum() + } + + /// Calculate the difference for the month. + pub fn diff(&self) -> f64 { + self.total_actual() - self.total_expected() + } + + /// Get the month name. + pub fn month_name(&self) -> &'static str { + match self.month { + 1 => "January", + 2 => "February", + 3 => "March", + 4 => "April", + 5 => "May", + 6 => "June", + 7 => "July", + 8 => "August", + 9 => "September", + 10 => "October", + 11 => "November", + 12 => "December", + _ => "Unknown", + } + } +} + +/// A warning to be displayed in the report summary. +#[derive(Debug, Clone)] +pub struct ReportWarning { + pub date: NaiveDate, + pub warning: DayWarning, +} + +impl ReportWarning { + pub fn new(date: NaiveDate, warning: DayWarning) -> Self { + Self { date, warning } + } +} + +/// Complete timesheet report. +#[derive(Debug, Clone)] +pub struct TimesheetReport { + pub months: Vec, + pub cumulative_balance: f64, + pub warnings: Vec, +} + +impl TimesheetReport { + pub fn new() -> Self { + Self { + months: Vec::new(), + cumulative_balance: 0.0, + warnings: Vec::new(), + } + } + + pub fn with_months(mut self, months: Vec) -> Self { + self.months = months; + self + } + + pub fn with_cumulative_balance(mut self, balance: f64) -> Self { + self.cumulative_balance = balance; + self + } + + pub fn with_warnings(mut self, warnings: Vec) -> Self { + self.warnings = warnings; + self + } + + /// Check if there are any warnings. + pub fn has_warnings(&self) -> bool { + !self.warnings.is_empty() + } +} + +impl Default for TimesheetReport { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn date(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).unwrap() + } + + fn time(hour: u32, min: u32) -> NaiveTime { + NaiveTime::from_hms_opt(hour, min, 0).unwrap() + } + + #[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); + } + + #[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); + } + + #[test] + fn test_month_report_totals() { + 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), + ]); + + 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); + } + + #[test] + fn test_month_name() { + let month = MonthReport::new(2026, 3); + assert_eq!(month.month_name(), "March"); + } + + #[test] + fn test_day_warning_overlap_display() { + let warning = DayWarning::OverlappingTimecards { + first: (time(9, 0), time(12, 30)), + second: (time(12, 0), time(13, 0)), + }; + assert_eq!(warning.to_string(), "09:00-12:30 overlaps with 12:00-13:00"); + } + + #[test] + fn test_day_warning_missing_display() { + let warning = DayWarning::MissingWithoutExplanation; + assert_eq!( + warning.to_string(), + "No entries and no leave/holiday marker" + ); + } + + #[test] + fn test_day_warning_outside_period_display() { + let warning = DayWarning::OutsidePeriod { hours_worked: 3.5 }; + 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( + DayWarning::OverlappingTimecards { + first: (time(9, 0), time(12, 30)), + second: (time(12, 0), time(13, 0)), + }, + ); + + assert!(report.has_warnings()); + assert_eq!(report.warnings.len(), 1); + } + + #[test] + fn test_timesheeet_report_has_warnings() { + let report = TimesheetReport::new().with_warnings(vec![ReportWarning::new( + date(2026, 3, 4), + DayWarning::MissingWithoutExplanation, + )]); + + assert!(report.has_warnings()); + } +} From 282d83bedbae5e391b895cb3c878ae3b1b999f77 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Sun, 29 Mar 2026 22:05:05 +0200 Subject: [PATCH 03/21] feat(timesheet): add overlap detection for timecards Add find_overlapping_timecards function to detect overlapping time ranges on the same day. This is used to generate warnings in the timesheet report. --- src/timesheet/mod.rs | 2 + src/timesheet/validation.rs | 111 ++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 src/timesheet/validation.rs diff --git a/src/timesheet/mod.rs b/src/timesheet/mod.rs index 4f0a9ad..0f76d12 100644 --- a/src/timesheet/mod.rs +++ b/src/timesheet/mod.rs @@ -3,9 +3,11 @@ mod configuration; mod extract; mod point_types; mod report; +mod validation; 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; pub use report::{DayReport, DayType, DayWarning, MonthReport, ReportWarning, TimesheetReport}; +pub use validation::find_overlapping_timecards; diff --git a/src/timesheet/validation.rs b/src/timesheet/validation.rs new file mode 100644 index 0000000..0eacf06 --- /dev/null +++ b/src/timesheet/validation.rs @@ -0,0 +1,111 @@ +use chrono::NaiveTime; + +use crate::models::Timecard; + +/// Check if two time ranges overlap. +fn timecards_overlap(a: &Timecard, b: &Timecard) -> bool { + a.from_time < b.to_time && b.from_time < a.to_time +} + +/// Find all overlapping timecard pairs for a day. +/// Returns a list of tuples containing the two overlapping timecards. +pub fn find_overlapping_timecards( + timecards: &[Timecard], +) -> Vec<((NaiveTime, NaiveTime), (NaiveTime, NaiveTime))> { + let mut overlaps = Vec::new(); + for i in 0..timecards.len() { + for j in (i + 1)..timecards.len() { + if timecards_overlap(&timecards[i], &timecards[j]) { + overlaps.push(( + (timecards[i].from_time, timecards[i].to_time), + (timecards[j].from_time, timecards[j].to_time), + )); + } + } + } + overlaps +} + +#[cfg(test)] +mod tests { + use super::*; + + fn time(hour: u32, min: u32) -> NaiveTime { + NaiveTime::from_hms_opt(hour, min, 0).unwrap() + } + + fn card(from_h: u32, from_m: u32, to_h: u32, to_m: u32) -> Timecard { + Timecard::new(time(from_h, from_m), time(to_h, to_m)) + } + + #[test] + fn test_no_overlap_adjacent_timecards() { + let timecards = vec![card(9, 0, 12, 0), card(13, 0, 17, 0)]; + let overlaps = find_overlapping_timecards(&timecards); + assert!(overlaps.is_empty()); + } + + #[test] + fn test_no_overlap_exact_touch() { + // Touching at 12:00 is NOT an overlap (end time = start time) + let timecards = vec![card(9, 0, 12, 0), card(12, 0, 17, 0)]; + let overlaps = find_overlapping_timecards(&timecards); + assert!(overlaps.is_empty()); + } + + #[test] + fn test_partial_overlap() { + let timecards = vec![card(9, 0, 12, 30), card(12, 0, 13, 0)]; + let overlaps = find_overlapping_timecards(&timecards); + assert_eq!(overlaps.len(), 1); + assert_eq!(overlaps[0].0, (time(9, 0), time(12, 30))); + assert_eq!(overlaps[0].1, (time(12, 0), time(13, 0))); + } + + #[test] + fn test_full_containment() { + // One timecard fully contains another + let timecards = vec![card(9, 0, 17, 0), card(10, 0, 11, 0)]; + let overlaps = find_overlapping_timecards(&timecards); + assert_eq!(overlaps.len(), 1); + } + + #[test] + fn test_exact_match_overlap() { + let timecards = vec![card(9, 0, 12, 0), card(9, 0, 12, 0)]; + let overlaps = find_overlapping_timecards(&timecards); + assert_eq!(overlaps.len(), 1); + } + + #[test] + fn test_multiple_overlaps_same_day() { + // First overlaps with second, and second overlaps with third + let timecards = vec![card(9, 0, 11, 0), card(10, 0, 13, 0), card(12, 0, 15, 0)]; + let overlaps = find_overlapping_timecards(&timecards); + // 9-11 overlaps with 10-13, and 10-13 overlaps with 12-15 + assert_eq!(overlaps.len(), 2); + } + + #[test] + fn test_single_timecard_no_overlap() { + let timecards = vec![card(9, 0, 17, 0)]; + let overlaps = find_overlapping_timecards(&timecards); + assert!(overlaps.is_empty()); + } + + #[test] + fn test_empty_timecards_no_overlap() { + let timecards: Vec = vec![]; + let overlaps = find_overlapping_timecards(&timecards); + assert!(overlaps.is_empty()); + } + + #[test] + fn test_three_timecards_all_overlap() { + // All three overlap with each other + let timecards = vec![card(9, 0, 15, 0), card(10, 0, 16, 0), card(11, 0, 17, 0)]; + let overlaps = find_overlapping_timecards(&timecards); + // 9-15 with 10-16, 9-15 with 11-17, and 10-16 with 11-17 + assert_eq!(overlaps.len(), 3); + } +} From 3429f2e65d0e94b1bc12b9bccdb97066cffdd280 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Sun, 29 Mar 2026 22:06:44 +0200 Subject: [PATCH 04/21] feat(timesheet): add report generation logic Implement the core report generation that: - Loads .streamd.toml configuration - Calculates expected hours based on periods and day types - Calculates actual hours following day type rules: - Sick leave: max(expected, worked) - Vacation: expected + worked - Flex day: 0 - Holiday: 0 expected - Generates warnings for missing days, overlapping timecards, and work outside configured periods - Calculates monthly and cumulative balances - Sorts months in descending order --- src/timesheet/generator.rs | 542 +++++++++++++++++++++++++++++++++++++ src/timesheet/mod.rs | 2 + 2 files changed, 544 insertions(+) create mode 100644 src/timesheet/generator.rs diff --git a/src/timesheet/generator.rs b/src/timesheet/generator.rs new file mode 100644 index 0000000..6ff191c --- /dev/null +++ b/src/timesheet/generator.rs @@ -0,0 +1,542 @@ +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +use chrono::{Datelike, NaiveDate, Weekday}; + +use crate::error::StreamdError; +use crate::models::{SpecialDayType, Timesheet}; + +use super::config::{RepositoryConfig, TimesheetConfig}; +use super::report::{DayReport, DayType, DayWarning, MonthReport, ReportWarning, TimesheetReport}; +use super::validation::find_overlapping_timecards; + +/// Load repository configuration from .streamd.toml file. +pub fn load_repository_config(base_folder: &Path) -> Result { + let config_path = base_folder.join(".streamd.toml"); + + if !config_path.exists() { + return Ok(RepositoryConfig::default()); + } + + let content = fs::read_to_string(&config_path)?; + let config: RepositoryConfig = toml::from_str(&content)?; + + // Validate timesheet config if present + if let Some(ref timesheet) = config.timesheet { + timesheet.validate()?; + } + + Ok(config) +} + +/// Calculate total hours worked from timecards. +fn calculate_timecard_hours(timesheet: &Timesheet) -> f64 { + timesheet + .timecards + .iter() + .map(|tc| { + let duration = tc.to_time - tc.from_time; + duration.num_minutes() as f64 / 60.0 + }) + .sum() +} + +/// Determine the day type based on timesheet data. +fn determine_day_type(date: NaiveDate, timesheet: Option<&Timesheet>, has_period: bool) -> DayType { + let weekday = date.weekday(); + + // Check weekend first + if weekday == Weekday::Sat || weekday == Weekday::Sun { + return DayType::Weekend; + } + + // Check if outside any period + if !has_period { + return DayType::OutsidePeriod; + } + + // Check special day types from timesheet + if let Some(ts) = timesheet { + if let Some(special) = ts.special_day_type { + match special { + SpecialDayType::Vacation => return DayType::Vacation, + SpecialDayType::Holiday => return DayType::Holiday, + SpecialDayType::Undertime => return DayType::FlexDay, + SpecialDayType::Weekend => return DayType::Weekend, + } + } + + if ts.is_sick_leave { + return DayType::SickLeave; + } + + // Has timecards or special type + return DayType::Regular; + } + + // No timesheet entry for a weekday = Missing + 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 { + 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, + } +} + +/// 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 { + 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, + } +} + +/// Generate a timesheet report from timesheets and configuration. +pub fn generate_report( + timesheets: &[Timesheet], + config: &TimesheetConfig, +) -> Result { + if config.periods.is_empty() { + return Ok(TimesheetReport::new()); + } + + // Index timesheets by date for quick lookup + let timesheets_by_date: HashMap = + timesheets.iter().map(|ts| (ts.date, ts)).collect(); + + // Find the date range to report on + let earliest_period_start = config.periods.iter().map(|p| p.start).min().unwrap(); + let latest_period_end = config.periods.iter().map(|p| p.end).max().unwrap(); + + // Limit to today + let today = chrono::Local::now().date_naive(); + let end_date = latest_period_end.min(today); + + // Group by month and generate reports + let mut month_reports: Vec = Vec::new(); + let mut all_warnings: Vec = Vec::new(); + let mut cumulative_balance: f64 = 0.0; + + // Iterate through all dates in the range + let mut current_date = earliest_period_start; + let mut current_month: Option<(i32, u32)> = None; + let mut current_month_days: Vec = Vec::new(); + + while current_date <= end_date { + let year = current_date.year(); + let month = current_date.month(); + + // Check if we've moved to a new month + if current_month != Some((year, month)) { + // Save the previous month if it had days + if let Some((prev_year, prev_month)) = current_month { + if !current_month_days.is_empty() { + let month_report = + MonthReport::new(prev_year, prev_month).with_days(current_month_days); + cumulative_balance += month_report.diff(); + month_reports.push(month_report); + } + } + current_month = Some((year, month)); + current_month_days = Vec::new(); + } + + // 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); + + // Get timesheet for this date + let timesheet = timesheets_by_date.get(¤t_date).copied(); + let timecard_hours = timesheet.map(calculate_timecard_hours).unwrap_or(0.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, + _ => 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); + + let mut day_report = + DayReport::new(current_date, expected_hours, actual_hours, day_type); + + // Collect warnings + let mut day_warnings: Vec = Vec::new(); + + // Warning: Missing without explanation + if day_type == DayType::Missing { + day_warnings.push(DayWarning::MissingWithoutExplanation); + all_warnings.push(ReportWarning::new( + current_date, + DayWarning::MissingWithoutExplanation, + )); + } + + // Warning: Overlapping timecards + if let Some(ts) = timesheet { + let overlaps = find_overlapping_timecards(&ts.timecards); + for (first, second) in overlaps { + let warning = DayWarning::OverlappingTimecards { first, second }; + day_warnings.push(warning.clone()); + all_warnings.push(ReportWarning::new(current_date, warning)); + } + } + + // Warning: Work outside period + if day_type == DayType::OutsidePeriod && timecard_hours > 0.0 { + let warning = DayWarning::OutsidePeriod { + hours_worked: timecard_hours, + }; + day_warnings.push(warning.clone()); + all_warnings.push(ReportWarning::new(current_date, warning)); + } + + day_report = day_report.with_warnings(day_warnings); + current_month_days.push(day_report); + } + + // Move to next day + current_date = current_date.succ_opt().unwrap_or(current_date); + if current_date == end_date && current_date <= latest_period_end { + // We've hit end_date, process this day then stop + } + } + + // Don't forget the last month + if let Some((year, month)) = current_month { + if !current_month_days.is_empty() { + let month_report = MonthReport::new(year, month).with_days(current_month_days); + cumulative_balance += month_report.diff(); + month_reports.push(month_report); + } + } + + // Sort months in descending order (most recent first) + month_reports.sort_by(|a, b| { + let a_date = (a.year, a.month); + let b_date = (b.year, b.month); + b_date.cmp(&a_date) + }); + + Ok(TimesheetReport::new() + .with_months(month_reports) + .with_cumulative_balance(cumulative_balance) + .with_warnings(all_warnings)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::Timecard; + use crate::timesheet::Period; + use chrono::NaiveTime; + + fn date(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).unwrap() + } + + fn time(hour: u32, min: u32) -> NaiveTime { + NaiveTime::from_hms_opt(hour, min, 0).unwrap() + } + + fn make_timesheet(date: NaiveDate, cards: Vec<(u32, u32, u32, u32)>) -> Timesheet { + Timesheet { + date, + is_sick_leave: false, + special_day_type: None, + timecards: cards + .into_iter() + .map(|(fh, fm, th, tm)| Timecard::new(time(fh, fm), time(th, tm))) + .collect(), + } + } + + fn make_config(start: NaiveDate, end: NaiveDate, hours_per_week: f64) -> TimesheetConfig { + TimesheetConfig { + periods: vec![Period { + start, + end, + hours_per_week, + }], + } + } + + #[test] + fn test_calculate_timecard_hours() { + 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); + } + + #[test] + fn test_calculate_timecard_hours_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); + } + + #[test] + fn test_day_type_regular() { + // Monday with timesheet + let ts = make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)]); + let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true); + assert_eq!(day_type, DayType::Regular); + } + + #[test] + fn test_day_type_weekend() { + // Saturday + let day_type = determine_day_type(date(2026, 3, 7), None, true); + assert_eq!(day_type, DayType::Weekend); + } + + #[test] + fn test_day_type_missing() { + // Monday with no timesheet + let day_type = determine_day_type(date(2026, 3, 2), None, true); + assert_eq!(day_type, DayType::Missing); + } + + #[test] + fn test_day_type_vacation() { + let ts = Timesheet { + date: date(2026, 3, 2), + is_sick_leave: false, + special_day_type: Some(SpecialDayType::Vacation), + timecards: vec![], + }; + let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true); + assert_eq!(day_type, DayType::Vacation); + } + + #[test] + fn test_day_type_sick_leave() { + let ts = Timesheet { + date: date(2026, 3, 2), + is_sick_leave: true, + special_day_type: None, + timecards: vec![], + }; + let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true); + assert_eq!(day_type, DayType::SickLeave); + } + + #[test] + fn test_day_type_holiday() { + let ts = Timesheet { + date: date(2026, 3, 2), + is_sick_leave: false, + special_day_type: Some(SpecialDayType::Holiday), + timecards: vec![], + }; + let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true); + assert_eq!(day_type, DayType::Holiday); + } + + #[test] + fn test_day_type_flex_day() { + let ts = Timesheet { + date: date(2026, 3, 2), + is_sick_leave: false, + special_day_type: Some(SpecialDayType::Undertime), + timecards: vec![], + }; + let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true); + assert_eq!(day_type, DayType::FlexDay); + } + + #[test] + fn test_day_type_outside_period() { + let day_type = determine_day_type(date(2026, 3, 2), None, false); + assert_eq!(day_type, DayType::OutsidePeriod); + } + + #[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); + } + + #[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); + } + + #[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); + } + + #[test] + fn test_actual_hours_regular() { + let hours = calculate_actual_hours(DayType::Regular, 8.0, 7.6); + assert!((hours - 8.0).abs() < 0.0001); + } + + #[test] + fn test_actual_hours_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); + } + + #[test] + fn test_actual_hours_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); + } + + #[test] + fn test_actual_hours_vacation() { + // Vacation: expected + worked + let hours = calculate_actual_hours(DayType::Vacation, 2.0, 7.6); + assert!((hours - 9.6).abs() < 0.0001); + } + + #[test] + fn test_actual_hours_flex_day() { + // Flex day: always 0 + let hours = calculate_actual_hours(DayType::FlexDay, 5.0, 7.6); + assert!((hours - 0.0).abs() < 0.0001); + } + + #[test] + fn test_generate_report_empty_config() { + let config = TimesheetConfig { periods: vec![] }; + let report = generate_report(&[], &config).unwrap(); + assert!(report.months.is_empty()); + } + + #[test] + fn test_generate_report_single_day() { + // Monday 2026-03-02 + let timesheets = vec![make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)])]; + let config = make_config(date(2026, 3, 2), date(2026, 3, 2), 40.0); + + let report = generate_report(×heets, &config).unwrap(); + + assert_eq!(report.months.len(), 1); + assert_eq!(report.months[0].days.len(), 1); + + 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.day_type, DayType::Regular); + } + + #[test] + fn test_generate_report_detects_missing_day() { + // Period covers Mon-Tue, but only Mon has timesheet + let timesheets = vec![make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)])]; + // March 2 is Monday, March 3 is Tuesday + let config = make_config(date(2026, 3, 2), date(2026, 3, 3), 40.0); + + let report = generate_report(×heets, &config).unwrap(); + + assert_eq!(report.months[0].days.len(), 2); + + // First day (Mon) should be regular + assert_eq!(report.months[0].days[0].day_type, DayType::Regular); + + // Second day (Tue) should be missing + assert_eq!(report.months[0].days[1].day_type, DayType::Missing); + assert!(report.months[0].days[1].has_warnings()); + } + + #[test] + fn test_generate_report_weekend_excluded_if_no_work() { + // Period covers Mon-Sun, but only Mon has timesheet + let timesheets = vec![make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)])]; + let config = make_config(date(2026, 3, 2), date(2026, 3, 8), 40.0); + + let report = generate_report(×heets, &config).unwrap(); + + // Should only include Mon-Fri (5 days), not Sat-Sun + let days = &report.months[0].days; + for day in days { + let weekday = day.date.weekday(); + assert!(weekday != Weekday::Sat && weekday != Weekday::Sun); + } + } + + #[test] + fn test_generate_report_weekend_included_if_work() { + // Work on Saturday + let timesheets = vec![ + make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)]), + make_timesheet(date(2026, 3, 7), vec![(10, 0, 14, 0)]), // Saturday + ]; + let config = make_config(date(2026, 3, 2), date(2026, 3, 8), 40.0); + + let report = generate_report(×heets, &config).unwrap(); + + // Should include Saturday + let has_saturday = report.months[0] + .days + .iter() + .any(|d| d.date == date(2026, 3, 7)); + assert!(has_saturday); + } + + #[test] + fn test_generate_report_overlapping_timecards_warning() { + let ts = Timesheet { + date: date(2026, 3, 2), + is_sick_leave: false, + special_day_type: None, + timecards: vec![ + Timecard::new(time(9, 0), time(12, 30)), + Timecard::new(time(12, 0), time(13, 0)), + ], + }; + let config = make_config(date(2026, 3, 2), date(2026, 3, 2), 40.0); + + let report = generate_report(&[ts], &config).unwrap(); + + assert!(report.has_warnings()); + assert!(report.months[0].days[0].has_warnings()); + } + + #[test] + fn test_generate_report_cumulative_balance() { + // Two days: one with 8h (expected 8h), one with 10h (expected 8h) + let timesheets = vec![ + make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)]), // 8h + make_timesheet(date(2026, 3, 3), vec![(8, 0, 18, 0)]), // 10h + ]; + let config = make_config(date(2026, 3, 2), date(2026, 3, 3), 40.0); + + let report = generate_report(×heets, &config).unwrap(); + + // Balance should be +2h (18h actual - 16h expected) + assert!((report.cumulative_balance - 2.0).abs() < 0.0001); + } +} diff --git a/src/timesheet/mod.rs b/src/timesheet/mod.rs index 0f76d12..9e4f000 100644 --- a/src/timesheet/mod.rs +++ b/src/timesheet/mod.rs @@ -1,6 +1,7 @@ mod config; mod configuration; mod extract; +mod generator; mod point_types; mod report; mod validation; @@ -8,6 +9,7 @@ mod validation; pub use config::{Period, RepositoryConfig, TimesheetConfig}; pub use configuration::{BasicTimesheetConfiguration, TIMESHEET_DIMENSION_NAME, TIMESHEET_TAG}; pub use extract::extract_timesheets; +pub use generator::{generate_report, load_repository_config}; pub use point_types::TimesheetPointType; pub use report::{DayReport, DayType, DayWarning, MonthReport, ReportWarning, TimesheetReport}; pub use validation::find_overlapping_timecards; From 1119d918541f3329255bec1b5b81fd76c382ab1d Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Sun, 29 Mar 2026 22:07:58 +0200 Subject: [PATCH 05/21] feat(timesheet): add formatted report output to CLI Replace CSV output with formatted table display showing: - Monthly breakdown with expected/actual hours per day - Day types (Regular, Sick Leave, Vacation, Holiday, Flex Day, etc.) - Warning indicators for missing days and overlapping timecards - Monthly summaries with total expected/actual hours - Cumulative balance across all months - Detailed warnings section at the end Shows helpful message when no .streamd.toml configuration is found. --- src/cli/commands/timesheet.rs | 264 +++++++++++++++++++++++++++++++--- 1 file changed, 247 insertions(+), 17 deletions(-) diff --git a/src/cli/commands/timesheet.rs b/src/cli/commands/timesheet.rs index f727edc..9fe413f 100644 --- a/src/cli/commands/timesheet.rs +++ b/src/cli/commands/timesheet.rs @@ -1,5 +1,7 @@ use std::fs; +use std::path::Path; +use chrono::Datelike; use walkdir::WalkDir; use crate::config::Settings; @@ -7,13 +9,15 @@ use crate::error::StreamdError; use crate::extract::parse_markdown_file; use crate::localize::localize_stream_file; use crate::models::LocalizedShard; -use crate::timesheet::{extract_timesheets, BasicTimesheetConfiguration}; +use crate::timesheet::{ + extract_timesheets, generate_report, load_repository_config, BasicTimesheetConfiguration, + DayType, DayWarning, MonthReport, TimesheetReport, +}; -fn all_files() -> Result, StreamdError> { - let settings = Settings::load()?; +fn load_all_shards(base_folder: &Path) -> Result, StreamdError> { let mut shards = Vec::new(); - for entry in WalkDir::new(&settings.base_folder) + for entry in WalkDir::new(base_folder) .max_depth(1) .into_iter() .filter_map(|e| e.ok()) @@ -33,20 +37,246 @@ fn all_files() -> Result, StreamdError> { Ok(shards) } -pub fn run() -> Result<(), StreamdError> { - let all_shards = all_files()?; - let mut sheets = extract_timesheets(&all_shards)?; - sheets.sort_by_key(|s| s.date); - - for sheet in sheets { - println!("{}", sheet.date); - let times: Vec = sheet - .timecards - .iter() - .map(|card| format!("{},{}", card.from_time, card.to_time)) - .collect(); - println!("{}", times.join(",")); +/// Format hours with sign for display. +fn format_diff(hours: f64) -> String { + if hours >= 0.0 { + format!("+{:.1}h", hours) + } else { + format!("{:.1}h", hours) } +} + +/// Format hours for display without sign. +fn format_hours(hours: f64) -> String { + format!("{:.1}h", hours) +} + +/// Get the weekday abbreviation. +fn weekday_abbrev(date: chrono::NaiveDate) -> &'static str { + match date.weekday() { + chrono::Weekday::Mon => "Mon", + chrono::Weekday::Tue => "Tue", + chrono::Weekday::Wed => "Wed", + chrono::Weekday::Thu => "Thu", + chrono::Weekday::Fri => "Fri", + chrono::Weekday::Sat => "Sat", + chrono::Weekday::Sun => "Sun", + } +} + +/// Print the timesheet report header. +fn print_header() { + println!( + "\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}" + ); + println!(" TIMESHEET REPORT"); + println!( + "\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}" + ); + println!(); +} + +/// Print a month report. +fn print_month(month: &MonthReport) { + let diff_str = format_diff(month.diff()); + let month_title = format!("{} {}", month.month_name(), month.year); + + // Month header with diff + print!("\u{2550}\u{2550}\u{2550} {} ", month_title); + let padding = 52 - month_title.len() - diff_str.len(); + for _ in 0..padding { + print!("\u{2550}"); + } + println!(" Diff: {} \u{2550}\u{2550}\u{2550}", diff_str); + println!(); + + // Column headers + println!(" Date Day Expected Actual Diff Type"); + println!( + " \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}" + ); + + // Day rows + for day in &month.days { + let date_str = day.date.format("%Y-%m-%d").to_string(); + let weekday = weekday_abbrev(day.date); + let expected = format_hours(day.expected_hours); + let actual = format_hours(day.actual_hours); + let diff = format_diff(day.diff()); + + let type_str = match day.day_type { + DayType::Regular => String::new(), + DayType::Missing if day.has_warnings() => "\u{26a0} Missing".to_string(), + _ => day.day_type.to_string(), + }; + + // Add overlap warning indicator + let type_str = if day + .warnings + .iter() + .any(|w| matches!(w, DayWarning::OverlappingTimecards { .. })) + { + if type_str.is_empty() { + "\u{26a0} Overlap".to_string() + } else { + format!("{} \u{26a0}", type_str) + } + } else { + type_str + }; + + println!( + " {} {} {:>7} {:>7} {:>6} {}", + date_str, weekday, expected, actual, diff, type_str + ); + } + + // Monthly totals + println!( + " \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}" + ); + println!( + " Monthly: {:>7} {:>7} {:>6}", + format_hours(month.total_expected()), + format_hours(month.total_actual()), + format_diff(month.diff()) + ); + println!(); +} + +/// Print the cumulative balance. +fn print_cumulative_balance(balance: f64) { + println!( + "\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}" + ); + println!( + " CUMULATIVE BALANCE: {}", + format_diff(balance) + ); + println!( + "\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}" + ); +} + +/// Print warnings section. +fn print_warnings(report: &TimesheetReport) { + if !report.has_warnings() { + return; + } + + println!(); + println!("\u{26a0} Warnings:"); + println!(); + + // Group warnings by type + let missing_warnings: Vec<_> = report + .warnings + .iter() + .filter(|w| matches!(w.warning, DayWarning::MissingWithoutExplanation)) + .collect(); + + let overlap_warnings: Vec<_> = report + .warnings + .iter() + .filter(|w| matches!(w.warning, DayWarning::OverlappingTimecards { .. })) + .collect(); + + let outside_period_warnings: Vec<_> = report + .warnings + .iter() + .filter(|w| matches!(w.warning, DayWarning::OutsidePeriod { .. })) + .collect(); + + if !missing_warnings.is_empty() { + println!(" Missing days without explanation:"); + for w in &missing_warnings { + let weekday = weekday_abbrev(w.date); + println!( + " - {} ({}): No entries and no leave/holiday marker", + w.date.format("%Y-%m-%d"), + weekday + ); + } + println!(); + } + + if !overlap_warnings.is_empty() { + println!(" Overlapping timecards:"); + for w in &overlap_warnings { + if let DayWarning::OverlappingTimecards { first, second } = &w.warning { + println!( + " - {}: {}-{} overlaps with {}-{}", + w.date.format("%Y-%m-%d"), + first.0.format("%H:%M"), + first.1.format("%H:%M"), + second.0.format("%H:%M"), + second.1.format("%H:%M") + ); + } + } + println!(); + } + + if !outside_period_warnings.is_empty() { + println!(" Work logged outside configured periods:"); + for w in &outside_period_warnings { + if let DayWarning::OutsidePeriod { hours_worked } = &w.warning { + println!( + " - {}: {:.1}h worked (no period configured)", + w.date.format("%Y-%m-%d"), + hours_worked + ); + } + } + println!(); + } +} + +pub fn run() -> Result<(), StreamdError> { + let settings = Settings::load()?; + let base_folder = Path::new(&settings.base_folder); + + // Load repository configuration + let repo_config = load_repository_config(base_folder)?; + + // Check if timesheet is configured + let timesheet_config = match repo_config.timesheet { + Some(config) => config, + None => { + println!("No timesheet configuration found in .streamd.toml"); + println!(); + println!("Add a [timesheet] section with periods to enable timesheet reporting:"); + println!(); + println!(" [timesheet]"); + println!(" [[timesheet.periods]]"); + println!(" start = \"2026-01-01\""); + println!(" end = \"2026-12-31\""); + println!(" hours_per_week = 40.0"); + return Ok(()); + } + }; + + // Load all markdown files and extract timesheets + let all_shards = load_all_shards(base_folder)?; + let timesheets = extract_timesheets(&all_shards)?; + + // Generate the report + let report = generate_report(×heets, ×heet_config)?; + + if report.months.is_empty() { + println!("No timesheet data found for the configured periods."); + return Ok(()); + } + + // Print the report + print_header(); + + for month in &report.months { + print_month(month); + } + + print_cumulative_balance(report.cumulative_balance); + print_warnings(&report); Ok(()) } From 14ae2909e45346d59936b898e8b8924aeaf76f8a Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Sun, 29 Mar 2026 22:08:53 +0200 Subject: [PATCH 06/21] docs: add timesheet management documentation Update README.md with: - Repository configuration section for .streamd.toml - Timesheet periods configuration example - Description of timesheet report features Update REQUIREMENTS.md with: - R18a: Timesheet report configuration format - R18b: Day type rules for expected/actual hours calculation - R18c: Timesheet report warning types - Updated R20 command description --- README.md | 29 ++++++++++++++++++++++++++++- REQUIREMENTS.md | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 474663c..e2e0bcd 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,34 @@ Within files, `@`-prefixed markers at the beginning of paragraphs or headings de ## Configuration -Streamd reads its configuration from `~/.config/streamd/config.toml` (XDG standard). The main setting is `base_folder`, which points to the directory containing your stream files (defaults to the current working directory). +### User Configuration + +Streamd reads its user configuration from `~/.config/streamd/config.toml` (XDG standard). The main setting is `base_folder`, which points to the directory containing your stream files (defaults to the current working directory). + +### Repository Configuration + +For timesheet reporting, create a `.streamd.toml` file in your stream files directory: + +```toml +timezone = "Europe/Berlin" # Optional: timezone for day boundaries + +[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 +``` + +The timesheet command will calculate expected vs actual working hours based on these periods, showing: +- Daily breakdown with expected/actual hours +- Special day types (sick leave, vacation, holidays, flex days) +- Warnings for missing entries and overlapping timecards +- Monthly and cumulative balance ## Usage diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 2ba44bd..04e8898 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -320,6 +320,52 @@ Process timesheet shards chronologically per day: **Validation:** The last entry of each day MUST be a `Break` (cannot end day while working). +### R18a: Timesheet Report Configuration + +The `.streamd.toml` file in the base folder configures timesheet periods: + +```toml +timezone = "Europe/Berlin" # Optional timezone for day boundaries + +[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 +``` + +**Configuration Rules:** +- Dates use ISO 8601 format (`YYYY-MM-DD`) +- Periods MUST NOT overlap (validation error if they do) +- Gaps between periods are allowed — days in gaps have 0 expected hours +- `hours_per_week` is distributed over Mon-Fri (e.g., 38h/week = 7.6h/day) + +### R18b: Day Type Rules + +| Day Type | Expected Hours | Actual Hours | +|----------|---------------|--------------| +| Regular work day | period.hours_per_week / 5 | Sum of timecards | +| Weekend (Sat/Sun) | 0 | Sum of timecards (hidden if 0) | +| Sick Leave (@SickLeave) | Normal expected | max(expected, worked) | +| Vacation (@VacationDay) | Normal expected | expected + worked | +| Holiday (@Holiday) | 0 | Sum of timecards | +| Flex Day (@UndertimeDay) | Normal expected | 0 | +| Day in gap (no period) | 0 | Sum of timecards + warning | +| Missing (no entries) | Normal expected | 0 + warning | + +### R18c: Timesheet Report Warnings + +The report generates warnings for: + +1. **Missing days without explanation**: A weekday within a configured period has no timecard entries and no special day type marker +2. **Overlapping timecards**: Two or more timecards on the same day have overlapping time ranges +3. **Work outside configured periods**: Work logged on a day that falls outside all configured periods + --- ## Query System @@ -343,7 +389,7 @@ Provide recursive search through the shard tree: | `streamd new` | Create new timestamped file, open editor, rename with markers on close | | `streamd todo` | List all shards with `task: "open"` | | `streamd edit [n]` | Edit nth file (supports negative indexing for recent files) | -| `streamd timesheet` | Extract and export timesheet data as CSV | +| `streamd timesheet` | Generate formatted timesheet report with expected/actual hours | | `streamd completions ` | Generate shell completions (bash, zsh, fish, elvish, powershell) | --- From f29840313dca946eabf2c1d008279df9bc59c723 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 30 Mar 2026 00:06:20 +0000 Subject: [PATCH 07/21] chore(deps): pin dependencies --- Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d933964..ce3e4fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,8 +24,8 @@ itertools = "0.13" directories = "5" [dev-dependencies] -pretty_assertions = "1" -tempfile = "3" +pretty_assertions = "=1.4.1" +tempfile = "=3.27.0" [[bin]] name = "streamd" From a7579a7083e4556c3c4cadeed57de8539fca90c7 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 30 Mar 2026 00:06:24 +0000 Subject: [PATCH 08/21] fix(deps): update rust crate itertools to 0.14 --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ba51b84..5210874 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -407,9 +407,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] diff --git a/Cargo.toml b/Cargo.toml index ce3e4fb..02e1c93 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ once_cell = "1" chrono = { version = "0.4", features = ["serde"] } walkdir = "2" indexmap = { version = "2", features = ["serde"] } -itertools = "0.13" +itertools = "0.14" directories = "5" [dev-dependencies] From 3b2d8c7e6308788d905fba18777b5e7d09908835 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 30 Mar 2026 00:06:29 +0000 Subject: [PATCH 09/21] fix(deps): update rust crate pulldown-cmark to 0.13 --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5210874..b1fc9b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -581,9 +581,9 @@ dependencies = [ [[package]] name = "pulldown-cmark" -version = "0.12.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" +checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" dependencies = [ "bitflags", "getopts", diff --git a/Cargo.toml b/Cargo.toml index 02e1c93..1f6ee34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] } toml = "0.8" thiserror = "2" miette = { version = "7", features = ["fancy"] } -pulldown-cmark = "0.12" +pulldown-cmark = "0.13" regex = "1" once_cell = "1" chrono = { version = "0.4", features = ["serde"] } From d548842fbb46d9ef5683ec3e23364cd0a929bd41 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 30 Mar 2026 00:06:34 +0000 Subject: [PATCH 10/21] fix(deps): update rust crate toml to 0.9 --- Cargo.lock | 51 ++++++++++++++++++++++++++------------------------- Cargo.toml | 2 +- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b1fc9b9..70ebb82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -738,11 +738,11 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.9" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -886,44 +886,42 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.23" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "serde", + "indexmap", + "serde_core", "serde_spanned", "toml_datetime", - "toml_edit", + "toml_parser", + "toml_writer", + "winnow 0.7.15", ] [[package]] name = "toml_datetime" -version = "0.6.11" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ - "serde", + "serde_core", ] [[package]] -name = "toml_edit" -version = "0.22.27" +name = "toml_parser" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", - "winnow", + "winnow 1.0.0", ] [[package]] -name = "toml_write" -version = "0.1.2" +name = "toml_writer" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" [[package]] name = "unicase" @@ -1228,9 +1226,12 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" [[package]] name = "wit-bindgen" diff --git a/Cargo.toml b/Cargo.toml index 1f6ee34..18a4b86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ repository = "https://github.com/konstantinfickel/streamd" clap = { version = "4", features = ["derive", "env"] } clap_complete = "4" serde = { version = "1", features = ["derive"] } -toml = "0.8" +toml = "0.9" thiserror = "2" miette = { version = "7", features = ["fancy"] } pulldown-cmark = "0.13" From 55d55ffb25639ebb9cae9b86437b4475278c84c1 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 31 Mar 2026 00:08:52 +0000 Subject: [PATCH 11/21] fix(deps): update rust crate toml to v1 --- Cargo.lock | 18 ++++++------------ Cargo.toml | 2 +- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 70ebb82..43c7d32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -886,9 +886,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.12+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" dependencies = [ "indexmap", "serde_core", @@ -896,14 +896,14 @@ dependencies = [ "toml_datetime", "toml_parser", "toml_writer", - "winnow 0.7.15", + "winnow", ] [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" dependencies = [ "serde_core", ] @@ -914,7 +914,7 @@ version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ - "winnow 1.0.0", + "winnow", ] [[package]] @@ -1221,12 +1221,6 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" - [[package]] name = "winnow" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index 18a4b86..124e3ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ repository = "https://github.com/konstantinfickel/streamd" clap = { version = "4", features = ["derive", "env"] } clap_complete = "4" serde = { version = "1", features = ["derive"] } -toml = "0.9" +toml = "1.0" thiserror = "2" miette = { version = "7", features = ["fancy"] } pulldown-cmark = "0.13" From 38597e9fbb333bf3fed810dd1a3429f8a964f5c7 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 31 Mar 2026 00:08:48 +0000 Subject: [PATCH 12/21] fix(deps): update rust crate directories to v6 --- Cargo.lock | 120 ++++++++--------------------------------------------- Cargo.toml | 2 +- 2 files changed, 18 insertions(+), 104 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 43c7d32..91789a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,7 +71,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -82,7 +82,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -232,23 +232,23 @@ checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" [[package]] name = "directories" -version = "5.0.1" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" dependencies = [ "dirs-sys", ] [[package]] name = "dirs-sys" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.48.0", + "windows-sys", ] [[package]] @@ -270,7 +270,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -615,13 +615,13 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "redox_users" -version = "0.4.6" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 1.0.69", + "thiserror", ] [[package]] @@ -669,7 +669,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -768,7 +768,7 @@ dependencies = [ "regex", "serde", "tempfile", - "thiserror 2.0.18", + "thiserror", "toml", "walkdir", ] @@ -821,7 +821,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -831,7 +831,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -844,33 +844,13 @@ dependencies = [ "unicode-width 0.2.2", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] @@ -1084,7 +1064,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1146,15 +1126,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -1164,63 +1135,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "winnow" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index 124e3ab..4cc5e27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ chrono = { version = "0.4", features = ["serde"] } walkdir = "2" indexmap = { version = "2", features = ["serde"] } itertools = "0.14" -directories = "5" +directories = "6" [dev-dependencies] pretty_assertions = "=1.4.1" From 92c8e9712ae4a2a36a06d827caff7b296dc2e977 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Sun, 29 Mar 2026 22:03:24 +0200 Subject: [PATCH 13/21] 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. --- Cargo.lock | 91 ++++++++++++++ Cargo.toml | 1 + src/timesheet/config.rs | 258 ++++++++++++++++++++++++++++++++++++++++ src/timesheet/mod.rs | 2 + 4 files changed, 352 insertions(+) create mode 100644 src/timesheet/config.rs diff --git a/Cargo.lock b/Cargo.lock index 91789a9..105e70e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 4cc5e27..9e2ef44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/timesheet/config.rs b/src/timesheet/config.rs new file mode 100644 index 0000000..e743cc2 --- /dev/null +++ b/src/timesheet/config.rs @@ -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, +} + +/// 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, + #[serde(default)] + pub timesheet: Option, +} + +#[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); + } +} diff --git a/src/timesheet/mod.rs b/src/timesheet/mod.rs index 85bbab2..6d1c1d7 100644 --- a/src/timesheet/mod.rs +++ b/src/timesheet/mod.rs @@ -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; From 92ca364e558d238b24fce13b75de6f05a8cc6ded Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Sun, 29 Mar 2026 22:04:29 +0200 Subject: [PATCH 14/21] feat(timesheet): add report data structures Add DayReport, DayType, DayWarning, MonthReport, and TimesheetReport structs for generating timesheet reports. These support: - Day types (Regular, SickLeave, Vacation, Holiday, FlexDay, Weekend, Missing, OutsidePeriod) - Warnings for missing days, overlapping timecards, and outside-period work - Monthly and cumulative calculations --- src/timesheet/mod.rs | 2 + src/timesheet/report.rs | 322 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 src/timesheet/report.rs diff --git a/src/timesheet/mod.rs b/src/timesheet/mod.rs index 6d1c1d7..4f0a9ad 100644 --- a/src/timesheet/mod.rs +++ b/src/timesheet/mod.rs @@ -2,8 +2,10 @@ mod config; mod configuration; mod extract; mod point_types; +mod report; 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; +pub use report::{DayReport, DayType, DayWarning, MonthReport, ReportWarning, TimesheetReport}; diff --git a/src/timesheet/report.rs b/src/timesheet/report.rs new file mode 100644 index 0000000..f96370d --- /dev/null +++ b/src/timesheet/report.rs @@ -0,0 +1,322 @@ +use chrono::{NaiveDate, NaiveTime}; +use std::fmt; + +/// Type of day for timesheet calculations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DayType { + /// Regular working day (Mon-Fri). + Regular, + /// Day with sick leave marker. + SickLeave, + /// Day with vacation marker. + Vacation, + /// Day with holiday marker. + Holiday, + /// Day with flex/undertime marker. + FlexDay, + /// Weekend day (Sat/Sun). + Weekend, + /// Missing: weekday with no entries and no explanation. + Missing, + /// Day outside any configured period. + OutsidePeriod, +} + +impl fmt::Display for DayType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DayType::Regular => write!(f, ""), + DayType::SickLeave => write!(f, "Sick Leave"), + DayType::Vacation => write!(f, "Vacation"), + DayType::Holiday => write!(f, "Holiday"), + DayType::FlexDay => write!(f, "Flex Day"), + DayType::Weekend => write!(f, "Weekend"), + DayType::Missing => write!(f, "\u{26a0} Missing"), + DayType::OutsidePeriod => write!(f, "Outside Period"), + } + } +} + +/// Warning associated with a specific day. +#[derive(Debug, Clone, PartialEq)] +pub enum DayWarning { + /// Weekday has no entries and no leave/holiday marker. + MissingWithoutExplanation, + /// Two timecards overlap. + OverlappingTimecards { + first: (NaiveTime, NaiveTime), + second: (NaiveTime, NaiveTime), + }, + /// Work logged outside any configured period. + OutsidePeriod { hours_worked: f64 }, +} + +impl fmt::Display for DayWarning { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DayWarning::MissingWithoutExplanation => { + write!(f, "No entries and no leave/holiday marker") + } + DayWarning::OverlappingTimecards { first, second } => { + write!( + f, + "{}-{} overlaps with {}-{}", + first.0.format("%H:%M"), + first.1.format("%H:%M"), + second.0.format("%H:%M"), + second.1.format("%H:%M") + ) + } + DayWarning::OutsidePeriod { hours_worked } => { + write!(f, "{:.1}h worked (no period configured)", hours_worked) + } + } + } +} + +/// Report for a single day. +#[derive(Debug, Clone)] +pub struct DayReport { + pub date: NaiveDate, + pub expected_hours: f64, + pub actual_hours: f64, + pub day_type: DayType, + pub warnings: Vec, +} + +impl DayReport { + pub fn new(date: NaiveDate, expected_hours: f64, actual_hours: f64, day_type: DayType) -> Self { + Self { + date, + expected_hours, + actual_hours, + day_type, + warnings: Vec::new(), + } + } + + pub fn with_warning(mut self, warning: DayWarning) -> Self { + self.warnings.push(warning); + self + } + + pub fn with_warnings(mut self, warnings: Vec) -> Self { + self.warnings = warnings; + self + } + + /// Calculate the difference between actual and expected hours. + pub fn diff(&self) -> f64 { + self.actual_hours - self.expected_hours + } + + /// Check if this day has any warnings. + pub fn has_warnings(&self) -> bool { + !self.warnings.is_empty() + } +} + +/// Report for a single month. +#[derive(Debug, Clone)] +pub struct MonthReport { + pub year: i32, + pub month: u32, + pub days: Vec, +} + +impl MonthReport { + pub fn new(year: i32, month: u32) -> Self { + Self { + year, + month, + days: Vec::new(), + } + } + + pub fn with_days(mut self, days: Vec) -> Self { + self.days = days; + 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 actual hours for the month. + pub fn total_actual(&self) -> f64 { + self.days.iter().map(|d| d.actual_hours).sum() + } + + /// Calculate the difference for the month. + pub fn diff(&self) -> f64 { + self.total_actual() - self.total_expected() + } + + /// Get the month name. + pub fn month_name(&self) -> &'static str { + match self.month { + 1 => "January", + 2 => "February", + 3 => "March", + 4 => "April", + 5 => "May", + 6 => "June", + 7 => "July", + 8 => "August", + 9 => "September", + 10 => "October", + 11 => "November", + 12 => "December", + _ => "Unknown", + } + } +} + +/// A warning to be displayed in the report summary. +#[derive(Debug, Clone)] +pub struct ReportWarning { + pub date: NaiveDate, + pub warning: DayWarning, +} + +impl ReportWarning { + pub fn new(date: NaiveDate, warning: DayWarning) -> Self { + Self { date, warning } + } +} + +/// Complete timesheet report. +#[derive(Debug, Clone)] +pub struct TimesheetReport { + pub months: Vec, + pub cumulative_balance: f64, + pub warnings: Vec, +} + +impl TimesheetReport { + pub fn new() -> Self { + Self { + months: Vec::new(), + cumulative_balance: 0.0, + warnings: Vec::new(), + } + } + + pub fn with_months(mut self, months: Vec) -> Self { + self.months = months; + self + } + + pub fn with_cumulative_balance(mut self, balance: f64) -> Self { + self.cumulative_balance = balance; + self + } + + pub fn with_warnings(mut self, warnings: Vec) -> Self { + self.warnings = warnings; + self + } + + /// Check if there are any warnings. + pub fn has_warnings(&self) -> bool { + !self.warnings.is_empty() + } +} + +impl Default for TimesheetReport { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn date(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).unwrap() + } + + fn time(hour: u32, min: u32) -> NaiveTime { + NaiveTime::from_hms_opt(hour, min, 0).unwrap() + } + + #[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); + } + + #[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); + } + + #[test] + fn test_month_report_totals() { + 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), + ]); + + 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); + } + + #[test] + fn test_month_name() { + let month = MonthReport::new(2026, 3); + assert_eq!(month.month_name(), "March"); + } + + #[test] + fn test_day_warning_overlap_display() { + let warning = DayWarning::OverlappingTimecards { + first: (time(9, 0), time(12, 30)), + second: (time(12, 0), time(13, 0)), + }; + assert_eq!(warning.to_string(), "09:00-12:30 overlaps with 12:00-13:00"); + } + + #[test] + fn test_day_warning_missing_display() { + let warning = DayWarning::MissingWithoutExplanation; + assert_eq!( + warning.to_string(), + "No entries and no leave/holiday marker" + ); + } + + #[test] + fn test_day_warning_outside_period_display() { + let warning = DayWarning::OutsidePeriod { hours_worked: 3.5 }; + 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( + DayWarning::OverlappingTimecards { + first: (time(9, 0), time(12, 30)), + second: (time(12, 0), time(13, 0)), + }, + ); + + assert!(report.has_warnings()); + assert_eq!(report.warnings.len(), 1); + } + + #[test] + fn test_timesheeet_report_has_warnings() { + let report = TimesheetReport::new().with_warnings(vec![ReportWarning::new( + date(2026, 3, 4), + DayWarning::MissingWithoutExplanation, + )]); + + assert!(report.has_warnings()); + } +} From e0ba2cddf38e83ced22d715c322837390b627127 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Sun, 29 Mar 2026 22:05:05 +0200 Subject: [PATCH 15/21] feat(timesheet): add overlap detection for timecards Add find_overlapping_timecards function to detect overlapping time ranges on the same day. This is used to generate warnings in the timesheet report. --- src/timesheet/mod.rs | 2 + src/timesheet/validation.rs | 111 ++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 src/timesheet/validation.rs diff --git a/src/timesheet/mod.rs b/src/timesheet/mod.rs index 4f0a9ad..0f76d12 100644 --- a/src/timesheet/mod.rs +++ b/src/timesheet/mod.rs @@ -3,9 +3,11 @@ mod configuration; mod extract; mod point_types; mod report; +mod validation; 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; pub use report::{DayReport, DayType, DayWarning, MonthReport, ReportWarning, TimesheetReport}; +pub use validation::find_overlapping_timecards; diff --git a/src/timesheet/validation.rs b/src/timesheet/validation.rs new file mode 100644 index 0000000..0eacf06 --- /dev/null +++ b/src/timesheet/validation.rs @@ -0,0 +1,111 @@ +use chrono::NaiveTime; + +use crate::models::Timecard; + +/// Check if two time ranges overlap. +fn timecards_overlap(a: &Timecard, b: &Timecard) -> bool { + a.from_time < b.to_time && b.from_time < a.to_time +} + +/// Find all overlapping timecard pairs for a day. +/// Returns a list of tuples containing the two overlapping timecards. +pub fn find_overlapping_timecards( + timecards: &[Timecard], +) -> Vec<((NaiveTime, NaiveTime), (NaiveTime, NaiveTime))> { + let mut overlaps = Vec::new(); + for i in 0..timecards.len() { + for j in (i + 1)..timecards.len() { + if timecards_overlap(&timecards[i], &timecards[j]) { + overlaps.push(( + (timecards[i].from_time, timecards[i].to_time), + (timecards[j].from_time, timecards[j].to_time), + )); + } + } + } + overlaps +} + +#[cfg(test)] +mod tests { + use super::*; + + fn time(hour: u32, min: u32) -> NaiveTime { + NaiveTime::from_hms_opt(hour, min, 0).unwrap() + } + + fn card(from_h: u32, from_m: u32, to_h: u32, to_m: u32) -> Timecard { + Timecard::new(time(from_h, from_m), time(to_h, to_m)) + } + + #[test] + fn test_no_overlap_adjacent_timecards() { + let timecards = vec![card(9, 0, 12, 0), card(13, 0, 17, 0)]; + let overlaps = find_overlapping_timecards(&timecards); + assert!(overlaps.is_empty()); + } + + #[test] + fn test_no_overlap_exact_touch() { + // Touching at 12:00 is NOT an overlap (end time = start time) + let timecards = vec![card(9, 0, 12, 0), card(12, 0, 17, 0)]; + let overlaps = find_overlapping_timecards(&timecards); + assert!(overlaps.is_empty()); + } + + #[test] + fn test_partial_overlap() { + let timecards = vec![card(9, 0, 12, 30), card(12, 0, 13, 0)]; + let overlaps = find_overlapping_timecards(&timecards); + assert_eq!(overlaps.len(), 1); + assert_eq!(overlaps[0].0, (time(9, 0), time(12, 30))); + assert_eq!(overlaps[0].1, (time(12, 0), time(13, 0))); + } + + #[test] + fn test_full_containment() { + // One timecard fully contains another + let timecards = vec![card(9, 0, 17, 0), card(10, 0, 11, 0)]; + let overlaps = find_overlapping_timecards(&timecards); + assert_eq!(overlaps.len(), 1); + } + + #[test] + fn test_exact_match_overlap() { + let timecards = vec![card(9, 0, 12, 0), card(9, 0, 12, 0)]; + let overlaps = find_overlapping_timecards(&timecards); + assert_eq!(overlaps.len(), 1); + } + + #[test] + fn test_multiple_overlaps_same_day() { + // First overlaps with second, and second overlaps with third + let timecards = vec![card(9, 0, 11, 0), card(10, 0, 13, 0), card(12, 0, 15, 0)]; + let overlaps = find_overlapping_timecards(&timecards); + // 9-11 overlaps with 10-13, and 10-13 overlaps with 12-15 + assert_eq!(overlaps.len(), 2); + } + + #[test] + fn test_single_timecard_no_overlap() { + let timecards = vec![card(9, 0, 17, 0)]; + let overlaps = find_overlapping_timecards(&timecards); + assert!(overlaps.is_empty()); + } + + #[test] + fn test_empty_timecards_no_overlap() { + let timecards: Vec = vec![]; + let overlaps = find_overlapping_timecards(&timecards); + assert!(overlaps.is_empty()); + } + + #[test] + fn test_three_timecards_all_overlap() { + // All three overlap with each other + let timecards = vec![card(9, 0, 15, 0), card(10, 0, 16, 0), card(11, 0, 17, 0)]; + let overlaps = find_overlapping_timecards(&timecards); + // 9-15 with 10-16, 9-15 with 11-17, and 10-16 with 11-17 + assert_eq!(overlaps.len(), 3); + } +} From 1a716f6d0ed4f4c9245221cc994a7942dad78ba2 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Sun, 29 Mar 2026 22:06:44 +0200 Subject: [PATCH 16/21] feat(timesheet): add report generation logic Implement the core report generation that: - Loads .streamd.toml configuration - Calculates expected hours based on periods and day types - Calculates actual hours following day type rules: - Sick leave: max(expected, worked) - Vacation: expected + worked - Flex day: 0 - Holiday: 0 expected - Generates warnings for missing days, overlapping timecards, and work outside configured periods - Calculates monthly and cumulative balances - Sorts months in descending order --- src/timesheet/generator.rs | 542 +++++++++++++++++++++++++++++++++++++ src/timesheet/mod.rs | 2 + 2 files changed, 544 insertions(+) create mode 100644 src/timesheet/generator.rs diff --git a/src/timesheet/generator.rs b/src/timesheet/generator.rs new file mode 100644 index 0000000..6ff191c --- /dev/null +++ b/src/timesheet/generator.rs @@ -0,0 +1,542 @@ +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +use chrono::{Datelike, NaiveDate, Weekday}; + +use crate::error::StreamdError; +use crate::models::{SpecialDayType, Timesheet}; + +use super::config::{RepositoryConfig, TimesheetConfig}; +use super::report::{DayReport, DayType, DayWarning, MonthReport, ReportWarning, TimesheetReport}; +use super::validation::find_overlapping_timecards; + +/// Load repository configuration from .streamd.toml file. +pub fn load_repository_config(base_folder: &Path) -> Result { + let config_path = base_folder.join(".streamd.toml"); + + if !config_path.exists() { + return Ok(RepositoryConfig::default()); + } + + let content = fs::read_to_string(&config_path)?; + let config: RepositoryConfig = toml::from_str(&content)?; + + // Validate timesheet config if present + if let Some(ref timesheet) = config.timesheet { + timesheet.validate()?; + } + + Ok(config) +} + +/// Calculate total hours worked from timecards. +fn calculate_timecard_hours(timesheet: &Timesheet) -> f64 { + timesheet + .timecards + .iter() + .map(|tc| { + let duration = tc.to_time - tc.from_time; + duration.num_minutes() as f64 / 60.0 + }) + .sum() +} + +/// Determine the day type based on timesheet data. +fn determine_day_type(date: NaiveDate, timesheet: Option<&Timesheet>, has_period: bool) -> DayType { + let weekday = date.weekday(); + + // Check weekend first + if weekday == Weekday::Sat || weekday == Weekday::Sun { + return DayType::Weekend; + } + + // Check if outside any period + if !has_period { + return DayType::OutsidePeriod; + } + + // Check special day types from timesheet + if let Some(ts) = timesheet { + if let Some(special) = ts.special_day_type { + match special { + SpecialDayType::Vacation => return DayType::Vacation, + SpecialDayType::Holiday => return DayType::Holiday, + SpecialDayType::Undertime => return DayType::FlexDay, + SpecialDayType::Weekend => return DayType::Weekend, + } + } + + if ts.is_sick_leave { + return DayType::SickLeave; + } + + // Has timecards or special type + return DayType::Regular; + } + + // No timesheet entry for a weekday = Missing + 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 { + 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, + } +} + +/// 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 { + 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, + } +} + +/// Generate a timesheet report from timesheets and configuration. +pub fn generate_report( + timesheets: &[Timesheet], + config: &TimesheetConfig, +) -> Result { + if config.periods.is_empty() { + return Ok(TimesheetReport::new()); + } + + // Index timesheets by date for quick lookup + let timesheets_by_date: HashMap = + timesheets.iter().map(|ts| (ts.date, ts)).collect(); + + // Find the date range to report on + let earliest_period_start = config.periods.iter().map(|p| p.start).min().unwrap(); + let latest_period_end = config.periods.iter().map(|p| p.end).max().unwrap(); + + // Limit to today + let today = chrono::Local::now().date_naive(); + let end_date = latest_period_end.min(today); + + // Group by month and generate reports + let mut month_reports: Vec = Vec::new(); + let mut all_warnings: Vec = Vec::new(); + let mut cumulative_balance: f64 = 0.0; + + // Iterate through all dates in the range + let mut current_date = earliest_period_start; + let mut current_month: Option<(i32, u32)> = None; + let mut current_month_days: Vec = Vec::new(); + + while current_date <= end_date { + let year = current_date.year(); + let month = current_date.month(); + + // Check if we've moved to a new month + if current_month != Some((year, month)) { + // Save the previous month if it had days + if let Some((prev_year, prev_month)) = current_month { + if !current_month_days.is_empty() { + let month_report = + MonthReport::new(prev_year, prev_month).with_days(current_month_days); + cumulative_balance += month_report.diff(); + month_reports.push(month_report); + } + } + current_month = Some((year, month)); + current_month_days = Vec::new(); + } + + // 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); + + // Get timesheet for this date + let timesheet = timesheets_by_date.get(¤t_date).copied(); + let timecard_hours = timesheet.map(calculate_timecard_hours).unwrap_or(0.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, + _ => 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); + + let mut day_report = + DayReport::new(current_date, expected_hours, actual_hours, day_type); + + // Collect warnings + let mut day_warnings: Vec = Vec::new(); + + // Warning: Missing without explanation + if day_type == DayType::Missing { + day_warnings.push(DayWarning::MissingWithoutExplanation); + all_warnings.push(ReportWarning::new( + current_date, + DayWarning::MissingWithoutExplanation, + )); + } + + // Warning: Overlapping timecards + if let Some(ts) = timesheet { + let overlaps = find_overlapping_timecards(&ts.timecards); + for (first, second) in overlaps { + let warning = DayWarning::OverlappingTimecards { first, second }; + day_warnings.push(warning.clone()); + all_warnings.push(ReportWarning::new(current_date, warning)); + } + } + + // Warning: Work outside period + if day_type == DayType::OutsidePeriod && timecard_hours > 0.0 { + let warning = DayWarning::OutsidePeriod { + hours_worked: timecard_hours, + }; + day_warnings.push(warning.clone()); + all_warnings.push(ReportWarning::new(current_date, warning)); + } + + day_report = day_report.with_warnings(day_warnings); + current_month_days.push(day_report); + } + + // Move to next day + current_date = current_date.succ_opt().unwrap_or(current_date); + if current_date == end_date && current_date <= latest_period_end { + // We've hit end_date, process this day then stop + } + } + + // Don't forget the last month + if let Some((year, month)) = current_month { + if !current_month_days.is_empty() { + let month_report = MonthReport::new(year, month).with_days(current_month_days); + cumulative_balance += month_report.diff(); + month_reports.push(month_report); + } + } + + // Sort months in descending order (most recent first) + month_reports.sort_by(|a, b| { + let a_date = (a.year, a.month); + let b_date = (b.year, b.month); + b_date.cmp(&a_date) + }); + + Ok(TimesheetReport::new() + .with_months(month_reports) + .with_cumulative_balance(cumulative_balance) + .with_warnings(all_warnings)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::Timecard; + use crate::timesheet::Period; + use chrono::NaiveTime; + + fn date(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).unwrap() + } + + fn time(hour: u32, min: u32) -> NaiveTime { + NaiveTime::from_hms_opt(hour, min, 0).unwrap() + } + + fn make_timesheet(date: NaiveDate, cards: Vec<(u32, u32, u32, u32)>) -> Timesheet { + Timesheet { + date, + is_sick_leave: false, + special_day_type: None, + timecards: cards + .into_iter() + .map(|(fh, fm, th, tm)| Timecard::new(time(fh, fm), time(th, tm))) + .collect(), + } + } + + fn make_config(start: NaiveDate, end: NaiveDate, hours_per_week: f64) -> TimesheetConfig { + TimesheetConfig { + periods: vec![Period { + start, + end, + hours_per_week, + }], + } + } + + #[test] + fn test_calculate_timecard_hours() { + 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); + } + + #[test] + fn test_calculate_timecard_hours_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); + } + + #[test] + fn test_day_type_regular() { + // Monday with timesheet + let ts = make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)]); + let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true); + assert_eq!(day_type, DayType::Regular); + } + + #[test] + fn test_day_type_weekend() { + // Saturday + let day_type = determine_day_type(date(2026, 3, 7), None, true); + assert_eq!(day_type, DayType::Weekend); + } + + #[test] + fn test_day_type_missing() { + // Monday with no timesheet + let day_type = determine_day_type(date(2026, 3, 2), None, true); + assert_eq!(day_type, DayType::Missing); + } + + #[test] + fn test_day_type_vacation() { + let ts = Timesheet { + date: date(2026, 3, 2), + is_sick_leave: false, + special_day_type: Some(SpecialDayType::Vacation), + timecards: vec![], + }; + let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true); + assert_eq!(day_type, DayType::Vacation); + } + + #[test] + fn test_day_type_sick_leave() { + let ts = Timesheet { + date: date(2026, 3, 2), + is_sick_leave: true, + special_day_type: None, + timecards: vec![], + }; + let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true); + assert_eq!(day_type, DayType::SickLeave); + } + + #[test] + fn test_day_type_holiday() { + let ts = Timesheet { + date: date(2026, 3, 2), + is_sick_leave: false, + special_day_type: Some(SpecialDayType::Holiday), + timecards: vec![], + }; + let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true); + assert_eq!(day_type, DayType::Holiday); + } + + #[test] + fn test_day_type_flex_day() { + let ts = Timesheet { + date: date(2026, 3, 2), + is_sick_leave: false, + special_day_type: Some(SpecialDayType::Undertime), + timecards: vec![], + }; + let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true); + assert_eq!(day_type, DayType::FlexDay); + } + + #[test] + fn test_day_type_outside_period() { + let day_type = determine_day_type(date(2026, 3, 2), None, false); + assert_eq!(day_type, DayType::OutsidePeriod); + } + + #[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); + } + + #[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); + } + + #[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); + } + + #[test] + fn test_actual_hours_regular() { + let hours = calculate_actual_hours(DayType::Regular, 8.0, 7.6); + assert!((hours - 8.0).abs() < 0.0001); + } + + #[test] + fn test_actual_hours_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); + } + + #[test] + fn test_actual_hours_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); + } + + #[test] + fn test_actual_hours_vacation() { + // Vacation: expected + worked + let hours = calculate_actual_hours(DayType::Vacation, 2.0, 7.6); + assert!((hours - 9.6).abs() < 0.0001); + } + + #[test] + fn test_actual_hours_flex_day() { + // Flex day: always 0 + let hours = calculate_actual_hours(DayType::FlexDay, 5.0, 7.6); + assert!((hours - 0.0).abs() < 0.0001); + } + + #[test] + fn test_generate_report_empty_config() { + let config = TimesheetConfig { periods: vec![] }; + let report = generate_report(&[], &config).unwrap(); + assert!(report.months.is_empty()); + } + + #[test] + fn test_generate_report_single_day() { + // Monday 2026-03-02 + let timesheets = vec![make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)])]; + let config = make_config(date(2026, 3, 2), date(2026, 3, 2), 40.0); + + let report = generate_report(×heets, &config).unwrap(); + + assert_eq!(report.months.len(), 1); + assert_eq!(report.months[0].days.len(), 1); + + 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.day_type, DayType::Regular); + } + + #[test] + fn test_generate_report_detects_missing_day() { + // Period covers Mon-Tue, but only Mon has timesheet + let timesheets = vec![make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)])]; + // March 2 is Monday, March 3 is Tuesday + let config = make_config(date(2026, 3, 2), date(2026, 3, 3), 40.0); + + let report = generate_report(×heets, &config).unwrap(); + + assert_eq!(report.months[0].days.len(), 2); + + // First day (Mon) should be regular + assert_eq!(report.months[0].days[0].day_type, DayType::Regular); + + // Second day (Tue) should be missing + assert_eq!(report.months[0].days[1].day_type, DayType::Missing); + assert!(report.months[0].days[1].has_warnings()); + } + + #[test] + fn test_generate_report_weekend_excluded_if_no_work() { + // Period covers Mon-Sun, but only Mon has timesheet + let timesheets = vec![make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)])]; + let config = make_config(date(2026, 3, 2), date(2026, 3, 8), 40.0); + + let report = generate_report(×heets, &config).unwrap(); + + // Should only include Mon-Fri (5 days), not Sat-Sun + let days = &report.months[0].days; + for day in days { + let weekday = day.date.weekday(); + assert!(weekday != Weekday::Sat && weekday != Weekday::Sun); + } + } + + #[test] + fn test_generate_report_weekend_included_if_work() { + // Work on Saturday + let timesheets = vec![ + make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)]), + make_timesheet(date(2026, 3, 7), vec![(10, 0, 14, 0)]), // Saturday + ]; + let config = make_config(date(2026, 3, 2), date(2026, 3, 8), 40.0); + + let report = generate_report(×heets, &config).unwrap(); + + // Should include Saturday + let has_saturday = report.months[0] + .days + .iter() + .any(|d| d.date == date(2026, 3, 7)); + assert!(has_saturday); + } + + #[test] + fn test_generate_report_overlapping_timecards_warning() { + let ts = Timesheet { + date: date(2026, 3, 2), + is_sick_leave: false, + special_day_type: None, + timecards: vec![ + Timecard::new(time(9, 0), time(12, 30)), + Timecard::new(time(12, 0), time(13, 0)), + ], + }; + let config = make_config(date(2026, 3, 2), date(2026, 3, 2), 40.0); + + let report = generate_report(&[ts], &config).unwrap(); + + assert!(report.has_warnings()); + assert!(report.months[0].days[0].has_warnings()); + } + + #[test] + fn test_generate_report_cumulative_balance() { + // Two days: one with 8h (expected 8h), one with 10h (expected 8h) + let timesheets = vec![ + make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)]), // 8h + make_timesheet(date(2026, 3, 3), vec![(8, 0, 18, 0)]), // 10h + ]; + let config = make_config(date(2026, 3, 2), date(2026, 3, 3), 40.0); + + let report = generate_report(×heets, &config).unwrap(); + + // Balance should be +2h (18h actual - 16h expected) + assert!((report.cumulative_balance - 2.0).abs() < 0.0001); + } +} diff --git a/src/timesheet/mod.rs b/src/timesheet/mod.rs index 0f76d12..9e4f000 100644 --- a/src/timesheet/mod.rs +++ b/src/timesheet/mod.rs @@ -1,6 +1,7 @@ mod config; mod configuration; mod extract; +mod generator; mod point_types; mod report; mod validation; @@ -8,6 +9,7 @@ mod validation; pub use config::{Period, RepositoryConfig, TimesheetConfig}; pub use configuration::{BasicTimesheetConfiguration, TIMESHEET_DIMENSION_NAME, TIMESHEET_TAG}; pub use extract::extract_timesheets; +pub use generator::{generate_report, load_repository_config}; pub use point_types::TimesheetPointType; pub use report::{DayReport, DayType, DayWarning, MonthReport, ReportWarning, TimesheetReport}; pub use validation::find_overlapping_timecards; From ca43106486b42a7b4902ff0404dc4e5dd83f63f1 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Sun, 29 Mar 2026 22:07:58 +0200 Subject: [PATCH 17/21] feat(timesheet): add formatted report output to CLI Replace CSV output with formatted table display showing: - Monthly breakdown with expected/actual hours per day - Day types (Regular, Sick Leave, Vacation, Holiday, Flex Day, etc.) - Warning indicators for missing days and overlapping timecards - Monthly summaries with total expected/actual hours - Cumulative balance across all months - Detailed warnings section at the end Shows helpful message when no .streamd.toml configuration is found. --- src/cli/commands/timesheet.rs | 264 +++++++++++++++++++++++++++++++--- 1 file changed, 247 insertions(+), 17 deletions(-) diff --git a/src/cli/commands/timesheet.rs b/src/cli/commands/timesheet.rs index f727edc..9fe413f 100644 --- a/src/cli/commands/timesheet.rs +++ b/src/cli/commands/timesheet.rs @@ -1,5 +1,7 @@ use std::fs; +use std::path::Path; +use chrono::Datelike; use walkdir::WalkDir; use crate::config::Settings; @@ -7,13 +9,15 @@ use crate::error::StreamdError; use crate::extract::parse_markdown_file; use crate::localize::localize_stream_file; use crate::models::LocalizedShard; -use crate::timesheet::{extract_timesheets, BasicTimesheetConfiguration}; +use crate::timesheet::{ + extract_timesheets, generate_report, load_repository_config, BasicTimesheetConfiguration, + DayType, DayWarning, MonthReport, TimesheetReport, +}; -fn all_files() -> Result, StreamdError> { - let settings = Settings::load()?; +fn load_all_shards(base_folder: &Path) -> Result, StreamdError> { let mut shards = Vec::new(); - for entry in WalkDir::new(&settings.base_folder) + for entry in WalkDir::new(base_folder) .max_depth(1) .into_iter() .filter_map(|e| e.ok()) @@ -33,20 +37,246 @@ fn all_files() -> Result, StreamdError> { Ok(shards) } -pub fn run() -> Result<(), StreamdError> { - let all_shards = all_files()?; - let mut sheets = extract_timesheets(&all_shards)?; - sheets.sort_by_key(|s| s.date); - - for sheet in sheets { - println!("{}", sheet.date); - let times: Vec = sheet - .timecards - .iter() - .map(|card| format!("{},{}", card.from_time, card.to_time)) - .collect(); - println!("{}", times.join(",")); +/// Format hours with sign for display. +fn format_diff(hours: f64) -> String { + if hours >= 0.0 { + format!("+{:.1}h", hours) + } else { + format!("{:.1}h", hours) } +} + +/// Format hours for display without sign. +fn format_hours(hours: f64) -> String { + format!("{:.1}h", hours) +} + +/// Get the weekday abbreviation. +fn weekday_abbrev(date: chrono::NaiveDate) -> &'static str { + match date.weekday() { + chrono::Weekday::Mon => "Mon", + chrono::Weekday::Tue => "Tue", + chrono::Weekday::Wed => "Wed", + chrono::Weekday::Thu => "Thu", + chrono::Weekday::Fri => "Fri", + chrono::Weekday::Sat => "Sat", + chrono::Weekday::Sun => "Sun", + } +} + +/// Print the timesheet report header. +fn print_header() { + println!( + "\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}" + ); + println!(" TIMESHEET REPORT"); + println!( + "\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}" + ); + println!(); +} + +/// Print a month report. +fn print_month(month: &MonthReport) { + let diff_str = format_diff(month.diff()); + let month_title = format!("{} {}", month.month_name(), month.year); + + // Month header with diff + print!("\u{2550}\u{2550}\u{2550} {} ", month_title); + let padding = 52 - month_title.len() - diff_str.len(); + for _ in 0..padding { + print!("\u{2550}"); + } + println!(" Diff: {} \u{2550}\u{2550}\u{2550}", diff_str); + println!(); + + // Column headers + println!(" Date Day Expected Actual Diff Type"); + println!( + " \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}" + ); + + // Day rows + for day in &month.days { + let date_str = day.date.format("%Y-%m-%d").to_string(); + let weekday = weekday_abbrev(day.date); + let expected = format_hours(day.expected_hours); + let actual = format_hours(day.actual_hours); + let diff = format_diff(day.diff()); + + let type_str = match day.day_type { + DayType::Regular => String::new(), + DayType::Missing if day.has_warnings() => "\u{26a0} Missing".to_string(), + _ => day.day_type.to_string(), + }; + + // Add overlap warning indicator + let type_str = if day + .warnings + .iter() + .any(|w| matches!(w, DayWarning::OverlappingTimecards { .. })) + { + if type_str.is_empty() { + "\u{26a0} Overlap".to_string() + } else { + format!("{} \u{26a0}", type_str) + } + } else { + type_str + }; + + println!( + " {} {} {:>7} {:>7} {:>6} {}", + date_str, weekday, expected, actual, diff, type_str + ); + } + + // Monthly totals + println!( + " \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}" + ); + println!( + " Monthly: {:>7} {:>7} {:>6}", + format_hours(month.total_expected()), + format_hours(month.total_actual()), + format_diff(month.diff()) + ); + println!(); +} + +/// Print the cumulative balance. +fn print_cumulative_balance(balance: f64) { + println!( + "\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}" + ); + println!( + " CUMULATIVE BALANCE: {}", + format_diff(balance) + ); + println!( + "\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}" + ); +} + +/// Print warnings section. +fn print_warnings(report: &TimesheetReport) { + if !report.has_warnings() { + return; + } + + println!(); + println!("\u{26a0} Warnings:"); + println!(); + + // Group warnings by type + let missing_warnings: Vec<_> = report + .warnings + .iter() + .filter(|w| matches!(w.warning, DayWarning::MissingWithoutExplanation)) + .collect(); + + let overlap_warnings: Vec<_> = report + .warnings + .iter() + .filter(|w| matches!(w.warning, DayWarning::OverlappingTimecards { .. })) + .collect(); + + let outside_period_warnings: Vec<_> = report + .warnings + .iter() + .filter(|w| matches!(w.warning, DayWarning::OutsidePeriod { .. })) + .collect(); + + if !missing_warnings.is_empty() { + println!(" Missing days without explanation:"); + for w in &missing_warnings { + let weekday = weekday_abbrev(w.date); + println!( + " - {} ({}): No entries and no leave/holiday marker", + w.date.format("%Y-%m-%d"), + weekday + ); + } + println!(); + } + + if !overlap_warnings.is_empty() { + println!(" Overlapping timecards:"); + for w in &overlap_warnings { + if let DayWarning::OverlappingTimecards { first, second } = &w.warning { + println!( + " - {}: {}-{} overlaps with {}-{}", + w.date.format("%Y-%m-%d"), + first.0.format("%H:%M"), + first.1.format("%H:%M"), + second.0.format("%H:%M"), + second.1.format("%H:%M") + ); + } + } + println!(); + } + + if !outside_period_warnings.is_empty() { + println!(" Work logged outside configured periods:"); + for w in &outside_period_warnings { + if let DayWarning::OutsidePeriod { hours_worked } = &w.warning { + println!( + " - {}: {:.1}h worked (no period configured)", + w.date.format("%Y-%m-%d"), + hours_worked + ); + } + } + println!(); + } +} + +pub fn run() -> Result<(), StreamdError> { + let settings = Settings::load()?; + let base_folder = Path::new(&settings.base_folder); + + // Load repository configuration + let repo_config = load_repository_config(base_folder)?; + + // Check if timesheet is configured + let timesheet_config = match repo_config.timesheet { + Some(config) => config, + None => { + println!("No timesheet configuration found in .streamd.toml"); + println!(); + println!("Add a [timesheet] section with periods to enable timesheet reporting:"); + println!(); + println!(" [timesheet]"); + println!(" [[timesheet.periods]]"); + println!(" start = \"2026-01-01\""); + println!(" end = \"2026-12-31\""); + println!(" hours_per_week = 40.0"); + return Ok(()); + } + }; + + // Load all markdown files and extract timesheets + let all_shards = load_all_shards(base_folder)?; + let timesheets = extract_timesheets(&all_shards)?; + + // Generate the report + let report = generate_report(×heets, ×heet_config)?; + + if report.months.is_empty() { + println!("No timesheet data found for the configured periods."); + return Ok(()); + } + + // Print the report + print_header(); + + for month in &report.months { + print_month(month); + } + + print_cumulative_balance(report.cumulative_balance); + print_warnings(&report); Ok(()) } From 070a47e241d9ba7a51e2253fcbddf55a1941c765 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Sun, 29 Mar 2026 22:08:53 +0200 Subject: [PATCH 18/21] docs: add timesheet management documentation Update README.md with: - Repository configuration section for .streamd.toml - Timesheet periods configuration example - Description of timesheet report features Update REQUIREMENTS.md with: - R18a: Timesheet report configuration format - R18b: Day type rules for expected/actual hours calculation - R18c: Timesheet report warning types - Updated R20 command description --- README.md | 29 ++++++++++++++++++++++++++++- REQUIREMENTS.md | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 474663c..e2e0bcd 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,34 @@ Within files, `@`-prefixed markers at the beginning of paragraphs or headings de ## Configuration -Streamd reads its configuration from `~/.config/streamd/config.toml` (XDG standard). The main setting is `base_folder`, which points to the directory containing your stream files (defaults to the current working directory). +### User Configuration + +Streamd reads its user configuration from `~/.config/streamd/config.toml` (XDG standard). The main setting is `base_folder`, which points to the directory containing your stream files (defaults to the current working directory). + +### Repository Configuration + +For timesheet reporting, create a `.streamd.toml` file in your stream files directory: + +```toml +timezone = "Europe/Berlin" # Optional: timezone for day boundaries + +[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 +``` + +The timesheet command will calculate expected vs actual working hours based on these periods, showing: +- Daily breakdown with expected/actual hours +- Special day types (sick leave, vacation, holidays, flex days) +- Warnings for missing entries and overlapping timecards +- Monthly and cumulative balance ## Usage diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 2ba44bd..04e8898 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -320,6 +320,52 @@ Process timesheet shards chronologically per day: **Validation:** The last entry of each day MUST be a `Break` (cannot end day while working). +### R18a: Timesheet Report Configuration + +The `.streamd.toml` file in the base folder configures timesheet periods: + +```toml +timezone = "Europe/Berlin" # Optional timezone for day boundaries + +[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 +``` + +**Configuration Rules:** +- Dates use ISO 8601 format (`YYYY-MM-DD`) +- Periods MUST NOT overlap (validation error if they do) +- Gaps between periods are allowed — days in gaps have 0 expected hours +- `hours_per_week` is distributed over Mon-Fri (e.g., 38h/week = 7.6h/day) + +### R18b: Day Type Rules + +| Day Type | Expected Hours | Actual Hours | +|----------|---------------|--------------| +| Regular work day | period.hours_per_week / 5 | Sum of timecards | +| Weekend (Sat/Sun) | 0 | Sum of timecards (hidden if 0) | +| Sick Leave (@SickLeave) | Normal expected | max(expected, worked) | +| Vacation (@VacationDay) | Normal expected | expected + worked | +| Holiday (@Holiday) | 0 | Sum of timecards | +| Flex Day (@UndertimeDay) | Normal expected | 0 | +| Day in gap (no period) | 0 | Sum of timecards + warning | +| Missing (no entries) | Normal expected | 0 + warning | + +### R18c: Timesheet Report Warnings + +The report generates warnings for: + +1. **Missing days without explanation**: A weekday within a configured period has no timecard entries and no special day type marker +2. **Overlapping timecards**: Two or more timecards on the same day have overlapping time ranges +3. **Work outside configured periods**: Work logged on a day that falls outside all configured periods + --- ## Query System @@ -343,7 +389,7 @@ Provide recursive search through the shard tree: | `streamd new` | Create new timestamped file, open editor, rename with markers on close | | `streamd todo` | List all shards with `task: "open"` | | `streamd edit [n]` | Edit nth file (supports negative indexing for recent files) | -| `streamd timesheet` | Extract and export timesheet data as CSV | +| `streamd timesheet` | Generate formatted timesheet report with expected/actual hours | | `streamd completions ` | Generate shell completions (bash, zsh, fish, elvish, powershell) | --- From 7bee32886ff64411008755db7a8182b249ecfa17 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Thu, 2 Apr 2026 17:06:28 +0200 Subject: [PATCH 19/21] feat(timesheet): sort months ascending so newest is at bottom --- src/timesheet/generator.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/timesheet/generator.rs b/src/timesheet/generator.rs index 6ff191c..45fbb32 100644 --- a/src/timesheet/generator.rs +++ b/src/timesheet/generator.rs @@ -235,11 +235,11 @@ pub fn generate_report( } } - // Sort months in descending order (most recent first) + // Sort months in ascending order (oldest first, newest at bottom) month_reports.sort_by(|a, b| { let a_date = (a.year, a.month); let b_date = (b.year, b.month); - b_date.cmp(&a_date) + a_date.cmp(&b_date) }); Ok(TimesheetReport::new() From b51fb511ac97868ae6a2ca7d3ff3e46a84aff8aa Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Thu, 2 Apr 2026 17:07:06 +0200 Subject: [PATCH 20/21] fix(timesheet): display zero hours as positive instead of negative zero --- src/cli/commands/timesheet.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/timesheet.rs b/src/cli/commands/timesheet.rs index 9fe413f..f116a82 100644 --- a/src/cli/commands/timesheet.rs +++ b/src/cli/commands/timesheet.rs @@ -40,7 +40,7 @@ fn load_all_shards(base_folder: &Path) -> Result, StreamdErr /// Format hours with sign for display. fn format_diff(hours: f64) -> String { if hours >= 0.0 { - format!("+{:.1}h", hours) + format!("+{:.1}h", hours.abs()) } else { format!("{:.1}h", hours) } @@ -48,7 +48,7 @@ fn format_diff(hours: f64) -> String { /// Format hours for display without sign. fn format_hours(hours: f64) -> String { - format!("{:.1}h", hours) + format!("{:.1}h", hours.abs()) } /// Get the weekday abbreviation. From d11a35c15736c26d03c9e5d6b4aac64127f2e265 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Thu, 2 Apr 2026 17:07:36 +0200 Subject: [PATCH 21/21] refactor(timesheet): use repeat() for separator lines and sort points before grouping --- src/cli/commands/timesheet.rs | 30 ++++++++++++------------------ src/timesheet/extract.rs | 6 +++++- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/cli/commands/timesheet.rs b/src/cli/commands/timesheet.rs index f116a82..80346b4 100644 --- a/src/cli/commands/timesheet.rs +++ b/src/cli/commands/timesheet.rs @@ -5,6 +5,9 @@ use chrono::Datelike; use walkdir::WalkDir; use crate::config::Settings; + +const SEPARATOR_WIDTH: usize = 71; +const COLUMN_SEPARATOR_WIDTH: usize = 65; use crate::error::StreamdError; use crate::extract::parse_markdown_file; use crate::localize::localize_stream_file; @@ -66,13 +69,10 @@ fn weekday_abbrev(date: chrono::NaiveDate) -> &'static str { /// Print the timesheet report header. fn print_header() { - println!( - "\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}" - ); + let double_line = "\u{2550}".repeat(SEPARATOR_WIDTH); + println!("{}", double_line); println!(" TIMESHEET REPORT"); - println!( - "\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}" - ); + println!("{}", double_line); println!(); } @@ -91,10 +91,9 @@ fn print_month(month: &MonthReport) { println!(); // Column headers + let light_line = "\u{2500}".repeat(COLUMN_SEPARATOR_WIDTH); println!(" Date Day Expected Actual Diff Type"); - println!( - " \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}" - ); + println!(" {}", light_line); // Day rows for day in &month.days { @@ -132,9 +131,7 @@ fn print_month(month: &MonthReport) { } // Monthly totals - println!( - " \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}" - ); + println!(" {}", light_line); println!( " Monthly: {:>7} {:>7} {:>6}", format_hours(month.total_expected()), @@ -146,16 +143,13 @@ fn print_month(month: &MonthReport) { /// Print the cumulative balance. fn print_cumulative_balance(balance: f64) { - println!( - "\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}" - ); + let light_line = "\u{2500}".repeat(SEPARATOR_WIDTH); + println!("{}", light_line); println!( " CUMULATIVE BALANCE: {}", format_diff(balance) ); - println!( - "\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}" - ); + println!("{}", light_line); } /// Print warnings section. diff --git a/src/timesheet/extract.rs b/src/timesheet/extract.rs index e11131e..017434f 100644 --- a/src/timesheet/extract.rs +++ b/src/timesheet/extract.rs @@ -136,8 +136,12 @@ fn aggregate_timecard_day(points: &[TimesheetPoint]) -> Result fn aggregate_timecards(points: &[TimesheetPoint]) -> Result, StreamdError> { let mut timesheets = Vec::new(); + // Sort points by moment to ensure proper grouping + let mut sorted_points = points.to_vec(); + sorted_points.sort_by_key(|p| p.moment); + // Group by date - for (_date, group) in &points.iter().chunk_by(|p| p.moment.date_naive()) { + for (_date, group) in &sorted_points.iter().chunk_by(|p| p.moment.date_naive()) { let day_points: Vec<_> = group.cloned().collect(); if let Some(timesheet) = aggregate_timecard_day(&day_points)? { timesheets.push(timesheet);