Compare commits

...

6 commits

Author SHA1 Message Date
14ae2909e4
docs: add timesheet management documentation
All checks were successful
Continuous Integration / Lint, Check & Test (push) Successful in 2m12s
Continuous Integration / Build Package (push) Successful in 3m0s
Update README.md with:
- Repository configuration section for .streamd.toml
- Timesheet periods configuration example
- Description of timesheet report features

Update REQUIREMENTS.md with:
- R18a: Timesheet report configuration format
- R18b: Day type rules for expected/actual hours calculation
- R18c: Timesheet report warning types
- Updated R20 command description
2026-03-29 22:08:53 +02:00
1119d91854
feat(timesheet): add formatted report output to CLI
Replace CSV output with formatted table display showing:
- Monthly breakdown with expected/actual hours per day
- Day types (Regular, Sick Leave, Vacation, Holiday, Flex Day, etc.)
- Warning indicators for missing days and overlapping timecards
- Monthly summaries with total expected/actual hours
- Cumulative balance across all months
- Detailed warnings section at the end

Shows helpful message when no .streamd.toml configuration is found.
2026-03-29 22:07:58 +02:00
3429f2e65d
feat(timesheet): add report generation logic
Implement the core report generation that:
- Loads .streamd.toml configuration
- Calculates expected hours based on periods and day types
- Calculates actual hours following day type rules:
  - Sick leave: max(expected, worked)
  - Vacation: expected + worked
  - Flex day: 0
  - Holiday: 0 expected
- Generates warnings for missing days, overlapping timecards,
  and work outside configured periods
- Calculates monthly and cumulative balances
- Sorts months in descending order
2026-03-29 22:06:44 +02:00
282d83bedb
feat(timesheet): add overlap detection for timecards
Add find_overlapping_timecards function to detect overlapping time
ranges on the same day. This is used to generate warnings in the
timesheet report.
2026-03-29 22:05:05 +02:00
7abf056609
feat(timesheet): add report data structures
Add DayReport, DayType, DayWarning, MonthReport, and TimesheetReport
structs for generating timesheet reports. These support:
- Day types (Regular, SickLeave, Vacation, Holiday, FlexDay, Weekend, Missing, OutsidePeriod)
- Warnings for missing days, overlapping timecards, and outside-period work
- Monthly and cumulative calculations
2026-03-29 22:04:29 +02:00
86433ca3dc
feat(timesheet): add configuration model with period validation
Add TimesheetConfig and Period structs for configuring timesheet
periods with expected working hours per week. Include RepositoryConfig
for loading .streamd.toml with timezone and timesheet configuration.

Validation ensures:
- No overlapping periods
- Valid date ranges (start <= end)

Also adds chrono-tz dependency for timezone support.
2026-03-29 22:03:24 +02:00
10 changed files with 1655 additions and 19 deletions

91
Cargo.lock generated
View file

@ -163,6 +163,28 @@ dependencies = [
"windows-link",
]
[[package]]
name = "chrono-tz"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
dependencies = [
"chrono",
"chrono-tz-build",
"phf",
]
[[package]]
name = "chrono-tz-build"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
dependencies = [
"parse-zoneinfo",
"phf",
"phf_codegen",
]
[[package]]
name = "clap"
version = "4.6.0"
@ -550,6 +572,53 @@ version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
[[package]]
name = "parse-zoneinfo"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24"
dependencies = [
"regex",
]
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_shared",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "pretty_assertions"
version = "1.4.1"
@ -613,6 +682,21 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "redox_users"
version = "0.4.6"
@ -751,11 +835,18 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "siphasher"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
[[package]]
name = "streamd"
version = "0.1.0"
dependencies = [
"chrono",
"chrono-tz",
"clap",
"clap_complete",
"directories",

View file

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

View file

@ -27,7 +27,34 @@ Within files, `@`-prefixed markers at the beginning of paragraphs or headings de
## Configuration
Streamd reads its configuration from `~/.config/streamd/config.toml` (XDG standard). The main setting is `base_folder`, which points to the directory containing your stream files (defaults to the current working directory).
### User Configuration
Streamd reads its user configuration from `~/.config/streamd/config.toml` (XDG standard). The main setting is `base_folder`, which points to the directory containing your stream files (defaults to the current working directory).
### Repository Configuration
For timesheet reporting, create a `.streamd.toml` file in your stream files directory:
```toml
timezone = "Europe/Berlin" # Optional: timezone for day boundaries
[timesheet]
[[timesheet.periods]]
start = "2026-01-01"
end = "2026-06-30"
hours_per_week = 38.0
[[timesheet.periods]]
start = "2026-07-01"
end = "2026-12-31"
hours_per_week = 40.0
```
The timesheet command will calculate expected vs actual working hours based on these periods, showing:
- Daily breakdown with expected/actual hours
- Special day types (sick leave, vacation, holidays, flex days)
- Warnings for missing entries and overlapping timecards
- Monthly and cumulative balance
## Usage

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).
### R18a: Timesheet Report Configuration
The `.streamd.toml` file in the base folder configures timesheet periods:
```toml
timezone = "Europe/Berlin" # Optional timezone for day boundaries
[timesheet]
[[timesheet.periods]]
start = "2026-01-01"
end = "2026-06-30"
hours_per_week = 38.0
[[timesheet.periods]]
start = "2026-07-01"
end = "2026-12-31"
hours_per_week = 40.0
```
**Configuration Rules:**
- Dates use ISO 8601 format (`YYYY-MM-DD`)
- Periods MUST NOT overlap (validation error if they do)
- Gaps between periods are allowed — days in gaps have 0 expected hours
- `hours_per_week` is distributed over Mon-Fri (e.g., 38h/week = 7.6h/day)
### R18b: Day Type Rules
| Day Type | Expected Hours | Actual Hours |
|----------|---------------|--------------|
| Regular work day | period.hours_per_week / 5 | Sum of timecards |
| Weekend (Sat/Sun) | 0 | Sum of timecards (hidden if 0) |
| Sick Leave (@SickLeave) | Normal expected | max(expected, worked) |
| Vacation (@VacationDay) | Normal expected | expected + worked |
| Holiday (@Holiday) | 0 | Sum of timecards |
| Flex Day (@UndertimeDay) | Normal expected | 0 |
| Day in gap (no period) | 0 | Sum of timecards + warning |
| Missing (no entries) | Normal expected | 0 + warning |
### R18c: Timesheet Report Warnings
The report generates warnings for:
1. **Missing days without explanation**: A weekday within a configured period has no timecard entries and no special day type marker
2. **Overlapping timecards**: Two or more timecards on the same day have overlapping time ranges
3. **Work outside configured periods**: Work logged on a day that falls outside all configured periods
---
## Query System
@ -343,7 +389,7 @@ Provide recursive search through the shard tree:
| `streamd new` | Create new timestamped file, open editor, rename with markers on close |
| `streamd todo` | List all shards with `task: "open"` |
| `streamd edit [n]` | Edit nth file (supports negative indexing for recent files) |
| `streamd timesheet` | Extract and export timesheet data as CSV |
| `streamd timesheet` | Generate formatted timesheet report with expected/actual hours |
| `streamd completions <shell>` | Generate shell completions (bash, zsh, fish, elvish, powershell) |
---

View file

