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