diff --git a/Cargo.lock b/Cargo.lock index ba51b84..e0a0cfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,6 +163,28 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", +] + [[package]] name = "clap" version = "4.6.0" @@ -550,6 +572,53 @@ version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -613,6 +682,21 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "redox_users" version = "0.4.6" @@ -751,11 +835,18 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "streamd" version = "0.1.0" dependencies = [ "chrono", + "chrono-tz", "clap", "clap_complete", "directories", diff --git a/Cargo.toml b/Cargo.toml index d933964..8b8d502 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ pulldown-cmark = "0.12" regex = "1" once_cell = "1" chrono = { version = "0.4", features = ["serde"] } +chrono-tz = "0.9" walkdir = "2" indexmap = { version = "2", features = ["serde"] } itertools = "0.13" diff --git a/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) | --- 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(()) } 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/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 85bbab2..9e4f000 100644 --- a/src/timesheet/mod.rs +++ b/src/timesheet/mod.rs @@ -1,7 +1,15 @@ +mod config; mod configuration; mod extract; +mod generator; 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 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; 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()); + } +} 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); + } +}