29_timesheet-management #64

Merged
kfickel merged 9 commits from 29_timesheet-management into main 2026-04-02 18:17:37 +02:00
11 changed files with 1654 additions and 20 deletions

91
Cargo.lock generated
View file

@ -163,6 +163,28 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "clap" name = "clap"
version = "4.6.0" version = "4.6.0"
@ -550,6 +572,53 @@ version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" 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]] [[package]]
name = "pretty_assertions" name = "pretty_assertions"
version = "1.4.1" version = "1.4.1"
@ -613,6 +682,21 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" 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]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.5.2" version = "0.5.2"
@ -751,11 +835,18 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "siphasher"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
[[package]] [[package]]
name = "streamd" name = "streamd"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"chrono-tz",
"clap", "clap",
"clap_complete", "clap_complete",
"directories", "directories",

View file

@ -18,6 +18,7 @@ pulldown-cmark = "0.13"
regex = "1" regex = "1"
once_cell = "1" once_cell = "1"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
chrono-tz = "0.9"
walkdir = "2" walkdir = "2"
indexmap = { version = "2", features = ["serde"] } indexmap = { version = "2", features = ["serde"] }
itertools = "0.14" itertools = "0.14"

View file

@ -27,7 +27,34 @@ Within files, `@`-prefixed markers at the beginning of paragraphs or headings de
## Configuration ## 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 ## Usage

View file

@ -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). **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 ## 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 new` | Create new timestamped file, open editor, rename with markers on close |
| `streamd todo` | List all shards with `task: "open"` | | `streamd todo` | List all shards with `task: "open"` |
| `streamd edit [n]` | Edit nth file (supports negative indexing for recent files) | | `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 <shell>` | Generate shell completions (bash, zsh, fish, elvish, powershell) | | `streamd completions <shell>` | Generate shell completions (bash, zsh, fish, elvish, powershell) |
--- ---

View file

@ -1,19 +1,26 @@
use std::fs; use std::fs;
use std::path::Path;
use chrono::Datelike;
use walkdir::WalkDir; use walkdir::WalkDir;
use crate::config::Settings; use crate::config::Settings;
const SEPARATOR_WIDTH: usize = 71;
const COLUMN_SEPARATOR_WIDTH: usize = 65;
use crate::error::StreamdError; use crate::error::StreamdError;
use crate::extract::parse_markdown_file; use crate::extract::parse_markdown_file;
use crate::localize::localize_stream_file; use crate::localize::localize_stream_file;
use crate::models::LocalizedShard; 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<Vec<LocalizedShard>, StreamdError> { fn load_all_shards(base_folder: &Path) -> Result<Vec<LocalizedShard>, StreamdError> {
let settings = Settings::load()?;
let mut shards = Vec::new(); let mut shards = Vec::new();
for entry in WalkDir::new(&settings.base_folder) for entry in WalkDir::new(base_folder)
.max_depth(1) .max_depth(1)
.into_iter() .into_iter()
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
@ -33,20 +40,237 @@ fn all_files() -> Result<Vec<LocalizedShard>, StreamdError> {
Ok(shards) Ok(shards)
} }
pub fn run() -> Result<(), StreamdError> { /// Format hours with sign for display.
let all_shards = all_files()?; fn format_diff(hours: f64) -> String {
let mut sheets = extract_timesheets(&all_shards)?; if hours >= 0.0 {
sheets.sort_by_key(|s| s.date); format!("+{:.1}h", hours.abs())
} else {
for sheet in sheets { format!("{:.1}h", hours)
println!("{}", sheet.date);
let times: Vec<String> = sheet
.timecards
.iter()
.map(|card| format!("{},{}", card.from_time, card.to_time))
.collect();
println!("{}", times.join(","));
} }
}
/// Format hours for display without sign.
fn format_hours(hours: f64) -> String {
format!("{:.1}h", hours.abs())
}
/// 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() {
let double_line = "\u{2550}".repeat(SEPARATOR_WIDTH);
println!("{}", double_line);
println!(" TIMESHEET REPORT");
println!("{}", double_line);
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
let light_line = "\u{2500}".repeat(COLUMN_SEPARATOR_WIDTH);
println!(" Date Day Expected Actual Diff Type");
println!(" {}", light_line);
// 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!(" {}", light_line);
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) {
let light_line = "\u{2500}".repeat(SEPARATOR_WIDTH);
println!("{}", light_line);
println!(
" CUMULATIVE BALANCE: {}",
format_diff(balance)
);
println!("{}", light_line);
}
/// 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(&timesheets, &timesheet_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(()) Ok(())
} }

258
src/timesheet/config.rs Normal file
View file

@ -0,0 +1,258 @@
use chrono::NaiveDate;
use serde::Deserialize;
use crate::error::StreamdError;
/// Configuration for timesheet periods and timezone.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct TimesheetConfig {
#[serde(default)]
pub periods: Vec<Period>,
}
/// A period of time with expected working hours per week.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Period {
pub start: NaiveDate,
pub end: NaiveDate,
pub hours_per_week: f64,
}
impl Period {
/// Calculate the expected hours per day (Mon-Fri distribution).
pub fn hours_per_day(&self) -> f64 {
self.hours_per_week / 5.0
}
/// Check if a date falls within this period.
pub fn contains(&self, date: NaiveDate) -> bool {
date >= self.start && date <= self.end
}
}
impl TimesheetConfig {
/// Validate the timesheet configuration.
/// - Ensures no periods overlap
/// - Ensures start <= end for each period
pub fn validate(&self) -> Result<(), StreamdError> {
// Check each period has valid date range
for period in &self.periods {
if period.start > period.end {
return Err(StreamdError::ConfigError(format!(
"Period start date {} is after end date {}",
period.start, period.end
)));
}
}
// Check for overlapping periods
for i in 0..self.periods.len() {
for j in (i + 1)..self.periods.len() {
if periods_overlap(&self.periods[i], &self.periods[j]) {
return Err(StreamdError::ConfigError(format!(
"Periods overlap: {}-{} and {}-{}",
self.periods[i].start,
self.periods[i].end,
self.periods[j].start,
self.periods[j].end
)));
}
}
}
Ok(())
}
/// Find the period that contains a given date.
pub fn find_period(&self, date: NaiveDate) -> Option<&Period> {
self.periods.iter().find(|p| p.contains(date))
}
}
/// Check if two periods overlap.
fn periods_overlap(a: &Period, b: &Period) -> bool {
a.start <= b.end && b.start <= a.end
}
/// Repository-level configuration loaded from .streamd.toml.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct RepositoryConfig {
#[serde(default)]
pub timezone: Option<String>,
#[serde(default)]
pub timesheet: Option<TimesheetConfig>,
}
#[cfg(test)]
mod tests {
use super::*;
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).unwrap()
}
#[test]
fn test_period_contains_date() {
let period = Period {
start: date(2026, 1, 1),
end: date(2026, 6, 30),
hours_per_week: 38.0,
};
assert!(period.contains(date(2026, 1, 1)));
assert!(period.contains(date(2026, 3, 15)));
assert!(period.contains(date(2026, 6, 30)));
assert!(!period.contains(date(2025, 12, 31)));
assert!(!period.contains(date(2026, 7, 1)));
}
#[test]
fn test_period_hours_per_day() {
let period = Period {
start: date(2026, 1, 1),
end: date(2026, 6, 30),
hours_per_week: 38.0,
};
assert!((period.hours_per_day() - 7.6).abs() < 0.0001);
}
#[test]
fn test_validate_valid_config() {
let config = TimesheetConfig {
periods: vec![
Period {
start: date(2026, 1, 1),
end: date(2026, 6, 30),
hours_per_week: 38.0,
},
Period {
start: date(2026, 7, 1),
end: date(2026, 12, 31),
hours_per_week: 40.0,
},
],
};
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_overlapping_periods() {
let config = TimesheetConfig {
periods: vec![
Period {
start: date(2026, 1, 1),
end: date(2026, 6, 30),
hours_per_week: 38.0,
},
Period {
start: date(2026, 6, 15),
end: date(2026, 12, 31),
hours_per_week: 40.0,
},
],
};
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("overlap"));
}
#[test]
fn test_validate_start_after_end() {
let config = TimesheetConfig {
periods: vec![Period {
start: date(2026, 6, 30),
end: date(2026, 1, 1),
hours_per_week: 38.0,
}],
};
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("after"));
}
#[test]
fn test_find_period() {
let config = TimesheetConfig {
periods: vec![
Period {
start: date(2026, 1, 1),
end: date(2026, 6, 30),
hours_per_week: 38.0,
},
Period {
start: date(2026, 7, 1),
end: date(2026, 12, 31),
hours_per_week: 40.0,
},
],
};
let period = config.find_period(date(2026, 3, 15));
assert!(period.is_some());
assert!((period.unwrap().hours_per_week - 38.0).abs() < 0.0001);
let period = config.find_period(date(2026, 9, 15));
assert!(period.is_some());
assert!((period.unwrap().hours_per_week - 40.0).abs() < 0.0001);
let period = config.find_period(date(2025, 12, 15));
assert!(period.is_none());
}
#[test]
fn test_empty_config_is_valid() {
let config = TimesheetConfig { periods: vec![] };
assert!(config.validate().is_ok());
}
#[test]
fn test_adjacent_periods_not_overlapping() {
let config = TimesheetConfig {
periods: vec![
Period {
start: date(2026, 1, 1),
end: date(2026, 6, 30),
hours_per_week: 38.0,
},
Period {
start: date(2026, 7, 1),
end: date(2026, 12, 31),
hours_per_week: 40.0,
},
],
};
assert!(config.validate().is_ok());
}
#[test]
fn test_deserialize_repository_config() {
let toml_str = r#"
timezone = "Europe/Berlin"
[timesheet]
[[timesheet.periods]]
start = "2026-01-01"
end = "2026-06-30"
hours_per_week = 38.0
[[timesheet.periods]]
start = "2026-07-01"
end = "2026-12-31"
hours_per_week = 40.0
"#;
let config: RepositoryConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.timezone, Some("Europe/Berlin".to_string()));
assert!(config.timesheet.is_some());
let timesheet = config.timesheet.unwrap();
assert_eq!(timesheet.periods.len(), 2);
assert!((timesheet.periods[0].hours_per_week - 38.0).abs() < 0.0001);
assert!((timesheet.periods[1].hours_per_week - 40.0).abs() < 0.0001);
}
}

