From 7f4d3bb147af15102def6f7b41f9022f5b8ff861 Mon Sep 17 00:00:00 2001 From: Konstantin Fickel Date: Tue, 7 Apr 2026 09:42:38 +0200 Subject: [PATCH 01/36] 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 02/36] 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 03/36] 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 04/36] 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 05/36] 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 06/36] 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 07/36] 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 08/36] 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 09/36] 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 10/36] 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