@ -1,5 +1,7 @@
use std::fs;
use std::path::Path;
use chrono::Datelike;
use walkdir::WalkDir;
use crate::config::Settings;
@ -7,13 +9,15 @@ use crate::error::StreamdError;
use crate::extract::parse_markdown_file;
use crate::localize::localize_stream_file;
use crate::models::LocalizedShard;
use crate::timesheet::{extract_timesheets, BasicTimesheetConfiguration};
use crate::timesheet::{
extract_timesheets, generate_report, load_repository_config, BasicTimesheetConfiguration,
DayType, DayWarning, MonthReport, TimesheetReport,
};
fn all_files() -> Result<Vec<LocalizedShard>, StreamdError> {
let settings = Settings::load()?;
fn load_all_shards(base_folder: &Path) -> Result<Vec<LocalizedShard>, StreamdError> {
let mut shards = Vec::new();
for entry in WalkDir::new(&settings.base_folder)
for entry in WalkDir::new(base_folder)
.max_depth(1)
.into_iter()
.filter_map(|e| e.ok())
@ -33,20 +37,246 @@ fn all_files() -> Result<Vec<LocalizedShard>, StreamdError> {
Ok(shards)
}
pub fn run() -> Result<(), StreamdError> {
let all_shards = all_files()?;
let mut sheets = extract_timesheets(&all_shards)?;
sheets.sort_by_key(|s| s.date);
for sheet in sheets {
println!("{}", sheet.date);
let times: Vec<String> = sheet
.timecards
.iter()
.map(|card| format!("{},{}", card.from_time, card.to_time))
.collect();
println!("{}", times.join(","));
/// Format hours with sign for display.
fn format_diff(hours: f64) -> String {
if hours >= 0.0 {
format!("+{:.1}h", hours)
} else {
format!("{:.1}h", hours)
}
}
/// Format hours for display without sign.
fn format_hours(hours: f64) -> String {
format!("{:.1}h", hours)
}
/// Get the weekday abbreviation.
fn weekday_abbrev(date: chrono::NaiveDate) -> &'static str {
match date.weekday() {
chrono::Weekday::Mon => "Mon",
chrono::Weekday::Tue => "Tue",
chrono::Weekday::Wed => "Wed",
chrono::Weekday::Thu => "Thu",
chrono::Weekday::Fri => "Fri",
chrono::Weekday::Sat => "Sat",
chrono::Weekday::Sun => "Sun",
}
}
/// Print the timesheet report header.
fn print_header() {
println!(
"\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}"
);
println!(" TIMESHEET REPORT");
println!(
"\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}"
);
println!();
}
/// Print a month report.
fn print_month(month: &MonthReport) {
let diff_str = format_diff(month.diff());
let month_title = format!("{} {}", month.month_name(), month.year);
// Month header with diff
print!("\u{2550}\u{2550}\u{2550} {} ", month_title);
let padding = 52 - month_title.len() - diff_str.len();
for _ in 0..padding {
print!("\u{2550}");
}
println!(" Diff: {} \u{2550}\u{2550}\u{2550}", diff_str);
println!();
// Column headers
println!(" Date Day Expected Actual Diff Type");
println!(
" \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
);
// Day rows
for day in &month.days {
let date_str = day.date.format("%Y-%m-%d").to_string();
let weekday = weekday_abbrev(day.date);
let expected = format_hours(day.expected_hours);
let actual = format_hours(day.actual_hours);
let diff = format_diff(day.diff());
let type_str = match day.day_type {
DayType::Regular => String::new(),
DayType::Missing if day.has_warnings() => "\u{26a0} Missing".to_string(),
_ => day.day_type.to_string(),
};
// Add overlap warning indicator
let type_str = if day
.warnings
.iter()
.any(|w| matches!(w, DayWarning::OverlappingTimecards { .. }))
{
if type_str.is_empty() {
"\u{26a0} Overlap".to_string()
} else {
format!("{} \u{26a0}", type_str)
}
} else {
type_str
};
println!(
" {} {} {:>7} {:>7} {:>6} {}",
date_str, weekday, expected, actual, diff, type_str
);
}
// Monthly totals
println!(
" \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
);
println!(
" Monthly: {:>7} {:>7} {:>6}",
format_hours(month.total_expected()),
format_hours(month.total_actual()),
format_diff(month.diff())
);
println!();
}
/// Print the cumulative balance.
fn print_cumulative_balance(balance: f64) {
println!(
"\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
);
println!(
" CUMULATIVE BALANCE: {}",
format_diff(balance)
);
println!(
"\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
);
}
/// Print warnings section.
fn print_warnings(report: &TimesheetReport) {
if !report.has_warnings() {
return;
}
println!();
println!("\u{26a0} Warnings:");
println!();
// Group warnings by type
let missing_warnings: Vec<_> = report
.warnings
.iter()
.filter(|w| matches!(w.warning, DayWarning::MissingWithoutExplanation))
.collect();
let overlap_warnings: Vec<_> = report
.warnings
.iter()
.filter(|w| matches!(w.warning, DayWarning::OverlappingTimecards { .. }))
.collect();
let outside_period_warnings: Vec<_> = report
.warnings
.iter()
.filter(|w| matches!(w.warning, DayWarning::OutsidePeriod { .. }))
.collect();
if !missing_warnings.is_empty() {
println!(" Missing days without explanation:");
for w in &missing_warnings {
let weekday = weekday_abbrev(w.date);
println!(
" - {} ({}): No entries and no leave/holiday marker",
w.date.format("%Y-%m-%d"),
weekday
);
}
println!();
}
if !overlap_warnings.is_empty() {
println!(" Overlapping timecards:");
for w in &overlap_warnings {
if let DayWarning::OverlappingTimecards { first, second } = &w.warning {
println!(
" - {}: {}-{} overlaps with {}-{}",
w.date.format("%Y-%m-%d"),
first.0.format("%H:%M"),
first.1.format("%H:%M"),
second.0.format("%H:%M"),
second.1.format("%H:%M")
);
}
}
println!();
}
if !outside_period_warnings.is_empty() {
println!(" Work logged outside configured periods:");
for w in &outside_period_warnings {
if let DayWarning::OutsidePeriod { hours_worked } = &w.warning {
println!(
" - {}: {:.1}h worked (no period configured)",
w.date.format("%Y-%m-%d"),
hours_worked
);
}
}
println!();
}
}
pub fn run() -> Result<(), StreamdError> {
let settings = Settings::load()?;
let base_folder = Path::new(&settings.base_folder);
// Load repository configuration
let repo_config = load_repository_config(base_folder)?;
// Check if timesheet is configured
let timesheet_config = match repo_config.timesheet {
Some(config) => config,
None => {
println!("No timesheet configuration found in .streamd.toml");
println!();
println!("Add a [timesheet] section with periods to enable timesheet reporting:");
println!();
println!(" [timesheet]");
println!(" [[timesheet.periods]]");
println!(" start = \"2026-01-01\"");
println!(" end = \"2026-12-31\"");
println!(" hours_per_week = 40.0");
return Ok(());
}
};
// Load all markdown files and extract timesheets
let all_shards = load_all_shards(base_folder)?;
let timesheets = extract_timesheets(&all_shards)?;
// Generate the report
let report = generate_report(&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(())
}

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);
}
}

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 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(&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 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;

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);
}
}