View file

@ -136,8 +136,12 @@ fn aggregate_timecard_day(points: &[TimesheetPoint]) -> Result<Option<Timesheet>
fn aggregate_timecards(points: &[TimesheetPoint]) -> Result<Vec<Timesheet>, StreamdError> { fn aggregate_timecards(points: &[TimesheetPoint]) -> Result<Vec<Timesheet>, StreamdError> {
let mut timesheets = Vec::new(); let mut timesheets = Vec::new();
// Sort points by moment to ensure proper grouping
let mut sorted_points = points.to_vec();
sorted_points.sort_by_key(|p| p.moment);
// Group by date // Group by date
for (_date, group) in &points.iter().chunk_by(|p| p.moment.date_naive()) { for (_date, group) in &sorted_points.iter().chunk_by(|p| p.moment.date_naive()) {
let day_points: Vec<_> = group.cloned().collect(); let day_points: Vec<_> = group.cloned().collect();
if let Some(timesheet) = aggregate_timecard_day(&day_points)? { if let Some(timesheet) = aggregate_timecard_day(&day_points)? {
timesheets.push(timesheet); timesheets.push(timesheet);

542
src/timesheet/generator.rs Normal file
View file

@ -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<RepositoryConfig, StreamdError> {
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<TimesheetReport, StreamdError> {
if config.periods.is_empty() {
return Ok(TimesheetReport::new());
}
// Index timesheets by date for quick lookup
let timesheets_by_date: HashMap<NaiveDate, &Timesheet> =
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<MonthReport> = Vec::new();
let mut all_warnings: Vec<ReportWarning> = 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<DayReport> = 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(&current_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<DayWarning> = 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 ascending order (oldest first, newest at bottom)
month_reports.sort_by(|a, b| {
let a_date = (a.year, a.month);
let b_date = (b.year, b.month);
a_date.cmp(&b_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(&timesheets, &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(&timesheets, &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(&timesheets, &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(&timesheets, &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(&timesheets, &config).unwrap();
// Balance should be +2h (18h actual - 16h expected)
assert!((report.cumulative_balance - 2.0).abs() < 0.0001);
}
}

View file

@ -1,7 +1,15 @@
mod config;
mod configuration; mod configuration;
mod extract; mod extract;
mod generator;
mod point_types; mod point_types;
mod report;
mod validation;
pub use config::{Period, RepositoryConfig, TimesheetConfig};
pub use configuration::{BasicTimesheetConfiguration, TIMESHEET_DIMENSION_NAME, TIMESHEET_TAG}; pub use configuration::{BasicTimesheetConfiguration, TIMESHEET_DIMENSION_NAME, TIMESHEET_TAG};
pub use extract::extract_timesheets; pub use extract::extract_timesheets;
pub use generator::{generate_report, load_repository_config};
pub use point_types::TimesheetPointType; pub use point_types::TimesheetPointType;
pub use report::{DayReport, DayType, DayWarning, MonthReport, ReportWarning, TimesheetReport};
pub use validation::find_overlapping_timecards;

322
src/timesheet/report.rs Normal file
View file

@ -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<DayWarning>,
}
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<DayWarning>) -> 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<DayReport>,
}
impl MonthReport {
pub fn new(year: i32, month: u32) -> Self {
Self {
year,
month,
days: Vec::new(),
}
}
pub fn with_days(mut self, days: Vec<DayReport>) -> 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<MonthReport>,
pub cumulative_balance: f64,
pub warnings: Vec<ReportWarning>,
}
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<MonthReport>) -> 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<ReportWarning>) -> 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());
}
}

111
src/timesheet/validation.rs Normal file
View file

@ -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<Timecard> = 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);
}
}