From 19a9e6a72e50fbcc38ad0a18865f1a208d3143e6 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Fri, 3 Apr 2026 16:01:15 +0200 Subject: [PATCH 01/46] ci: change release pipeline to run on every push --- .forgejo/workflows/release.yml | 47 ++++++++++++++++------------------ 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 0db21a7..755c0d5 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -2,8 +2,6 @@ name: Release on: push: - tags: - - 'v*' workflow_dispatch: jobs: @@ -20,45 +18,44 @@ jobs: - name: Extract version and handle tagging id: version run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - # Manual trigger: read version from Cargo.toml - VERSION_LINE=$(grep '^version' Cargo.toml | head -1) - VERSION="${VERSION_LINE#*\"}" - VERSION="${VERSION%\"*}" - TAG="v${VERSION}" + # Read version from Cargo.toml + VERSION_LINE=$(grep '^version' Cargo.toml | head -1) + VERSION="${VERSION_LINE#*\"}" + VERSION="${VERSION%\"*}" + TAG="v${VERSION}" - # Check if tag already exists - if git rev-parse "$TAG" >/dev/null 2>&1; then - echo "::error::Version ${VERSION} is already released" - exit 1 - fi - - # Create and push the tag - git tag "$TAG" - git push origin "$TAG" - - echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT - echo "TAG=${TAG}" >> $GITHUB_OUTPUT - else - # Tag push trigger: extract version from tag - VERSION="${GITHUB_REF_NAME#v}" - echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT - echo "TAG=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT + # Check if tag already exists + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag ${TAG} already exists, skipping release" + echo "SKIP=true" >> $GITHUB_OUTPUT + exit 0 fi + # Create and push the tag + git tag "$TAG" + git push origin "$TAG" + + echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT + echo "TAG=${TAG}" >> $GITHUB_OUTPUT + echo "SKIP=false" >> $GITHUB_OUTPUT + - name: Build .deb package + if: steps.version.outputs.SKIP != 'true' run: nix build .#streamd-deb -o result-deb - name: Build static binary + if: steps.version.outputs.SKIP != 'true' run: nix build .#streamd-musl -o result-musl - name: Prepare release artifacts + if: steps.version.outputs.SKIP != 'true' run: | mkdir -p release cp result-deb release/streamd_${{ steps.version.outputs.VERSION }}_amd64.deb cp result-musl/bin/streamd release/streamd-${{ steps.version.outputs.VERSION }}-linux-x86_64 - name: Create release + if: steps.version.outputs.SKIP != 'true' uses: https://git.konstantinfickel.de/actions/forgejo-release@v2 with: direction: upload From e4ed1d839ea315eb38fe01e251fc830c9cc5f1b0 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 6 Apr 2026 00:06:43 +0000 Subject: [PATCH 02/46] chore(deps): lock file maintenance --- Cargo.lock | 8 ++++---- flake.lock | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bf965c3..f858312 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,9 +285,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f" [[package]] name = "find-msvc-tools" @@ -717,9 +717,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" diff --git a/flake.lock b/flake.lock index 20b479f..009c465 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1774313767, - "narHash": "sha256-hy0XTQND6avzGEUFrJtYBBpFa/POiiaGBr2vpU6Y9tY=", + "lastModified": 1775236976, + "narHash": "sha256-gCgX+AXN7K1gAIEqcLcZHxmC+QoZcwn9m6Z9r2Az+N8=", "owner": "ipetkov", "repo": "crane", - "rev": "3d9df76e29656c679c744968b17fbaf28f0e923d", + "rev": "6c23998526351a53ce734f0ac84940da988ccef1", "type": "github" }, "original": { @@ -105,11 +105,11 @@ ] }, "locked": { - "lastModified": 1775099554, - "narHash": "sha256-3xBsGnGDLOFtnPZ1D3j2LU19wpAlYefRKTlkv648rU0=", + "lastModified": 1775358767, + "narHash": "sha256-f2eC+WIfhjevCPQILuV08i/kmKZzYZpUvkom/33VxCA=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "8d6387ed6d8e6e6672fd3ed4b61b59d44b124d99", + "rev": "20fd44bc663daa53a2575e01293e24e681d62244", "type": "github" }, "original": { From 42d9ecd3d9cf941053c889d73e50324c554f260e Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Tue, 7 Apr 2026 07:57:28 +0200 Subject: [PATCH 03/46] feat: add --minutes flag to timesheet command Adds a -m/--minutes flag to `streamd timesheet` that displays time in HH:MM format (e.g., 8:30) instead of decimal hours (e.g., 8.5h). Includes unit tests for both formatting functions. --- src/cli/args.rs | 6 ++- src/cli/commands/timesheet.rs | 90 +++++++++++++++++++++++++++-------- src/main.rs | 2 +- 3 files changed, 75 insertions(+), 23 deletions(-) diff --git a/src/cli/args.rs b/src/cli/args.rs index be56ee3..83c762c 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -50,7 +50,11 @@ pub enum Commands { }, /// Display extracted timesheets - Timesheet, + Timesheet { + /// Display time as minutes (HH:MM) instead of decimal hours (H.Hh) + #[arg(short, long)] + minutes: bool, + }, /// Generate shell completions Completions { diff --git a/src/cli/commands/timesheet.rs b/src/cli/commands/timesheet.rs index 80346b4..f2571dc 100644 --- a/src/cli/commands/timesheet.rs +++ b/src/cli/commands/timesheet.rs @@ -41,8 +41,14 @@ fn load_all_shards(base_folder: &Path) -> Result, StreamdErr } /// Format hours with sign for display. -fn format_diff(hours: f64) -> String { - if hours >= 0.0 { +fn format_diff(hours: f64, use_minutes: bool) -> String { + if use_minutes { + let total_minutes = (hours * 60.0).round() as i32; + let h = total_minutes.abs() / 60; + let m = total_minutes.abs() % 60; + let sign = if hours >= 0.0 { "+" } else { "-" }; + format!("{}{}:{:02}", sign, h, m) + } else if hours >= 0.0 { format!("+{:.1}h", hours.abs()) } else { format!("{:.1}h", hours) @@ -50,8 +56,15 @@ fn format_diff(hours: f64) -> String { } /// Format hours for display without sign. -fn format_hours(hours: f64) -> String { - format!("{:.1}h", hours.abs()) +fn format_hours(hours: f64, use_minutes: bool) -> String { + if use_minutes { + let total_minutes = (hours * 60.0).round() as i32; + let h = total_minutes.abs() / 60; + let m = total_minutes.abs() % 60; + format!("{}:{:02}", h, m) + } else { + format!("{:.1}h", hours.abs()) + } } /// Get the weekday abbreviation. @@ -77,8 +90,8 @@ fn print_header() { } /// Print a month report. -fn print_month(month: &MonthReport) { - let diff_str = format_diff(month.diff()); +fn print_month(month: &MonthReport, use_minutes: bool) { + let diff_str = format_diff(month.diff(), use_minutes); let month_title = format!("{} {}", month.month_name(), month.year); // Month header with diff @@ -99,9 +112,9 @@ fn print_month(month: &MonthReport) { 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 expected = format_hours(day.expected_hours, use_minutes); + let actual = format_hours(day.actual_hours, use_minutes); + let diff = format_diff(day.diff(), use_minutes); let type_str = match day.day_type { DayType::Regular => String::new(), @@ -134,26 +147,26 @@ fn print_month(month: &MonthReport) { println!(" {}", light_line); println!( " Monthly: {:>7} {:>7} {:>6}", - format_hours(month.total_expected()), - format_hours(month.total_actual()), - format_diff(month.diff()) + format_hours(month.total_expected(), use_minutes), + format_hours(month.total_actual(), use_minutes), + format_diff(month.diff(), use_minutes) ); println!(); } /// Print the cumulative balance. -fn print_cumulative_balance(balance: f64) { +fn print_cumulative_balance(balance: f64, use_minutes: bool) { let light_line = "\u{2500}".repeat(SEPARATOR_WIDTH); println!("{}", light_line); println!( " CUMULATIVE BALANCE: {}", - format_diff(balance) + format_diff(balance, use_minutes) ); println!("{}", light_line); } /// Print warnings section. -fn print_warnings(report: &TimesheetReport) { +fn print_warnings(report: &TimesheetReport, use_minutes: bool) { if !report.has_warnings() { return; } @@ -216,9 +229,9 @@ fn print_warnings(report: &TimesheetReport) { for w in &outside_period_warnings { if let DayWarning::OutsidePeriod { hours_worked } = &w.warning { println!( - " - {}: {:.1}h worked (no period configured)", + " - {}: {} worked (no period configured)", w.date.format("%Y-%m-%d"), - hours_worked + format_hours(*hours_worked, use_minutes) ); } } @@ -226,7 +239,7 @@ fn print_warnings(report: &TimesheetReport) { } } -pub fn run() -> Result<(), StreamdError> { +pub fn run(use_minutes: bool) -> Result<(), StreamdError> { let settings = Settings::load()?; let base_folder = Path::new(&settings.base_folder); @@ -266,11 +279,46 @@ pub fn run() -> Result<(), StreamdError> { print_header(); for month in &report.months { - print_month(month); + print_month(month, use_minutes); } - print_cumulative_balance(report.cumulative_balance); - print_warnings(&report); + print_cumulative_balance(report.cumulative_balance, use_minutes); + print_warnings(&report, use_minutes); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_hours_decimal() { + assert_eq!(format_hours(8.0, false), "8.0h"); + assert_eq!(format_hours(8.5, false), "8.5h"); + assert_eq!(format_hours(0.0, false), "0.0h"); + } + + #[test] + fn test_format_hours_minutes() { + assert_eq!(format_hours(8.0, true), "8:00"); + assert_eq!(format_hours(8.5, true), "8:30"); + assert_eq!(format_hours(0.0, true), "0:00"); + assert_eq!(format_hours(1.25, true), "1:15"); + } + + #[test] + fn test_format_diff_decimal() { + assert_eq!(format_diff(0.5, false), "+0.5h"); + assert_eq!(format_diff(-1.5, false), "-1.5h"); + assert_eq!(format_diff(0.0, false), "+0.0h"); + } + + #[test] + fn test_format_diff_minutes() { + assert_eq!(format_diff(0.5, true), "+0:30"); + assert_eq!(format_diff(-1.5, true), "-1:30"); + assert_eq!(format_diff(0.0, true), "+0:00"); + assert_eq!(format_diff(1.25, true), "+1:15"); + } +} diff --git a/src/main.rs b/src/main.rs index e6325c8..2a7e1f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ fn main() -> miette::Result<()> { Some(TodoAction::Done { number }) => streamd::cli::commands::todo::run_done(number)?, }, Some(Commands::Edit { number }) => streamd::cli::commands::edit::run(number)?, - Some(Commands::Timesheet) => streamd::cli::commands::timesheet::run()?, + Some(Commands::Timesheet { minutes }) => streamd::cli::commands::timesheet::run(minutes)?, Some(Commands::Completions { shell }) => { streamd::cli::commands::completions::run(shell); } From 9563ff4d936c7c0b64d035155c8e4de2c1a8be8a Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Tue, 7 Apr 2026 07:59:50 +0200 Subject: [PATCH 04/46] feat: bump to 0.2.0 --- Cargo.lock | 6 +++--- Cargo.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f858312..f42563f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,9 +285,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "find-msvc-tools" @@ -787,7 +787,7 @@ checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "streamd" -version = "0.1.1" +version = "0.2.0" dependencies = [ "chrono", "chrono-tz", diff --git a/Cargo.toml b/Cargo.toml index cb39165..c073e7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "streamd" -version = "0.1.1" +version = "0.2.0" edition = "2021" description = "Personal knowledge management and time-tracking CLI using @Tag annotations" license = "AGPL-3.0-only" From a9acd34801bc782450f53c2febe05b844b0405fd Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Tue, 7 Apr 2026 08:09:47 +0200 Subject: [PATCH 05/46] ci: fix release-pipeline running on feature-branches --- .forgejo/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 755c0d5..7c93504 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -2,6 +2,8 @@ name: Release on: push: + branches: + - main workflow_dispatch: jobs: From a79111c650c9f51c2898746ea88b46a9308607de Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Fri, 3 Apr 2026 16:12:59 +0200 Subject: [PATCH 06/46] ci: add cargo audit --- flake.nix | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/flake.nix b/flake.nix index 38723ae..dbfaed7 100644 --- a/flake.nix +++ b/flake.nix @@ -103,6 +103,12 @@ package = toolchain; }; commitizen.enable = true; + cargo-audit = { + enable = true; + entry = "${pkgs.cargo-audit}/bin/cargo-audit audit"; + files = "^Cargo\\.(toml|lock)$"; + pass_filenames = false; + }; }; }; From d614d678af77231d7bf25336a1afd00ad7757ab7 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Tue, 7 Apr 2026 08:28:50 +0200 Subject: [PATCH 07/46] chore: switch from h-float to min-int in timesheet --- src/cli/commands/timesheet.rs | 68 ++++++++-------- src/timesheet/generator.rs | 143 ++++++++++++++++++---------------- src/timesheet/report.rs | 80 +++++++++++-------- 3 files changed, 156 insertions(+), 135 deletions(-) diff --git a/src/cli/commands/timesheet.rs b/src/cli/commands/timesheet.rs index f2571dc..665e7d4 100644 --- a/src/cli/commands/timesheet.rs +++ b/src/cli/commands/timesheet.rs @@ -40,30 +40,28 @@ fn load_all_shards(base_folder: &Path) -> Result, StreamdErr Ok(shards) } -/// Format hours with sign for display. -fn format_diff(hours: f64, use_minutes: bool) -> String { +/// Format minutes with sign for display. +fn format_diff(minutes: i64, use_minutes: bool) -> String { + let sign = if minutes >= 0 { "+" } else { "-" }; if use_minutes { - let total_minutes = (hours * 60.0).round() as i32; - let h = total_minutes.abs() / 60; - let m = total_minutes.abs() % 60; - let sign = if hours >= 0.0 { "+" } else { "-" }; + let h = minutes.unsigned_abs() / 60; + let m = minutes.unsigned_abs() % 60; format!("{}{}:{:02}", sign, h, m) - } else if hours >= 0.0 { - format!("+{:.1}h", hours.abs()) } else { - format!("{:.1}h", hours) + let hours = minutes.unsigned_abs() as f64 / 60.0; + format!("{}{:.1}h", sign, hours) } } -/// Format hours for display without sign. -fn format_hours(hours: f64, use_minutes: bool) -> String { +/// Format minutes for display without sign. +fn format_hours(minutes: i64, use_minutes: bool) -> String { if use_minutes { - let total_minutes = (hours * 60.0).round() as i32; - let h = total_minutes.abs() / 60; - let m = total_minutes.abs() % 60; + let h = minutes.unsigned_abs() / 60; + let m = minutes.unsigned_abs() % 60; format!("{}:{:02}", h, m) } else { - format!("{:.1}h", hours.abs()) + let hours = minutes.unsigned_abs() as f64 / 60.0; + format!("{:.1}h", hours) } } @@ -112,8 +110,8 @@ fn print_month(month: &MonthReport, use_minutes: bool) { 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, use_minutes); - let actual = format_hours(day.actual_hours, use_minutes); + let expected = format_hours(day.expected_minutes, use_minutes); + let actual = format_hours(day.actual_minutes, use_minutes); let diff = format_diff(day.diff(), use_minutes); let type_str = match day.day_type { @@ -155,7 +153,7 @@ fn print_month(month: &MonthReport, use_minutes: bool) { } /// Print the cumulative balance. -fn print_cumulative_balance(balance: f64, use_minutes: bool) { +fn print_cumulative_balance(balance: i64, use_minutes: bool) { let light_line = "\u{2500}".repeat(SEPARATOR_WIDTH); println!("{}", light_line); println!( @@ -227,11 +225,11 @@ fn print_warnings(report: &TimesheetReport, use_minutes: bool) { 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 { + if let DayWarning::OutsidePeriod { minutes_worked } = &w.warning { println!( " - {}: {} worked (no period configured)", w.date.format("%Y-%m-%d"), - format_hours(*hours_worked, use_minutes) + format_hours(*minutes_worked, use_minutes) ); } } @@ -294,31 +292,33 @@ mod tests { #[test] fn test_format_hours_decimal() { - assert_eq!(format_hours(8.0, false), "8.0h"); - assert_eq!(format_hours(8.5, false), "8.5h"); - assert_eq!(format_hours(0.0, false), "0.0h"); + assert_eq!(format_hours(480, false), "8.0h"); + assert_eq!(format_hours(510, false), "8.5h"); + assert_eq!(format_hours(0, false), "0.0h"); } #[test] fn test_format_hours_minutes() { - assert_eq!(format_hours(8.0, true), "8:00"); - assert_eq!(format_hours(8.5, true), "8:30"); - assert_eq!(format_hours(0.0, true), "0:00"); - assert_eq!(format_hours(1.25, true), "1:15"); + assert_eq!(format_hours(480, true), "8:00"); + assert_eq!(format_hours(510, true), "8:30"); + assert_eq!(format_hours(0, true), "0:00"); + assert_eq!(format_hours(75, true), "1:15"); + assert_eq!(format_hours(77, true), "1:17"); + assert_eq!(format_hours(200, true), "3:20"); } #[test] fn test_format_diff_decimal() { - assert_eq!(format_diff(0.5, false), "+0.5h"); - assert_eq!(format_diff(-1.5, false), "-1.5h"); - assert_eq!(format_diff(0.0, false), "+0.0h"); + assert_eq!(format_diff(30, false), "+0.5h"); + assert_eq!(format_diff(-90, false), "-1.5h"); + assert_eq!(format_diff(0, false), "+0.0h"); } #[test] fn test_format_diff_minutes() { - assert_eq!(format_diff(0.5, true), "+0:30"); - assert_eq!(format_diff(-1.5, true), "-1:30"); - assert_eq!(format_diff(0.0, true), "+0:00"); - assert_eq!(format_diff(1.25, true), "+1:15"); + assert_eq!(format_diff(30, true), "+0:30"); + assert_eq!(format_diff(-90, true), "-1:30"); + assert_eq!(format_diff(0, true), "+0:00"); + assert_eq!(format_diff(75, true), "+1:15"); } } diff --git a/src/timesheet/generator.rs b/src/timesheet/generator.rs index 45fbb32..e6cde40 100644 --- a/src/timesheet/generator.rs +++ b/src/timesheet/generator.rs @@ -30,14 +30,14 @@ pub fn load_repository_config(base_folder: &Path) -> Result f64 { +/// Calculate total minutes worked from timecards. +fn calculate_timecard_minutes(timesheet: &Timesheet) -> i64 { timesheet .timecards .iter() .map(|tc| { let duration = tc.to_time - tc.from_time; - duration.num_minutes() as f64 / 60.0 + duration.num_minutes() }) .sum() } @@ -79,31 +79,35 @@ fn determine_day_type(date: NaiveDate, timesheet: Option<&Timesheet>, has_period 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 { +/// Calculate expected minutes for a day based on period config and day type. +fn calculate_expected_minutes(day_type: DayType, minutes_per_day: i64) -> i64 { 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, + DayType::Regular => minutes_per_day, + DayType::SickLeave => minutes_per_day, + DayType::Vacation => minutes_per_day, + DayType::Holiday => 0, + DayType::FlexDay => minutes_per_day, + DayType::Weekend => 0, + DayType::Missing => minutes_per_day, + DayType::OutsidePeriod => 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 { +/// Calculate actual minutes for a day based on day type rules. +fn calculate_actual_minutes( + day_type: DayType, + timecard_minutes: i64, + expected_minutes: i64, +) -> i64 { 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, + DayType::Regular => timecard_minutes, + DayType::SickLeave => expected_minutes.max(timecard_minutes), + DayType::Vacation => expected_minutes + timecard_minutes, + DayType::Holiday => timecard_minutes, + DayType::FlexDay => 0, + DayType::Weekend => timecard_minutes, + DayType::Missing => 0, + DayType::OutsidePeriod => timecard_minutes, } } @@ -131,7 +135,7 @@ pub fn generate_report( // Group by month and generate reports let mut month_reports: Vec = Vec::new(); let mut all_warnings: Vec = Vec::new(); - let mut cumulative_balance: f64 = 0.0; + let mut cumulative_balance: i64 = 0; // Iterate through all dates in the range let mut current_date = earliest_period_start; @@ -160,29 +164,32 @@ pub fn generate_report( // 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); + let minutes_per_day = period + .map(|p| (p.hours_per_day() * 60.0).round() as i64) + .unwrap_or(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); + let timecard_minutes = timesheet.map(calculate_timecard_minutes).unwrap_or(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, + DayType::Weekend => timecard_minutes > 0, + DayType::OutsidePeriod => timecard_minutes > 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); + // Calculate expected and actual minutes + let expected_minutes = calculate_expected_minutes(day_type, minutes_per_day); + let actual_minutes = + calculate_actual_minutes(day_type, timecard_minutes, expected_minutes); let mut day_report = - DayReport::new(current_date, expected_hours, actual_hours, day_type); + DayReport::new(current_date, expected_minutes, actual_minutes, day_type); // Collect warnings let mut day_warnings: Vec = Vec::new(); @@ -207,9 +214,9 @@ pub fn generate_report( } // Warning: Work outside period - if day_type == DayType::OutsidePeriod && timecard_hours > 0.0 { + if day_type == DayType::OutsidePeriod && timecard_minutes > 0 { let warning = DayWarning::OutsidePeriod { - hours_worked: timecard_hours, + minutes_worked: timecard_minutes, }; day_warnings.push(warning.clone()); all_warnings.push(ReportWarning::new(current_date, warning)); @@ -286,17 +293,17 @@ mod tests { } #[test] - fn test_calculate_timecard_hours() { + fn test_calculate_timecard_minutes() { 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); + let minutes = calculate_timecard_minutes(&ts); + assert_eq!(minutes, 420); // 3h + 4h = 7h = 420 min } #[test] - fn test_calculate_timecard_hours_with_minutes() { + fn test_calculate_timecard_minutes_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); + let minutes = calculate_timecard_minutes(&ts); + assert_eq!(minutes, 465); // 3.5h + 4.25h = 7.75h = 465 min } #[test] @@ -376,55 +383,55 @@ mod tests { } #[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); + fn test_expected_minutes_regular() { + let minutes = calculate_expected_minutes(DayType::Regular, 456); // 7.6h = 456 min + assert_eq!(minutes, 456); } #[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); + fn test_expected_minutes_holiday() { + let minutes = calculate_expected_minutes(DayType::Holiday, 456); + assert_eq!(minutes, 0); } #[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); + fn test_expected_minutes_weekend() { + let minutes = calculate_expected_minutes(DayType::Weekend, 456); + assert_eq!(minutes, 0); } #[test] - fn test_actual_hours_regular() { - let hours = calculate_actual_hours(DayType::Regular, 8.0, 7.6); - assert!((hours - 8.0).abs() < 0.0001); + fn test_actual_minutes_regular() { + let minutes = calculate_actual_minutes(DayType::Regular, 480, 456); // 8h, expected 7.6h + assert_eq!(minutes, 480); } #[test] - fn test_actual_hours_sick_leave_max() { + fn test_actual_minutes_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); + let minutes = calculate_actual_minutes(DayType::SickLeave, 180, 456); // 3h worked, 7.6h expected + assert_eq!(minutes, 456); } #[test] - fn test_actual_hours_sick_leave_worked_more() { + fn test_actual_minutes_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); + let minutes = calculate_actual_minutes(DayType::SickLeave, 540, 456); // 9h worked, 7.6h expected + assert_eq!(minutes, 540); } #[test] - fn test_actual_hours_vacation() { + fn test_actual_minutes_vacation() { // Vacation: expected + worked - let hours = calculate_actual_hours(DayType::Vacation, 2.0, 7.6); - assert!((hours - 9.6).abs() < 0.0001); + let minutes = calculate_actual_minutes(DayType::Vacation, 120, 456); // 2h worked, 7.6h expected + assert_eq!(minutes, 576); // 2h + 7.6h = 9.6h = 576 min } #[test] - fn test_actual_hours_flex_day() { + fn test_actual_minutes_flex_day() { // Flex day: always 0 - let hours = calculate_actual_hours(DayType::FlexDay, 5.0, 7.6); - assert!((hours - 0.0).abs() < 0.0001); + let minutes = calculate_actual_minutes(DayType::FlexDay, 300, 456); + assert_eq!(minutes, 0); } #[test] @@ -447,8 +454,8 @@ mod tests { 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.expected_minutes, 480); // 8h = 480 min + assert_eq!(day.actual_minutes, 480); assert_eq!(day.day_type, DayType::Regular); } @@ -536,7 +543,7 @@ mod tests { let report = generate_report(×heets, &config).unwrap(); - // Balance should be +2h (18h actual - 16h expected) - assert!((report.cumulative_balance - 2.0).abs() < 0.0001); + // Balance should be +120 min (+2h: 18h actual - 16h expected) + assert_eq!(report.cumulative_balance, 120); } } diff --git a/src/timesheet/report.rs b/src/timesheet/report.rs index 8c405d8..92682d6 100644 --- a/src/timesheet/report.rs +++ b/src/timesheet/report.rs @@ -48,7 +48,7 @@ pub enum DayWarning { second: (NaiveTime, NaiveTime), }, /// Work logged outside any configured period. - OutsidePeriod { hours_worked: f64 }, + OutsidePeriod { minutes_worked: i64 }, } impl fmt::Display for DayWarning { @@ -67,8 +67,12 @@ impl fmt::Display for DayWarning { second.1.format("%H:%M") ) } - DayWarning::OutsidePeriod { hours_worked } => { - write!(f, "{:.1}h worked (no period configured)", hours_worked) + DayWarning::OutsidePeriod { minutes_worked } => { + write!( + f, + "{:.1}h worked (no period configured)", + *minutes_worked as f64 / 60.0 + ) } } } @@ -78,18 +82,23 @@ impl fmt::Display for DayWarning { #[derive(Debug, Clone)] pub struct DayReport { pub date: NaiveDate, - pub expected_hours: f64, - pub actual_hours: f64, + pub expected_minutes: i64, + pub actual_minutes: i64, pub day_type: DayType, pub warnings: Vec, } impl DayReport { - pub fn new(date: NaiveDate, expected_hours: f64, actual_hours: f64, day_type: DayType) -> Self { + pub fn new( + date: NaiveDate, + expected_minutes: i64, + actual_minutes: i64, + day_type: DayType, + ) -> Self { Self { date, - expected_hours, - actual_hours, + expected_minutes, + actual_minutes, day_type, warnings: Vec::new(), } @@ -105,9 +114,9 @@ impl DayReport { self } - /// Calculate the difference between actual and expected hours. - pub fn diff(&self) -> f64 { - self.actual_hours - self.expected_hours + /// Calculate the difference between actual and expected minutes. + pub fn diff(&self) -> i64 { + self.actual_minutes - self.expected_minutes } /// Check if this day has any warnings. @@ -138,18 +147,18 @@ impl MonthReport { 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 expected minutes for the month. + pub fn total_expected(&self) -> i64 { + self.days.iter().map(|d| d.expected_minutes).sum() } - /// Calculate total actual hours for the month. - pub fn total_actual(&self) -> f64 { - self.days.iter().map(|d| d.actual_hours).sum() + /// Calculate total actual minutes for the month. + pub fn total_actual(&self) -> i64 { + self.days.iter().map(|d| d.actual_minutes).sum() } /// Calculate the difference for the month. - pub fn diff(&self) -> f64 { + pub fn diff(&self) -> i64 { self.total_actual() - self.total_expected() } @@ -178,7 +187,7 @@ impl ReportWarning { #[derive(Debug, Clone)] pub struct TimesheetReport { pub months: Vec, - pub cumulative_balance: f64, + pub cumulative_balance: i64, pub warnings: Vec, } @@ -186,7 +195,7 @@ impl TimesheetReport { pub fn new() -> Self { Self { months: Vec::new(), - cumulative_balance: 0.0, + cumulative_balance: 0, warnings: Vec::new(), } } @@ -196,7 +205,7 @@ impl TimesheetReport { self } - pub fn with_cumulative_balance(mut self, balance: f64) -> Self { + pub fn with_cumulative_balance(mut self, balance: i64) -> Self { self.cumulative_balance = balance; self } @@ -232,27 +241,30 @@ mod tests { #[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); + // 7.6h = 456 min, 8.2h = 492 min, diff = 36 min + let report = DayReport::new(date(2026, 3, 2), 456, 492, DayType::Regular); + assert_eq!(report.diff(), 36); } #[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); + // 7.6h = 456 min, 6.0h = 360 min, diff = -96 min + let report = DayReport::new(date(2026, 3, 2), 456, 360, DayType::Regular); + assert_eq!(report.diff(), -96); } #[test] fn test_month_report_totals() { + // 7.6h = 456 min, 8.2h = 492 min, 6.0h = 360 min 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), + DayReport::new(date(2026, 3, 2), 456, 492, DayType::Regular), + DayReport::new(date(2026, 3, 3), 456, 456, DayType::Regular), + DayReport::new(date(2026, 3, 4), 456, 360, 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); + assert_eq!(month.total_expected(), 1368); // 456 * 3 + assert_eq!(month.total_actual(), 1308); // 492 + 456 + 360 + assert_eq!(month.diff(), -60); // -1 hour } #[test] @@ -281,13 +293,15 @@ mod tests { #[test] fn test_day_warning_outside_period_display() { - let warning = DayWarning::OutsidePeriod { hours_worked: 3.5 }; + let warning = DayWarning::OutsidePeriod { + minutes_worked: 210, + }; // 3.5h 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( + let report = DayReport::new(date(2026, 3, 2), 456, 492, DayType::Regular).with_warning( DayWarning::OverlappingTimecards { first: (time(9, 0), time(12, 30)), second: (time(12, 0), time(13, 0)), From 0533c7777aa23cde36416ad3a72b85d2af9624c9 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Tue, 7 Apr 2026 08:42:14 +0200 Subject: [PATCH 08/46] feat: add timesheet debug --- src/cli/args.rs | 4 ++ src/cli/commands/timesheet.rs | 81 +++++++++++++++++++++++++++++++---- src/main.rs | 4 +- 3 files changed, 79 insertions(+), 10 deletions(-) diff --git a/src/cli/args.rs b/src/cli/args.rs index 83c762c..b0da540 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -54,6 +54,10 @@ pub enum Commands { /// Display time as minutes (HH:MM) instead of decimal hours (H.Hh) #[arg(short, long)] minutes: bool, + + /// Show all timecards grouped by day instead of the summary report + #[arg(short, long)] + debug: bool, }, /// Generate shell completions diff --git a/src/cli/commands/timesheet.rs b/src/cli/commands/timesheet.rs index 665e7d4..2cea3ae 100644 --- a/src/cli/commands/timesheet.rs +++ b/src/cli/commands/timesheet.rs @@ -1,7 +1,9 @@ +use std::collections::HashMap; use std::fs; use std::path::Path; use chrono::Datelike; +use chrono::NaiveDate; use walkdir::WalkDir; use crate::config::Settings; @@ -11,7 +13,7 @@ const COLUMN_SEPARATOR_WIDTH: usize = 65; use crate::error::StreamdError; use crate::extract::parse_markdown_file; use crate::localize::localize_stream_file; -use crate::models::LocalizedShard; +use crate::models::{LocalizedShard, Timesheet}; use crate::timesheet::{ extract_timesheets, generate_report, load_repository_config, BasicTimesheetConfiguration, DayType, DayWarning, MonthReport, TimesheetReport, @@ -237,7 +239,65 @@ fn print_warnings(report: &TimesheetReport, use_minutes: bool) { } } -pub fn run(use_minutes: bool) -> Result<(), StreamdError> { +/// Print debug view: all timecards grouped and sorted by day. +fn print_debug(report: &TimesheetReport, timesheets: &[Timesheet]) { + let timesheets_by_date: HashMap = + timesheets.iter().map(|ts| (ts.date, ts)).collect(); + + for month in &report.months { + let month_title = format!("{} {}", month.month_name(), month.year); + let separator = "\u{2550}".repeat(SEPARATOR_WIDTH); + println!("{}", separator); + println!(" {}", month_title); + println!("{}", separator); + println!(); + + for day in &month.days { + let date_str = day.date.format("%Y-%m-%d").to_string(); + let weekday = weekday_abbrev(day.date); + + let mut parts: Vec = Vec::new(); + + // Add day type label for non-regular days + let type_label = match day.day_type { + DayType::Regular => None, + DayType::SickLeave => Some("Sick Leave"), + DayType::Vacation => Some("Vacation"), + DayType::Holiday => Some("Holiday"), + DayType::FlexDay => Some("Flex Day"), + DayType::Weekend => Some("Weekend"), + DayType::Missing => Some("Missing"), + DayType::OutsidePeriod => Some("Outside Period"), + }; + if let Some(label) = type_label { + parts.push(label.to_string()); + } + + // Add timecards + if let Some(ts) = timesheets_by_date.get(&day.date) { + for tc in &ts.timecards { + parts.push(format!( + "{} - {}", + tc.from_time.format("%H:%M"), + tc.to_time.format("%H:%M") + )); + } + } + + let content = if parts.is_empty() { + String::new() + } else { + parts.join(", ") + }; + + println!(" {} ({}): {}", date_str, weekday, content); + } + + println!(); + } +} + +pub fn run(use_minutes: bool, debug: bool) -> Result<(), StreamdError> { let settings = Settings::load()?; let base_folder = Path::new(&settings.base_folder); @@ -273,16 +333,19 @@ pub fn run(use_minutes: bool) -> Result<(), StreamdError> { return Ok(()); } - // Print the report - print_header(); + if debug { + print_debug(&report, ×heets); + } else { + print_header(); - for month in &report.months { - print_month(month, use_minutes); + for month in &report.months { + print_month(month, use_minutes); + } + + print_cumulative_balance(report.cumulative_balance, use_minutes); + print_warnings(&report, use_minutes); } - print_cumulative_balance(report.cumulative_balance, use_minutes); - print_warnings(&report, use_minutes); - Ok(()) } diff --git a/src/main.rs b/src/main.rs index 2a7e1f6..354b8dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,9 @@ fn main() -> miette::Result<()> { Some(TodoAction::Done { number }) => streamd::cli::commands::todo::run_done(number)?, }, Some(Commands::Edit { number }) => streamd::cli::commands::edit::run(number)?, - Some(Commands::Timesheet { minutes }) => streamd::cli::commands::timesheet::run(minutes)?, + Some(Commands::Timesheet { minutes, debug }) => { + streamd::cli::commands::timesheet::run(minutes, debug)? + } Some(Commands::Completions { shell }) => { streamd::cli::commands::completions::run(shell); } From b322c0307de7940cfdda8c4152e5152729cd125e Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Tue, 7 Apr 2026 08:42:42 +0200 Subject: [PATCH 09/46] chore: bump version to 0.2.1 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index c073e7e..9199c46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "streamd" -version = "0.2.0" +version = "0.2.1" edition = "2021" description = "Personal knowledge management and time-tracking CLI using @Tag annotations" license = "AGPL-3.0-only" From 60fac418f892f136823b37cdb966df08fb74763c Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Tue, 7 Apr 2026 08:47:32 +0200 Subject: [PATCH 10/46] fix: forgot to bump lock-file --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index f42563f..6580127 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -787,7 +787,7 @@ checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "streamd" -version = "0.2.0" +version = "0.2.1" dependencies = [ "chrono", "chrono-tz", From 7f4d3bb147af15102def6f7b41f9022f5b8ff861 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Tue, 7 Apr 2026 09:42:38 +0200 Subject: [PATCH 11/46] ci: remove cargo-audito from pre-commit-hooks --- flake.nix | 6 ------ 1 file changed, 6 deletions(-) diff --git a/flake.nix b/flake.nix index dbfaed7..38723ae 100644 --- a/flake.nix +++ b/flake.nix @@ -103,12 +103,6 @@ package = toolchain; }; commitizen.enable = true; - cargo-audit = { - enable = true; - entry = "${pkgs.cargo-audit}/bin/cargo-audit audit"; - files = "^Cargo\\.(toml|lock)$"; - pass_filenames = false; - }; }; }; From 22ada87de6c1e51f09aee91806bd73da25391c3a Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Tue, 7 Apr 2026 09:56:46 +0200 Subject: [PATCH 12/46] feat(timesheet): make hour-output default, decimal with two decimals --- src/cli/args.rs | 4 +- src/cli/commands/timesheet.rs | 113 +++++++++++++++++++--------------- src/main.rs | 4 +- 3 files changed, 69 insertions(+), 52 deletions(-) diff --git a/src/cli/args.rs b/src/cli/args.rs index b0da540..11fda90 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -51,9 +51,9 @@ pub enum Commands { /// Display extracted timesheets Timesheet { - /// Display time as minutes (HH:MM) instead of decimal hours (H.Hh) + /// Display time as decimal hours (X.XXh) instead of the default HH:MM format #[arg(short, long)] - minutes: bool, + decimal: bool, /// Show all timecards grouped by day instead of the summary report #[arg(short, long)] diff --git a/src/cli/commands/timesheet.rs b/src/cli/commands/timesheet.rs index 2cea3ae..67ab956 100644 --- a/src/cli/commands/timesheet.rs +++ b/src/cli/commands/timesheet.rs @@ -42,28 +42,39 @@ fn load_all_shards(base_folder: &Path) -> Result, StreamdErr Ok(shards) } +enum DisplayMode { + Minutes, + Decimal, +} + /// Format minutes with sign for display. -fn format_diff(minutes: i64, use_minutes: bool) -> String { +fn format_diff(minutes: i64, mode: &DisplayMode) -> String { let sign = if minutes >= 0 { "+" } else { "-" }; - if use_minutes { - let h = minutes.unsigned_abs() / 60; - let m = minutes.unsigned_abs() % 60; - format!("{}{}:{:02}", sign, h, m) - } else { - let hours = minutes.unsigned_abs() as f64 / 60.0; - format!("{}{:.1}h", sign, hours) + match mode { + DisplayMode::Minutes => { + let h = minutes.unsigned_abs() / 60; + let m = minutes.unsigned_abs() % 60; + format!("{}{}:{:02}", sign, h, m) + } + DisplayMode::Decimal => { + let hours = minutes.unsigned_abs() as f64 / 60.0; + format!("{}{:.2}h", sign, hours) + } } } /// Format minutes for display without sign. -fn format_hours(minutes: i64, use_minutes: bool) -> String { - if use_minutes { - let h = minutes.unsigned_abs() / 60; - let m = minutes.unsigned_abs() % 60; - format!("{}:{:02}", h, m) - } else { - let hours = minutes.unsigned_abs() as f64 / 60.0; - format!("{:.1}h", hours) +fn format_hours(minutes: i64, mode: &DisplayMode) -> String { + match mode { + DisplayMode::Minutes => { + let h = minutes.unsigned_abs() / 60; + let m = minutes.unsigned_abs() % 60; + format!("{}:{:02}", h, m) + } + DisplayMode::Decimal => { + let hours = minutes.unsigned_abs() as f64 / 60.0; + format!("{:.2}h", hours) + } } } @@ -90,8 +101,8 @@ fn print_header() { } /// Print a month report. -fn print_month(month: &MonthReport, use_minutes: bool) { - let diff_str = format_diff(month.diff(), use_minutes); +fn print_month(month: &MonthReport, mode: &DisplayMode) { + let diff_str = format_diff(month.diff(), mode); let month_title = format!("{} {}", month.month_name(), month.year); // Month header with diff @@ -112,9 +123,9 @@ fn print_month(month: &MonthReport, use_minutes: bool) { 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_minutes, use_minutes); - let actual = format_hours(day.actual_minutes, use_minutes); - let diff = format_diff(day.diff(), use_minutes); + let expected = format_hours(day.expected_minutes, mode); + let actual = format_hours(day.actual_minutes, mode); + let diff = format_diff(day.diff(), mode); let type_str = match day.day_type { DayType::Regular => String::new(), @@ -147,26 +158,26 @@ fn print_month(month: &MonthReport, use_minutes: bool) { println!(" {}", light_line); println!( " Monthly: {:>7} {:>7} {:>6}", - format_hours(month.total_expected(), use_minutes), - format_hours(month.total_actual(), use_minutes), - format_diff(month.diff(), use_minutes) + format_hours(month.total_expected(), mode), + format_hours(month.total_actual(), mode), + format_diff(month.diff(), mode) ); println!(); } /// Print the cumulative balance. -fn print_cumulative_balance(balance: i64, use_minutes: bool) { +fn print_cumulative_balance(balance: i64, mode: &DisplayMode) { let light_line = "\u{2500}".repeat(SEPARATOR_WIDTH); println!("{}", light_line); println!( " CUMULATIVE BALANCE: {}", - format_diff(balance, use_minutes) + format_diff(balance, mode) ); println!("{}", light_line); } /// Print warnings section. -fn print_warnings(report: &TimesheetReport, use_minutes: bool) { +fn print_warnings(report: &TimesheetReport, mode: &DisplayMode) { if !report.has_warnings() { return; } @@ -231,7 +242,7 @@ fn print_warnings(report: &TimesheetReport, use_minutes: bool) { println!( " - {}: {} worked (no period configured)", w.date.format("%Y-%m-%d"), - format_hours(*minutes_worked, use_minutes) + format_hours(*minutes_worked, mode) ); } } @@ -297,7 +308,12 @@ fn print_debug(report: &TimesheetReport, timesheets: &[Timesheet]) { } } -pub fn run(use_minutes: bool, debug: bool) -> Result<(), StreamdError> { +pub fn run(decimal: bool, debug: bool) -> Result<(), StreamdError> { + let mode = if decimal { + DisplayMode::Decimal + } else { + DisplayMode::Minutes + }; let settings = Settings::load()?; let base_folder = Path::new(&settings.base_folder); @@ -339,11 +355,11 @@ pub fn run(use_minutes: bool, debug: bool) -> Result<(), StreamdError> { print_header(); for month in &report.months { - print_month(month, use_minutes); + print_month(month, &mode); } - print_cumulative_balance(report.cumulative_balance, use_minutes); - print_warnings(&report, use_minutes); + print_cumulative_balance(report.cumulative_balance, &mode); + print_warnings(&report, &mode); } Ok(()) @@ -355,33 +371,34 @@ mod tests { #[test] fn test_format_hours_decimal() { - assert_eq!(format_hours(480, false), "8.0h"); - assert_eq!(format_hours(510, false), "8.5h"); - assert_eq!(format_hours(0, false), "0.0h"); + assert_eq!(format_hours(480, &DisplayMode::Decimal), "8.00h"); + assert_eq!(format_hours(510, &DisplayMode::Decimal), "8.50h"); + assert_eq!(format_hours(507, &DisplayMode::Decimal), "8.45h"); + assert_eq!(format_hours(0, &DisplayMode::Decimal), "0.00h"); } #[test] fn test_format_hours_minutes() { - assert_eq!(format_hours(480, true), "8:00"); - assert_eq!(format_hours(510, true), "8:30"); - assert_eq!(format_hours(0, true), "0:00"); - assert_eq!(format_hours(75, true), "1:15"); - assert_eq!(format_hours(77, true), "1:17"); - assert_eq!(format_hours(200, true), "3:20"); + assert_eq!(format_hours(480, &DisplayMode::Minutes), "8:00"); + assert_eq!(format_hours(510, &DisplayMode::Minutes), "8:30"); + assert_eq!(format_hours(0, &DisplayMode::Minutes), "0:00"); + assert_eq!(format_hours(75, &DisplayMode::Minutes), "1:15"); + assert_eq!(format_hours(77, &DisplayMode::Minutes), "1:17"); + assert_eq!(format_hours(200, &DisplayMode::Minutes), "3:20"); } #[test] fn test_format_diff_decimal() { - assert_eq!(format_diff(30, false), "+0.5h"); - assert_eq!(format_diff(-90, false), "-1.5h"); - assert_eq!(format_diff(0, false), "+0.0h"); + assert_eq!(format_diff(30, &DisplayMode::Decimal), "+0.50h"); + assert_eq!(format_diff(-90, &DisplayMode::Decimal), "-1.50h"); + assert_eq!(format_diff(0, &DisplayMode::Decimal), "+0.00h"); } #[test] fn test_format_diff_minutes() { - assert_eq!(format_diff(30, true), "+0:30"); - assert_eq!(format_diff(-90, true), "-1:30"); - assert_eq!(format_diff(0, true), "+0:00"); - assert_eq!(format_diff(75, true), "+1:15"); + assert_eq!(format_diff(30, &DisplayMode::Minutes), "+0:30"); + assert_eq!(format_diff(-90, &DisplayMode::Minutes), "-1:30"); + assert_eq!(format_diff(0, &DisplayMode::Minutes), "+0:00"); + assert_eq!(format_diff(75, &DisplayMode::Minutes), "+1:15"); } } diff --git a/src/main.rs b/src/main.rs index 354b8dd..4d311a5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,8 +15,8 @@ fn main() -> miette::Result<()> { Some(TodoAction::Done { number }) => streamd::cli::commands::todo::run_done(number)?, }, Some(Commands::Edit { number }) => streamd::cli::commands::edit::run(number)?, - Some(Commands::Timesheet { minutes, debug }) => { - streamd::cli::commands::timesheet::run(minutes, debug)? + Some(Commands::Timesheet { decimal, debug }) => { + streamd::cli::commands::timesheet::run(decimal, debug)? } Some(Commands::Completions { shell }) => { streamd::cli::commands::completions::run(shell); From 2ab2b6a52b890dec43029f6f70a936a061a17c86 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Tue, 7 Apr 2026 09:57:30 +0200 Subject: [PATCH 13/46] ci: bump to 0.2.2 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6580127..bd8f6e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -787,7 +787,7 @@ checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "streamd" -version = "0.2.1" +version = "0.2.2" dependencies = [ "chrono", "chrono-tz", diff --git a/Cargo.toml b/Cargo.toml index 9199c46..b4d48cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "streamd" -version = "0.2.1" +version = "0.2.2" edition = "2021" description = "Personal knowledge management and time-tracking CLI using @Tag annotations" license = "AGPL-3.0-only" From e8dc2013bc6818a2fea8f7ba97711725169c97e4 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Tue, 7 Apr 2026 13:13:55 +0200 Subject: [PATCH 14/46] chore: make datetime pure --- src/cli/commands/timesheet.rs | 3 +- src/timesheet/extract.rs | 63 +++++++++++++++++++++++------------ 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/src/cli/commands/timesheet.rs b/src/cli/commands/timesheet.rs index 67ab956..fa7f923 100644 --- a/src/cli/commands/timesheet.rs +++ b/src/cli/commands/timesheet.rs @@ -4,6 +4,7 @@ use std::path::Path; use chrono::Datelike; use chrono::NaiveDate; +use chrono::Utc; use walkdir::WalkDir; use crate::config::Settings; @@ -339,7 +340,7 @@ pub fn run(decimal: bool, debug: bool) -> Result<(), StreamdError> { // Load all markdown files and extract timesheets let all_shards = load_all_shards(base_folder)?; - let timesheets = extract_timesheets(&all_shards)?; + let timesheets = extract_timesheets(&all_shards, Utc::now())?; // Generate the report let report = generate_report(×heets, ×heet_config)?; diff --git a/src/timesheet/extract.rs b/src/timesheet/extract.rs index 017434f..304492e 100644 --- a/src/timesheet/extract.rs +++ b/src/timesheet/extract.rs @@ -35,7 +35,10 @@ fn shards_to_timesheet_points(shards: &[LocalizedShard]) -> Vec } /// Aggregate timesheet points for a single day into a Timesheet. -fn aggregate_timecard_day(points: &[TimesheetPoint]) -> Result, StreamdError> { +fn aggregate_timecard_day( + points: &[TimesheetPoint], + now: DateTime, +) -> Result, StreamdError> { if points.is_empty() { return Ok(None); } @@ -113,10 +116,15 @@ fn aggregate_timecard_day(points: &[TimesheetPoint]) -> Result // Check that we ended in break mode if !last_is_break { - return Err(StreamdError::TimesheetError(format!( - "Last Timecard of {} is not a break!", - card_date - ))); + if card_date == now.date_naive() { + // No closing break yet for today — artificially close at now + timecards.push(Timecard::new(last_time, now.time())); + } else { + return Err(StreamdError::TimesheetError(format!( + "Last Timecard of {} is not a break!", + card_date + ))); + } } // Only return a timesheet if there's meaningful data @@ -133,7 +141,10 @@ fn aggregate_timecard_day(points: &[TimesheetPoint]) -> Result } /// Aggregate timesheet points into timesheets, grouped by day. -fn aggregate_timecards(points: &[TimesheetPoint]) -> Result, StreamdError> { +fn aggregate_timecards( + points: &[TimesheetPoint], + now: DateTime, +) -> Result, StreamdError> { let mut timesheets = Vec::new(); // Sort points by moment to ensure proper grouping @@ -143,7 +154,7 @@ fn aggregate_timecards(points: &[TimesheetPoint]) -> Result, Stre // Group by date for (_date, group) in &sorted_points.iter().chunk_by(|p| p.moment.date_naive()) { let day_points: Vec<_> = group.cloned().collect(); - if let Some(timesheet) = aggregate_timecard_day(&day_points)? { + if let Some(timesheet) = aggregate_timecard_day(&day_points, now)? { timesheets.push(timesheet); } } @@ -152,9 +163,12 @@ fn aggregate_timecards(points: &[TimesheetPoint]) -> Result, Stre } /// Extract timesheets from localized shards. -pub fn extract_timesheets(shards: &[LocalizedShard]) -> Result, StreamdError> { +pub fn extract_timesheets( + shards: &[LocalizedShard], + now: DateTime, +) -> Result, StreamdError> { let points = shards_to_timesheet_points(shards); - aggregate_timecards(&points) + aggregate_timecards(&points, now) } #[cfg(test)] @@ -163,6 +177,11 @@ mod tests { use chrono::{NaiveTime, TimeZone}; use indexmap::IndexMap; + /// A fixed "now" in the past, so tests never match today. + fn past_now() -> DateTime { + Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap() + } + fn point(at: DateTime, point_type: TimesheetPointType) -> LocalizedShard { let mut location = IndexMap::new(); location.insert( @@ -198,7 +217,7 @@ mod tests { ), ]; - let result = extract_timesheets(&shards).unwrap(); + let result = extract_timesheets(&shards, past_now()).unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].date, day.date_naive()); @@ -251,7 +270,7 @@ mod tests { ), ]; - let result = extract_timesheets(&shards).unwrap(); + let result = extract_timesheets(&shards, past_now()).unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].timecards.len(), 3); @@ -302,7 +321,7 @@ mod tests { ), ]; - let result = extract_timesheets(&shards).unwrap(); + let result = extract_timesheets(&shards, past_now()).unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].timecards.len(), 3); @@ -336,7 +355,7 @@ mod tests { ), ]; - let result = extract_timesheets(&shards).unwrap(); + let result = extract_timesheets(&shards, past_now()).unwrap(); assert_eq!(result.len(), 2); assert_eq!(result[0].date, day1.date_naive()); @@ -359,7 +378,7 @@ mod tests { ), ]; - let result = extract_timesheets(&shards).unwrap(); + let result = extract_timesheets(&shards, past_now()).unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].special_day_type, Some(SpecialDayType::Vacation)); @@ -382,7 +401,7 @@ mod tests { ), ]; - let result = extract_timesheets(&shards).unwrap(); + let result = extract_timesheets(&shards, past_now()).unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].special_day_type, Some(SpecialDayType::Holiday)); @@ -404,7 +423,7 @@ mod tests { ), ]; - let result = extract_timesheets(&shards).unwrap(); + let result = extract_timesheets(&shards, past_now()).unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].special_day_type, Some(SpecialDayType::Undertime)); @@ -431,7 +450,7 @@ mod tests { ), ]; - let result = extract_timesheets(&shards).unwrap(); + let result = extract_timesheets(&shards, past_now()).unwrap(); assert_eq!(result.len(), 1); assert!(result[0].is_sick_leave); @@ -454,7 +473,7 @@ mod tests { ), ]; - let result = extract_timesheets(&shards).unwrap(); + let result = extract_timesheets(&shards, past_now()).unwrap(); assert_eq!(result.len(), 1); assert!(result[0].is_sick_leave); @@ -463,7 +482,7 @@ mod tests { #[test] fn test_empty_input() { - let result = extract_timesheets(&[]).unwrap(); + let result = extract_timesheets(&[], past_now()).unwrap(); assert!(result.is_empty()); } @@ -483,7 +502,7 @@ mod tests { ), ]; - let result = extract_timesheets(&shards); + let result = extract_timesheets(&shards, past_now()); assert!(result.is_err()); let err = result.unwrap_err(); @@ -511,7 +530,7 @@ mod tests { ), ]; - let result = extract_timesheets(&shards); + let result = extract_timesheets(&shards, past_now()); assert!(result.is_err()); let err = result.unwrap_err(); @@ -534,7 +553,7 @@ mod tests { ), ]; - let result = extract_timesheets(&shards).unwrap(); + let result = extract_timesheets(&shards, past_now()).unwrap(); assert!(result.is_empty()); } From 5dca68037def5977d323b203d1d60d09060aeb77 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Tue, 7 Apr 2026 13:26:34 +0200 Subject: [PATCH 15/46] chore: fix timezone handling --- src/cli/commands/edit.rs | 4 +- src/cli/commands/timesheet.rs | 21 +++-- src/cli/commands/todo.rs | 4 +- src/localize/datetime.rs | 141 +++++++++++++++++++++++----------- src/localize/shard.rs | 31 +++++--- src/timesheet/extract.rs | 56 ++++++++------ src/timesheet/generator.rs | 31 +++++--- 7 files changed, 194 insertions(+), 94 deletions(-) diff --git a/src/cli/commands/edit.rs b/src/cli/commands/edit.rs index 89a0240..a8761f3 100644 --- a/src/cli/commands/edit.rs +++ b/src/cli/commands/edit.rs @@ -24,7 +24,9 @@ fn all_files() -> Result, StreamdError> { let content = fs::read_to_string(path)?; let stream_file = parse_markdown_file(&file_name, &content); - if let Ok(shard) = localize_stream_file(&stream_file, &TaskConfiguration) { + if let Ok(shard) = + localize_stream_file(&stream_file, &TaskConfiguration, chrono_tz::UTC) + { shards.push(shard); } } diff --git a/src/cli/commands/timesheet.rs b/src/cli/commands/timesheet.rs index fa7f923..a1c4a37 100644 --- a/src/cli/commands/timesheet.rs +++ b/src/cli/commands/timesheet.rs @@ -5,6 +5,7 @@ use std::path::Path; use chrono::Datelike; use chrono::NaiveDate; use chrono::Utc; +use chrono_tz::Tz; use walkdir::WalkDir; use crate::config::Settings; @@ -20,7 +21,7 @@ use crate::timesheet::{ DayType, DayWarning, MonthReport, TimesheetReport, }; -fn load_all_shards(base_folder: &Path) -> Result, StreamdError> { +fn load_all_shards(base_folder: &Path, tz: Tz) -> Result, StreamdError> { let mut shards = Vec::new(); for entry in WalkDir::new(base_folder) @@ -34,7 +35,8 @@ fn load_all_shards(base_folder: &Path) -> Result, StreamdErr let content = fs::read_to_string(path)?; let stream_file = parse_markdown_file(&file_name, &content); - if let Ok(shard) = localize_stream_file(&stream_file, &BasicTimesheetConfiguration) { + if let Ok(shard) = localize_stream_file(&stream_file, &BasicTimesheetConfiguration, tz) + { shards.push(shard); } } @@ -321,6 +323,13 @@ pub fn run(decimal: bool, debug: bool) -> Result<(), StreamdError> { // Load repository configuration let repo_config = load_repository_config(base_folder)?; + // Parse timezone from config, defaulting to UTC + let tz: Tz = repo_config + .timezone + .as_deref() + .and_then(|s| s.parse().ok()) + .unwrap_or(chrono_tz::UTC); + // Check if timesheet is configured let timesheet_config = match repo_config.timesheet { Some(config) => config, @@ -338,12 +347,14 @@ pub fn run(decimal: bool, debug: bool) -> Result<(), StreamdError> { } }; + let now = Utc::now(); + // Load all markdown files and extract timesheets - let all_shards = load_all_shards(base_folder)?; - let timesheets = extract_timesheets(&all_shards, Utc::now())?; + let all_shards = load_all_shards(base_folder, tz)?; + let timesheets = extract_timesheets(&all_shards, now, tz)?; // Generate the report - let report = generate_report(×heets, ×heet_config)?; + let report = generate_report(×heets, ×heet_config, now, tz)?; if report.months.is_empty() { println!("No timesheet data found for the configured periods."); diff --git a/src/cli/commands/todo.rs b/src/cli/commands/todo.rs index fe8e870..17bfba2 100644 --- a/src/cli/commands/todo.rs +++ b/src/cli/commands/todo.rs @@ -26,7 +26,9 @@ fn all_files() -> Result, StreamdError> { let content = fs::read_to_string(path)?; let stream_file = parse_markdown_file(&file_name, &content); - if let Ok(shard) = localize_stream_file(&stream_file, &TaskConfiguration) { + if let Ok(shard) = + localize_stream_file(&stream_file, &TaskConfiguration, chrono_tz::UTC) + { shards.push(shard); } } diff --git a/src/localize/datetime.rs b/src/localize/datetime.rs index 87a2718..3ff90a2 100644 --- a/src/localize/datetime.rs +++ b/src/localize/datetime.rs @@ -1,4 +1,5 @@ -use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc}; +use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; +use chrono_tz::Tz; use once_cell::sync::Lazy; use regex::Regex; use std::path::Path; @@ -17,15 +18,25 @@ static DATE_MARKER_REGEX: Lazy = Lazy::new(|| Regex::new(r"^\d{8}$").unwr /// Regex for validating time marker format (6 digits). static TIME_MARKER_REGEX: Lazy = Lazy::new(|| Regex::new(r"^\d{6}$").unwrap()); +/// Convert a NaiveDateTime to UTC via the given timezone. +/// Falls back to the earliest local interpretation for ambiguous DST times. +fn naive_to_utc(dt: NaiveDateTime, tz: Tz) -> Option> { + tz.from_local_datetime(&dt) + .single() + .or_else(|| tz.from_local_datetime(&dt).earliest()) + .map(|dt| dt.with_timezone(&Utc)) +} + /// Extract a datetime from a file name in the format YYYYMMDD-HHMMSS. /// /// The time component is optional and can be 4-6 digits (HHMM, HHMMS, or HHMMSS). +/// The datetime is interpreted in the given timezone. /// /// # Examples -/// - "20230101-123456 Some Text.md" -> DateTime for 2023-01-01 12:34:56 -/// - "20230101 Some Text.md" -> DateTime for 2023-01-01 00:00:00 +/// - "20230101-123456 Some Text.md" -> DateTime for 2023-01-01 12:34:56 in tz +/// - "20230101 Some Text.md" -> DateTime for 2023-01-01 00:00:00 in tz /// - "invalid-file-name.md" -> None -pub fn extract_datetime_from_file_name(file_name: &str) -> Option> { +pub fn extract_datetime_from_file_name(file_name: &str, tz: Tz) -> Option> { let base_name = Path::new(file_name) .file_name() .and_then(|s| s.to_str()) @@ -48,20 +59,23 @@ pub fn extract_datetime_from_file_name(file_name: &str) -> Option> NaiveDateTime::parse_from_str(&datetime_str, "%Y%m%d %H:%M:%S") .ok() - .map(|dt| dt.and_utc()) + .and_then(|dt| naive_to_utc(dt, tz)) +} + +/// Parse a 14-digit marker string as a NaiveDateTime without timezone conversion. +fn parse_naive_datetime_from_marker(marker: &str) -> Option { + if !DATETIME_MARKER_REGEX.is_match(marker) { + return None; + } + NaiveDateTime::parse_from_str(marker, "%Y%m%d%H%M%S").ok() } /// Extract a datetime from a marker string in the exact format: YYYYMMDDHHMMSS. /// +/// The datetime is interpreted in the given timezone. /// Returns the parsed datetime if the format matches and values are valid. -pub fn extract_datetime_from_marker(marker: &str) -> Option> { - if !DATETIME_MARKER_REGEX.is_match(marker) { - return None; - } - - NaiveDateTime::parse_from_str(marker, "%Y%m%d%H%M%S") - .ok() - .map(|dt| dt.and_utc()) +pub fn extract_datetime_from_marker(marker: &str, tz: Tz) -> Option> { + parse_naive_datetime_from_marker(marker).and_then(|dt| naive_to_utc(dt, tz)) } /// Extract a date from a marker string in the exact format: YYYYMMDD. @@ -90,6 +104,7 @@ pub fn extract_time_from_marker(marker: &str) -> Option { /// /// The function processes markers in reverse order, allowing later markers to override /// earlier ones. It combines date-only and time-only markers when both are present. +/// All naive datetimes (from markers and the inherited fallback) are interpreted in `tz`. /// /// Rules: /// - If a full datetime marker (14 digits) is found, it sets both date and time @@ -99,6 +114,7 @@ pub fn extract_time_from_marker(marker: &str) -> Option { pub fn extract_datetime_from_marker_list( markers: &[String], inherited_datetime: DateTime, + tz: Tz, ) -> DateTime { let mut shard_time: Option = None; let mut shard_date: Option = None; @@ -111,34 +127,39 @@ pub fn extract_datetime_from_marker_list( if let Some(date) = extract_date_from_marker(marker) { shard_date = Some(date); } - if let Some(datetime) = extract_datetime_from_marker(marker) { - shard_date = Some(datetime.naive_utc().date()); - shard_time = Some(datetime.naive_utc().time()); + if let Some(naive_dt) = parse_naive_datetime_from_marker(marker) { + shard_date = Some(naive_dt.date()); + shard_time = Some(naive_dt.time()); } } + // Interpret the inherited datetime in the configured timezone for fallback values + let inherited_local = inherited_datetime.with_timezone(&tz).naive_local(); + // Combine date and time, applying defaults as needed - let final_date = shard_date.unwrap_or_else(|| inherited_datetime.naive_utc().date()); + let final_date = shard_date.unwrap_or_else(|| inherited_local.date()); let final_time = match (shard_date, shard_time) { // If we have a date but no time, use midnight (Some(_), None) => NaiveTime::from_hms_opt(0, 0, 0).unwrap(), // Otherwise use the shard time or inherit - _ => shard_time.unwrap_or_else(|| inherited_datetime.naive_utc().time()), + _ => shard_time.unwrap_or_else(|| inherited_local.time()), }; - NaiveDateTime::new(final_date, final_time).and_utc() + let naive = NaiveDateTime::new(final_date, final_time); + naive_to_utc(naive, tz).unwrap_or_else(|| inherited_datetime) } #[cfg(test)] mod tests { use super::*; use chrono::TimeZone; + use chrono_tz::UTC; #[test] fn test_extract_date_from_file_name_valid() { let file_name = "20230101-123456 Some Text.md"; assert_eq!( - extract_datetime_from_file_name(file_name), + extract_datetime_from_file_name(file_name, UTC), Some(Utc.with_ymd_and_hms(2023, 1, 1, 12, 34, 56).unwrap()) ); } @@ -146,14 +167,14 @@ mod tests { #[test] fn test_extract_date_from_file_name_invalid() { let file_name = "invalid-file-name.md"; - assert_eq!(extract_datetime_from_file_name(file_name), None); + assert_eq!(extract_datetime_from_file_name(file_name, UTC), None); } #[test] fn test_extract_date_from_file_name_without_time() { let file_name = "20230101 Some Text.md"; assert_eq!( - extract_datetime_from_file_name(file_name), + extract_datetime_from_file_name(file_name, UTC), Some(Utc.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap()) ); } @@ -162,7 +183,7 @@ mod tests { fn test_extract_date_from_file_name_short_time() { let file_name = "20230101-1234 Some Text.md"; assert_eq!( - extract_datetime_from_file_name(file_name), + extract_datetime_from_file_name(file_name, UTC), Some(Utc.with_ymd_and_hms(2023, 1, 1, 12, 34, 0).unwrap()) ); } @@ -170,41 +191,61 @@ mod tests { #[test] fn test_extract_date_from_file_name_empty_string() { let file_name = ""; - assert_eq!(extract_datetime_from_file_name(file_name), None); + assert_eq!(extract_datetime_from_file_name(file_name, UTC), None); } #[test] fn test_extract_date_from_file_name_with_full_path() { let file_name = "/path/to/20230101-123456 Some Text.md"; assert_eq!( - extract_datetime_from_file_name(file_name), + extract_datetime_from_file_name(file_name, UTC), Some(Utc.with_ymd_and_hms(2023, 1, 1, 12, 34, 56).unwrap()) ); } + #[test] + fn test_extract_date_from_file_name_with_timezone_offset() { + // Europe/Berlin is UTC+1 in January (CET) + let file_name = "20230101-120000 Some Text.md"; + assert_eq!( + extract_datetime_from_file_name(file_name, chrono_tz::Europe::Berlin), + Some(Utc.with_ymd_and_hms(2023, 1, 1, 11, 0, 0).unwrap()) + ); + } + #[test] fn test_extract_datetime_from_marker_valid() { let marker = "20250101150000"; assert_eq!( - extract_datetime_from_marker(marker), + extract_datetime_from_marker(marker, UTC), Some(Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap()) ); } + #[test] + fn test_extract_datetime_from_marker_with_timezone_offset() { + // Europe/Berlin is UTC+1 in January (CET) + let marker = "20250101150000"; + assert_eq!( + extract_datetime_from_marker(marker, chrono_tz::Europe::Berlin), + Some(Utc.with_ymd_and_hms(2025, 1, 1, 14, 0, 0).unwrap()) + ); + } + #[test] fn test_extract_datetime_from_marker_invalid_format() { - assert_eq!(extract_datetime_from_marker("2025010115000"), None); // too short - assert_eq!(extract_datetime_from_marker("202501011500000"), None); // too long - assert_eq!(extract_datetime_from_marker("2025-01-01T150000"), None); // separators - assert_eq!(extract_datetime_from_marker("2025010115000a"), None); // non-digit - assert_eq!(extract_datetime_from_marker(""), None); + assert_eq!(extract_datetime_from_marker("2025010115000", UTC), None); // too short + assert_eq!(extract_datetime_from_marker("202501011500000", UTC), None); // too long + assert_eq!(extract_datetime_from_marker("2025-01-01T150000", UTC), None); // separators + assert_eq!(extract_datetime_from_marker("2025010115000a", UTC), None); // non-digit + assert_eq!(extract_datetime_from_marker("", UTC), None); } #[test] fn test_extract_datetime_from_marker_invalid_values() { - assert_eq!(extract_datetime_from_marker("20250230120000"), None); // Feb 30 - assert_eq!(extract_datetime_from_marker("20250101126000"), None); // minute 60 - assert_eq!(extract_datetime_from_marker("20250101240000"), None); // hour 24 + assert_eq!(extract_datetime_from_marker("20250230120000", UTC), None); // Feb 30 + assert_eq!(extract_datetime_from_marker("20250101126000", UTC), None); // minute 60 + assert_eq!(extract_datetime_from_marker("20250101240000", UTC), None); // hour 24 } #[test] @@ -260,7 +301,10 @@ mod tests { #[test] fn test_no_markers_inherits_datetime() { let inherited = Utc.with_ymd_and_hms(2025, 1, 2, 3, 4, 5).unwrap(); - assert_eq!(extract_datetime_from_marker_list(&[], inherited), inherited); + assert_eq!( + extract_datetime_from_marker_list(&[], inherited, UTC), + inherited + ); } #[test] @@ -273,7 +317,7 @@ mod tests { "1234567".to_string(), ]; assert_eq!( - extract_datetime_from_marker_list(&markers, inherited), + extract_datetime_from_marker_list(&markers, inherited, UTC), inherited ); } @@ -283,7 +327,7 @@ mod tests { let inherited = Utc.with_ymd_and_hms(2025, 6, 7, 8, 9, 10).unwrap(); let markers = vec!["20250101".to_string()]; assert_eq!( - extract_datetime_from_marker_list(&markers, inherited), + extract_datetime_from_marker_list(&markers, inherited, UTC), Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap() ); } @@ -293,7 +337,7 @@ mod tests { let inherited = Utc.with_ymd_and_hms(2025, 6, 7, 8, 9, 10).unwrap(); let markers = vec!["150000".to_string()]; assert_eq!( - extract_datetime_from_marker_list(&markers, inherited), + extract_datetime_from_marker_list(&markers, inherited, UTC), Utc.with_ymd_and_hms(2025, 6, 7, 15, 0, 0).unwrap() ); } @@ -303,7 +347,7 @@ mod tests { let inherited = Utc.with_ymd_and_hms(2025, 6, 7, 8, 9, 10).unwrap(); let markers = vec!["20250101150000".to_string()]; assert_eq!( - extract_datetime_from_marker_list(&markers, inherited), + extract_datetime_from_marker_list(&markers, inherited, UTC), Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap() ); } @@ -313,7 +357,7 @@ mod tests { let inherited = Utc.with_ymd_and_hms(2025, 6, 7, 8, 9, 10).unwrap(); let markers = vec!["20250101".to_string(), "150000".to_string()]; assert_eq!( - extract_datetime_from_marker_list(&markers, inherited), + extract_datetime_from_marker_list(&markers, inherited, UTC), Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap() ); } @@ -328,7 +372,7 @@ mod tests { "160000".to_string(), ]; assert_eq!( - extract_datetime_from_marker_list(&markers, inherited), + extract_datetime_from_marker_list(&markers, inherited, UTC), Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap() ); } @@ -343,7 +387,7 @@ mod tests { ]; // The first date (20250101) and first time (150000) should win over the later combined datetime assert_eq!( - extract_datetime_from_marker_list(&markers, inherited), + extract_datetime_from_marker_list(&markers, inherited, UTC), Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap() ); } @@ -358,8 +402,19 @@ mod tests { "150000".to_string(), // valid ]; assert_eq!( - extract_datetime_from_marker_list(&markers, inherited), + extract_datetime_from_marker_list(&markers, inherited, UTC), Utc.with_ymd_and_hms(2025, 1, 1, 15, 0, 0).unwrap() ); } + + #[test] + fn test_marker_list_with_timezone_offset() { + // Europe/Berlin is UTC+2 in summer (CEST) + let inherited = Utc.with_ymd_and_hms(2025, 6, 7, 8, 9, 10).unwrap(); + let markers = vec!["150000".to_string()]; + assert_eq!( + extract_datetime_from_marker_list(&markers, inherited, chrono_tz::Europe::Berlin), + Utc.with_ymd_and_hms(2025, 6, 7, 13, 0, 0).unwrap() + ); + } } diff --git a/src/localize/shard.rs b/src/localize/shard.rs index 279b8c6..0badda5 100644 --- a/src/localize/shard.rs +++ b/src/localize/shard.rs @@ -1,4 +1,5 @@ use chrono::{DateTime, Utc}; +use chrono_tz::Tz; use indexmap::{IndexMap, IndexSet}; use crate::error::StreamdError; @@ -17,12 +18,13 @@ pub fn localize_shard( config: &RepositoryConfiguration, propagated: &IndexMap, moment: DateTime, + tz: Tz, ) -> LocalizedShard { let mut position = propagated.clone(); let mut private_position: IndexMap = IndexMap::new(); // Extract datetime from markers - let adjusted_moment = extract_datetime_from_marker_list(&shard.markers, moment); + let adjusted_moment = extract_datetime_from_marker_list(&shard.markers, moment, tz); // Convert markers to a set for if_with checking let marker_set: IndexSet = shard.markers.iter().cloned().collect(); @@ -64,7 +66,7 @@ pub fn localize_shard( let children: Vec = shard .children .iter() - .map(|child| localize_shard(child, config, &position, adjusted_moment)) + .map(|child| localize_shard(child, config, &position, adjusted_moment, tz)) .collect(); // Merge private position into final position @@ -84,11 +86,13 @@ pub fn localize_shard( /// Localize an entire stream file. /// /// Extracts the datetime from the file name and localizes the root shard. +/// Timestamps in the file name and markers are interpreted in `tz`. pub fn localize_stream_file( stream_file: &StreamFile, config: &RepositoryConfiguration, + tz: Tz, ) -> Result { - let shard_date = extract_datetime_from_file_name(&stream_file.file_name) + let shard_date = extract_datetime_from_file_name(&stream_file.file_name, tz) .ok_or_else(|| StreamdError::DateExtractionError(stream_file.file_name.clone()))?; let shard = stream_file @@ -99,7 +103,13 @@ pub fn localize_stream_file( let mut initial_location = IndexMap::new(); initial_location.insert("file".to_string(), stream_file.file_name.clone()); - Ok(localize_shard(shard, config, &initial_location, shard_date)) + Ok(localize_shard( + shard, + config, + &initial_location, + shard_date, + tz, + )) } #[cfg(test)] @@ -107,6 +117,7 @@ mod tests { use super::*; use crate::models::{Dimension, Marker, MarkerPlacement}; use chrono::TimeZone; + use chrono_tz::UTC; fn make_config() -> RepositoryConfiguration { RepositoryConfiguration::new() @@ -149,7 +160,7 @@ mod tests { let stream_file = StreamFile::new("20250622-121000 Test File.md") .with_shard(Shard::new(1, 1).with_markers(vec!["Streamd".to_string()])); - let result = localize_stream_file(&stream_file, &config).unwrap(); + let result = localize_stream_file(&stream_file, &config, UTC).unwrap(); assert_eq!( result.moment, @@ -170,7 +181,7 @@ mod tests { Shard::new(1, 1).with_markers(vec!["Timesheet".to_string(), "Streamd".to_string()]), ); - let result = localize_stream_file(&stream_file, &config).unwrap(); + let result = localize_stream_file(&stream_file, &config, UTC).unwrap(); assert_eq!( result.moment, @@ -202,7 +213,7 @@ mod tests { let stream_file = StreamFile::new("20260131-210000 Test File.md") .with_shard(Shard::new(1, 1).with_markers(vec!["A".to_string(), "B".to_string()])); - let result = localize_stream_file(&stream_file, &config).unwrap(); + let result = localize_stream_file(&stream_file, &config, UTC).unwrap(); assert_eq!(result.location.get("project"), Some(&"b".to_string())); } @@ -226,7 +237,7 @@ mod tests { let stream_file = StreamFile::new("20260131-210000 Test File.md") .with_shard(Shard::new(1, 1).with_markers(vec!["A".to_string(), "B".to_string()])); - let result = localize_stream_file(&stream_file, &config).unwrap(); + let result = localize_stream_file(&stream_file, &config, UTC).unwrap(); assert_eq!(result.location.get("project"), Some(&"a".to_string())); } @@ -250,7 +261,7 @@ mod tests { let stream_file = StreamFile::new("20260131-210000 Test File.md") .with_shard(Shard::new(1, 1).with_markers(vec!["A".to_string(), "B".to_string()])); - let result = localize_stream_file(&stream_file, &config).unwrap(); + let result = localize_stream_file(&stream_file, &config, UTC).unwrap(); assert_eq!(result.location.get("label"), Some(&"b".to_string())); } @@ -275,7 +286,7 @@ mod tests { let stream_file = StreamFile::new("20260131-210000 Test File.md") .with_shard(Shard::new(1, 1).with_markers(vec!["A".to_string(), "B".to_string()])); - let result = localize_stream_file(&stream_file, &config).unwrap(); + let result = localize_stream_file(&stream_file, &config, UTC).unwrap(); assert_eq!(result.location.get("label"), Some(&"a".to_string())); } diff --git a/src/timesheet/extract.rs b/src/timesheet/extract.rs index 304492e..0a6c8ff 100644 --- a/src/timesheet/extract.rs +++ b/src/timesheet/extract.rs @@ -1,4 +1,5 @@ use chrono::{DateTime, Utc}; +use chrono_tz::Tz; use itertools::Itertools; use crate::error::StreamdError; @@ -38,6 +39,7 @@ fn shards_to_timesheet_points(shards: &[LocalizedShard]) -> Vec fn aggregate_timecard_day( points: &[TimesheetPoint], now: DateTime, + tz: Tz, ) -> Result, StreamdError> { if points.is_empty() { return Ok(None); @@ -49,23 +51,23 @@ fn aggregate_timecard_day( pts }; - let card_date = sorted_points[0].moment.date_naive(); + let card_date = sorted_points[0].moment.with_timezone(&tz).date_naive(); let mut is_sick_leave = false; let mut special_day_type: Option = None; // State machine: starting in "break" mode (not working) let mut last_is_break = true; - let mut last_time = sorted_points[0].moment.time(); + let mut last_time = sorted_points[0].moment.with_timezone(&tz).time(); let mut timecards: Vec = Vec::new(); for point in &sorted_points { - if point.moment.date_naive() != card_date { + if point.moment.with_timezone(&tz).date_naive() != card_date { return Err(StreamdError::TimesheetError( "Dates of all given timesheet days should be consistent".to_string(), )); } - let point_time = point.moment.time(); + let point_time = point.moment.with_timezone(&tz).time(); match point.point_type { TimesheetPointType::Holiday => { @@ -116,9 +118,10 @@ fn aggregate_timecard_day( // Check that we ended in break mode if !last_is_break { - if card_date == now.date_naive() { + let now_local = now.with_timezone(&tz); + if card_date == now_local.date_naive() { // No closing break yet for today — artificially close at now - timecards.push(Timecard::new(last_time, now.time())); + timecards.push(Timecard::new(last_time, now_local.time())); } else { return Err(StreamdError::TimesheetError(format!( "Last Timecard of {} is not a break!", @@ -144,6 +147,7 @@ fn aggregate_timecard_day( fn aggregate_timecards( points: &[TimesheetPoint], now: DateTime, + tz: Tz, ) -> Result, StreamdError> { let mut timesheets = Vec::new(); @@ -151,10 +155,13 @@ fn aggregate_timecards( let mut sorted_points = points.to_vec(); sorted_points.sort_by_key(|p| p.moment); - // Group by date - for (_date, group) in &sorted_points.iter().chunk_by(|p| p.moment.date_naive()) { + // Group by local date in the configured timezone + for (_date, group) in &sorted_points + .iter() + .chunk_by(|p| p.moment.with_timezone(&tz).date_naive()) + { let day_points: Vec<_> = group.cloned().collect(); - if let Some(timesheet) = aggregate_timecard_day(&day_points, now)? { + if let Some(timesheet) = aggregate_timecard_day(&day_points, now, tz)? { timesheets.push(timesheet); } } @@ -166,9 +173,10 @@ fn aggregate_timecards( pub fn extract_timesheets( shards: &[LocalizedShard], now: DateTime, + tz: Tz, ) -> Result, StreamdError> { let points = shards_to_timesheet_points(shards); - aggregate_timecards(&points, now) + aggregate_timecards(&points, now, tz) } #[cfg(test)] @@ -177,6 +185,8 @@ mod tests { use chrono::{NaiveTime, TimeZone}; use indexmap::IndexMap; + use chrono_tz::UTC; + /// A fixed "now" in the past, so tests never match today. fn past_now() -> DateTime { Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap() @@ -217,7 +227,7 @@ mod tests { ), ]; - let result = extract_timesheets(&shards, past_now()).unwrap(); + let result = extract_timesheets(&shards, past_now(), UTC).unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].date, day.date_naive()); @@ -270,7 +280,7 @@ mod tests { ), ]; - let result = extract_timesheets(&shards, past_now()).unwrap(); + let result = extract_timesheets(&shards, past_now(), UTC).unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].timecards.len(), 3); @@ -321,7 +331,7 @@ mod tests { ), ]; - let result = extract_timesheets(&shards, past_now()).unwrap(); + let result = extract_timesheets(&shards, past_now(), UTC).unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].timecards.len(), 3); @@ -355,7 +365,7 @@ mod tests { ), ]; - let result = extract_timesheets(&shards, past_now()).unwrap(); + let result = extract_timesheets(&shards, past_now(), UTC).unwrap(); assert_eq!(result.len(), 2); assert_eq!(result[0].date, day1.date_naive()); @@ -378,7 +388,7 @@ mod tests { ), ]; - let result = extract_timesheets(&shards, past_now()).unwrap(); + let result = extract_timesheets(&shards, past_now(), UTC).unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].special_day_type, Some(SpecialDayType::Vacation)); @@ -401,7 +411,7 @@ mod tests { ), ]; - let result = extract_timesheets(&shards, past_now()).unwrap(); + let result = extract_timesheets(&shards, past_now(), UTC).unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].special_day_type, Some(SpecialDayType::Holiday)); @@ -423,7 +433,7 @@ mod tests { ), ]; - let result = extract_timesheets(&shards, past_now()).unwrap(); + let result = extract_timesheets(&shards, past_now(), UTC).unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].special_day_type, Some(SpecialDayType::Undertime)); @@ -450,7 +460,7 @@ mod tests { ), ]; - let result = extract_timesheets(&shards, past_now()).unwrap(); + let result = extract_timesheets(&shards, past_now(), UTC).unwrap(); assert_eq!(result.len(), 1); assert!(result[0].is_sick_leave); @@ -473,7 +483,7 @@ mod tests { ), ]; - let result = extract_timesheets(&shards, past_now()).unwrap(); + let result = extract_timesheets(&shards, past_now(), UTC).unwrap(); assert_eq!(result.len(), 1); assert!(result[0].is_sick_leave); @@ -482,7 +492,7 @@ mod tests { #[test] fn test_empty_input() { - let result = extract_timesheets(&[], past_now()).unwrap(); + let result = extract_timesheets(&[], past_now(), UTC).unwrap(); assert!(result.is_empty()); } @@ -502,7 +512,7 @@ mod tests { ), ]; - let result = extract_timesheets(&shards, past_now()); + let result = extract_timesheets(&shards, past_now(), UTC); assert!(result.is_err()); let err = result.unwrap_err(); @@ -530,7 +540,7 @@ mod tests { ), ]; - let result = extract_timesheets(&shards, past_now()); + let result = extract_timesheets(&shards, past_now(), UTC); assert!(result.is_err()); let err = result.unwrap_err(); @@ -553,7 +563,7 @@ mod tests { ), ]; - let result = extract_timesheets(&shards, past_now()).unwrap(); + let result = extract_timesheets(&shards, past_now(), UTC).unwrap(); assert!(result.is_empty()); } diff --git a/src/timesheet/generator.rs b/src/timesheet/generator.rs index e6cde40..ede7898 100644 --- a/src/timesheet/generator.rs +++ b/src/timesheet/generator.rs @@ -2,7 +2,8 @@ use std::collections::HashMap; use std::fs; use std::path::Path; -use chrono::{Datelike, NaiveDate, Weekday}; +use chrono::{DateTime, Datelike, NaiveDate, Utc, Weekday}; +use chrono_tz::Tz; use crate::error::StreamdError; use crate::models::{SpecialDayType, Timesheet}; @@ -115,6 +116,8 @@ fn calculate_actual_minutes( pub fn generate_report( timesheets: &[Timesheet], config: &TimesheetConfig, + now: DateTime, + tz: Tz, ) -> Result { if config.periods.is_empty() { return Ok(TimesheetReport::new()); @@ -128,8 +131,8 @@ pub fn generate_report( 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(); + // Limit to today in the configured timezone + let today = now.with_timezone(&tz).date_naive(); let end_date = latest_period_end.min(today); // Group by month and generate reports @@ -260,7 +263,13 @@ mod tests { use super::*; use crate::models::Timecard; use crate::timesheet::Period; - use chrono::NaiveTime; + use chrono::{NaiveTime, TimeZone}; + use chrono_tz::UTC; + + /// A "now" well past all test dates so report limits aren't hit. + fn future_now() -> DateTime { + Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap() + } fn date(year: i32, month: u32, day: u32) -> NaiveDate { NaiveDate::from_ymd_opt(year, month, day).unwrap() @@ -437,7 +446,7 @@ mod tests { #[test] fn test_generate_report_empty_config() { let config = TimesheetConfig { periods: vec![] }; - let report = generate_report(&[], &config).unwrap(); + let report = generate_report(&[], &config, future_now(), UTC).unwrap(); assert!(report.months.is_empty()); } @@ -447,7 +456,7 @@ mod tests { 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(); + let report = generate_report(×heets, &config, future_now(), UTC).unwrap(); assert_eq!(report.months.len(), 1); assert_eq!(report.months[0].days.len(), 1); @@ -466,7 +475,7 @@ mod tests { // 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(); + let report = generate_report(×heets, &config, future_now(), UTC).unwrap(); assert_eq!(report.months[0].days.len(), 2); @@ -484,7 +493,7 @@ mod tests { 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(); + let report = generate_report(×heets, &config, future_now(), UTC).unwrap(); // Should only include Mon-Fri (5 days), not Sat-Sun let days = &report.months[0].days; @@ -503,7 +512,7 @@ mod tests { ]; let config = make_config(date(2026, 3, 2), date(2026, 3, 8), 40.0); - let report = generate_report(×heets, &config).unwrap(); + let report = generate_report(×heets, &config, future_now(), UTC).unwrap(); // Should include Saturday let has_saturday = report.months[0] @@ -526,7 +535,7 @@ mod tests { }; let config = make_config(date(2026, 3, 2), date(2026, 3, 2), 40.0); - let report = generate_report(&[ts], &config).unwrap(); + let report = generate_report(&[ts], &config, future_now(), UTC).unwrap(); assert!(report.has_warnings()); assert!(report.months[0].days[0].has_warnings()); @@ -541,7 +550,7 @@ mod tests { ]; let config = make_config(date(2026, 3, 2), date(2026, 3, 3), 40.0); - let report = generate_report(×heets, &config).unwrap(); + let report = generate_report(×heets, &config, future_now(), UTC).unwrap(); // Balance should be +120 min (+2h: 18h actual - 16h expected) assert_eq!(report.cumulative_balance, 120); From e562af0dc3d6cfd8296d47d617a2aaca810ba92a Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Tue, 7 Apr 2026 13:43:40 +0200 Subject: [PATCH 16/46] fix: clippy warnings --- CLAUDE.md | 2 ++ src/localize/datetime.rs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3ca95b6..8ec1886 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,8 @@ cargo clippy # Lint cargo fmt # Format ``` +After finishing tests, always check the package with `nix flake check`. + ## Architecture Streamd parses markdown files into hierarchical **Shards**, then **localizes** them by assigning temporal moments and dimensional placements based on `@Tag` markers. diff --git a/src/localize/datetime.rs b/src/localize/datetime.rs index 3ff90a2..9f6d2ec 100644 --- a/src/localize/datetime.rs +++ b/src/localize/datetime.rs @@ -146,7 +146,7 @@ pub fn extract_datetime_from_marker_list( }; let naive = NaiveDateTime::new(final_date, final_time); - naive_to_utc(naive, tz).unwrap_or_else(|| inherited_datetime) + naive_to_utc(naive, tz).unwrap_or(inherited_datetime) } #[cfg(test)] From c2b4fb5160d4fc80ad0f63dcbbfb3b964a4b8bc8 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 10 Apr 2026 00:06:13 +0000 Subject: [PATCH 17/46] chore(deps): update rust crate indexmap to v2.14.0 --- Cargo.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bd8f6e1..ccfabbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -351,9 +351,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "heck" @@ -393,12 +393,12 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "indexmap" -version = "2.13.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "serde", "serde_core", ] From 10f4ae282a07f42d7382992ab09cb932e79ac413 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sat, 11 Apr 2026 00:05:42 +0000 Subject: [PATCH 18/46] chore(deps): update rust crate clap_complete to v4.6.1 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ccfabbe..8f34603 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -197,9 +197,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb" +checksum = "406e68b4de5c59cfb8f750a7cbd4d31ae153788b8352167c1e5f4fc26e8c91e9" dependencies = [ "clap", ] From e15e6f105313dc373650ad6a51a3390b4921c296 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Mon, 13 Apr 2026 19:26:09 +0200 Subject: [PATCH 19/46] fix: broken tasks extraction --- src/extract/parser.rs | 68 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/src/extract/parser.rs b/src/extract/parser.rs index 1326371..b1d0374 100644 --- a/src/extract/parser.rs +++ b/src/extract/parser.rs @@ -12,6 +12,8 @@ struct BlockInfo { end_line: usize, block_type: BlockType, events: Vec>, + /// Nested list items contained within this block (for ListItem blocks with sub-lists). + nested_items: Vec, } #[derive(Debug, Clone, PartialEq)] @@ -110,12 +112,14 @@ pub fn parse_markdown_file(file_name: &str, file_content: &str) -> StreamFile { fn collect_blocks(content: &str, parser: Parser) -> Vec { let mut blocks = Vec::new(); let mut current_block: Option = None; - let _current_events: Vec> = Vec::new(); let mut depth = 0; let mut list_items: Vec = Vec::new(); let mut in_list = false; let mut list_start_line = 0; + // Stack for nested lists: (saved current_block, saved list_items, saved list_start_line) + let mut list_nesting_stack: Vec<(Option, Vec, usize)> = Vec::new(); + // Pre-compute line starts for offset-to-line mapping let line_starts: Vec = std::iter::once(0) .chain(content.match_indices('\n').map(|(i, _)| i + 1)) @@ -135,6 +139,7 @@ fn collect_blocks(content: &str, parser: Parser) -> Vec { end_line: line, block_type: BlockType::Paragraph, events: Vec::new(), + nested_items: Vec::new(), }); } depth += 1; @@ -166,6 +171,7 @@ fn collect_blocks(content: &str, parser: Parser) -> Vec { end_line: line, block_type: BlockType::Heading(heading_level), events: Vec::new(), + nested_items: Vec::new(), }); } depth += 1; @@ -186,7 +192,15 @@ fn collect_blocks(content: &str, parser: Parser) -> Vec { } } Event::Start(Tag::List(_)) => { - if !in_list { + if in_list { + // Entering a nested list: save current list item and collected items + list_nesting_stack.push(( + current_block.take(), + std::mem::take(&mut list_items), + list_start_line, + )); + list_start_line = line; + } else { in_list = true; list_start_line = line; list_items.clear(); @@ -195,7 +209,18 @@ fn collect_blocks(content: &str, parser: Parser) -> Vec { } Event::End(TagEnd::List(_)) => { depth -= 1; - if depth == 0 && in_list { + if let Some((parent_block, parent_items, parent_start_line)) = + list_nesting_stack.pop() + { + // Nested list ended: attach collected items as nested children of parent item + let nested = std::mem::take(&mut list_items); + list_start_line = parent_start_line; + list_items = parent_items; + current_block = parent_block.map(|mut item| { + item.nested_items = nested; + item + }); + } else if depth == 0 && in_list { in_list = false; // Create a list block containing all list items if !list_items.is_empty() { @@ -204,6 +229,7 @@ fn collect_blocks(content: &str, parser: Parser) -> Vec { end_line: line, block_type: BlockType::List, events: vec![], // List events are handled through list_items + nested_items: vec![], }); // Store list items for later processing for item in list_items.drain(..) { @@ -222,6 +248,7 @@ fn collect_blocks(content: &str, parser: Parser) -> Vec { end_line: line, block_type: BlockType::ListItem, events: Vec::new(), + nested_items: Vec::new(), }); } } @@ -240,6 +267,7 @@ fn collect_blocks(content: &str, parser: Parser) -> Vec { end_line: line, block_type: BlockType::CodeBlock, events: Vec::new(), + nested_items: Vec::new(), }); } depth += 1; @@ -507,13 +535,21 @@ fn parse_single_block_shard( } } BlockType::List | BlockType::ListItem => { - // List handling is complex - for now, extract any markers/tags let (markers, tags) = extract_block_markers_and_tags(block); - if markers.is_empty() { + // Recursively build child shards from nested list items + let children: Vec = block + .nested_items + .iter() + .filter_map(|item| { + let (child, _) = parse_single_block_shard(item, item.start_line, item.end_line); + child + }) + .collect(); + if markers.is_empty() && children.is_empty() { (None, tags) } else { ( - Some(build_shard(start_line, end_line, markers, tags, vec![])), + Some(build_shard(start_line, end_line, markers, tags, children)), vec![], ) } @@ -716,6 +752,26 @@ mod tests { ); } + #[test] + fn test_parse_nested_list_creates_three_shards() { + let content = "* @Task 1\n * @Task 2\n* @Task 3"; + let result = parse_markdown_file(&make_file_name(), content); + let root = result.shard.unwrap(); + // The root shard should have two top-level children: @Task 1 and @Task 3 + assert_eq!(root.children.len(), 2, "expected 2 top-level shards"); + let task1 = &root.children[0]; + let task3 = &root.children[1]; + // @Task 1 must carry its marker and contain @Task 2 as a child + assert_eq!(task1.markers, vec!["Task"], "@Task 1 marker"); + assert_eq!(task1.children.len(), 1, "@Task 1 should have one child"); + let task2 = &task1.children[0]; + assert_eq!(task2.markers, vec!["Task"], "@Task 2 marker"); + assert!(task2.children.is_empty(), "@Task 2 should have no children"); + // @Task 3 is a sibling of @Task 1 + assert_eq!(task3.markers, vec!["Task"], "@Task 3 marker"); + assert!(task3.children.is_empty(), "@Task 3 should have no children"); + } + #[test] fn test_parse_continues_looking_for_markers_after_first_link_marker() { let result = parse_markdown_file( From b653590c366a818b4781b41bd0aff11f1f80db50 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Mon, 13 Apr 2026 19:30:59 +0200 Subject: [PATCH 20/46] feat(localize): extract file_type from filename prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `extract_file_type_from_file_name` to parse prefixes like `_daily` from filenames (e.g. `20260412-123456_daily.md` → `"daily"`). Insert the result into `initial_location` in `localize_stream_file` so all localized shards carry a `file_type` dimension value. Also register the `file_type` dimension in `TaskConfiguration` so the propagation contract is documented. --- src/localize/datetime.rs | 72 +++++++++++++++++++++++++++++++++++ src/localize/mod.rs | 2 +- src/localize/preconfigured.rs | 6 +++ src/localize/shard.rs | 8 +++- 4 files changed, 86 insertions(+), 2 deletions(-) diff --git a/src/localize/datetime.rs b/src/localize/datetime.rs index 9f6d2ec..f5fd601 100644 --- a/src/localize/datetime.rs +++ b/src/localize/datetime.rs @@ -9,6 +9,11 @@ use std::path::Path; static FILE_NAME_REGEX: Lazy = Lazy::new(|| Regex::new(r"^(?P\d{8})(?:-(?P