diff --git a/Cargo.lock b/Cargo.lock index e0a0cfa..ba51b84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,28 +163,6 @@ 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" @@ -572,53 +550,6 @@ 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" @@ -682,21 +613,6 @@ 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" @@ -835,18 +751,11 @@ 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 8b8d502..d933964 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,6 @@ 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 e2e0bcd..474663c 100644 --- a/README.md +++ b/README.md @@ -27,34 +27,7 @@ Within files, `@`-prefixed markers at the beginning of paragraphs or headings de ## Configuration -### 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 +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). ## Usage diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 04e8898..2ba44bd 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -320,52 +320,6 @@ 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 @@ -389,7 +343,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` | Generate formatted timesheet report with expected/actual hours | +| `streamd timesheet` | Extract and export timesheet data as CSV | | `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 9fe413f..f727edc 100644 --- a/src/cli/commands/timesheet.rs +++ b/src/cli/commands/timesheet.rs @@ -1,7 +1,5 @@ use std::fs; -use std::path::Path; -use chrono::Datelike; use walkdir::WalkDir; use crate::config::Settings; @@ -9,15 +7,13 @@ 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, generate_report, load_repository_config, BasicTimesheetConfiguration, - DayType, DayWarning, MonthReport, TimesheetReport, -}; +use crate::timesheet::{extract_timesheets, BasicTimesheetConfiguration}; -fn load_all_shards(base_folder: &Path) -> Result, StreamdError> { +fn all_files() -> Result, StreamdError> { + let settings = Settings::load()?; let mut shards = Vec::new(); - for entry in WalkDir::new(base_folder) + for entry in WalkDir::new(&settings.base_folder) .max_depth(1) .into_iter() .filter_map(|e| e.ok()) @@ -37,246 +33,20 @@ fn load_all_shards(base_folder: &Path) -> Result, StreamdErr Ok(shards) } -/// 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); + let all_shards = all_files()?; + let mut sheets = extract_timesheets(&all_shards)?; + sheets.sort_by_key(|s| s.date); - // 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(()); + 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(",")); } - // 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 deleted file mode 100644 index e743cc2..0000000 --- a/src/timesheet/config.rs +++ /dev/null @@ -1,258 +0,0 @@ -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 deleted file mode 100644 index 6ff191c..0000000 --- a/src/timesheet/generator.rs +++ /dev/null @@ -1,542 +0,0 @@ -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 9e4f000..85bbab2 100644 --- a/src/timesheet/mod.rs +++ b/src/timesheet/mod.rs @@ -1,15 +1,7 @@ -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 deleted file mode 100644 index f96370d..0000000 --- a/src/timesheet/report.rs +++ /dev/null @@ -1,322 +0,0 @@ -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 deleted file mode 100644 index 0eacf06..0000000 --- a/src/timesheet/validation.rs +++ /dev/null @@ -1,111 +0,0 @@ -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); - } -}