Compare commits
No commits in common. "14ae2909e45346d59936b898e8b8924aeaf76f8a" and "8d07a86fc4fcbd1121c4c99b4b30189546ba1f6c" have entirely different histories.
14ae2909e4
...
8d07a86fc4
10 changed files with 17 additions and 1653 deletions
91
Cargo.lock
generated
91
Cargo.lock
generated
|
|
@ -163,28 +163,6 @@ 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"
|
||||||
|
|
@ -572,53 +550,6 @@ 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"
|
||||||
|
|
@ -682,21 +613,6 @@ 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.4.6"
|
version = "0.4.6"
|
||||||
|
|
@ -835,18 +751,11 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ pulldown-cmark = "0.12"
|
||||||
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.13"
|
itertools = "0.13"
|
||||||
|
|
|
||||||
29
README.md
29
README.md
|
|
@ -27,34 +27,7 @@ Within files, `@`-prefixed markers at the beginning of paragraphs or headings de
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### User 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).
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -320,52 +320,6 @@ Process timesheet shards chronologically per day:
|
||||||
|
|
||||||
**Validation:** The last entry of each day MUST be a `Break` (cannot end day while working).
|
**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
|
||||||
|
|
@ -389,7 +343,7 @@ Provide recursive search through the shard tree:
|
||||||
| `streamd new` | Create new timestamped file, open editor, rename with markers on close |
|
| `streamd 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` | Generate formatted timesheet report with expected/actual hours |
|
| `streamd timesheet` | Extract and export timesheet data as CSV |
|
||||||
| `streamd completions <shell>` | Generate shell completions (bash, zsh, fish, elvish, powershell) |
|
| `streamd completions <shell>` | Generate shell completions (bash, zsh, fish, elvish, powershell) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
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;
|
||||||
|
|
@ -9,15 +7,13 @@ 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::{
|
use crate::timesheet::{extract_timesheets, BasicTimesheetConfiguration};
|
||||||
extract_timesheets, generate_report, load_repository_config, BasicTimesheetConfiguration,
|
|
||||||
DayType, DayWarning, MonthReport, TimesheetReport,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn load_all_shards(base_folder: &Path) -> Result<Vec<LocalizedShard>, StreamdError> {
|
fn all_files() -> Result<Vec<LocalizedShard>, StreamdError> {
|
||||||
|
let settings = Settings::load()?;
|
||||||
let mut shards = Vec::new();
|
let mut shards = Vec::new();
|
||||||
|
|
||||||
for entry in WalkDir::new(base_folder)
|
for entry in WalkDir::new(&settings.base_folder)
|
||||||
.max_depth(1)
|
.max_depth(1)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
|
|
@ -37,246 +33,20 @@ fn load_all_shards(base_folder: &Path) -> Result<Vec<LocalizedShard>, StreamdErr
|
||||||
Ok(shards)
|
Ok(shards)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format hours with sign for display.
|
|
||||||
fn format_diff(hours: f64) -> String {
|
|
||||||
if hours >= 0.0 {
|
|
||||||
format!("+{:.1}h", hours)
|
|
||||||
} else {
|
|
||||||
format!("{:.1}h", hours)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format hours for display without sign.
|
|
||||||
fn format_hours(hours: f64) -> String {
|
|
||||||
format!("{:.1}h", hours)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the weekday abbreviation.
|
|
||||||
fn weekday_abbrev(date: chrono::NaiveDate) -> &'static str {
|
|
||||||
match date.weekday() {
|
|
||||||
chrono::Weekday::Mon => "Mon",
|
|
||||||
chrono::Weekday::Tue => "Tue",
|
|
||||||
chrono::Weekday::Wed => "Wed",
|
|
||||||
chrono::Weekday::Thu => "Thu",
|
|
||||||
chrono::Weekday::Fri => "Fri",
|
|
||||||
chrono::Weekday::Sat => "Sat",
|
|
||||||
chrono::Weekday::Sun => "Sun",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Print the timesheet report header.
|
|
||||||
fn print_header() {
|
|
||||||
println!(
|
|
||||||
"\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}"
|
|
||||||
);
|
|
||||||
println!(" TIMESHEET REPORT");
|
|
||||||
println!(
|
|
||||||
"\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}"
|
|
||||||
);
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Print a month report.
|
|
||||||
fn print_month(month: &MonthReport) {
|
|
||||||
let diff_str = format_diff(month.diff());
|
|
||||||
let month_title = format!("{} {}", month.month_name(), month.year);
|
|
||||||
|
|
||||||
// Month header with diff
|
|
||||||
print!("\u{2550}\u{2550}\u{2550} {} ", month_title);
|
|
||||||
let padding = 52 - month_title.len() - diff_str.len();
|
|
||||||
for _ in 0..padding {
|
|
||||||
print!("\u{2550}");
|
|
||||||
}
|
|
||||||
println!(" Diff: {} \u{2550}\u{2550}\u{2550}", diff_str);
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Column headers
|
|
||||||
println!(" Date Day Expected Actual Diff Type");
|
|
||||||
println!(
|
|
||||||
" \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Day rows
|
|
||||||
for day in &month.days {
|
|
||||||
let date_str = day.date.format("%Y-%m-%d").to_string();
|
|
||||||
let weekday = weekday_abbrev(day.date);
|
|
||||||
let expected = format_hours(day.expected_hours);
|
|
||||||
let actual = format_hours(day.actual_hours);
|
|
||||||
let diff = format_diff(day.diff());
|
|
||||||
|
|
||||||
let type_str = match day.day_type {
|
|
||||||
DayType::Regular => String::new(),
|
|
||||||
DayType::Missing if day.has_warnings() => "\u{26a0} Missing".to_string(),
|
|
||||||
_ => day.day_type.to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add overlap warning indicator
|
|
||||||
let type_str = if day
|
|
||||||
.warnings
|
|
||||||
.iter()
|
|
||||||
.any(|w| matches!(w, DayWarning::OverlappingTimecards { .. }))
|
|
||||||
{
|
|
||||||
if type_str.is_empty() {
|
|
||||||
"\u{26a0} Overlap".to_string()
|
|
||||||
} else {
|
|
||||||
format!("{} \u{26a0}", type_str)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
type_str
|
|
||||||
};
|
|
||||||
|
|
||||||
println!(
|
|
||||||
" {} {} {:>7} {:>7} {:>6} {}",
|
|
||||||
date_str, weekday, expected, actual, diff, type_str
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Monthly totals
|
|
||||||
println!(
|
|
||||||
" \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
" Monthly: {:>7} {:>7} {:>6}",
|
|
||||||
format_hours(month.total_expected()),
|
|
||||||
format_hours(month.total_actual()),
|
|
||||||
format_diff(month.diff())
|
|
||||||
);
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Print the cumulative balance.
|
|
||||||
fn print_cumulative_balance(balance: f64) {
|
|
||||||
println!(
|
|
||||||
"\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
" CUMULATIVE BALANCE: {}",
|
|
||||||
format_diff(balance)
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
"\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Print warnings section.
|
|
||||||
fn print_warnings(report: &TimesheetReport) {
|
|
||||||
if !report.has_warnings() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
println!();
|
|
||||||
println!("\u{26a0} Warnings:");
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Group warnings by type
|
|
||||||
let missing_warnings: Vec<_> = report
|
|
||||||
.warnings
|
|
||||||
.iter()
|
|
||||||
.filter(|w| matches!(w.warning, DayWarning::MissingWithoutExplanation))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let overlap_warnings: Vec<_> = report
|
|
||||||
.warnings
|
|
||||||
.iter()
|
|
||||||
.filter(|w| matches!(w.warning, DayWarning::OverlappingTimecards { .. }))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let outside_period_warnings: Vec<_> = report
|
|
||||||
.warnings
|
|
||||||
.iter()
|
|
||||||
.filter(|w| matches!(w.warning, DayWarning::OutsidePeriod { .. }))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if !missing_warnings.is_empty() {
|
|
||||||
println!(" Missing days without explanation:");
|
|
||||||
for w in &missing_warnings {
|
|
||||||
let weekday = weekday_abbrev(w.date);
|
|
||||||
println!(
|
|
||||||
" - {} ({}): No entries and no leave/holiday marker",
|
|
||||||
w.date.format("%Y-%m-%d"),
|
|
||||||
weekday
|
|
||||||
);
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
|
|
||||||
if !overlap_warnings.is_empty() {
|
|
||||||
println!(" Overlapping timecards:");
|
|
||||||
for w in &overlap_warnings {
|
|
||||||
if let DayWarning::OverlappingTimecards { first, second } = &w.warning {
|
|
||||||
println!(
|
|
||||||
" - {}: {}-{} overlaps with {}-{}",
|
|
||||||
w.date.format("%Y-%m-%d"),
|
|
||||||
first.0.format("%H:%M"),
|
|
||||||
first.1.format("%H:%M"),
|
|
||||||
second.0.format("%H:%M"),
|
|
||||||
second.1.format("%H:%M")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
|
|
||||||
if !outside_period_warnings.is_empty() {
|
|
||||||
println!(" Work logged outside configured periods:");
|
|
||||||
for w in &outside_period_warnings {
|
|
||||||
if let DayWarning::OutsidePeriod { hours_worked } = &w.warning {
|
|
||||||
println!(
|
|
||||||
" - {}: {:.1}h worked (no period configured)",
|
|
||||||
w.date.format("%Y-%m-%d"),
|
|
||||||
hours_worked
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run() -> Result<(), StreamdError> {
|
pub fn run() -> Result<(), StreamdError> {
|
||||||
let settings = Settings::load()?;
|
let all_shards = all_files()?;
|
||||||
let base_folder = Path::new(&settings.base_folder);
|
let mut sheets = extract_timesheets(&all_shards)?;
|
||||||
|
sheets.sort_by_key(|s| s.date);
|
||||||
|
|
||||||
// Load repository configuration
|
for sheet in sheets {
|
||||||
let repo_config = load_repository_config(base_folder)?;
|
println!("{}", sheet.date);
|
||||||
|
let times: Vec<String> = sheet
|
||||||
// Check if timesheet is configured
|
.timecards
|
||||||
let timesheet_config = match repo_config.timesheet {
|
.iter()
|
||||||
Some(config) => config,
|
.map(|card| format!("{},{}", card.from_time, card.to_time))
|
||||||
None => {
|
.collect();
|
||||||
println!("No timesheet configuration found in .streamd.toml");
|
println!("{}", times.join(","));
|
||||||
println!();
|
|
||||||
println!("Add a [timesheet] section with periods to enable timesheet reporting:");
|
|
||||||
println!();
|
|
||||||
println!(" [timesheet]");
|
|
||||||
println!(" [[timesheet.periods]]");
|
|
||||||
println!(" start = \"2026-01-01\"");
|
|
||||||
println!(" end = \"2026-12-31\"");
|
|
||||||
println!(" hours_per_week = 40.0");
|
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Load all markdown files and extract timesheets
|
|
||||||
let all_shards = load_all_shards(base_folder)?;
|
|
||||||
let timesheets = extract_timesheets(&all_shards)?;
|
|
||||||
|
|
||||||
// Generate the report
|
|
||||||
let report = generate_report(×heets, ×heet_config)?;
|
|
||||||
|
|
||||||
if report.months.is_empty() {
|
|
||||||
println!("No timesheet data found for the configured periods.");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print the report
|
|
||||||
print_header();
|
|
||||||
|
|
||||||
for month in &report.months {
|
|
||||||
print_month(month);
|
|
||||||
}
|
|
||||||
|
|
||||||
print_cumulative_balance(report.cumulative_balance);
|
|
||||||
print_warnings(&report);
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,258 +0,0 @@
|
||||||
use chrono::NaiveDate;
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::error::StreamdError;
|
|
||||||
|
|
||||||
/// Configuration for timesheet periods and timezone.
|
|
||||||
#[derive(Debug, Clone, Deserialize, Default)]
|
|
||||||
pub struct TimesheetConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub periods: Vec<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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,542 +0,0 @@
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use chrono::{Datelike, NaiveDate, Weekday};
|
|
||||||
|
|
||||||
use crate::error::StreamdError;
|
|
||||||
use crate::models::{SpecialDayType, Timesheet};
|
|
||||||
|
|
||||||
use super::config::{RepositoryConfig, TimesheetConfig};
|
|
||||||
use super::report::{DayReport, DayType, DayWarning, MonthReport, ReportWarning, TimesheetReport};
|
|
||||||
use super::validation::find_overlapping_timecards;
|
|
||||||
|
|
||||||
/// Load repository configuration from .streamd.toml file.
|
|
||||||
pub fn load_repository_config(base_folder: &Path) -> Result<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(¤t_date).copied();
|
|
||||||
let timecard_hours = timesheet.map(calculate_timecard_hours).unwrap_or(0.0);
|
|
||||||
|
|
||||||
// Determine day type
|
|
||||||
let day_type = determine_day_type(current_date, timesheet, has_period);
|
|
||||||
|
|
||||||
// Skip weekends with no work and days outside periods with no work
|
|
||||||
let should_include = match day_type {
|
|
||||||
DayType::Weekend => timecard_hours > 0.0,
|
|
||||||
DayType::OutsidePeriod => timecard_hours > 0.0,
|
|
||||||
_ => has_period, // Only include days within periods
|
|
||||||
};
|
|
||||||
|
|
||||||
if should_include {
|
|
||||||
// Calculate expected and actual hours
|
|
||||||
let expected_hours = calculate_expected_hours(day_type, hours_per_day, current_date);
|
|
||||||
let actual_hours = calculate_actual_hours(day_type, timecard_hours, expected_hours);
|
|
||||||
|
|
||||||
let mut day_report =
|
|
||||||
DayReport::new(current_date, expected_hours, actual_hours, day_type);
|
|
||||||
|
|
||||||
// Collect warnings
|
|
||||||
let mut day_warnings: Vec<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(×heets, &config).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(report.months.len(), 1);
|
|
||||||
assert_eq!(report.months[0].days.len(), 1);
|
|
||||||
|
|
||||||
let day = &report.months[0].days[0];
|
|
||||||
assert_eq!(day.date, date(2026, 3, 2));
|
|
||||||
assert!((day.expected_hours - 8.0).abs() < 0.0001);
|
|
||||||
assert!((day.actual_hours - 8.0).abs() < 0.0001);
|
|
||||||
assert_eq!(day.day_type, DayType::Regular);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_generate_report_detects_missing_day() {
|
|
||||||
// Period covers Mon-Tue, but only Mon has timesheet
|
|
||||||
let timesheets = vec![make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)])];
|
|
||||||
// March 2 is Monday, March 3 is Tuesday
|
|
||||||
let config = make_config(date(2026, 3, 2), date(2026, 3, 3), 40.0);
|
|
||||||
|
|
||||||
let report = generate_report(×heets, &config).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(report.months[0].days.len(), 2);
|
|
||||||
|
|
||||||
// First day (Mon) should be regular
|
|
||||||
assert_eq!(report.months[0].days[0].day_type, DayType::Regular);
|
|
||||||
|
|
||||||
// Second day (Tue) should be missing
|
|
||||||
assert_eq!(report.months[0].days[1].day_type, DayType::Missing);
|
|
||||||
assert!(report.months[0].days[1].has_warnings());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_generate_report_weekend_excluded_if_no_work() {
|
|
||||||
// Period covers Mon-Sun, but only Mon has timesheet
|
|
||||||
let timesheets = vec![make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)])];
|
|
||||||
let config = make_config(date(2026, 3, 2), date(2026, 3, 8), 40.0);
|
|
||||||
|
|
||||||
let report = generate_report(×heets, &config).unwrap();
|
|
||||||
|
|
||||||
// Should only include Mon-Fri (5 days), not Sat-Sun
|
|
||||||
let days = &report.months[0].days;
|
|
||||||
for day in days {
|
|
||||||
let weekday = day.date.weekday();
|
|
||||||
assert!(weekday != Weekday::Sat && weekday != Weekday::Sun);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_generate_report_weekend_included_if_work() {
|
|
||||||
// Work on Saturday
|
|
||||||
let timesheets = vec![
|
|
||||||
make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)]),
|
|
||||||
make_timesheet(date(2026, 3, 7), vec![(10, 0, 14, 0)]), // Saturday
|
|
||||||
];
|
|
||||||
let config = make_config(date(2026, 3, 2), date(2026, 3, 8), 40.0);
|
|
||||||
|
|
||||||
let report = generate_report(×heets, &config).unwrap();
|
|
||||||
|
|
||||||
// Should include Saturday
|
|
||||||
let has_saturday = report.months[0]
|
|
||||||
.days
|
|
||||||
.iter()
|
|
||||||
.any(|d| d.date == date(2026, 3, 7));
|
|
||||||
assert!(has_saturday);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_generate_report_overlapping_timecards_warning() {
|
|
||||||
let ts = Timesheet {
|
|
||||||
date: date(2026, 3, 2),
|
|
||||||
is_sick_leave: false,
|
|
||||||
special_day_type: None,
|
|
||||||
timecards: vec![
|
|
||||||
Timecard::new(time(9, 0), time(12, 30)),
|
|
||||||
Timecard::new(time(12, 0), time(13, 0)),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
let config = make_config(date(2026, 3, 2), date(2026, 3, 2), 40.0);
|
|
||||||
|
|
||||||
let report = generate_report(&[ts], &config).unwrap();
|
|
||||||
|
|
||||||
assert!(report.has_warnings());
|
|
||||||
assert!(report.months[0].days[0].has_warnings());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_generate_report_cumulative_balance() {
|
|
||||||
// Two days: one with 8h (expected 8h), one with 10h (expected 8h)
|
|
||||||
let timesheets = vec![
|
|
||||||
make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)]), // 8h
|
|
||||||
make_timesheet(date(2026, 3, 3), vec![(8, 0, 18, 0)]), // 10h
|
|
||||||
];
|
|
||||||
let config = make_config(date(2026, 3, 2), date(2026, 3, 3), 40.0);
|
|
||||||
|
|
||||||
let report = generate_report(×heets, &config).unwrap();
|
|
||||||
|
|
||||||
// Balance should be +2h (18h actual - 16h expected)
|
|
||||||
assert!((report.cumulative_balance - 2.0).abs() < 0.0001);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +1,7 @@
|
||||||
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;
|
|
||||||
|
|
|
||||||
|
|
@ -1,322 +0,0 @@
|
||||||
use chrono::{NaiveDate, NaiveTime};
|
|
||||||
use std::fmt;
|
|
||||||
|
|
||||||
/// Type of day for timesheet calculations.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum DayType {
|
|
||||||
/// Regular working day (Mon-Fri).
|
|
||||||
Regular,
|
|
||||||
/// Day with sick leave marker.
|
|
||||||
SickLeave,
|
|
||||||
/// Day with vacation marker.
|
|
||||||
Vacation,
|
|
||||||
/// Day with holiday marker.
|
|
||||||
Holiday,
|
|
||||||
/// Day with flex/undertime marker.
|
|
||||||
FlexDay,
|
|
||||||
/// Weekend day (Sat/Sun).
|
|
||||||
Weekend,
|
|
||||||
/// Missing: weekday with no entries and no explanation.
|
|
||||||
Missing,
|
|
||||||
/// Day outside any configured period.
|
|
||||||
OutsidePeriod,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for DayType {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
DayType::Regular => write!(f, ""),
|
|
||||||
DayType::SickLeave => write!(f, "Sick Leave"),
|
|
||||||
DayType::Vacation => write!(f, "Vacation"),
|
|
||||||
DayType::Holiday => write!(f, "Holiday"),
|
|
||||||
DayType::FlexDay => write!(f, "Flex Day"),
|
|
||||||
DayType::Weekend => write!(f, "Weekend"),
|
|
||||||
DayType::Missing => write!(f, "\u{26a0} Missing"),
|
|
||||||
DayType::OutsidePeriod => write!(f, "Outside Period"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Warning associated with a specific day.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub enum DayWarning {
|
|
||||||
/// Weekday has no entries and no leave/holiday marker.
|
|
||||||
MissingWithoutExplanation,
|
|
||||||
/// Two timecards overlap.
|
|
||||||
OverlappingTimecards {
|
|
||||||
first: (NaiveTime, NaiveTime),
|
|
||||||
second: (NaiveTime, NaiveTime),
|
|
||||||
},
|
|
||||||
/// Work logged outside any configured period.
|
|
||||||
OutsidePeriod { hours_worked: f64 },
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for DayWarning {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
DayWarning::MissingWithoutExplanation => {
|
|
||||||
write!(f, "No entries and no leave/holiday marker")
|
|
||||||
}
|
|
||||||
DayWarning::OverlappingTimecards { first, second } => {
|
|
||||||
write!(
|
|
||||||
f,
|
|
||||||
"{}-{} overlaps with {}-{}",
|
|
||||||
first.0.format("%H:%M"),
|
|
||||||
first.1.format("%H:%M"),
|
|
||||||
second.0.format("%H:%M"),
|
|
||||||
second.1.format("%H:%M")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DayWarning::OutsidePeriod { hours_worked } => {
|
|
||||||
write!(f, "{:.1}h worked (no period configured)", hours_worked)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Report for a single day.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct DayReport {
|
|
||||||
pub date: NaiveDate,
|
|
||||||
pub expected_hours: f64,
|
|
||||||
pub actual_hours: f64,
|
|
||||||
pub day_type: DayType,
|
|
||||||
pub warnings: Vec<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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
use chrono::NaiveTime;
|
|
||||||
|
|
||||||
use crate::models::Timecard;
|
|
||||||
|
|
||||||
/// Check if two time ranges overlap.
|
|
||||||
fn timecards_overlap(a: &Timecard, b: &Timecard) -> bool {
|
|
||||||
a.from_time < b.to_time && b.from_time < a.to_time
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find all overlapping timecard pairs for a day.
|
|
||||||
/// Returns a list of tuples containing the two overlapping timecards.
|
|
||||||
pub fn find_overlapping_timecards(
|
|
||||||
timecards: &[Timecard],
|
|
||||||
) -> Vec<((NaiveTime, NaiveTime), (NaiveTime, NaiveTime))> {
|
|
||||||
let mut overlaps = Vec::new();
|
|
||||||
for i in 0..timecards.len() {
|
|
||||||
for j in (i + 1)..timecards.len() {
|
|
||||||
if timecards_overlap(&timecards[i], &timecards[j]) {
|
|
||||||
overlaps.push((
|
|
||||||
(timecards[i].from_time, timecards[i].to_time),
|
|
||||||
(timecards[j].from_time, timecards[j].to_time),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
overlaps
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn time(hour: u32, min: u32) -> NaiveTime {
|
|
||||||
NaiveTime::from_hms_opt(hour, min, 0).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn card(from_h: u32, from_m: u32, to_h: u32, to_m: u32) -> Timecard {
|
|
||||||
Timecard::new(time(from_h, from_m), time(to_h, to_m))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_no_overlap_adjacent_timecards() {
|
|
||||||
let timecards = vec![card(9, 0, 12, 0), card(13, 0, 17, 0)];
|
|
||||||
let overlaps = find_overlapping_timecards(&timecards);
|
|
||||||
assert!(overlaps.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_no_overlap_exact_touch() {
|
|
||||||
// Touching at 12:00 is NOT an overlap (end time = start time)
|
|
||||||
let timecards = vec![card(9, 0, 12, 0), card(12, 0, 17, 0)];
|
|
||||||
let overlaps = find_overlapping_timecards(&timecards);
|
|
||||||
assert!(overlaps.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_partial_overlap() {
|
|
||||||
let timecards = vec![card(9, 0, 12, 30), card(12, 0, 13, 0)];
|
|
||||||
let overlaps = find_overlapping_timecards(&timecards);
|
|
||||||
assert_eq!(overlaps.len(), 1);
|
|
||||||
assert_eq!(overlaps[0].0, (time(9, 0), time(12, 30)));
|
|
||||||
assert_eq!(overlaps[0].1, (time(12, 0), time(13, 0)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_full_containment() {
|
|
||||||
// One timecard fully contains another
|
|
||||||
let timecards = vec![card(9, 0, 17, 0), card(10, 0, 11, 0)];
|
|
||||||
let overlaps = find_overlapping_timecards(&timecards);
|
|
||||||
assert_eq!(overlaps.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_exact_match_overlap() {
|
|
||||||
let timecards = vec![card(9, 0, 12, 0), card(9, 0, 12, 0)];
|
|
||||||
let overlaps = find_overlapping_timecards(&timecards);
|
|
||||||
assert_eq!(overlaps.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_multiple_overlaps_same_day() {
|
|
||||||
// First overlaps with second, and second overlaps with third
|
|
||||||
let timecards = vec![card(9, 0, 11, 0), card(10, 0, 13, 0), card(12, 0, 15, 0)];
|
|
||||||
let overlaps = find_overlapping_timecards(&timecards);
|
|
||||||
// 9-11 overlaps with 10-13, and 10-13 overlaps with 12-15
|
|
||||||
assert_eq!(overlaps.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_single_timecard_no_overlap() {
|
|
||||||
let timecards = vec![card(9, 0, 17, 0)];
|
|
||||||
let overlaps = find_overlapping_timecards(&timecards);
|
|
||||||
assert!(overlaps.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_timecards_no_overlap() {
|
|
||||||
let timecards: Vec<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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue