From 55d55ffb25639ebb9cae9b86437b4475278c84c1 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 31 Mar 2026 00:08:52 +0000 Subject: [PATCH 01/17] 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 02/17] 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 03/17] 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 04/17] 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 05/17] 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 06/17] 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 07/17] 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 08/17] 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 09/17] 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 10/17] 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 11/17] 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); From a8c41ec8335b3a819dddb57238b1f0d98da28ba2 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Thu, 2 Apr 2026 18:18:48 +0200 Subject: [PATCH 12/17] refactor: use chrono for month names --- src/timesheet/report.rs | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/src/timesheet/report.rs b/src/timesheet/report.rs index f96370d..8c405d8 100644 --- a/src/timesheet/report.rs +++ b/src/timesheet/report.rs @@ -154,22 +154,10 @@ impl MonthReport { } /// 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", - } + pub fn month_name(&self) -> String { + NaiveDate::from_ymd_opt(self.year, self.month, 1) + .map(|d| d.format("%B").to_string()) + .unwrap_or_else(|| "Unknown".to_string()) } } From 124a5b7e2aa8af3fa07702cb8d4db4ea28badd25 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Thu, 2 Apr 2026 18:27:19 +0200 Subject: [PATCH 13/17] feat(todo): add numbered tasks, edit, done, and future filtering Implement smoother todo editing with the following features: - Display numbered tasks [1], [2], etc. in `streamd todo` - Add `streamd todo N edit` to open editor at task line - Add `streamd todo N done` to insert @Done after @Task - Add `--show-future` flag to include future tasks (hidden by default) --- src/cli/args.rs | 23 +++++- src/cli/commands/todo.rs | 154 ++++++++++++++++++++++++++++++++++++++- src/cli/mod.rs | 2 +- src/error.rs | 15 ++++ src/main.rs | 11 ++- 5 files changed, 198 insertions(+), 7 deletions(-) diff --git a/src/cli/args.rs b/src/cli/args.rs index cb2c504..be56ee3 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -13,13 +13,34 @@ pub struct Cli { pub command: Option, } +#[derive(Subcommand)] +pub enum TodoAction { + /// Edit a task by its number + Edit { + /// Task number to edit + number: usize, + }, + /// Mark a task as done + Done { + /// Task number to mark as done + number: usize, + }, +} + #[derive(Subcommand)] pub enum Commands { /// Create a new stream file New, /// Display open tasks - Todo, + Todo { + /// Show tasks with dates in the future + #[arg(long)] + show_future: bool, + + #[command(subcommand)] + action: Option, + }, /// Edit a stream file by position Edit { diff --git a/src/cli/commands/todo.rs b/src/cli/commands/todo.rs index ee5a422..2102050 100644 --- a/src/cli/commands/todo.rs +++ b/src/cli/commands/todo.rs @@ -1,5 +1,7 @@ use std::fs; +use std::process::Command; +use chrono::Utc; use walkdir::WalkDir; use crate::config::Settings; @@ -33,10 +35,26 @@ fn all_files() -> Result, StreamdError> { Ok(shards) } -pub fn run() -> Result<(), StreamdError> { +pub fn collect_open_tasks(show_future: bool) -> Result, StreamdError> { let all_shards = all_files()?; + let now = Utc::now(); - for task_shard in find_shard_by_position(&all_shards, "task", "open") { + let mut tasks: Vec = find_shard_by_position(&all_shards, "task", "open") + .into_iter() + .filter(|shard| show_future || shard.moment <= now) + .collect(); + + // Sort by moment ascending (oldest first = task 1) + tasks.sort_by(|a, b| a.moment.cmp(&b.moment)); + + Ok(tasks) +} + +pub fn run_list(show_future: bool) -> Result<(), StreamdError> { + let tasks = collect_open_tasks(show_future)?; + + for (index, task_shard) in tasks.iter().enumerate() { + let task_number = index + 1; // 1-indexed if let Some(file_path) = task_shard.location.get("file") { let content = fs::read_to_string(file_path)?; let lines: Vec<&str> = content.lines().collect(); @@ -44,7 +62,10 @@ pub fn run() -> Result<(), StreamdError> { let start = task_shard.start_line.saturating_sub(1); let end = std::cmp::min(task_shard.end_line, lines.len()); - println!("--- {}:{} ---", file_path, task_shard.start_line); + println!( + "[{}] --- {}:{} ---", + task_number, file_path, task_shard.start_line + ); for line in &lines[start..end] { println!("{}", line); } @@ -54,3 +75,130 @@ pub fn run() -> Result<(), StreamdError> { Ok(()) } + +pub fn run_edit(number: usize) -> Result<(), StreamdError> { + // Always include all tasks for edit (user might want to edit a future task) + let tasks = collect_open_tasks(true)?; + + if number == 0 || number > tasks.len() { + return Err(StreamdError::InvalidTaskNumber(number, tasks.len())); + } + + let task = &tasks[number - 1]; // Convert to 0-indexed + let file_path = task + .location + .get("file") + .ok_or(StreamdError::MissingFilePath)?; + + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()); + let line_arg = format!("+{}", task.start_line); + + let status = Command::new(&editor) + .arg(&line_arg) + .arg(file_path) + .status()?; + + if !status.success() { + return Err(StreamdError::IoError(std::io::Error::other( + "Editor exited with non-zero status", + ))); + } + + Ok(()) +} + +pub fn run_done(number: usize) -> Result<(), StreamdError> { + // Always include all tasks for done (user might want to mark a future task as done) + let tasks = collect_open_tasks(true)?; + + if number == 0 || number > tasks.len() { + return Err(StreamdError::InvalidTaskNumber(number, tasks.len())); + } + + let task = &tasks[number - 1]; + let file_path = task + .location + .get("file") + .ok_or(StreamdError::MissingFilePath)?; + + let content = fs::read_to_string(file_path)?; + let mut lines: Vec = content.lines().map(String::from).collect(); + + // Find the line containing @Task (should be at start_line) + let task_line_idx = task.start_line.saturating_sub(1); + if task_line_idx >= lines.len() { + return Err(StreamdError::InvalidLineNumber); + } + + let line = &lines[task_line_idx]; + + // Check for multiple @Task occurrences + let task_count = line.matches("@Task").count(); + if task_count > 1 { + return Err(StreamdError::MultipleTaskMarkers( + file_path.clone(), + task.start_line, + )); + } + if task_count == 0 { + return Err(StreamdError::NoTaskMarker( + file_path.clone(), + task.start_line, + )); + } + + // Insert @Done after @Task + let new_line = line.replacen("@Task", "@Task @Done", 1); + lines[task_line_idx] = new_line; + + // Write back to file, preserving trailing newline if present + let new_content = if content.ends_with('\n') { + format!("{}\n", lines.join("\n")) + } else { + lines.join("\n") + }; + fs::write(file_path, new_content)?; + + println!("Marked task {} as done", number); + + Ok(()) +} + +pub fn run() -> Result<(), StreamdError> { + run_list(false) +} + +#[cfg(test)] +mod tests { + #[test] + fn test_insert_done_after_task() { + let line = "Some content @Task with more text"; + let result = line.replacen("@Task", "@Task @Done", 1); + assert_eq!(result, "Some content @Task @Done with more text"); + } + + #[test] + fn test_insert_done_at_line_end() { + let line = "Some content @Task"; + let result = line.replacen("@Task", "@Task @Done", 1); + assert_eq!(result, "Some content @Task @Done"); + } + + #[test] + fn test_task_count_single() { + let line = "Some content @Task with more text"; + assert_eq!(line.matches("@Task").count(), 1); + } + + #[test] + fn test_task_count_multiple() { + let line = "Some @Task content @Task again"; + assert_eq!(line.matches("@Task").count(), 2); + } + + #[test] + fn test_task_count_none() { + let line = "Some content without task marker"; + assert_eq!(line.matches("@Task").count(), 0); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 6fdae80..7642ab0 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,4 +1,4 @@ pub mod args; pub mod commands; -pub use args::{Cli, Commands}; +pub use args::{Cli, Commands, TodoAction}; diff --git a/src/error.rs b/src/error.rs index c76801c..2512ef9 100644 --- a/src/error.rs +++ b/src/error.rs @@ -16,6 +16,21 @@ pub enum StreamdError { #[error("TOML error: {0}")] TomlError(#[from] toml::de::Error), + + #[error("Invalid task number {0}: only {1} tasks available")] + InvalidTaskNumber(usize, usize), + + #[error("Task shard missing file path")] + MissingFilePath, + + #[error("Invalid line number in task")] + InvalidLineNumber, + + #[error("Multiple @Task markers found in {0}:{1} - cannot auto-insert @Done")] + MultipleTaskMarkers(String, usize), + + #[error("No @Task marker found in {0}:{1}")] + NoTaskMarker(String, usize), } impl From for miette::Report { diff --git a/src/main.rs b/src/main.rs index d4bfd9e..e6325c8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,19 @@ use clap::Parser; -use streamd::cli::{Cli, Commands}; +use streamd::cli::{Cli, Commands, TodoAction}; fn main() -> miette::Result<()> { let cli = Cli::parse(); match cli.command { Some(Commands::New) => streamd::cli::commands::new::run()?, - Some(Commands::Todo) => streamd::cli::commands::todo::run()?, + Some(Commands::Todo { + show_future, + action, + }) => match action { + None => streamd::cli::commands::todo::run_list(show_future)?, + Some(TodoAction::Edit { number }) => streamd::cli::commands::todo::run_edit(number)?, + Some(TodoAction::Done { number }) => streamd::cli::commands::todo::run_done(number)?, + }, Some(Commands::Edit { number }) => streamd::cli::commands::edit::run(number)?, Some(Commands::Timesheet) => streamd::cli::commands::timesheet::run()?, Some(Commands::Completions { shell }) => { From e05e9cfd90388209bb569fa0ebf34d17ae43ed23 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Thu, 2 Apr 2026 18:28:13 +0200 Subject: [PATCH 14/17] test(todo): add comprehensive unit tests for todo features Add tests for: - @Done insertion at various line positions - Future task filtering with show_future flag - Task sorting by moment ascending - Error message formatting for all new error variants - Trailing newline preservation --- src/cli/commands/todo.rs | 127 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/src/cli/commands/todo.rs b/src/cli/commands/todo.rs index 2102050..d443203 100644 --- a/src/cli/commands/todo.rs +++ b/src/cli/commands/todo.rs @@ -170,6 +170,26 @@ pub fn run() -> Result<(), StreamdError> { #[cfg(test)] mod tests { + use super::*; + use chrono::{Duration, TimeZone}; + use indexmap::IndexMap; + + fn make_task_shard(moment: chrono::DateTime, file: &str) -> LocalizedShard { + let mut location = IndexMap::new(); + location.insert("file".to_string(), file.to_string()); + location.insert("task".to_string(), "open".to_string()); + + LocalizedShard { + markers: vec!["Task".to_string()], + tags: vec![], + start_line: 1, + end_line: 1, + moment, + location, + children: vec![], + } + } + #[test] fn test_insert_done_after_task() { let line = "Some content @Task with more text"; @@ -184,6 +204,13 @@ mod tests { assert_eq!(result, "Some content @Task @Done"); } + #[test] + fn test_insert_done_only_first_task() { + let line = "Some @Task content @Task again"; + let result = line.replacen("@Task", "@Task @Done", 1); + assert_eq!(result, "Some @Task @Done content @Task again"); + } + #[test] fn test_task_count_single() { let line = "Some content @Task with more text"; @@ -201,4 +228,104 @@ mod tests { let line = "Some content without task marker"; assert_eq!(line.matches("@Task").count(), 0); } + + #[test] + fn test_filter_future_tasks_excludes_future_when_show_future_false() { + let now = Utc::now(); + let past = now - Duration::hours(1); + let future = now + Duration::hours(1); + + let tasks = vec![ + make_task_shard(past, "past.md"), + make_task_shard(future, "future.md"), + ]; + + let filtered: Vec<_> = tasks + .into_iter() + .filter(|shard| shard.moment <= now) + .collect(); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].location.get("file").unwrap(), "past.md"); + } + + #[test] + fn test_filter_future_tasks_includes_all_when_show_future_true() { + let now = Utc::now(); + let past = now - Duration::hours(1); + let future = now + Duration::hours(1); + + let tasks = vec![ + make_task_shard(past, "past.md"), + make_task_shard(future, "future.md"), + ]; + + let filtered: Vec<_> = tasks.into_iter().filter(|_| true).collect(); + + assert_eq!(filtered.len(), 2); + } + + #[test] + fn test_sort_tasks_by_moment_ascending() { + let oldest = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap(); + let middle = Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 0).unwrap(); + let newest = Utc.with_ymd_and_hms(2022, 1, 1, 0, 0, 0).unwrap(); + + let mut tasks = vec![ + make_task_shard(newest, "newest.md"), + make_task_shard(oldest, "oldest.md"), + make_task_shard(middle, "middle.md"), + ]; + + tasks.sort_by(|a, b| a.moment.cmp(&b.moment)); + + assert_eq!(tasks[0].location.get("file").unwrap(), "oldest.md"); + assert_eq!(tasks[1].location.get("file").unwrap(), "middle.md"); + assert_eq!(tasks[2].location.get("file").unwrap(), "newest.md"); + } + + #[test] + fn test_preserve_trailing_newline() { + let content_with_newline = "line1\nline2\n"; + let content_without_newline = "line1\nline2"; + + assert!(content_with_newline.ends_with('\n')); + assert!(!content_without_newline.ends_with('\n')); + } + + #[test] + fn test_invalid_task_number_zero() { + let result = StreamdError::InvalidTaskNumber(0, 5); + assert_eq!( + result.to_string(), + "Invalid task number 0: only 5 tasks available" + ); + } + + #[test] + fn test_invalid_task_number_exceeds_count() { + let result = StreamdError::InvalidTaskNumber(10, 3); + assert_eq!( + result.to_string(), + "Invalid task number 10: only 3 tasks available" + ); + } + + #[test] + fn test_multiple_task_markers_error_message() { + let result = StreamdError::MultipleTaskMarkers("/path/file.md".to_string(), 42); + assert_eq!( + result.to_string(), + "Multiple @Task markers found in /path/file.md:42 - cannot auto-insert @Done" + ); + } + + #[test] + fn test_no_task_marker_error_message() { + let result = StreamdError::NoTaskMarker("/path/file.md".to_string(), 42); + assert_eq!( + result.to_string(), + "No @Task marker found in /path/file.md:42" + ); + } } From 926a239d7e694d8720e69e2c638a533281787e0f Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Thu, 2 Apr 2026 18:29:02 +0200 Subject: [PATCH 15/17] docs: add todo edit/done and --show-future documentation Document the new todo command features: - Numbered task display - `streamd todo N edit` for editing tasks - `streamd todo N done` for marking tasks done - `--show-future` flag for including future tasks - Add R21 specification for todo command behavior --- README.md | 11 +++++++++-- REQUIREMENTS.md | 31 ++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e2e0bcd..1ee95a9 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,10 @@ Within files, `@`-prefixed markers at the beginning of paragraphs or headings de ## Commands - `streamd` / `streamd new` — Create a new timestamped markdown entry, opening your editor -- `streamd todo` — Show all open tasks (shards with `@Task` markers) +- `streamd todo` — Show all open tasks (shards with `@Task` markers), numbered for easy reference +- `streamd todo N edit` — Edit task N in your editor, jumping to the task's line +- `streamd todo N done` — Mark task N as done by inserting `@Done` after `@Task` +- `streamd todo --show-future` — Include tasks with future dates in the listing - `streamd edit [number]` — Edit a stream file by index (most recent first) - `streamd timesheet` — Generate time reports from `@Timesheet` markers @@ -60,4 +63,8 @@ The timesheet command will calculate expected vs actual working hours based on t Running `streamd` opens your editor to create a new entry. After saving, the file is renamed based on its timestamp and any markers found in the content. -Running `streamd todo` finds all shards marked as open tasks and displays them as rich-formatted panels in your terminal. +Running `streamd todo` finds all shards marked as open tasks and displays them numbered in your terminal. Tasks with future dates are hidden by default (use `--show-future` to include them). Tasks are sorted by date with oldest first (task 1 is the oldest). + +You can quickly edit or complete tasks by number: +- `streamd todo 1 edit` opens task 1 in your editor at the correct line +- `streamd todo 1 done` marks task 1 as done by inserting `@Done` after `@Task` diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 04e8898..49f3f42 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -387,11 +387,40 @@ Provide recursive search through the shard tree: | Command | Description | |---------|-------------| | `streamd new` | Create new timestamped file, open editor, rename with markers on close | -| `streamd todo` | List all shards with `task: "open"` | +| `streamd todo` | List all shards with `task: "open"`, numbered, hiding future tasks | +| `streamd todo --show-future` | Include tasks with future dates in the todo listing | +| `streamd todo N edit` | Edit task N in editor, cursor positioned at task line | +| `streamd todo N done` | Mark task N as done by inserting `@Done` after `@Task` | | `streamd edit [n]` | Edit nth file (supports negative indexing for recent files) | | `streamd timesheet` | Generate formatted timesheet report with expected/actual hours | | `streamd completions ` | Generate shell completions (bash, zsh, fish, elvish, powershell) | +### R21: Todo Command Behavior + +**Task Numbering:** +- Tasks are numbered starting from 1 (oldest task = 1) +- Tasks are sorted by their `moment` field in ascending order +- Output format: `[N] --- file.md:line ---` followed by task content + +**Future Task Filtering:** +- By default, tasks with `moment > now` are hidden from the listing +- The `--show-future` flag includes all tasks regardless of their moment +- When using `todo N edit` or `todo N done`, all tasks (including future) are considered for number lookup + +**Edit Action (`todo N edit`):** +- Opens the task's file in `$EDITOR` (defaults to `vi`) +- Uses `+LINE` argument to position cursor at task's start line +- Errors if N is 0 or exceeds the task count + +**Done Action (`todo N done`):** +- Reads the file and modifies the line at task's start_line +- Inserts ` @Done` immediately after `@Task` +- Preserves trailing newline if the original file had one +- Errors if: + - N is 0 or exceeds the task count + - Multiple `@Task` markers found on the same line + - No `@Task` marker found on the expected line + --- ## Application Configuration From aa8f83e3211b521dd1ad065ae0e69d3691e5a1f6 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Thu, 2 Apr 2026 18:36:10 +0200 Subject: [PATCH 16/17] fix: replace vec! with array to satisfy clippy useless_vec lint --- src/cli/commands/todo.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/commands/todo.rs b/src/cli/commands/todo.rs index d443203..fe8e870 100644 --- a/src/cli/commands/todo.rs +++ b/src/cli/commands/todo.rs @@ -271,7 +271,7 @@ mod tests { let middle = Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 0).unwrap(); let newest = Utc.with_ymd_and_hms(2022, 1, 1, 0, 0, 0).unwrap(); - let mut tasks = vec![ + let mut tasks = [ make_task_shard(newest, "newest.md"), make_task_shard(oldest, "oldest.md"), make_task_shard(middle, "middle.md"), From be6c3511669bd821838e6ae5c5ab01980b0d250d Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 3 Apr 2026 00:07:23 +0000 Subject: [PATCH 17/17] chore(deps): lock file maintenance --- Cargo.lock | 52 ++++++++++++++++++++++++++-------------------------- flake.lock | 18 +++++++++--------- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 105e70e..d2aacd1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -405,9 +405,9 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "indexmap" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -444,9 +444,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.92" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ "once_cell", "wasm-bindgen", @@ -460,9 +460,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libredox" @@ -822,9 +822,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -957,9 +957,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.0+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", @@ -972,27 +972,27 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" -version = "1.1.0+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "unicase" @@ -1072,9 +1072,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.115" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" dependencies = [ "cfg-if", "once_cell", @@ -1085,9 +1085,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.115" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1095,9 +1095,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.115" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" dependencies = [ "bumpalo", "proc-macro2", @@ -1108,9 +1108,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.115" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" dependencies = [ "unicode-ident", ] @@ -1228,9 +1228,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" [[package]] name = "wit-bindgen" diff --git a/flake.lock b/flake.lock index 3232a9b..20b479f 100644 --- a/flake.lock +++ b/flake.lock @@ -40,11 +40,11 @@ ] }, "locked": { - "lastModified": 1774104215, - "narHash": "sha256-EAtviqz0sEAxdHS4crqu7JGR5oI3BwaqG0mw7CmXkO8=", + "lastModified": 1775036584, + "narHash": "sha256-zW0lyy7ZNNT/x8JhzFHBsP2IPx7ATZIPai4FJj12BgU=", "owner": "cachix", "repo": "git-hooks.nix", - "rev": "f799ae951fde0627157f40aec28dec27b22076d0", + "rev": "4e0eb042b67d863b1b34b3f64d52ceb9cd926735", "type": "github" }, "original": { @@ -76,11 +76,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1774386573, - "narHash": "sha256-4hAV26quOxdC6iyG7kYaZcM3VOskcPUrdCQd/nx8obc=", + "lastModified": 1775036866, + "narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=", "owner": "nixos", "repo": "nixpkgs", - "rev": "46db2e09e1d3f113a13c0d7b81e2f221c63b8ce9", + "rev": "6201e203d09599479a3b3450ed24fa81537ebc4e", "type": "github" }, "original": { @@ -105,11 +105,11 @@ ] }, "locked": { - "lastModified": 1774753967, - "narHash": "sha256-HpT5fE8JQSbAxolUnw3VgGAo3urVjcrgtB2rtoxURVw=", + "lastModified": 1775099554, + "narHash": "sha256-3xBsGnGDLOFtnPZ1D3j2LU19wpAlYefRKTlkv648rU0=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "405b9b4c2c6c5a2b1d390524ce8a240729f34a96", + "rev": "8d6387ed6d8e6e6672fd3ed4b61b59d44b124d99", "type": "github" }, "original": {